sqlpostgresqlexplainperformance

Reading EXPLAIN (ANALYZE, BUFFERS) in PostgreSQL: Actual vs Estimated

How to read EXPLAIN (ANALYZE, BUFFERS): actual vs estimated rows and time, loops, the estimate-to-actual gap that signals stale stats or a missing index.

3 min läsningReferencesql · postgresql · explain · performance · index
Den här artikeln finns för närvarande på ryska — en engelsk översättning är på gång.

EXPLAIN без ANALYZE показывает только план и оценки планировщика. EXPLAIN (ANALYZE, BUFFERS) реально выполняет запрос и докладывает фактические строки, время и обращения к страницам. Именно расхождение оценки с фактом обычно и указывает на проблему.

Для инженера это не академический разбор дерева, а быстрый способ перестать спорить на глаз. План показывает, где запрос читает миллионы строк ради девяти, где вложенный цикл незаметно повторился тысячи раз и где кэш скрывает настоящую цену ввода-вывода.

Реальные и оценочные значения

Каждый узел плана показывает две пары чисел. Оценочные — cost и rows (что планировщик предполагал до запуска). Фактические — actual time и rows (что вышло на самом деле).

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 5;

Типичная строка узла выглядит так:

Seq Scan on orders  (cost=0.00..1934.00 rows=12 width=40)
                    (actual time=0.40..18.7 rows=9 loops=1)
  • cost=0.00..1934.00 — оценка в условных единицах: запуск и полный проход.
  • rows=12 — ожидалось 12 строк, actual ... rows=9 — реально вернулось 9.
  • actual time=0.40..18.7 — миллисекунды до первой и до последней строки.

Читайте план снизу вверх и изнутри наружу: листовые узлы выполняются первыми.

Loops: умножайте на число проходов

В nested loop внутренний узел запускается много раз, и actual time/rows в нём указаны на один проход. Умножайте на loops, чтобы получить итог.

EXPLAIN (ANALYZE, BUFFERS)
SELECT u.email, o.amount
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.country = 'DE';

Если внутренний Index Scan показывает actual time=0.02 rows=3 loops=5000, то это не 0.02 мс, а около 100 мс суммарно и 15000 строк. Грабли: маленький per-loop тайминг при больших loops — самый частый способ недооценить дорогой узел.

Разрыв оценки и факта

Большой разрыв между rows оценочным и фактическим — главный сигнал. Планировщик выбирает стратегию по оценке; если она врёт, он берёт неоптимальный план.

ANALYZE orders;  -- rebuild statistics

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE status = 'paid' AND created_at >= '2026-01-01';
  • Оценка занижена в десятки раз — обычно устаревшая статистика: запустите ANALYZE.
  • Разрыв не уходит после ANALYZE — вероятно, коррелированные условия; помогут расширенная статистика (CREATE STATISTICS) или индекс.
  • Завышенная оценка заставляет планировщик зря брать Seq Scan вместо Index Scan.

BUFFERS: shared hit и read

BUFFERS показывает работу с памятью на каждом узле. shared hit — страницы, найденные в кэше; shared read — прочитанные с диска (медленно).

Seq Scan on orders  (actual time=0.4..210 rows=2000000 loops=1)
  Buffers: shared hit=128 read=16234
  • shared hit — попадание в shared_buffers, дёшево.
  • shared read — промах кэша, физическое чтение; много read = узел тяжёлый по вводу-выводу.
  • Buffers помогает понять, был ли запрос медленным из-за диска или из-за плохого плана.

Seq Scan, который должен быть Index Scan

Главный практический сценарий: запрос фильтрует по селективному столбцу, но видите Seq Scan с огромным read и фактическим временем.

-- before: Seq Scan, reads the whole table for 9 rows
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 5;

CREATE INDEX idx_orders_user ON orders (user_id);

-- after: Index Scan, pinpoint read
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 5;

После индекса Seq Scan сменяется на Index Scan, а Buffers: shared read падает с тысяч до единиц.

  • MySQL: EXPLAIN ANALYZE (8.0+) даёт actual time и loops; буферов в стиле PostgreSQL нет, смотрите Handler_read* и профайлер.
  • ClickHouse: вместо плана с фактами используют EXPLAIN для структуры и системные логи (query_log) для реальных метрик чтения.

Грабли: ANALYZE действительно запускает запрос. Для обычного SELECT это безопасно, но для модифицирующих команд используйте транзакцию с откатом или анализируйте эквивалентный читающий запрос. Сначала найдите узел с максимальным временем, чтениями или ошибкой оценки, затем уже добавляйте индекс или меняйте SQL.

Итог: читайте план как протокол выполнения, а не как набор названий узлов. Большие расхождения строк, крупные shared read и высокий множитель loops почти всегда быстрее приведут к причине, чем перебор индексов наугад.

Öva på riktiga uppgifter

Lös uppgifter i SQL-tränaren med omedelbar rättning och ledtrådar.

Öppna tränaren