Привет, Хабр!

Запрос работает 30 секунд. Вы смотрите на него, всё вроде ок: JOIN по индексированным полям, WHERE по дате, LIMIT 100. Должен летать, но что-то не летает. Добавляете индекс наугад — не помогает. Переписываете подзапрос в CTE и стало ещё хуже.

Проблема не в запросе, а в в том, что вы не смотрели в план выполнения. EXPLAIN ANALYZE показывает не что вы написали, а что PostgreSQL делает: какие индексы использует (и использует ли вообще), в каком порядке соединяет таблицы, где тратит время, сколько строк ожидал и сколько получил.

EXPLAIN и EXPLAIN ANALYZE

-- EXPLAIN: показывает план БЕЗ выполнения запроса
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;

-- EXPLAIN ANALYZE: выполняет запрос и показывает реальные цифры
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

EXPLAIN полезен, когда нельзя выполнять запрос (тяжёлый INSERT, DELETE). EXPLAIN ANALYZE уже показывает реальное время, реальное количество строк, реальные I/O-операции. Почти всегда вам нужен ANALYZE.

Максимум информации:

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE customer_id = 42;

BUFFERS покажет, сколько страниц прочитано с диска и из кеша.

Строение плана

EXPLAIN ANALYZE
SELECT o.id, o.amount, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= '2025-01-01'
  AND o.status = 'paid'
ORDER BY o.amount DESC
LIMIT 10;
Limit  (cost=1234.56..1234.58 rows=10 width=52) (actual time=12.345..12.367 rows=10 loops=1)
  ->  Sort  (cost=1234.56..1256.78 rows=8901 width=52) (actual time=12.344..12.355 rows=10 loops=1)
        Sort Key: o.amount DESC
        Sort Method: top-N heapsort  Memory: 26kB
        ->  Hash Join  (cost=234.56..1100.00 rows=8901 width=52) (actual time=1.234..10.567 rows=9200 loops=1)
              Hash Cond: (o.customer_id = c.id)
              ->  Index Scan using idx_orders_created on orders o  (cost=0.43..800.00 rows=8901 width=36) (actual time=0.023..7.891 rows=9200 loops=1)
                    Index Cond: (created_at >= '2025-01-01')
                    Filter: (status = 'paid')
                    Rows Removed by Filter: 3800
              ->  Hash  (cost=150.00..150.00 rows=5000 width=20) (actual time=1.100..1.100 rows=5000 loops=1)
                    Buckets: 8192  Batches: 1  Memory Usage: 280kB
                    ->  Seq Scan on customers c  (cost=0.00..150.00 rows=5000 width=20) (actual time=0.010..0.600 rows=5000 loops=1)
Planning Time: 0.234 ms
Execution Time: 12.456 ms

Читаем снизу вверх и изнутри наружу:

Seq Scan on customers — полное сканирование таблицы customers. 5000 строк. Время 0.6 мс. Таблица маленькая, seq scan нормален.

Index Scan on orders — сканирование по индексу idx_orders_created. Index Cond: фильтр по дате выполняется через индекс. Filter: status = 'paid' — дополнительный фильтр уже после индекса. Rows Removed by Filter: 3800 — индекс вернул 13000 строк (9200 + 3800), а фильтр по статусу убрал 3800. Если таких лишних строк много, стоит подумать о составном индексе (created_at, status).

Hash Join — соединение через хеш-таблицу. Customers загружены в хеш (280kB в памяти), orders проходят через неё. Быстро для этих объёмов.

Sort — сортировка по amount. top-N heapsort — PostgreSQL умён: раз нужны только 10 строк, он не сортирует все 9200, а поддерживает кучу из 10 элементов. 26kB памяти.

Limit — берём первые 10.

На что смотреть

rows (estimated vs actual). rows=8901 в cost — прогноз планировщика. rows=9200 в actual — реальность. Близко — планировщик хорошо знает данные. Если прогноз 100, а факт 100000 — кривая статистика, нужен ANALYZE таблицы.

Rows Removed by Filter. Если из индекса пришло 100000 строк, а фильтр убрал 99000 — индекс неэффективен. Нужен составной индекс, который покрывает все условия WHERE.

actual time. Два числа: start..total. Первое — время до первой строки. Второе — время до последней. Если первое большое — долго стартуем (например, сортировка — нужно всё прочитать перед выдачей первой строки). Если разница между ними большая — долго итерируем.

loops. Сколько раз узел выполнялся. Для Nested Loop Join внутренний узел выполняется loops раз. actual time и rowsна одну итерацию. Общее время = time × loops.

->  Index Scan on orders  (actual time=0.010..0.015 rows=3 loops=5000)

0.015 мс × 5000 = 75 мс суммарно. Не 0.015 мс.

BUFFERS: диск или кеш?

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE id = 42;
Index Scan using orders_pkey on orders (actual time=0.023..0.025 rows=1 loops=1)
  Buffers: shared hit=4

