sqlpostgresqlexplainperformance

EXPLAIN ANALYZE BUFFERS: как читать реальный план PostgreSQL

План с ANALYZE показывает фактические строки, loops и буферы; по ним видно, где планировщик ошибся и почему запрос читает лишнее.

13 мин чтенияСправочникsql · postgresql · explain · performance · index

Когда SQL-запрос работает медленно, очень хочется сразу «добавить индекс» или переписать запрос наугад.

Но это плохая стратегия.

PostgreSQL уже умеет показывать, как именно он выполняет запрос:

  • какую таблицу читает;
  • использует индекс или идёт по всей таблице;
  • сколько строк ожидал найти;
  • сколько строк нашёл на самом деле;
  • сколько времени потратил;
  • сколько страниц взял из памяти;
  • сколько пришлось читать с диска.

Для этого используют EXPLAIN.

А если хочется увидеть не просто предположение планировщика, а реальное выполнение запроса, нужен:

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

Это один из самых полезных инструментов для разработчика, QA-инженера и любого человека, который работает с SQL не только «чтобы получить данные», но и «чтобы запрос не убивал базу».

Разберёмся, как читать такой план спокойно и без паники.

EXPLAIN и EXPLAIN ANALYZE: в чём разница

Обычный EXPLAIN показывает план, который PostgreSQL собирается использовать.

Например:

EXPLAIN
SELECT *
FROM orders
WHERE user_id = 5;

Такой запрос не выполняет сам SELECT. PostgreSQL просто показывает предполагаемый план:

«Я думаю, что буду читать таблицу вот так, строк будет примерно столько, цена будет примерно такая».

Это полезно, но это только прогноз.

А вот:

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 5;

уже реально выполняет запрос и показывает, что произошло на самом деле.

То есть PostgreSQL говорит не только:

«Я планировал найти 12 строк».

но и:

«По факту нашёл 9 строк и потратил 18 миллисекунд».

А если добавить BUFFERS:

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

PostgreSQL покажет ещё и работу с памятью и диском.

Именно этот вариант чаще всего используют для разбора медленных запросов.

Важное предупреждение: ANALYZE реально запускает запрос

EXPLAIN ANALYZE не просто показывает план. Он выполняет запрос.

Для обычного SELECT это чаще всего безопасно:

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 5;

Но если вы сделаете так:

EXPLAIN ANALYZE
DELETE FROM orders
WHERE created_at < DATE '2020-01-01';

PostgreSQL действительно выполнит DELETE.

Да, он покажет план. Но строки будут удалены.

Поэтому для изменяющих команд (INSERT, UPDATE, DELETE) нужно быть очень осторожным.

Обычно используют транзакцию с откатом:

BEGIN;

EXPLAIN (ANALYZE, BUFFERS)
DELETE FROM orders
WHERE created_at < DATE '2020-01-01';

ROLLBACK;

Так PostgreSQL реально выполнит команду, покажет настоящий план, но потом изменения откатятся.

Если вы не уверены, лучше сначала разбирать обычный SELECT или использовать простой EXPLAIN без ANALYZE.

Зачем добавлять BUFFERS

Сам по себе ANALYZE показывает фактическое время и строки.

Но BUFFERS добавляет очень важный слой: сколько страниц данных PostgreSQL прочитал из памяти или с диска.

Пример:

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.70 rows=9 loops=1)
  Filter: (user_id = 5)
  Rows Removed by Filter: 99991
  Buffers: shared hit=128 read=16234

Здесь важны не только миллисекунды. Важна ещё строка:

Buffers: shared hit=128 read=16234

Она говорит, что часть страниц PostgreSQL нашёл в памяти, а часть пришлось читать с диска.

Это помогает понять, почему запрос медленный:

  • он плохо использует индекс;
  • он читает слишком много строк;
  • он упирается в диск;
  • он много раз повторяет один и тот же внутренний поиск;
  • он получает совсем не то количество строк, которое ожидал планировщик.

Как выглядит один узел плана

Типичная строка в плане может выглядеть так:

Seq Scan on orders  (cost=0.00..1934.00 rows=12 width=40)
                    (actual time=0.40..18.70 rows=9 loops=1)

