Когда SQL-запрос внезапно начинает тормозить, первое желание — угадать причину: «наверное, нужен индекс», «может быть, таблица слишком большая», «а вдруг JOIN плохой». Но в базе данных гадание почти всегда проигрывает наблюдению.
PostgreSQL умеет сам показать, как именно он собирается выполнять запрос. Для этого есть команда EXPLAIN.
Она показывает план выполнения: какие таблицы будут читаться, какие индексы использоваться, как будут соединяться данные и сколько строк PostgreSQL ожидает получить на каждом шаге.
А если добавить ANALYZE и BUFFERS, база не просто построит план, а реально выполнит запрос и покажет фактическое время, количество строк и чтение данных из памяти или с диска.
В этой статье разберём:
- чем отличается
EXPLAIN от EXPLAIN ANALYZE;
- что значат
cost, rows, actual time, loops;
- чем
Seq Scan отличается от Index Scan;
- почему планировщик иногда ошибается;
- как по плану понять, что запросу не хватает индекса.
Будем представлять обычную схему интернет-магазина: есть пользователи, заказы и сотрудники. Например, таблицы users, orders и employees.
Зачем вообще нужен EXPLAIN
SQL выглядит как просьба:
SELECT *
FROM orders
WHERE user_id = 42;
Мы говорим базе: «Дай заказы пользователя с идентификатором 42».
Но внутри PostgreSQL должен решить, как именно это сделать.
Например, у него могут быть варианты:
- прочитать всю таблицу
orders от начала до конца;
- найти строки через индекс по
user_id;
- сначала использовать один индекс, потом другой;
- соединить таблицы через
Nested Loop, Hash Join или другим способом.
Именно это решение принимает планировщик PostgreSQL. А EXPLAIN позволяет заглянуть ему через плечо.
EXPLAIN и EXPLAIN ANALYZE: в чём разница
EXPLAIN показывает план, но не выполняет сам запрос.
EXPLAIN
SELECT *
FROM orders
WHERE user_id = 42;
Это безопасный способ посмотреть, что PostgreSQL собирается делать. Он особенно удобен, когда вы хотите быстро проверить идею и не запускать тяжёлый запрос по-настоящему.
А вот EXPLAIN ANALYZE уже выполняет запрос и показывает реальные данные выполнения:
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 42;
Если добавить BUFFERS, PostgreSQL покажет ещё и информацию о чтении страниц данных:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE user_id = 42;
Именно этот вариант чаще всего используют при разборе медленных запросов, потому что он отвечает не только на вопрос «что база планировала сделать», но и на вопрос «что на самом деле произошло».
Осторожно с изменяющими запросами
Важно помнить: EXPLAIN ANALYZE реально выполняет запрос.
С SELECT это обычно нормально. Но если вы запускаете его для DELETE, UPDATE или INSERT, данные действительно изменятся.
Например, такой запрос реально удалит строки:
EXPLAIN ANALYZE
DELETE FROM orders
WHERE status = 'cancelled';
Чтобы безопасно проверить изменяющий запрос, можно обернуть его в транзакцию и в конце сделать ROLLBACK:
BEGIN;
EXPLAIN (ANALYZE, BUFFERS)
DELETE FROM orders
WHERE status = 'cancelled';
ROLLBACK;
Так PostgreSQL выполнит запрос, покажет план и реальные числа, но после ROLLBACK изменения будут отменены.
Как выглядит простой план
Допустим, мы выполняем запрос:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE user_id = 42;
План может выглядеть примерно так:
Seq Scan on orders (cost=0.00..1834.00 rows=120 width=64)
Filter: (user_id = 42)
Rows Removed by Filter: 99880
Buffers: shared read=820
(actual time=0.015..12.400 rows=118 loops=1)
На первый взгляд это похоже на набор странных чисел. Но если читать план спокойно, там всё довольно логично.
Разберём главные части.
cost: стоимость, но не миллисекунды
Фрагмент:
cost=0.00..1834.00
означает оценочную стоимость выполнения узла плана.
Первое число — стоимость до получения первой строки.
Второе число — стоимость до получения всех строк.
Важно: cost — это не время в миллисекундах. Это условные единицы PostgreSQL. Планировщик использует их, чтобы сравнивать разные варианты плана между собой.
Например, он может сравнить:
- прочитать таблицу целиком;
- сходить в индекс;
- построить хеш-таблицу для соединения;
- выполнить вложенные циклы.
И выбрать вариант, который кажется ему дешевле по оценкам.
rows: сколько строк ожидал PostgreSQL
Фрагмент:
rows=120
означает, что планировщик ожидал получить примерно 120 строк.
Это не факт, а прогноз.
PostgreSQL делает такой прогноз на основе статистики: сколько строк в таблице, какие значения часто встречаются, насколько данные распределены равномерно и так далее.
actual rows: сколько строк получилось на самом деле
Если используется EXPLAIN ANALYZE, в плане появляется фактическая информация:
actual time=0.015..12.400 rows=118 loops=1
Здесь rows=118 означает, что на самом деле узел вернул 118 строк.
И вот тут начинается самое интересное.
Если PostgreSQL ожидал 120 строк, а получил 118 — всё хорошо. Планировщик почти угадал.
Но если он ожидал 5 строк, а получил 48000 — это уже проблема. Он мог выбрать плохой план просто потому, что неправильно представлял объём данных.
actual time: реальное время выполнения
Фрагмент:
actual time=0.015..12.400
показывает реальное время выполнения узла.
Первое число — время до первой строки.
Второе число — время до завершения работы узла.
Например, если запрос быстро отдаёт первую строку, но долго добирает остальные, это будет видно по этим числам.
loops: сколько раз выполнялся узел
Фрагмент:
loops=1
показывает, сколько раз этот узел плана был выполнен.
Для простого запроса часто будет loops=1.
Но в планах с соединениями, особенно с Nested Loop, внутренний узел может выполняться много раз.
Например:
Index Scan on orders (actual time=0.010..0.050 rows=3 loops=10000)
Это значит: сам по себе один проход быстрый, но он повторился 10000 раз. В итоге такой «быстрый маленький шаг» может стать дорогим.
Seq Scan: чтение всей таблицы
Seq Scan означает последовательное сканирование таблицы.
PostgreSQL читает таблицу строка за строкой и проверяет условие.
Например:
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE status = 'paid';
План может быть таким:
Seq Scan on orders
Filter: (status = 'paid')
Многие новички видят Seq Scan и сразу думают: «Плохо, нужен индекс».
Но это не всегда так.
Если условие возвращает большую часть таблицы, последовательное чтение может быть самым разумным вариантом.
Представьте бумажную картотеку. Если вам нужно найти одну карточку по номеру, полезен алфавитный указатель. Но если вам нужно просмотреть половину всех карточек, проще идти подряд, чем постоянно прыгать туда-сюда.
Так же и с таблицей.
Если status = 'paid' встречается у половины заказов, индекс может не помочь. Базе всё равно придётся читать очень много строк из таблицы.
Index Scan: точечный поиск через индекс
Index Scan означает, что PostgreSQL использует индекс, чтобы найти подходящие строки.
Например:
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 42;
Если есть индекс по user_id, план может быть таким:
Index Scan using idx_orders_user_id on orders
Index Cond: (user_id = 42)
Это хороший знак, если условие селективное.
Селективное условие — это условие, которое отбирает небольшую часть таблицы.
Например:
- один пользователь из миллиона;
- один email;
- один номер заказа;
- заказы за конкретную минуту в большой таблице.
Для таких случаев индекс обычно отлично подходит.
Bitmap Heap Scan: промежуточный вариант
Кроме Seq Scan и Index Scan, в PostgreSQL часто встречается Bitmap Heap Scan.
Примерно он означает следующее:
- PostgreSQL использует индекс и находит много подходящих строк.
- Складывает ссылки на них в специальную битовую карту.
- Потом читает нужные страницы таблицы более упорядоченно.
Такой план часто выбирается, когда строк не одна-две, но и не половина таблицы. Например, нужно достать сотни или тысячи строк из большой таблицы.
План может выглядеть так:
Bitmap Heap Scan on orders
Recheck Cond: (user_id = 42)
-> Bitmap Index Scan on idx_orders_user_id
Index Cond: (user_id = 42)
Для новичка главное запомнить так:
Seq Scan — читаем таблицу целиком;
Index Scan — точечно ходим по индексу;
Bitmap Heap Scan — сначала собираем много совпадений через индекс, потом читаем таблицу пачками.
Почему PostgreSQL не всегда использует индекс
Наличие индекса ещё не означает, что PostgreSQL обязан его использовать.
Например, есть индекс по status, но запрос такой:
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE status = 'paid';
Если почти все заказы имеют статус paid, индекс бесполезен. Он приведёт к огромному количеству переходов из индекса в таблицу. В такой ситуации Seq Scan может быть быстрее.
Индекс особенно полезен, когда он резко сужает поиск.
Например:
EXPLAIN ANALYZE
SELECT *
FROM employees
WHERE email = 'anna@example.com';
Если email уникален или почти уникален, индекс по email будет очень полезен.
Как понять, что не хватает индекса
Недостающий индекс часто выглядит в плане очень узнаваемо.
Допустим, есть запрос:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM employees
WHERE email = 'anna@example.com';
А план показывает:
Seq Scan on employees (cost=0.00..21000.00 rows=1 width=80)
Filter: (email = 'anna@example.com')
Rows Removed by Filter: 499999
Buffers: shared read=8200
(actual time=0.120..95.000 rows=1 loops=1)
Здесь сразу несколько тревожных сигналов.
PostgreSQL искал одну строку, но прочитал всю таблицу.
Строка:
Rows Removed by Filter: 499999
говорит: база проверила почти 500 тысяч строк и выбросила их, потому что они не подошли под условие.
А строка:
Buffers: shared read=8200
говорит, что пришлось прочитать много страниц данных.
Если таблица большая, условие точечное, а PostgreSQL делает Seq Scan, это сильный намёк: возможно, нужен индекс.
В таком случае можно создать индекс:
CREATE INDEX idx_employees_email
ON employees (email);
После этого план может стать таким:
Index Scan using idx_employees_email on employees
Index Cond: (email = 'anna@example.com')
Теперь PostgreSQL не перебирает всю таблицу, а быстро находит нужную строку через индекс.
Filter и Index Cond: важное различие
В плане можно увидеть две похожие вещи:
Index Cond: (user_id = 42)
и
Filter: (status = 'paid')
Разница большая.
Index Cond означает, что условие используется прямо при поиске по индексу. Это хорошо: индекс помогает сузить набор строк.
Filter означает, что строки уже прочитаны, а потом PostgreSQL дополнительно проверяет условие и выбрасывает неподходящие.
Например:
Index Scan using idx_orders_user_id on orders
Index Cond: (user_id = 42)
Filter: (status = 'paid')
Такой план означает: PostgreSQL нашёл заказы пользователя через индекс по user_id, а потом среди них оставил только оплаченные.
Это может быть нормально. Но если после Index Cond остаётся слишком много строк, а Filter выбрасывает большую часть, стоит подумать о составном индексе.
Например:
CREATE INDEX idx_orders_user_status
ON orders (user_id, status);
Составные индексы: когда одного столбца мало
Допустим, в приложении часто выполняется запрос:
SELECT *
FROM orders
WHERE user_id = 42
AND created_at >= '2026-01-01'
ORDER BY created_at;
Для него может быть полезен составной индекс:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at);
Такой индекс помогает PostgreSQL быстро найти заказы конкретного пользователя и сразу идти по ним в порядке даты.
Но порядок столбцов в составном индексе важен.
Индекс:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at);
хорошо подходит для условий по user_id, а затем по created_at.
А вот если запросы чаще ищут просто по дате без пользователя, такой индекс может быть не лучшим выбором.
Покрывающий индекс: когда можно не ходить в таблицу
Иногда запросу нужны не все столбцы, а только несколько.
Например:
SELECT status
FROM orders
WHERE user_id = 42
AND created_at >= '2026-01-01';
Можно создать индекс с дополнительным столбцом через INCLUDE:
CREATE INDEX idx_orders_user_created_include_status
ON orders (user_id, created_at) INCLUDE (status);
Здесь user_id и created_at участвуют в поиске, а status хранится в индексе как дополнительное значение.
В удачных условиях PostgreSQL сможет взять всё нужное из индекса и меньше обращаться к самой таблице.
Оценки против реальности
Один из самых важных навыков при чтении плана — сравнивать оценку и факт.
Смотрите на пару:
rows=5
actual rows=48000
Это означает: PostgreSQL ожидал 5 строк, а получил 48000.
Такое расхождение опасно. Планировщик мог выбрать план, который хорош для 5 строк, но ужасен для 48000.
Например:
EXPLAIN ANALYZE
SELECT *
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE u.country = 'DE'
AND o.created_at > '2026-01-01';
В плане можно увидеть:
Nested Loop (cost=0.00..100.00 rows=5 width=120)
(actual time=0.200..850.000 rows=48000 loops=1)
Это красный флаг.
Nested Loop часто хорош, когда внешних строк мало. Например, нашли 5 пользователей и для каждого быстро сходили в заказы.
Но если строк оказалось 48000, вложенный цикл может стать дорогим: маленькая операция повторяется слишком много раз.
Для большого объёма данных PostgreSQL мог бы выбрать, например, Hash Join, но не выбрал, потому что ожидал совсем другое количество строк.
Почему планировщик ошибается
PostgreSQL не ясновидящий. Он строит план на основе статистики.
Если статистика устарела или не отражает реальные связи в данных, оценки могут сильно расходиться с фактом.
Частые причины:
-
Устаревшая статистика
Таблица сильно изменилась: добавили много строк, удалили старые, поменяли значения. А статистика ещё старая.
Помогает команда:
ANALYZE orders;
-
Связанные между собой столбцы
Например, country и city.
Если в таблице есть country = 'DE', то город Berlin становится гораздо вероятнее. Но обычная статистика по отдельным столбцам может не понимать эту связь.
Для таких случаев в PostgreSQL есть расширенная статистика:
CREATE STATISTICS stats_users_country_city
ON country, city
FROM users;
ANALYZE users;
-
Функции и выражения в условиях
Например:
SELECT *
FROM employees
WHERE lower(email) = 'anna@example.com';
Если есть обычный индекс по email, он не обязательно поможет для выражения lower(email).
В таком случае можно создать функциональный индекс:
CREATE INDEX idx_employees_lower_email
ON employees (lower(email));
Почему функция в WHERE может сломать использование индекса
Представьте, что есть индекс:
CREATE INDEX idx_employees_email
ON employees (email);
И есть запрос:
SELECT *
FROM employees
WHERE lower(email) = 'anna@example.com';
Проблема в том, что индекс построен по исходному значению email, а запрос ищет по результату функции lower(email).
Для PostgreSQL это уже другое выражение.
Поэтому возможны два варианта.
Первый — хранить email в нормализованном виде, например всегда в нижнем регистре, и писать простой фильтр:
SELECT *
FROM employees
WHERE email = 'anna@example.com';
Второй — создать индекс именно по выражению:
CREATE INDEX idx_employees_lower_email
ON employees (lower(email));
Тогда условие с lower(email) сможет использовать этот индекс.
BUFFERS: откуда читались данные
Опция BUFFERS показывает, как PostgreSQL работал со страницами данных.
Пример:
Buffers: shared hit=120 read=8
Упрощённо:
shared hit — страницы уже были в памяти;
shared read — страницы пришлось читать с диска.
Если shared read большой, запрос мог быть медленным из-за активного чтения с диска.
Например:
Buffers: shared read=8200
Это уже серьёзный сигнал: PostgreSQL прочитал много страниц не из кэша, а с диска.
При анализе медленного запроса полезно смотреть не только на время, но и на BUFFERS. Иногда запрос медленный не потому, что «плохой SQL», а потому что он читает огромный объём данных.
Как читать план: снизу вверх
План выполнения — это дерево.
Верхняя строка показывает итоговый узел, но данные обычно рождаются в нижних узлах.
Поэтому план удобно читать снизу вверх:
- Сначала найти, как PostgreSQL читает исходные таблицы.
- Посмотреть, где появляются большие расхождения между
rows и actual rows.
- Найти узлы, которые выполняются много раз через
loops.
- Проверить, где много строк отбрасывается через
Rows Removed by Filter.
- Посмотреть, где много чтений через
BUFFERS.
Если ошибка в оценке появилась в самом нижнем узле, дальше она может раздуть весь план. PostgreSQL неверно оценил одну таблицу, потом выбрал неудачный JOIN, потом весь запрос стал медленным.
Пример: медленный поиск сотрудника по email
Допустим, пользователь в админке ищет сотрудника по email. Запрос простой:
SELECT *
FROM employees
WHERE email = 'anna@example.com';
Но он работает медленно.
Запускаем:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM employees
WHERE email = 'anna@example.com';
Видим:
Seq Scan on employees
Filter: (email = 'anna@example.com')
Rows Removed by Filter: 499999
Buffers: shared read=8200
(actual time=0.120..95.000 rows=1 loops=1)
Что говорит план?
PostgreSQL прочитал таблицу целиком. Он нашёл одну строку, но перед этим проверил почти 500 тысяч лишних строк.
Для поиска по email это плохой знак. Email обычно селективен: по нему ожидается одна строка или очень маленькое количество строк.
Создаём индекс:
CREATE INDEX idx_employees_email
ON employees (email);
Обновляем статистику:
ANALYZE employees;
Проверяем снова:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM employees
WHERE email = 'anna@example.com';
Теперь ожидаем увидеть что-то вроде:
Index Scan using idx_employees_email on employees
Index Cond: (email = 'anna@example.com')
Buffers: shared hit=3 read=1
(actual time=0.020..0.025 rows=1 loops=1)
Вот это уже похоже на здоровый план: PostgreSQL быстро нашёл строку через индекс и прочитал совсем немного страниц.
Пример: почему Seq Scan может быть нормальным
Теперь другой запрос:
SELECT *
FROM orders
WHERE status = 'paid';
Допустим, 80% заказов имеют статус paid.
Даже если создать индекс по status, PostgreSQL может всё равно выбрать Seq Scan.
И это нормально.
Потому что индекс полезен, когда нужно найти маленькую часть таблицы. А если нужно вернуть почти всю таблицу, проще прочитать её последовательно.
Плохая оптимизация начинается там, где разработчик видит Seq Scan и автоматически считает это ошибкой.
Правильный вопрос другой: сколько строк отбирает условие и сколько данных приходится читать?
Не добавляйте индексы наугад
Индекс ускоряет одни запросы, но не даётся бесплатно.
У индекса есть цена:
- он занимает место на диске;
- замедляет
INSERT, UPDATE, DELETE;
- требует обслуживания;
- может вообще не использоваться.
Поэтому хороший порядок действий такой:
- Найти медленный запрос.
- Запустить
EXPLAIN (ANALYZE, BUFFERS).
- Посмотреть фактический план.
- Найти узкое место.
- Только потом создавать индекс или переписывать запрос.
Индекс — это не украшение таблицы, а инструмент под конкретный сценарий чтения.
Полезная привычка: обновлять статистику
Если план выглядит странно, а оценки сильно расходятся с реальностью, начните с простого:
ANALYZE orders;
Для нескольких таблиц:
ANALYZE users;
ANALYZE orders;
ANALYZE employees;
После этого снова запустите EXPLAIN.
Иногда запрос «лечится» не новым индексом, а свежей статистикой. PostgreSQL просто начинает лучше понимать данные и выбирает другой план.
Чем PostgreSQL отличается от MySQL и ClickHouse
В PostgreSQL основной инструмент для разбора плана — EXPLAIN, а для фактического выполнения — EXPLAIN ANALYZE.
В MySQL тоже есть EXPLAIN, а в новых версиях есть EXPLAIN ANALYZE, но формат вывода другой. Там иначе показываются узлы и фактическое время.
В ClickHouse подход тоже отличается: используются команды вроде EXPLAIN PLAN и EXPLAIN PIPELINE. И важно понимать, что привычного индекса, как в PostgreSQL, там нет. ClickHouse работает иначе: у него есть разреженный primary key, сортировка данных и механизм пропуска гранул.
Поэтому планы разных баз нельзя читать одинаково. Названия похожи, но устройство выполнения запросов отличается.
Короткий чек-лист чтения EXPLAIN
Когда открываете план, смотрите на такие вещи:
-
Как читается таблица
Seq Scan, Index Scan, Bitmap Heap Scan.
-
Совпали ли оценки с реальностью
Сравнивайте rows и actual rows.
-
Сколько раз выполнялся узел
Проверяйте loops.
-
Сколько строк было отброшено фильтром
Ищите Rows Removed by Filter.
-
Есть ли чтение с диска
Смотрите на Buffers, особенно на shared read.
-
Где именно теряется время
Не смотрите только на верхнюю строку. Читайте план снизу вверх.
Главное
EXPLAIN показывает, какой план выполнения выбрал PostgreSQL.
EXPLAIN ANALYZE выполняет запрос и показывает реальные числа: время, строки, количество повторов.
EXPLAIN (ANALYZE, BUFFERS) — один из лучших инструментов для разбора медленных запросов, потому что он показывает не только план, но и фактическую работу с данными.
Seq Scan не всегда плох. Если запрос возвращает большую часть таблицы, последовательное чтение может быть самым дешёвым вариантом.
Index Scan полезен, когда условие селективное и возвращает маленькую часть таблицы.
Большое расхождение между rows и actual rows — сигнал, что планировщик ошибся в оценке. Часто помогает ANALYZE, иногда — расширенная статистика или другой индекс.
Недостающий индекс часто выглядит так: большая таблица читается через Seq Scan, в плане есть селективный Filter, много Rows Removed by Filter и много чтений в BUFFERS.
Главная мысль простая: не угадывайте причину медленного запроса. Сначала посмотрите план. PostgreSQL почти всегда показывает, где именно теряется время.
Когда SQL-запрос внезапно начинает тормозить, первое желание — угадать причину: «наверное, нужен индекс», «может быть, таблица слишком большая», «а вдруг JOIN плохой». Но в базе данных гадание почти всегда проигрывает наблюдению.
PostgreSQL умеет сам показать, как именно он собирается выполнять запрос. Для этого есть команда
EXPLAIN.Она показывает план выполнения: какие таблицы будут читаться, какие индексы использоваться, как будут соединяться данные и сколько строк PostgreSQL ожидает получить на каждом шаге.
А если добавить
ANALYZEиBUFFERS, база не просто построит план, а реально выполнит запрос и покажет фактическое время, количество строк и чтение данных из памяти или с диска.В этой статье разберём:
EXPLAINотEXPLAIN ANALYZE;cost,rows,actual time,loops;Seq Scanотличается отIndex Scan;Будем представлять обычную схему интернет-магазина: есть пользователи, заказы и сотрудники. Например, таблицы
users,ordersиemployees.Зачем вообще нужен EXPLAIN
SQL выглядит как просьба:
SELECT * FROM orders WHERE user_id = 42;Мы говорим базе: «Дай заказы пользователя с идентификатором 42».
Но внутри PostgreSQL должен решить, как именно это сделать.
Например, у него могут быть варианты:
ordersот начала до конца;user_id;Nested Loop,Hash Joinили другим способом.Именно это решение принимает планировщик PostgreSQL. А
EXPLAINпозволяет заглянуть ему через плечо.EXPLAIN и EXPLAIN ANALYZE: в чём разница
EXPLAINпоказывает план, но не выполняет сам запрос.EXPLAIN SELECT * FROM orders WHERE user_id = 42;Это безопасный способ посмотреть, что PostgreSQL собирается делать. Он особенно удобен, когда вы хотите быстро проверить идею и не запускать тяжёлый запрос по-настоящему.
А вот
EXPLAIN ANALYZEуже выполняет запрос и показывает реальные данные выполнения:EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;Если добавить
BUFFERS, PostgreSQL покажет ещё и информацию о чтении страниц данных:EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 42;Именно этот вариант чаще всего используют при разборе медленных запросов, потому что он отвечает не только на вопрос «что база планировала сделать», но и на вопрос «что на самом деле произошло».
Осторожно с изменяющими запросами
Важно помнить:
EXPLAIN ANALYZEреально выполняет запрос.С
SELECTэто обычно нормально. Но если вы запускаете его дляDELETE,UPDATEилиINSERT, данные действительно изменятся.Например, такой запрос реально удалит строки:
EXPLAIN ANALYZE DELETE FROM orders WHERE status = 'cancelled';Чтобы безопасно проверить изменяющий запрос, можно обернуть его в транзакцию и в конце сделать
ROLLBACK:BEGIN; EXPLAIN (ANALYZE, BUFFERS) DELETE FROM orders WHERE status = 'cancelled'; ROLLBACK;Так PostgreSQL выполнит запрос, покажет план и реальные числа, но после
ROLLBACKизменения будут отменены.Как выглядит простой план
Допустим, мы выполняем запрос:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 42;План может выглядеть примерно так:
На первый взгляд это похоже на набор странных чисел. Но если читать план спокойно, там всё довольно логично.
Разберём главные части.
cost: стоимость, но не миллисекунды
Фрагмент:
означает оценочную стоимость выполнения узла плана.
Первое число — стоимость до получения первой строки.
Второе число — стоимость до получения всех строк.
Важно:
cost— это не время в миллисекундах. Это условные единицы PostgreSQL. Планировщик использует их, чтобы сравнивать разные варианты плана между собой.Например, он может сравнить:
И выбрать вариант, который кажется ему дешевле по оценкам.
rows: сколько строк ожидал PostgreSQL
Фрагмент:
означает, что планировщик ожидал получить примерно 120 строк.
Это не факт, а прогноз.
PostgreSQL делает такой прогноз на основе статистики: сколько строк в таблице, какие значения часто встречаются, насколько данные распределены равномерно и так далее.
actual rows: сколько строк получилось на самом деле
Если используется
EXPLAIN ANALYZE, в плане появляется фактическая информация:Здесь
rows=118означает, что на самом деле узел вернул 118 строк.И вот тут начинается самое интересное.
Если PostgreSQL ожидал 120 строк, а получил 118 — всё хорошо. Планировщик почти угадал.
Но если он ожидал 5 строк, а получил 48000 — это уже проблема. Он мог выбрать плохой план просто потому, что неправильно представлял объём данных.
actual time: реальное время выполнения
Фрагмент:
показывает реальное время выполнения узла.
Первое число — время до первой строки.
Второе число — время до завершения работы узла.
Например, если запрос быстро отдаёт первую строку, но долго добирает остальные, это будет видно по этим числам.
loops: сколько раз выполнялся узел
Фрагмент:
показывает, сколько раз этот узел плана был выполнен.
Для простого запроса часто будет
loops=1.Но в планах с соединениями, особенно с
Nested Loop, внутренний узел может выполняться много раз.Например:
Это значит: сам по себе один проход быстрый, но он повторился 10000 раз. В итоге такой «быстрый маленький шаг» может стать дорогим.
Seq Scan: чтение всей таблицы
Seq Scanозначает последовательное сканирование таблицы.PostgreSQL читает таблицу строка за строкой и проверяет условие.
Например:
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'paid';План может быть таким:
Многие новички видят
Seq Scanи сразу думают: «Плохо, нужен индекс».Но это не всегда так.
Если условие возвращает большую часть таблицы, последовательное чтение может быть самым разумным вариантом.
Представьте бумажную картотеку. Если вам нужно найти одну карточку по номеру, полезен алфавитный указатель. Но если вам нужно просмотреть половину всех карточек, проще идти подряд, чем постоянно прыгать туда-сюда.
Так же и с таблицей.
Если
status = 'paid'встречается у половины заказов, индекс может не помочь. Базе всё равно придётся читать очень много строк из таблицы.Index Scan: точечный поиск через индекс
Index Scanозначает, что PostgreSQL использует индекс, чтобы найти подходящие строки.Например:
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;Если есть индекс по
user_id, план может быть таким:Это хороший знак, если условие селективное.
Селективное условие — это условие, которое отбирает небольшую часть таблицы.
Например:
Для таких случаев индекс обычно отлично подходит.
Bitmap Heap Scan: промежуточный вариант
Кроме
Seq ScanиIndex Scan, в PostgreSQL часто встречаетсяBitmap Heap Scan.Примерно он означает следующее:
Такой план часто выбирается, когда строк не одна-две, но и не половина таблицы. Например, нужно достать сотни или тысячи строк из большой таблицы.
План может выглядеть так:
Для новичка главное запомнить так:
Seq Scan— читаем таблицу целиком;Index Scan— точечно ходим по индексу;Bitmap Heap Scan— сначала собираем много совпадений через индекс, потом читаем таблицу пачками.Почему PostgreSQL не всегда использует индекс
Наличие индекса ещё не означает, что PostgreSQL обязан его использовать.
Например, есть индекс по
status, но запрос такой:EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'paid';Если почти все заказы имеют статус
paid, индекс бесполезен. Он приведёт к огромному количеству переходов из индекса в таблицу. В такой ситуацииSeq Scanможет быть быстрее.Индекс особенно полезен, когда он резко сужает поиск.
Например:
EXPLAIN ANALYZE SELECT * FROM employees WHERE email = 'anna@example.com';Если email уникален или почти уникален, индекс по
emailбудет очень полезен.Как понять, что не хватает индекса
Недостающий индекс часто выглядит в плане очень узнаваемо.
Допустим, есть запрос:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM employees WHERE email = 'anna@example.com';А план показывает:
Здесь сразу несколько тревожных сигналов.
PostgreSQL искал одну строку, но прочитал всю таблицу.
Строка:
говорит: база проверила почти 500 тысяч строк и выбросила их, потому что они не подошли под условие.
А строка:
говорит, что пришлось прочитать много страниц данных.
Если таблица большая, условие точечное, а PostgreSQL делает
Seq Scan, это сильный намёк: возможно, нужен индекс.В таком случае можно создать индекс:
CREATE INDEX idx_employees_email ON employees (email);После этого план может стать таким:
Теперь PostgreSQL не перебирает всю таблицу, а быстро находит нужную строку через индекс.
Filter и Index Cond: важное различие
В плане можно увидеть две похожие вещи:
и
Разница большая.
Index Condозначает, что условие используется прямо при поиске по индексу. Это хорошо: индекс помогает сузить набор строк.Filterозначает, что строки уже прочитаны, а потом PostgreSQL дополнительно проверяет условие и выбрасывает неподходящие.Например:
Такой план означает: PostgreSQL нашёл заказы пользователя через индекс по
user_id, а потом среди них оставил только оплаченные.Это может быть нормально. Но если после
Index Condостаётся слишком много строк, аFilterвыбрасывает большую часть, стоит подумать о составном индексе.Например:
CREATE INDEX idx_orders_user_status ON orders (user_id, status);Составные индексы: когда одного столбца мало
Допустим, в приложении часто выполняется запрос:
SELECT * FROM orders WHERE user_id = 42 AND created_at >= '2026-01-01' ORDER BY created_at;Для него может быть полезен составной индекс:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);Такой индекс помогает PostgreSQL быстро найти заказы конкретного пользователя и сразу идти по ним в порядке даты.
Но порядок столбцов в составном индексе важен.
Индекс:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);хорошо подходит для условий по
user_id, а затем поcreated_at.А вот если запросы чаще ищут просто по дате без пользователя, такой индекс может быть не лучшим выбором.
Покрывающий индекс: когда можно не ходить в таблицу
Иногда запросу нужны не все столбцы, а только несколько.
Например:
SELECT status FROM orders WHERE user_id = 42 AND created_at >= '2026-01-01';Можно создать индекс с дополнительным столбцом через
INCLUDE:CREATE INDEX idx_orders_user_created_include_status ON orders (user_id, created_at) INCLUDE (status);Здесь
user_idиcreated_atучаствуют в поиске, аstatusхранится в индексе как дополнительное значение.В удачных условиях PostgreSQL сможет взять всё нужное из индекса и меньше обращаться к самой таблице.
Оценки против реальности
Один из самых важных навыков при чтении плана — сравнивать оценку и факт.
Смотрите на пару:
Это означает: PostgreSQL ожидал 5 строк, а получил 48000.
Такое расхождение опасно. Планировщик мог выбрать план, который хорош для 5 строк, но ужасен для 48000.
Например:
EXPLAIN ANALYZE SELECT * FROM orders o JOIN users u ON u.id = o.user_id WHERE u.country = 'DE' AND o.created_at > '2026-01-01';В плане можно увидеть:
Это красный флаг.
Nested Loopчасто хорош, когда внешних строк мало. Например, нашли 5 пользователей и для каждого быстро сходили в заказы.Но если строк оказалось 48000, вложенный цикл может стать дорогим: маленькая операция повторяется слишком много раз.
Для большого объёма данных PostgreSQL мог бы выбрать, например,
Hash Join, но не выбрал, потому что ожидал совсем другое количество строк.Почему планировщик ошибается
PostgreSQL не ясновидящий. Он строит план на основе статистики.
Если статистика устарела или не отражает реальные связи в данных, оценки могут сильно расходиться с фактом.
Частые причины:
Устаревшая статистика
Таблица сильно изменилась: добавили много строк, удалили старые, поменяли значения. А статистика ещё старая.
Помогает команда:
Связанные между собой столбцы
Например,
countryиcity.Если в таблице есть
country = 'DE', то городBerlinстановится гораздо вероятнее. Но обычная статистика по отдельным столбцам может не понимать эту связь.Для таких случаев в PostgreSQL есть расширенная статистика:
CREATE STATISTICS stats_users_country_city ON country, city FROM users; ANALYZE users;Функции и выражения в условиях
Например:
SELECT * FROM employees WHERE lower(email) = 'anna@example.com';Если есть обычный индекс по
email, он не обязательно поможет для выраженияlower(email).В таком случае можно создать функциональный индекс:
CREATE INDEX idx_employees_lower_email ON employees (lower(email));Почему функция в WHERE может сломать использование индекса
Представьте, что есть индекс:
CREATE INDEX idx_employees_email ON employees (email);И есть запрос:
SELECT * FROM employees WHERE lower(email) = 'anna@example.com';Проблема в том, что индекс построен по исходному значению
email, а запрос ищет по результату функцииlower(email).Для PostgreSQL это уже другое выражение.
Поэтому возможны два варианта.
Первый — хранить email в нормализованном виде, например всегда в нижнем регистре, и писать простой фильтр:
SELECT * FROM employees WHERE email = 'anna@example.com';Второй — создать индекс именно по выражению:
CREATE INDEX idx_employees_lower_email ON employees (lower(email));Тогда условие с
lower(email)сможет использовать этот индекс.BUFFERS: откуда читались данные
Опция
BUFFERSпоказывает, как PostgreSQL работал со страницами данных.Пример:
Упрощённо:
shared hit— страницы уже были в памяти;shared read— страницы пришлось читать с диска.Если
shared readбольшой, запрос мог быть медленным из-за активного чтения с диска.Например:
Это уже серьёзный сигнал: PostgreSQL прочитал много страниц не из кэша, а с диска.
При анализе медленного запроса полезно смотреть не только на время, но и на
BUFFERS. Иногда запрос медленный не потому, что «плохой SQL», а потому что он читает огромный объём данных.Как читать план: снизу вверх
План выполнения — это дерево.
Верхняя строка показывает итоговый узел, но данные обычно рождаются в нижних узлах.
Поэтому план удобно читать снизу вверх:
rowsиactual rows.loops.Rows Removed by Filter.BUFFERS.Если ошибка в оценке появилась в самом нижнем узле, дальше она может раздуть весь план. PostgreSQL неверно оценил одну таблицу, потом выбрал неудачный JOIN, потом весь запрос стал медленным.
Пример: медленный поиск сотрудника по email
Допустим, пользователь в админке ищет сотрудника по email. Запрос простой:
SELECT * FROM employees WHERE email = 'anna@example.com';Но он работает медленно.
Запускаем:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM employees WHERE email = 'anna@example.com';Видим:
Что говорит план?
PostgreSQL прочитал таблицу целиком. Он нашёл одну строку, но перед этим проверил почти 500 тысяч лишних строк.
Для поиска по email это плохой знак. Email обычно селективен: по нему ожидается одна строка или очень маленькое количество строк.
Создаём индекс:
CREATE INDEX idx_employees_email ON employees (email);Обновляем статистику:
Проверяем снова:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM employees WHERE email = 'anna@example.com';Теперь ожидаем увидеть что-то вроде:
Вот это уже похоже на здоровый план: PostgreSQL быстро нашёл строку через индекс и прочитал совсем немного страниц.
Пример: почему Seq Scan может быть нормальным
Теперь другой запрос:
SELECT * FROM orders WHERE status = 'paid';Допустим, 80% заказов имеют статус
paid.Даже если создать индекс по
status, PostgreSQL может всё равно выбратьSeq Scan.И это нормально.
Потому что индекс полезен, когда нужно найти маленькую часть таблицы. А если нужно вернуть почти всю таблицу, проще прочитать её последовательно.
Плохая оптимизация начинается там, где разработчик видит
Seq Scanи автоматически считает это ошибкой.Правильный вопрос другой: сколько строк отбирает условие и сколько данных приходится читать?
Не добавляйте индексы наугад
Индекс ускоряет одни запросы, но не даётся бесплатно.
У индекса есть цена:
INSERT,UPDATE,DELETE;Поэтому хороший порядок действий такой:
EXPLAIN (ANALYZE, BUFFERS).Индекс — это не украшение таблицы, а инструмент под конкретный сценарий чтения.
Полезная привычка: обновлять статистику
Если план выглядит странно, а оценки сильно расходятся с реальностью, начните с простого:
Для нескольких таблиц:
После этого снова запустите
EXPLAIN.Иногда запрос «лечится» не новым индексом, а свежей статистикой. PostgreSQL просто начинает лучше понимать данные и выбирает другой план.
Чем PostgreSQL отличается от MySQL и ClickHouse
В PostgreSQL основной инструмент для разбора плана —
EXPLAIN, а для фактического выполнения —EXPLAIN ANALYZE.В MySQL тоже есть
EXPLAIN, а в новых версиях естьEXPLAIN ANALYZE, но формат вывода другой. Там иначе показываются узлы и фактическое время.В ClickHouse подход тоже отличается: используются команды вроде
EXPLAIN PLANиEXPLAIN PIPELINE. И важно понимать, что привычного индекса, как в PostgreSQL, там нет. ClickHouse работает иначе: у него есть разреженный primary key, сортировка данных и механизм пропуска гранул.Поэтому планы разных баз нельзя читать одинаково. Названия похожи, но устройство выполнения запросов отличается.
Короткий чек-лист чтения EXPLAIN
Когда открываете план, смотрите на такие вещи:
Как читается таблица
Seq Scan,Index Scan,Bitmap Heap Scan.Совпали ли оценки с реальностью
Сравнивайте
rowsиactual rows.Сколько раз выполнялся узел
Проверяйте
loops.Сколько строк было отброшено фильтром
Ищите
Rows Removed by Filter.Есть ли чтение с диска
Смотрите на
Buffers, особенно наshared read.Где именно теряется время
Не смотрите только на верхнюю строку. Читайте план снизу вверх.
Главное
EXPLAINпоказывает, какой план выполнения выбрал PostgreSQL.EXPLAIN ANALYZEвыполняет запрос и показывает реальные числа: время, строки, количество повторов.EXPLAIN (ANALYZE, BUFFERS)— один из лучших инструментов для разбора медленных запросов, потому что он показывает не только план, но и фактическую работу с данными.Seq Scanне всегда плох. Если запрос возвращает большую часть таблицы, последовательное чтение может быть самым дешёвым вариантом.Index Scanполезен, когда условие селективное и возвращает маленькую часть таблицы.Большое расхождение между
rowsиactual rows— сигнал, что планировщик ошибся в оценке. Часто помогаетANALYZE, иногда — расширенная статистика или другой индекс.Недостающий индекс часто выглядит так: большая таблица читается через
Seq Scan, в плане есть селективныйFilter, многоRows Removed by Filterи много чтений вBUFFERS.Главная мысль простая: не угадывайте причину медленного запроса. Сначала посмотрите план. PostgreSQL почти всегда показывает, где именно теряется время.