shared hit=4 — четыре страницы прочитаны из shared_buffers (кеш PostgreSQL).

Seq Scan on orders (actual time=0.100..450.000 rows=1000000 loops=1)
  Buffers: shared hit=50000 read=15000

shared read=15000 — 15000 страниц пришлось читать с диска (или из кеша ОС). Если read велик — данные не помещаются в shared_buffers, запрос I/O-bound.

shared dirtied и shared written — страницы, которые пришлось записать (при UPDATE/DELETE или при hint bit updates).

Популярные проблемы и что делать

Seq Scan на большой таблице

Seq Scan on orders (actual time=0.100..1200.000 rows=50 width=100)
  Filter: (customer_id = 42)
  Rows Removed by Filter: 999950

Миллион строк просканировано, 50 подошли. Нет индекса на customer_id.

Решение:

CREATE INDEX idx_orders_customer ON orders (customer_id);

Но не всегда. Если customer_id = 42 возвращает 80% таблицы — seq scan быстрее индекса (меньше random I/O). Планировщик знает это и выбирает seq scan сознательно.

Оценка строк далека от реальности

Hash Join (rows=100) (actual rows=500000)

Планировщик думал 100 строк, получил полмиллиона. Выбрал Hash Join, хотя для 500K лучше Merge Join или другая стратегия. Причина в устаревшей статистике.

ANALYZE orders;  -- Обновить статистику таблицы

Или для сложных случаев можно увеличить статистику по столбцу:

ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;

Nested Loop на больших таблицах

Nested Loop (actual time=0.050..45000.000 rows=100000 loops=1)
  ->  Seq Scan on customers (rows=5000)
  ->  Index Scan on orders (rows=20 loops=5000)

5000 × 20 обращений к индексу = 100000 index lookups. Если таблица orders не в кеше — каждый lookup = random read с диска. Для больших объёмов Hash Join или Merge Join будет поэффективнее.

Почему планировщик выбрал Nested Loop? Возможно, неверная оценка строк в customers. ANALYZE и проверяйте.

Sort на диске

Sort (actual time=500.000..600.000 rows=1000000)
  Sort Method: external merge  Disk: 150000kB

external merge Disk: 150MB — данные не влезли в work_mem, сортировка ушла на диск.

Решения:

SET work_mem = '256MB';  -- Увеличить для сессии

Или добавить индекс, который даёт данные уже в нужном порядке:

CREATE INDEX idx_orders_amount_desc ON orders (amount DESC);

CTE и подзапросы

В PostgreSQL 12+ CTE по умолчанию инлайнятся (оптимизатор может «развернуть» CTE в подзапрос). Но если CTE используется несколько раз, PostgreSQL материализует его. Это может быть как быстрее (один раз вычислили), так и медленнее (нельзя протолкнуть фильтр внутрь).

-- Принудительно материализовать
WITH cte AS MATERIALIZED (
    SELECT * FROM orders WHERE status = 'paid'
)
SELECT * FROM cte WHERE amount > 1000;
-- Фильтр amount > 1000 НЕ попадёт внутрь CTE

-- Принудительно инлайнить
WITH cte AS NOT MATERIALIZED (
    SELECT * FROM orders WHERE status = 'paid'
)
SELECT * FROM cte WHERE amount > 1000;
-- PostgreSQL объединит оба фильтра

Смотрите в EXPLAIN если CTE материализован, вы увидите CTE Scan, а фильтры будут снаружи.

explain.dalibo.com: визуализация

Текстовый план тяжело читать для сложных запросов. Скопируйте вывод EXPLAIN (FORMAT JSON) в explain.dalibo.com или explain.depesz.com, получите визуальное дерево с подсветкой узких мест.

EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT ...;

Привычка

Перед каждым CREATE INDEX — смотрите EXPLAIN ANALYZE. Индекс может не помочь (или даже навредить, если update-heavy таблица). После создания индекса — снова EXPLAIN ANALYZE. Убедитесь, что планировщик его использует.

Перед деплоем запроса на прод — EXPLAIN ANALYZE на копии прода с реальными данными. На таблице с 1000 строк всё быстро. На таблице с 100 миллионов уже будет совсем другой план и совсем другое время.

Если чтения плана уже недостаточно и нужно понимать, где именно упирается PostgreSQL — в статистику, ввод-вывод, индексы или саму архитектуру кластера, — дальше нужен уже системный уровень. Курс «Администрирование PostgreSQL. Экспертный уровень» как раз про это: производительность, профилирование, мониторинг, резервное копирование и работа с отказоустойчивыми кластерами без гадания на симптомах.

Чтобы узнать больше о формате обучения и познакомиться с преподавателем, приходите на бесплатный урок «PostgreSQL как векторная база данных: ИИ‑поиск без лишних сервисов» 9 апреля в 20:00. Записаться на урок

Немного практики в тему — пройдите вступительный тест по PostgreSQL и узнаете, есть ли пробелы в знаниях. До 30 апреля за прохождение теста действует скидка 15%