Здесь много цифр, но пугаться не нужно. Сначала достаточно понимать основные.

Разберём по частям.

cost: оценка планировщика

cost=0.00..1934.00

cost — это не миллисекунды.

Это условная «стоимость» операции, которую PostgreSQL использует, чтобы сравнивать разные варианты плана.

Например, база может выбирать:

  • пройти всю таблицу через Seq Scan;
  • пойти по индексу через Index Scan;
  • соединить таблицы через Nested Loop;
  • построить хэш и сделать Hash Join.

Планировщик сравнивает варианты и выбирает тот, у которого ожидаемая стоимость ниже.

В записи:

cost=0.00..1934.00

первое число — стартовая стоимость. Второе число — общая стоимость до получения всех строк.

Для повседневного анализа новичку не нужно глубоко считать cost. Важнее другое:

если оценки PostgreSQL сильно отличаются от реальности, он может выбрать плохой план.

А это уже видно по rows и actual rows.

rows: сколько строк PostgreSQL ожидал получить

В этой части:

cost=0.00..1934.00 rows=12 width=40

есть:

rows=12

Это оценка планировщика.

PostgreSQL до выполнения запроса предположил:

«После этого шага я получу примерно 12 строк».

Но это только прогноз.

Реальность находится в другой строке:

actual time=0.40..18.70 rows=9 loops=1

Здесь:

rows=9

означает:

«Фактически этот узел вернул 9 строк».

Если планировщик ожидал 12 строк, а получил 9 — всё нормально. Ошибка небольшая.

Но если он ожидал 12 строк, а получил 2 000 000 — это уже серьёзный сигнал.

actual time: реальное время выполнения

actual time=0.40..18.70

Здесь два значения.

Первое:

0.40

время до получения первой строки.

Второе:

18.70

время до получения всех строк на этом узле.

Значения указаны в миллисекундах.

То есть в нашем примере PostgreSQL начал отдавать первую строку примерно через 0.40 ms, а закончил работу узла примерно через 18.70 ms.

Но здесь есть важный нюанс: в сложном плане время родительских узлов обычно включает время дочерних узлов.

Поэтому нельзя просто взять все actual time в плане и сложить. Так вы получите неправильную картину.

Лучше использовать план как карту:

где видно, какой узел выполнялся долго, сколько строк через него прошло и сколько раз он запускался.

loops: сколько раз узел выполнялся

Параметр loops очень часто недооценивают.

Пример:

Index Scan using idx_orders_user_id on orders
  (actual time=0.02..0.03 rows=3 loops=5000)

На первый взгляд кажется:

«Ну, 0.03 миллисекунды — вообще ерунда».

Но смотрим дальше:

loops=5000

Это значит, что узел выполнялся 5000 раз.

А значит, реальная суммарная работа примерно такая:

0.03 ms * 5000 = 150 ms

То же самое со строками:

rows=3 loops=5000

Означает не «всего 3 строки», а примерно:

3 * 5000 = 15000 строк

Это особенно важно для Nested Loop.

Почему loops важен в Nested Loop

Представим запрос:

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

PostgreSQL может выбрать план с Nested Loop.

Упрощённо это работает так:

  1. Найти пользователей из Германии.
  2. Для каждого такого пользователя сходить в orders.
  3. Найти его заказы.

Если пользователей из Германии 5000, внутренний поиск по заказам может выполниться 5000 раз.

В плане это может выглядеть так:

Nested Loop
  -> Seq Scan on users
       Filter: (country = 'DE')
       rows=5000
  -> Index Scan using idx_orders_user_id on orders
       Index Cond: (user_id = users.id)
       actual time=0.02..0.03 rows=3 loops=5000

Сам по себе один Index Scan быстрый.

Но он повторяется 5000 раз.

Поэтому при чтении плана всегда проверяйте loops. Маленькое время на один проход может превратиться в большую суммарную стоимость, если проходов очень много.

Как читать план: снизу вверх и изнутри наружу

План PostgreSQL похож на дерево.

Пример:

Nested Loop
  -> Seq Scan on users
       Filter: (country = 'DE')
  -> Index Scan using idx_orders_user_id on orders
       Index Cond: (user_id = users.id)

