Медленные запросы в PostgreSQL: путь от симптома до плана выполнения
В пятницу вечером дежурному прилетает тикет: страница заказов грузится четыре секунды. Через час таких тикетов три. Это не происшествие, а классический маршрут от симптома в мониторинге до виноватого оператора в плане выполнения — и полезно заранее знать все его станции.

Содержание
Симптом: страница заказов грузится четыре секунды #
-- Отчёт по последним заказам клиента: в продакшене занимает 4,2 секунды
SELECT o.id,
o.created_at,
o.total_amount,
count(oi.id) AS items
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN customers c ON c.id = o.customer_id
WHERE c.email = 'user@example.com'
AND o.created_at >= now() - interval '90 days'
AND o.status <> 'draft'
ORDER BY o.created_at DESC
LIMIT 20;Диагностика: план вместо догадок #
EXPLAIN (ANALYZE, BUFFERS) . ANALYZE действительно исполняет запрос и показывает фактическое время и фактическое число строк на каждом операторе, BUFFERS добавляет главное: сколько страниц база взяла из кэша, а сколько прочла с диска. Смотрите три вещи: где оператор с наибольшим фактическим временем, где оценки планировщика разошлись с реальностью в разы и где buffers read существенно больше buffers hit.
Limit (actual time=4187.431..4187.437 rows=20 loops=1)
Buffers: shared hit=121842 read=8421
-> Sort (actual time=4187.428..4187.431 rows=20 loops=1)
Sort Key: o.created_at DESC
Sort Method: top-N heapsort Memory: 29kB
-> Hash Join (actual time=14.322..3944.180 rows=1284312 loops=1)
Hash Cond: (oi.order_id = o.id)
Buffers: shared hit=121842 read=8421
-> Seq Scan on order_items oi
(actual time=0.021..1912.445 rows=8941450 loops=1)
Buffers: shared hit=104980 read=7320
-> Hash (actual time=13.855..13.856 rows=2412 loops=1)
-> Nested Loop (actual time=0.081..12.902 rows=2412 loops=1)
-> Index Scan on customers c
(actual time=0.031..0.540 rows=1 loops=1)
Index Cond: (email = 'user@example.com')
-> Index Scan on orders o
(actual time=0.041..12.201 rows=2412 loops=1)Лечение: индекс под реальный запрос #
-- Индекс под конкретный фильтр и сортировку
CREATE INDEX CONCURRENTLY orders_customer_recent_idx
ON orders (customer_id, created_at DESC)
INCLUDE (total_amount, status)
WHERE status <> 'draft';
-- После массовых загрузок не забываем освежить статистику
ANALYZE orders;(customer_id, created_at DESC) совпадает и с фильтром, и с сортировкой — сортировка исчезает из плана, LIMIT отдаёт первые двадцать записей и останавливается. Частичное условие WHERE status <> 'draft' выкидывает черновики из индекса ещё на записи: индекс меньше, покрывает ровно то, что нужно запросу. INCLUDE добавляет суммы и статусы прямо в индекс — дополнительные столбцы не участвуют в поиске, но позволяют закрыть выборку без обращения к таблице.
CONCURRENTLY не блокирует запись при построении — на живой таблице это единственный допустимый способ.
| Оператор плана | Типичная причина | Лечение |
|---|---|---|
| Seq Scan по большой таблице | Нет индекса под фильтр или планировщик счёл его дороже | Проверить актуальность статистики (ANALYZE), добавить индекс под реальные предикаты |
| Sort по миллионам строк | ORDER BY без подходящего индекса | Составной индекс с нужным порядком столбцов и направлением сортировки |
| Nested Loop с миллионами итераций | Оценка строк занижена: планировщик ждёт сотни, а получает миллионы | Освежить статистику, при необходимости повысить её детализацию |
| Hash: batches с выгрузкой на диск | work_mem слишком мала для хеш-таблицы | Поднять work_mem для сессии или уменьшить объём данных до соединения |


