Медленный SQL-запрос¶
Runbook для ситуации: endpoint тормозит, в трассе видно долгий
SQL-span, в логах pgx превышает таймаут. Или профильный
outbox_forwarder_lag_seconds растёт, потому что SELECT FROM outbox
идёт 5 секунд. Эта страница — как локализовать проблемный запрос, найти
причину и починить.
Reference по pgx, индексам, транзакциям — в
../conventions/db-pgx.md. Про миграции и
EXPLAIN ANALYZE — в ../how-to/add-migration.md.
Содержание¶
- Симптомы
- Что смотреть первым
- 1. Найти проблемный запрос
- 2. pg_stat_statements
- 3. EXPLAIN ANALYZE
- 4. pg_stat_activity и блокировки
- Типовые причины
- Фиксы
- Что нельзя делать под нагрузкой
- Чеклист
- Связанные разделы
Симптомы¶
- HTTP-endpoint отдаёт 504 (gateway timeout) или 500 с
context deadline exceededв логе. - Tempo-trace показывает span
SELECT ...2+ секунды. - Метрика
http_request_duration_secondsпо конкретному route выбивается выше обычного p99. pg_stat_activityполонstate = 'active'сquery_startминуту назад.- Pool:
PoolStat().AcquireCount()растёт, ноAvailableConnsнизкий → handler'ы ждут соединение.
Что смотреть первым¶
Порядок диагностики:
- Grafana → Dashboard сервиса → график p99 по route. Один конкретный route или все сразу? Один → проблема в его SQL. Все → скорее всего pool исчерпан или БД-инфра лагает.
- Tempo → trace проблемного запроса → span'ы.
pgx-span'ы показывают точное SQL-statement и длительность. pg_stat_statements(§2) → топ медленных запросов за последний час.pg_stat_activity(§4) → что сейчас висит, кто держит lock.
1. Найти проблемный запрос¶
Через OpenTelemetry (pgx-tracer включён по
../conventions/observability.md)
каждый SQL-запрос = span с атрибутами db.statement, db.operation.
В Tempo ищи по атрибуту:
Либо открой конкретный долгий trace → внутри найди самый длинный
query span → текст запроса и параметры.
Если tracing по какой-то причине недоступен — найдёшь в логе:
(предполагается, что pgx-tracer логирует запросы > SlowQueryThreshold).
2. pg_stat_statements¶
Postgres-модуль, агрегирующий статистику по каждому уникальному SQL-
запросу. Должен быть включён в postgresql.conf:
Включение в БД (разовая команда):
Топ-20 самых медленных запросов за последний интервал:
SELECT
calls,
round(total_exec_time::numeric / 1000, 2) AS total_sec,
round(mean_exec_time::numeric, 2) AS mean_ms,
round((mean_exec_time * calls / 1000)::numeric, 2) AS wall_sec,
rows,
query
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 20;
Читай так:
mean_msвысокий (> 100ms для OLTP) → запрос сам медленный, нужен индекс / переписывание.callsвысокий +mean_msнизкий → «N+1», каждый вызов быстрый, но их тысячи. Обычно виден в api-composition (см.../patterns/api-composition.md).wall_secвысокий — реально больше всего процессорного времени съедает. Это твой первый кандидат на оптимизацию.rows / callsбольшое — запрос возвращает много строк, может, нуженLIMIT/ пагинация.
Сброс счётчиков (если хочешь смотреть на свежем окне):
3. EXPLAIN ANALYZE¶
Для конкретного запроса смотри план:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT r.id, r.rating, r.text
FROM review.reviews r
WHERE r.place_id = $1
AND r.deleted_at IS NULL
ORDER BY r.created_at DESC
LIMIT 20;
Что искать в output:
Seq Scanна таблице > 10k строк — индекса нет или он не выбран планировщиком. СмотриWHERE-предикат, добавь индекс.Rows Removed by Filterбольшой — Postgres читает много строк и отбрасывает большую часть. Индекс по фильтрующей колонке или partial index.- Несоответствие
actual rowsиestimated rows(> 10x) — статистика устарела:ANALYZE <table>. Nested Loopна больших таблицах — часто индикатор N+1 или отсутствия join-индекса.Sort,Hash— если читают много данных, запросу не хватает индекса для порядка.Heap Fetches>Index Only Scan— индекс есть, но Postgres лезет в heap:VACUUMдля обновления visibility map.Buffers: shared read=N— БД читает с диска, не из кэша. Большое N → холодный кэш или нехваткаshared_buffers.
Общая метрика качества — actual time vs rows. Оптимально —
actual time растёт линейно от rows. Если растёт экспоненциально
— есть Cartesian product или неэффективный sort.
4. pg_stat_activity и блокировки¶
Что сейчас активно:
SELECT pid,
state,
wait_event_type,
wait_event,
NOW() - query_start AS duration,
left(query, 100) AS q
FROM pg_stat_activity
WHERE state != 'idle'
AND backend_type = 'client backend'
ORDER BY query_start;
Столбцы:
state = 'active'— запрос исполняется.state = 'idle in transaction'— транзакция открыта, ничего не делает (код забылcommit/rollback). Долгаяidle in transactionдержит locks на измененных строках → все, кто ждёт lock, висят.wait_event_type = 'Lock'— запрос заблокирован чужим lock'ом.wait_eventпоказывает тип (relation,transactionid,tuple).
Кто кого держит:
SELECT blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
NOW() - blocking.xact_start AS blocking_xact_age
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE NOT pg_try_advisory_lock(blocked.pid);
Если blocking_xact_age > 1 минуты — это либо «висящая» транзакция
(см. типовые причины §c), либо долгий backfill из миграции. В
крайнем случае:
Террминат транзакции освобождает locks, но убивает работу этого
pid'а — делай только, если уверен, что это не user-facing запрос
(обычно SELECT ... FOR UPDATE от самого сервиса).
Типовые причины¶
a) Отсутствует индекс¶
Симптом. Seq Scan на большой таблице, mean_ms высокий.
Проверка. EXPLAIN ANALYZE + список индексов:
\di review.reviews*
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'review' AND tablename = 'reviews';
Фикс. Добавь индекс миграцией:
-- в миграции, НЕ вручную в prod
CREATE INDEX CONCURRENTLY idx_reviews_place_created
ON review.reviews (place_id, created_at DESC)
WHERE deleted_at IS NULL;
CONCURRENTLY — обязательно для prod: не блокирует writes. Важно: вне
транзакции (нет BEGIN/COMMIT), миграция должна быть одним
statement'ом или дроблена на отдельные миграции.
b) N+1 в api-composition¶
Симптом. Endpoint отдаёт список из 20 элементов, в trace видно 21
SQL-запрос (SELECT FROM reviews + 20× SELECT FROM users).
Проверка. В handler-коде поиск for _, r := range reviews {
user, _ := c.users.Get(...) }.
Фикс. Один batch-запрос в downstream-сервис:
userIDs := make([]int64, len(reviews))
for i, r := range reviews {
userIDs[i] = r.UserID
}
users, err := c.users.GetBatch(ctx, userIDs) // один HTTP-запрос
На стороне владельца downstream endpoint'а:
Подробнее — ../patterns/api-composition.md.
c) Висячая idle in transaction¶
Симптом. wait_event = 'Lock' / 'transactionid', у blocking_pid
— state = 'idle in transaction', xact_start давно.
Причина. Код открыл транзакцию, сделал SELECT/UPDATE, и не
закрыл (забыл defer tx.Rollback, висит на внешнем HTTP-вызове
внутри InTx, что запрещено — см.
../conventions/db-pgx.md).
Фикс.
- Немедленно: SELECT pg_terminate_backend(<blocking_pid>) — разорвёт
транзакцию, откатит её, освободит lock.
- Долгосрочно: ищи InTx с внешними вызовами; выноси их наружу
InTx.
d) Устаревшая статистика планировщика¶
Симптом. EXPLAIN ANALYZE показывает rows=100000 (actual rows=10)
— планировщик ждёт много строк, берёт Seq Scan вместо Index Scan.
Фикс.
Для больших таблиц занимает минуты. Делается штатно автовакуумом, но
после massive insert/delete может запаздывать — ручной ANALYZE
ускоряет.
e) Bloat таблицы / индекса¶
Симптом. Таблица физически большая, хотя по SELECT count(*) строк
немного. pg_stat_user_tables.n_dead_tup высокий.
SELECT relname, n_live_tup, n_dead_tup,
round(n_dead_tup::numeric / NULLIF(n_live_tup,0), 2) AS dead_ratio
FROM pg_stat_user_tables
WHERE schemaname = 'review'
ORDER BY n_dead_tup DESC;
Фикс.
- VACUUM ANALYZE <table> — для таблиц, где bloat < 50%.
- VACUUM FULL <table> — требует exclusive lock, только в maintenance-окне.
- Настроить autovacuum_vacuum_scale_factor = 0.05 per-table для
hot-таблиц (outbox, sessions) — agressive autovacuum держит bloat
низким.
f) Pool исчерпан¶
Симптом. Не все запросы медленные, но все handler'ы ждут
pool.Acquire. Метрика pool_acquired_connections ≈ PoolMax.
Причина.
- Долгие транзакции (§c) занимают соединения.
- PoolMax слишком маленький для RPS × mean query duration.
- В том же пуле работает и forwarder, и HTTP — они конкурируют.
Фикс.
- Увеличь DB_POOL_MAX в env. Начни с удвоения.
- Выдели forwarder'у отдельный пул (3–5 соединений), чтобы он не
конкурировал с HTTP —
../patterns/outbox.md.
- Проверь statement_timeout — без него одна висящая транзакция
навсегда забирает соединение.
g) Огромный JSON в payload¶
Симптом. EXPLAIN ANALYZE показывает нормальный план, но запрос
медленный. Buffers: shared read большой → диск.
Причина. Колонка типа JSONB хранит 100 KB на строку, SELECT *
читает весь payload.
Фикс.
- Не делай SELECT * — перечисляй нужные колонки.
- Для аналитики: SELECT payload->'field' без всего блоба.
- Рассмотри, точно ли нужен JSONB — часто нормализованные колонки
быстрее.
h) ORDER BY + LIMIT без индекса на sort-колонку¶
Симптом. EXPLAIN ANALYZE показывает Sort node, читающий всю
таблицу.
Фикс. Композитный индекс с sort-колонкой в конце:
Теперь Postgres делает Index Scan в нужном порядке и
останавливается через LIMIT.
Фиксы¶
Приоритет:
- Индекс — решает 70% медленных OLTP-запросов.
CONCURRENTLY, миграцией, после проверкиEXPLAIN ANALYZE. - Переписать запрос — выкинуть лишние
JOIN, заменить correlated subquery наLATERALилиJOIN, убратьSELECT *. - Кэш — если запрос результат стабилен (read-mostly), оберни в
Redis cache-aside (
../conventions/caching.md). - Батч — для N+1: один запрос с
WHERE id = ANY($1). - Архивирование — если таблица растёт до десятков миллионов строк, выдели холодные данные (reviews старше года) в отдельную таблицу.
- Разбиение на партиции — если таблица > 100M строк и это неизбежно.
Каждый фикс прогоняй через EXPLAIN ANALYZE на prod-snapshot'е БД,
не на пустой dev-БД: план планировщика зависит от статистики, которой
на dev нет.
Что нельзя делать под нагрузкой¶
CREATE INDEXбезCONCURRENTLY— exclusive lock, блокирует writes на минуты. ВсегдаCONCURRENTLY, вынесенное в отдельную миграцию безBEGIN/COMMIT.VACUUM FULL— эксклюзивный lock, перепакует таблицу. Только в maintenance-окне.ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT <expr>на большой таблице — Postgres переписывает все строки, держит lock. Разбивай на expand-contract —../conventions/db-pgx.md.- Массовый
DELETE FROM— создаёт bloat, лочит индексы. Батчи по 1000–10000 строк с паузой. pg_terminate_backendна случайный pid — без понимания, что это за транзакция, можешь порвать in-flight бизнес-операцию.
Чеклист¶
- Tempo-trace показал конкретный SQL-statement или группу.
-
pg_stat_statementsподтвердил, что этот запрос — в топе поtotal_exec_time. -
EXPLAIN ANALYZE (BUFFERS)прогнан на prod-snapshot'е или staging. - Причина определена: отсутствующий индекс / N+1 / idle-tx / bloat / pool / payload.
- Фикс подготовлен миграцией с
CONCURRENTLY(если индекс). - После фикса повторный
EXPLAIN ANALYZEпоказывает правильный план. - Метрика p99 по route вернулась в норму в течение 15 минут.
- Если правил indexes —
ANALYZE <table>для обновления статистики.
Связанные разделы¶
../conventions/db-pgx.md— pgx, pool config, индексы, ErrNotFound.../how-to/add-migration.md— как внести изменение схемы безопасно.../how-to/rollback-migration.md— если новая миграция сделала хуже.../patterns/api-composition.md— N+1 паттерн и как его избегать.../conventions/caching.md— Redis cache-aside как альтернатива оптимизации запроса.migration-fails.md— если самCREATE INDEX CONCURRENTLYне применяется.