Верхний узел — это итоговая операция.

Но выполняться всё начинает с нижних узлов.

Поэтому план удобно читать так:

  1. Сначала смотрим самые вложенные строки.
  2. Потом поднимаемся выше.
  3. Понимаем, как данные передаются от одного узла к другому.

В примере выше:

  • сначала PostgreSQL читает users;
  • находит пользователей с country = 'DE';
  • потом для каждого такого пользователя ищет заказы в orders;
  • затем собирает результат через Nested Loop.

Не пытайтесь читать план как обычный текст сверху вниз. Так он часто кажется сложнее, чем есть на самом деле.

Самый важный сигнал: оценка строк против факта

Одна из главных причин плохих планов — PostgreSQL неправильно оценил количество строк.

Например:

Seq Scan on orders
  (cost=0.00..1934.00 rows=12 width=40)
  (actual time=0.40..210.00 rows=2000000 loops=1)

Планировщик ожидал:

rows=12

А получил:

actual rows=2000000

Это огромная разница.

Почему это плохо?

Потому что PostgreSQL выбирает стратегию до выполнения запроса.

Если он думает, что строк будет 12, он может выбрать план, хороший для 12 строк.

Но если строк на самом деле 2 миллиона, этот план может оказаться ужасным.

Пример из жизни:

  • PostgreSQL ожидал мало строк и выбрал Nested Loop;
  • фактически строк оказалось много;
  • внутренний узел выполнился десятки тысяч раз;
  • запрос стал медленным.

Поэтому при анализе плана всегда ищите места, где rows и actual rows отличаются в десятки, сотни или тысячи раз.

Почему PostgreSQL ошибается в оценках

PostgreSQL строит план на основе статистики.

Статистика говорит базе примерно следующее:

  • сколько строк в таблице;
  • какие значения часто встречаются;
  • насколько значения разнообразны;
  • какая доля NULL;
  • как распределены данные.

Если статистика устарела, планировщик может ошибаться.

Например, в таблицу orders недавно загрузили миллион новых заказов, а статистика ещё старая. PostgreSQL думает, что таблица маленькая или что значения распределены иначе.

В таком случае помогает:

ANALYZE orders;

Эта команда обновляет статистику по таблице.

После неё стоит снова проверить план:

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE status = 'paid'
  AND created_at >= DATE '2026-01-01';

Если оценки стали ближе к факту, проблема была в устаревшей статистике.

Когда ANALYZE не помогает

Иногда статистика свежая, но PostgreSQL всё равно ошибается.

Частая причина — связанные условия.

Например:

SELECT *
FROM orders
WHERE status = 'paid'
  AND paid_at IS NOT NULL;

Для человека связь очевидна:

если заказ оплачен, у него почти всегда заполнен paid_at.

Но PostgreSQL может оценивать условия слишком независимо:

  • отдельно вероятность status = 'paid';
  • отдельно вероятность paid_at IS NOT NULL.

Из-за этого итоговая оценка может быть неверной.

В таких случаях помогают:

  • более подходящий индекс;
  • переписывание запроса;
  • расширенная статистика через CREATE STATISTICS;
  • иногда изменение структуры данных.

Но для начала важно просто увидеть сам факт:

планировщик ожидал одно количество строк, а получил совсем другое.

Это уже хорошая зацепка.

BUFFERS: что такое shared hit и shared read

Теперь разберём строку:

Buffers: shared hit=128 read=16234

PostgreSQL работает не с отдельными строками, а со страницами данных.

Когда запросу нужны данные, база проверяет, есть ли нужные страницы в памяти.

shared hit

shared hit=128

означает:

PostgreSQL нашёл 128 страниц в общем кэше shared buffers.

Это хорошо. Данные уже были в памяти, читать с диска не пришлось.

shared read

shared read=16234

означает:

PostgreSQL пришлось прочитать 16234 страницы с диска.

Это обычно дороже.

Если у узла много shared read, значит он активно ходил на диск. Такой запрос может быть медленным не только из-за SQL-логики, но и из-за физического чтения данных.

Почему BUFFERS важнее, чем кажется

Представим два запуска одного и того же запроса.

Первый запуск:

Buffers: shared hit=100 read=15000
Execution Time: 800 ms

Второй запуск:

Buffers: shared hit=15100 read=0
Execution Time: 120 ms

Запрос тот же, но второй раз он быстрее.

Почему?

Потому что данные уже оказались в кэше.

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

«Запрос нормальный, он же за 120 ms работает».

Но BUFFERS покажет, что первый запуск читал много страниц с диска.

Это особенно важно при тестах на локальной базе или staging. Один и тот же запрос после прогрева кэша может выглядеть гораздо лучше, чем на холодном запуске.

Поэтому BUFFERS помогает отделить две ситуации:

  1. Запрос медленный, потому что читает слишком много данных.
  2. Запрос быстрый только потому, что данные уже были в памяти.

Seq Scan: всегда ли это плохо?

Seq Scan означает последовательное чтение таблицы.

Пример:

Seq Scan on orders

Новички часто думают:

«Вижу Seq Scan — значит всё плохо, нужен индекс».

Но это не всегда так.

Seq Scan нормален, если:

  • таблица маленькая;
  • запрос возвращает большую часть таблицы;
  • фильтр не очень селективный;
  • чтение всей таблицы дешевле, чем прыжки по индексу.

Например:

SELECT *
FROM orders
WHERE status = 'paid';

Если 80% заказов имеют статус paid, индекс по status может быть бесполезен. Базе проще прочитать таблицу целиком, чем идти по индексу и всё равно доставать почти все строки.

Плохой сигнал — не сам Seq Scan.

Плохой сигнал — когда видим что-то такое:

Seq Scan on orders
  Filter: (user_id = 5)
  Rows Removed by Filter: 9999991
  Buffers: shared read=45000

Это означает:

PostgreSQL прочитал почти всю таблицу, выбросил миллионы строк и оставил несколько.

Вот тут уже стоит задуматься об индексе.

Пример: Seq Scan там, где нужен индекс

Допустим, есть запрос:

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..210.00 rows=9 loops=1)
  Filter: (user_id = 5)
  Rows Removed by Filter: 1999991
  Buffers: shared hit=128 read=16234

Что видим:

  • PostgreSQL сделал Seq Scan;
  • прочитал много страниц;
  • проверил почти 2 миллиона лишних строк;
  • вернул только 9 строк.

Это классический случай, когда индекс может помочь.

Создадим индекс:

CREATE INDEX idx_orders_user_id
ON orders(user_id);

Теперь снова проверим:

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

План может стать таким:

Index Scan using idx_orders_user_id on orders
  (cost=0.42..45.80 rows=12 width=40)
  (actual time=0.05..0.12 rows=9 loops=1)
  Index Cond: (user_id = 5)
  Buffers: shared hit=7 read=2

Теперь PostgreSQL не читает всю таблицу. Он идёт по индексу и быстро находит нужные строки.

Сравните:

До индекса:

Seq Scan
Rows Removed by Filter: 1999991
Buffers: shared hit=128 read=16234
Execution Time: 210 ms

После индекса:

Index Scan
Buffers: shared hit=7 read=2
Execution Time: 0.12 ms

Вот ради таких сравнений и используют EXPLAIN (ANALYZE, BUFFERS).

Index Scan, Bitmap Index Scan и Index Only Scan

В планах PostgreSQL можно увидеть разные способы работы с индексами.

Index Scan

Index Scan using idx_orders_user_id on orders

PostgreSQL идёт по индексу, находит ссылки на строки, а потом достаёт сами строки из таблицы.

Это хороший вариант, когда строк немного.

Bitmap Index Scan

Bitmap Index Scan on idx_orders_user_id

Часто используется, когда подходящих строк не совсем мало. PostgreSQL сначала собирает «карту» подходящих строк, а потом читает нужные страницы таблицы более пачками.

В плане обычно рядом будет:

Bitmap Heap Scan

Это нормально.

Index Only Scan

Index Only Scan using idx_orders_user_id on orders

Это особенно приятный вариант.

Он означает, что PostgreSQL смог взять нужные данные прямо из индекса и почти не ходить в таблицу.

Например, если запросу нужен только user_id, а он уже есть в индексе:

SELECT user_id
FROM orders
WHERE user_id = 5;

Но Index Only Scan зависит не только от состава индекса, но и от внутренней информации PostgreSQL о видимости строк. Поэтому не каждый подходящий индекс автоматически даёт идеальный Index Only Scan.

Rows Removed by Filter: сколько строк выбросили

Очень полезная строка:

Rows Removed by Filter: 1999991

Она говорит:

PostgreSQL прочитал строки, проверил условие и выбросил почти 2 миллиона из них.

Это не всегда проблема.

Но если запрос вернул 9 строк, а выбросил 2 миллиона, стоит спросить:

  • почему база читает так много лишнего;
  • есть ли подходящий индекс;
  • можно ли сделать условие более селективным;
  • не мешает ли функция на колонке использовать индекс;
  • актуальна ли статистика.

Пример плохого условия:

SELECT *
FROM orders
WHERE EXTRACT(YEAR FROM created_at) = 2026;

Если есть индекс по created_at, PostgreSQL может не использовать его эффективно, потому что колонка обёрнута в функцию.

Лучше:

SELECT *
FROM orders
WHERE created_at >= DATE '2026-01-01'
  AND created_at <  DATE '2027-01-01';

Так условие становится более дружелюбным к индексу.

Planning Time и Execution Time

В конце плана часто есть:

Planning Time: 0.250 ms
Execution Time: 18.900 ms

Planning Time — сколько PostgreSQL потратил на выбор плана.

Execution Time — сколько заняло фактическое выполнение запроса.

Обычно для обычных запросов нас больше интересует Execution Time.

Но если запрос очень сложный, с большим количеством таблиц, подзапросов и вариантов соединений, время планирования тоже может стать заметным.

Алгоритм чтения EXPLAIN ANALYZE BUFFERS

Когда перед вами большой план, не нужно пытаться понять всё сразу.

Идите по шагам.

Шаг 1. Посмотрите на Execution Time

Внизу плана найдите Execution Time.

Так вы поймёте общий масштаб проблемы: запрос занимает 20 ms, 2 секунды или 2 минуты.

Шаг 2. Найдите самые тяжёлые узлы

Ищите узлы, где:

  • большое actual time;
  • много shared read;
  • много строк;
  • большой loops;
  • много Rows Removed by Filter.

Шаг 3. Сравните rows и actual rows

Ищите сильные расхождения:

rows=10
actual rows=500000

или наоборот:

rows=1000000
actual rows=5

Такие места часто объясняют, почему PostgreSQL выбрал странный план.

Шаг 4. Проверьте loops

Если видите:

loops=10000

обязательно умножайте время и строки на количество проходов.

Особенно внутри Nested Loop.

Шаг 5. Посмотрите на BUFFERS

Много shared read означает, что запрос много читал с диска.

Много shared hit означает, что запрос активно работал с кэшем.

Оба случая важны. Даже если всё было в кэше, запрос может читать слишком много страниц и создавать нагрузку на память и CPU.

Шаг 6. Только потом думайте про индекс или переписывание SQL

Не начинайте с решения.

Сначала найдите причину:

  • таблица читается целиком;
  • индекс не используется;
  • индекс используется, но слишком много раз;
  • статистика врёт;
  • условие написано так, что индекс не помогает;
  • соединение выбрало неудачную стратегию;
  • запрос возвращает слишком много данных.

После этого решение становится гораздо понятнее.

Частые причины плохого плана

Устаревшая статистика

Планировщик ожидал одно количество строк, а получил другое.

Что попробовать:

ANALYZE orders;

Нет подходящего индекса

Запрос ищет несколько строк, но PostgreSQL читает всю таблицу.

Что попробовать:

CREATE INDEX idx_orders_user_id
ON orders(user_id);

Условие мешает индексу

Плохо:

SELECT *
FROM orders
WHERE DATE(created_at) = DATE '2026-01-01';

Лучше:

SELECT *
FROM orders
WHERE created_at >= TIMESTAMP '2026-01-01 00:00:00'
  AND created_at <  TIMESTAMP '2026-01-02 00:00:00';

Слишком много loops

Внутренний узел быстрый за один проход, но выполняется тысячи раз.

Смотрите на loops и оценивайте суммарную работу.

Запрос читает слишком много лишних строк

Смотрите на Rows Removed by Filter.

Если PostgreSQL выбрасывает миллионы строк ради нескольких результатов, вероятно, стоит подумать об индексе или переписывании условия.

Мини-пример полного разбора

Есть запрос:

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE user_id = 5
  AND status = 'paid';

План:

Seq Scan on orders
  (cost=0.00..25000.00 rows=5 width=80)
  (actual time=1.20..420.00 rows=7 loops=1)
  Filter: ((user_id = 5) AND (status = 'paid'))
  Rows Removed by Filter: 2999993
  Buffers: shared hit=500 read=24500

Planning Time: 0.300 ms
Execution Time: 420.100 ms

Как читать:

  1. Запрос занял 420 ms.
  2. Был Seq Scan, то есть PostgreSQL читал всю таблицу.
  3. Вернул всего 7 строк.
  4. Выбросил почти 3 000 000 строк.
  5. С диска прочитано 24500 страниц.
  6. Запрос фильтрует по user_id и status.

Вероятная идея:

CREATE INDEX idx_orders_user_status
ON orders(user_id, status);

После индекса план может стать таким:

Index Scan using idx_orders_user_status on orders
  (cost=0.42..30.00 rows=5 width=80)
  (actual time=0.04..0.10 rows=7 loops=1)
  Index Cond: ((user_id = 5) AND (status = 'paid'))
  Buffers: shared hit=6 read=1

Planning Time: 0.400 ms
Execution Time: 0.150 ms

Теперь:

  • вместо Seq Scan используется Index Scan;
  • лишние миллионы строк не читаются;
  • чтения с диска почти исчезли;
  • время стало меньше.

Вот это уже не гадание, а нормальная диагностика.

Чем отличается от MySQL и ClickHouse

В PostgreSQL классический инструмент для реального плана:

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

В MySQL 8 тоже есть EXPLAIN ANALYZE. Он показывает фактическое выполнение, время и количество строк, но вывода по буферам в стиле PostgreSQL там нет.

В ClickHouse подход другой. Там можно смотреть структуру выполнения через EXPLAIN, а реальные метрики чтения и выполнения часто анализируют через системные таблицы и логи запросов, например system.query_log.

То есть идея везде похожая:

понять, что база реально сделала.

Но инструменты и формат вывода отличаются.

Короткая шпаргалка

Обычный прогноз плана:

EXPLAIN
SELECT *
FROM orders
WHERE user_id = 5;

Реальное выполнение:

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 5;

Реальное выполнение плюс чтение страниц:

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

Для изменяющего запроса — только осторожно, например через транзакцию:

BEGIN;

EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders
SET status = 'archived'
WHERE created_at < DATE '2020-01-01';

ROLLBACK;

Обновить статистику:

ANALYZE orders;

Создать индекс под частый фильтр:

CREATE INDEX idx_orders_user_id
ON orders(user_id);

Главное правило

EXPLAIN (ANALYZE, BUFFERS) — это не страшный академический инструмент. Это способ увидеть, что PostgreSQL реально сделал с вашим запросом.

Когда читаете план, смотрите в первую очередь на пять вещей:

  1. actual time — где потрачено время.
  2. actual rows — сколько строк реально прошло через узел.
  3. rows — сколько строк ожидал планировщик.
  4. loops — сколько раз узел повторялся.
  5. Buffers — сколько страниц пришло из памяти и с диска.

Если оценки строк сильно расходятся с фактом, PostgreSQL может выбрать плохой план.

Если много shared read, запрос тяжёлый по чтению с диска.

Если много loops, маленькая операция могла повториться тысячи раз и стать дорогой.

Если Seq Scan читает миллионы строк ради нескольких результатов, возможно, нужен индекс или более аккуратное условие.

Главная мысль простая:

не подбирайте индексы наугад. Сначала посмотрите реальный план, найдите самое дорогое место, а уже потом меняйте SQL или структуру индексов.

Так вы будете оптимизировать не «по ощущениям», а по фактам.

Закрепи на практике

Решай задачи в SQL-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр