Медленный запрос сначала находят — через pg_stat_statements или лог с log_min_duration_statement, — затем смотрят его план командой EXPLAIN (ANALYZE, BUFFERS). В плане ищут узел, где уходит больше всего времени и страниц, и сравнивают оценку строк (rows) с реальностью (actual rows): расхождение в разы почти всегда объясняет плохой выбор планировщика. Типичные исправления — индекс под условие или соединение, переписывание условия без функции над столбцом, NOT EXISTS вместо NOT IN, свежая статистика через ANALYZE и расширенная статистика для зависимых столбцов.
После материала вы сможете
- найти медленные и частые запросы через
pg_stat_statementsиlog_min_duration_statement - прочитать план
EXPLAIN (ANALYZE, BUFFERS): стоимость, реальное время,loops, буферы и узлы сканирования и соединения - найти расхождение оценки и реальности и исправить его через
ANALYZEилиCREATE STATISTICS - применить типовые исправления: индекс на внешний ключ, условие без функции над столбцом,
NOT EXISTS, устранение N+1
Что знать заранее
Проверено на
PostgreSQL 17 (17.11, postgres:17-alpine)pg_stat_statements 1.11
Сверено с документацией: 11 октября 2026 г.
Содержание
Как найти медленные запросы
Оптимизировать вслепую бессмысленно: сначала нужен список запросов, которые действительно тратят время базы. У PostgreSQL для этого два штатных инструмента — расширение pg_stat_statements и журнал сервера.
pg_stat_statements
- Что даёт
- Сводку по всем запросам: число вызовов, суммарное и среднее время, строки, буферы. Константы заменяются на
$1, поэтому одинаковые запросы с разными значениями складываются в одну строку - Как включить
shared_preload_libraries = 'pg_stat_statements', перезапуск сервера, затемCREATE EXTENSION pg_stat_statementsв базе
log_min_duration_statement
- Что даёт
- Запись в лог каждого запроса дольше порога вместе с его текстом
- Как включить
- Например,
250ms;0логирует всё,-1(по умолчанию) — выключено
auto_explain
- Что даёт
- Планы медленных запросов прямо в логе
- Как включить
- Модуль из стандартной поставки, загружается так же, через
shared_preload_librariesилиLOAD
Учебный стенд — магазин на 100 000 клиентов и миллион заказов в PostgreSQL 17. Контейнер сразу запускаем с pg_stat_statements и порогом лога 200 мс.
docker run -d --rm --name kb-pg-explain -e POSTGRES_PASSWORD=test postgres:17-alpine \
-c shared_preload_libraries=pg_stat_statements -c log_min_duration_statement=200
docker exec -i kb-pg-explain psql -U postgres -f - < 01-seed.sql-- Учебная база магазина: 100 000 клиентов, 1 000 000 заказов
DROP TABLE IF EXISTS orders, customers;
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
city text NOT NULL,
region text NOT NULL, -- однозначно определяется городом
created_at timestamptz NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers (id), -- индекс на FK сам не создаётся
status text NOT NULL,
total numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX orders_created_at_idx ON orders (created_at);
SELECT setseed(0.17);
-- 50 городов в 10 регионах: город n принадлежит региону n % 10
INSERT INTO customers (email, city, region, created_at)
SELECT 'user' || g || '@example.com',
'city-' || c,
'region-' || (c % 10),
timestamptz '2024-01-01' + random() * interval '700 days'
FROM (SELECT g, floor(random() * 50)::int AS c FROM generate_series(1, 100000) AS g) AS s;
-- примерно 15 % клиентов без заказов
INSERT INTO orders (customer_id, status, total, created_at)
SELECT 1 + floor(random() * 85000)::int,
(ARRAY['new', 'paid', 'shipped', 'done', 'done', 'done', 'cancelled'])[1 + floor(random() * 7)::int],
round((500 + random() * 49500)::numeric, 2),
timestamptz '2025-01-01' + random() * interval '365 days'
FROM generate_series(1, 1000000);
VACUUM ANALYZE customers, orders;
SELECT relname, n_live_tup, pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables ORDER BY relname;$ docker logs kb-pg-explain 2>&1 | grep -E 'duration|ERROR'
LOG: duration: 277.542 ms statement: INSERT INTO customers (email, city, region, created_at)
LOG: duration: 16909.251 ms statement: INSERT INTO orders (customer_id, status, total, created_at)
ERROR: canceling statement due to statement timeout
LOG: duration: 1186.990 ms statement: INSERT INTO orders (customer_id, status, total, created_at)Как читать план EXPLAIN
План — дерево узлов. Внизу — узлы сканирования, которые достают строки из таблиц; выше — соединения, сортировки и агрегаты. Читать его удобно изнутри наружу: самый глубоко вложенный узел выполняется первым, а стоимость и время каждого узла включают его дочерние узлы.
-- Запрос 1: заказы клиента по email
\echo '=== ДО: на orders.customer_id нет индекса'
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.status, o.total, o.created_at
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.email = 'user4242@example.com';
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
\echo '=== ПОСЛЕ: индекс на внешний ключ'
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.status, o.total, o.created_at
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.email = 'user4242@example.com';$ docker exec -i kb-pg-explain psql -U postgres -f - < 02-join-fk.sql
=== ДО: на orders.customer_id нет индекса
Gather (cost=1008.45..14880.91 rows=10 width=30) (actual time=3.742..29.184 rows=8 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=8720 read=3
-> Hash Join (cost=8.45..13879.91 rows=4 width=30) (actual time=3.414..23.601 rows=3 loops=3)
Hash Cond: (o.customer_id = c.id)
Buffers: shared hit=8720 read=3
-> Parallel Seq Scan on orders o (cost=0.00..12777.67 rows=416667 width=38) (actual time=0.005..11.550 rows=333333 loops=3)
Buffers: shared hit=8611
-> Hash (cost=8.44..8.44 rows=1 width=8) (actual time=0.028..0.029 rows=1 loops=3)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
Buffers: shared hit=11 read=3
-> Index Scan using customers_email_key on customers c (cost=0.42..8.44 rows=1 width=8) (actual time=0.023..0.023 rows=1 loops=3)
Index Cond: (email = 'user4242@example.com'::text)
Buffers: shared hit=11 read=3
Planning:
Buffers: shared hit=264 read=9
Planning Time: 1.164 ms
Execution Time: 29.264 mscost=1008.45..14880.91
- Что означает
- Оценка стоимости в условных единицах: до первой строки и до последней. Сравнивать её с миллисекундами нельзя
rows=10 width=30
- Что означает
- Сколько строк узел, по оценке, вернёт и их средняя ширина в байтах
actual time=3.742..29.184
- Что означает
- Реальное время в миллисекундах: до первой строки и до последней
rows=3 loops=3
- Что означает
- Реальные строки и число выполнений узла. Время и строки — средние за одно выполнение: умножайте на
loops
Buffers: shared hit=8720 read=3
- Что означает
- Страницы по 8 КБ:
hit— из кэша PostgreSQL,read— прочитаны с диска или из кэша ОС
Rows Removed by Filter
- Что означает
- Сколько строк узел прочитал и отбросил — признак того, что условию не хватает индекса
Planning Time / Execution Time
- Что означает
- Время построения плана и выполнения запроса без передачи результата клиенту
В плане до исправления весь запрос тратит время на Parallel Seq Scan on orders: три процесса читают по 333 тысячи строк, чтобы Hash Join нашёл восемь заказов. Внешний ключ не создаёт индекс автоматически — документация прямо говорит, что индекс на ссылающиеся столбцы нужно объявить самому. После CREATE INDEX план становится Nested Loop: для одной найденной строки клиента выполняется точечный поиск по индексу заказов.
=== ПОСЛЕ: индекс на внешний ключ
Nested Loop (cost=4.94..63.74 rows=10 width=30) (actual time=0.035..0.047 rows=8 loops=1)
Buffers: shared hit=14 read=1
-> Index Scan using customers_email_key on customers c (cost=0.42..8.44 rows=1 width=8) (actual time=0.011..0.012 rows=1 loops=1)
Index Cond: (email = 'user4242@example.com'::text)
Buffers: shared hit=4
-> Bitmap Heap Scan on orders o (cost=4.53..55.17 rows=13 width=38) (actual time=0.021..0.032 rows=8 loops=1)
Recheck Cond: (c.id = customer_id)
Heap Blocks: exact=8
Buffers: shared hit=10 read=1
-> Bitmap Index Scan on orders_customer_id_idx (cost=0.00..4.52 rows=13 width=0) (actual time=0.012..0.012 rows=8 loops=1)
Index Cond: (customer_id = c.id)
Buffers: shared hit=2 read=1
Planning:
Buffers: shared hit=24 read=6
Planning Time: 0.269 ms
Execution Time: 0.076 msSeq Scan
- Когда планировщик его выбирает
- Нужна большая доля таблицы, нет подходящего индекса или таблица маленькая
Index Scan / Index Only Scan
- Когда планировщик его выбирает
- Мало строк или нужен порядок индекса; Only — если все столбцы есть в индексе
Bitmap Index Scan + Bitmap Heap Scan
- Когда планировщик его выбирает
- Средняя выборка: адреса строк собираются в битовую карту и читаются в физическом порядке
Nested Loop
- Когда планировщик его выбирает
- Внешний вход маленький, а для внутреннего есть индекс по условию соединения
Hash Join
- Когда планировщик его выбирает
- Оба входа большие, условие — равенство; меньший вход превращается в хеш-таблицу в памяти
Merge Join
- Когда планировщик его выбирает
- Оба входа уже отсортированы по ключу соединения (например, индексами)
Запросы 2 и 3: условие без функции и NOT EXISTS вместо NOT IN
Второй медленный запрос считает выручку за день и приводит created_at к дате. Индекс по created_at при этом не используется: в нём лежат метки времени, а не результат created_at::date. Тот же день, записанный диапазоном, использует индекс напрямую.
-- Запрос 2: выручка за день
\echo '=== ДО: функция над столбцом — индекс по created_at не подходит'
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*), sum(total)
FROM orders
WHERE created_at::date = '2025-06-01';
\echo '=== ПОСЛЕ: тот же день как диапазон'
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*), sum(total)
FROM orders
WHERE created_at >= '2025-06-01' AND created_at < '2025-06-02';
-- Результат не изменился (сессия в UTC, как и границы диапазона)
SELECT (SELECT count(*) FROM orders WHERE created_at::date = '2025-06-01') AS by_cast,
(SELECT count(*) FROM orders
WHERE created_at >= '2025-06-01' AND created_at < '2025-06-02') AS by_range;$ docker exec -i kb-pg-explain psql -U postgres -f - < 03-sargable.sql
=== ДО: функция над столбцом — индекс по created_at не подходит
Finalize Aggregate (cost=15871.65..15871.66 rows=1 width=40) (actual time=24.543..27.143 rows=1 loops=1)
Buffers: shared hit=8611
-> Gather (cost=15871.42..15871.63 rows=2 width=40) (actual time=24.389..27.131 rows=3 loops=1)
Buffers: shared hit=8611
-> Partial Aggregate (cost=14871.42..14871.43 rows=1 width=40) (actual time=22.536..22.537 rows=1 loops=3)
Buffers: shared hit=8611
-> Parallel Seq Scan on orders (cost=0.00..14861.00 rows=2083 width=8) (actual time=0.016..22.422 rows=892 loops=3)
Filter: ((created_at)::date = '2025-06-01'::date)
Rows Removed by Filter: 332442
Buffers: shared hit=8611
Planning Time: 0.534 ms
Execution Time: 27.193 ms
=== ПОСЛЕ: тот же день как диапазон
Aggregate (cost=5615.89..5615.90 rows=1 width=40) (actual time=2.811..2.811 rows=1 loops=1)
Buffers: shared hit=2301
-> Bitmap Heap Scan on orders (cost=66.54..5603.15 rows=2548 width=8) (actual time=0.463..2.560 rows=2675 loops=1)
Recheck Cond: ((created_at >= '2025-06-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2025-06-02 00:00:00+00'::timestamp with time zone))
Heap Blocks: exact=2288
Buffers: shared hit=2301
-> Bitmap Index Scan on orders_created_at_idx (cost=0.00..65.91 rows=2548 width=0) (actual time=0.291..0.291 rows=2675 loops=1)
Index Cond: ((created_at >= '2025-06-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2025-06-02 00:00:00+00'::timestamp with time zone))
Buffers: shared hit=13
Planning Time: 0.129 ms
Execution Time: 2.851 ms
by_cast | by_range
---------+----------
2675 | 2675
(1 row)Приведение к дате зависит от часового пояса сессии, поэтому границы диапазона должны быть в том же поясе — проверка в конце скрипта это подтверждает. Альтернатива — индекс по выражению, но он подходит, только если запрос пишет выражение ровно так же.
Третий запрос ищет клиентов без заказов через NOT IN (подзапрос). Когда результат подзапроса не помещается в work_mem для хешированного варианта, PostgreSQL проверяет каждую строку клиентов перебором — оценка стоимости уходит в сотни миллионов.
-- Запрос 3: клиенты без заказов
\echo '=== ДО: NOT IN с подзапросом — план без ANALYZE'
EXPLAIN
SELECT count(*)
FROM customers c
WHERE c.id NOT IN (SELECT o.customer_id FROM orders o);
SET statement_timeout = '10s';
SELECT count(*)
FROM customers c
WHERE c.id NOT IN (SELECT o.customer_id FROM orders o);
RESET statement_timeout;
\echo '=== ПОСЛЕ: NOT EXISTS'
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);$ docker exec -i kb-pg-explain psql -U postgres -f - < 04-not-in.sql
=== ДО: NOT IN с подзапросом — план без ANALYZE
Finalize Aggregate (cost=625378177.42..625378177.43 rows=1 width=8)
-> Gather (cost=625378177.21..625378177.42 rows=2 width=8)
Workers Planned: 2
-> Partial Aggregate (cost=625377177.21..625377177.22 rows=1 width=8)
-> Parallel Index Only Scan using customers_pkey on customers c (cost=0.29..625377125.13 rows=20833 width=0)
Filter: (NOT (ANY (id = (SubPlan 1).col1)))
SubPlan 1
-> Materialize (cost=0.00..27518.00 rows=1000000 width=8)
-> Seq Scan on orders o (cost=0.00..18611.00 rows=1000000 width=8)
JIT:
Functions: 8
Options: Inlining true, Optimization true, Expressions true, Deforming true
SET
psql:<stdin>:11: ERROR: canceling statement due to statement timeout
RESET
=== ПОСЛЕ: NOT EXISTS
Finalize Aggregate (cost=20931.19..20931.20 rows=1 width=8) (actual time=73.489..78.491 rows=1 loops=1)
Buffers: shared hit=9808
-> Gather (cost=20930.97..20931.18 rows=2 width=8) (actual time=70.581..78.484 rows=3 loops=1)
Buffers: shared hit=9808
-> Partial Aggregate (cost=19930.97..19930.98 rows=1 width=8) (actual time=58.105..58.107 rows=1 loops=3)
Buffers: shared hit=9808
-> Parallel Hash Right Anti Join (cost=2354.54..19909.58 rows=8555 width=0) (actual time=57.248..57.943 rows=5001 loops=3)
Hash Cond: (o.customer_id = c.id)
Buffers: shared hit=9808
-> Parallel Seq Scan on orders o (cost=0.00..12777.67 rows=416667 width=8) (actual time=0.009..15.220 rows=333333 loops=3)
Buffers: shared hit=8611
-> Parallel Hash (cost=1619.24..1619.24 rows=58824 width=8) (actual time=5.286..5.286 rows=33333 loops=3)
Buckets: 131072 Batches: 1 Memory Usage: 4960kB
Buffers: shared hit=1031
-> Parallel Seq Scan on customers c (cost=0.00..1619.24 rows=58824 width=8) (actual time=0.012..5.686 rows=100000 loops=1)
Buffers: shared hit=1031
Planning Time: 0.939 ms
Execution Time: 78.579 msОценки и реальность: статистика и ANALYZE
Планировщик выбирает план по оценкам числа строк, а оценки берёт из статистики: reltuples и relpages в pg_class и распределения значений в pg_statistic (удобнее смотреть через pg_stats). Статистику собирает ANALYZE — вручную или фоновым autovacuum. Документация называет главное, на что смотреть в EXPLAIN ANALYZE: насколько оценка строк близка к реальности.
Опыт 1: после массовой загрузки 60 000 заказов со статусом refund статистика ещё не знает о новом значении. Autovacuum для таблицы выключен только затем, чтобы он не успел запустить ANALYZE сам.
-- Оценки и реальность 1: устаревшая статистика после массовой загрузки
ALTER TABLE orders SET (autovacuum_enabled = off); -- только для воспроизводимости опыта
SELECT setseed(0.5);
INSERT INTO orders (customer_id, status, total, created_at)
SELECT 1 + floor(random() * 85000)::int, 'refund', 990.00,
timestamptz '2025-12-01' + random() * interval '30 days'
FROM generate_series(1, 60000);
\echo '=== ДО ANALYZE: о статусе refund планировщик ещё не знает'
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.city, count(*)
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'refund'
GROUP BY c.city;
ANALYZE orders;
\echo '=== ПОСЛЕ ANALYZE'
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.city, count(*)
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'refund'
GROUP BY c.city;
ALTER TABLE orders RESET (autovacuum_enabled);$ docker exec -i kb-pg-explain psql -U postgres -f - < 05-stats.sql
=== ДО ANALYZE: о статусе refund планировщик ещё не знает
GroupAggregate (cost=15630.18..15630.20 rows=1 width=15) (actual time=73.236..77.260 rows=50 loops=1)
Group Key: c.city
Buffers: shared hit=189114
-> Sort (cost=15630.18..15630.18 rows=1 width=7) (actual time=73.145..74.659 rows=60000 loops=1)
Sort Key: c.city
Sort Method: quicksort Memory: 1537kB
Buffers: shared hit=189114
-> Nested Loop (cost=1000.29..15630.17 rows=1 width=7) (actual time=12.064..61.700 rows=60000 loops=1)
Buffers: shared hit=189111
-> Gather (cost=1000.00..15621.86 rows=1 width=8) (actual time=12.035..15.321 rows=60000 loops=1)
Buffers: shared hit=9111
-> Parallel Seq Scan on orders o (cost=0.00..14621.76 rows=1 width=8) (actual time=10.219..13.308 rows=20000 loops=3)
Filter: (status = 'refund'::text)
Rows Removed by Filter: 333333
Buffers: shared hit=9111
-> Index Scan using customers_pkey on customers c (cost=0.29..8.31 rows=1 width=15) (actual time=0.001..0.001 rows=1 loops=60000)
Index Cond: (id = o.customer_id)
Buffers: shared hit=180000
Planning Time: 0.697 ms
Execution Time: 77.378 ms
=== ПОСЛЕ ANALYZE
Finalize GroupAggregate (cost=18179.59..18192.26 rows=50 width=15) (actual time=25.477..28.630 rows=50 loops=1)
Group Key: c.city
Buffers: shared hit=10188
-> Gather Merge (cost=18179.59..18191.26 rows=100 width=15) (actual time=25.471..28.613 rows=150 loops=1)
Buffers: shared hit=10188
-> Sort (cost=17179.57..17179.69 rows=50 width=15) (actual time=23.496..23.500 rows=50 loops=3)
Sort Key: c.city
Sort Method: quicksort Memory: 26kB
Buffers: shared hit=10188
-> Partial HashAggregate (cost=17177.66..17178.16 rows=50 width=15) (actual time=23.447..23.451 rows=50 loops=3)
Group Key: c.city
Buffers: shared hit=10172
-> Parallel Hash Join (cost=2354.54..17052.22 rows=25087 width=7) (actual time=16.102..21.541 rows=20000 loops=3)
Hash Cond: (o.customer_id = c.id)
Buffers: shared hit=10172
-> Parallel Seq Scan on orders o (cost=0.00..14631.83 rows=25087 width=8) (actual time=9.531..12.243 rows=20000 loops=3)
Filter: (status = 'refund'::text)
Rows Removed by Filter: 333333
Buffers: shared hit=9111
-> Parallel Hash (cost=1619.24..1619.24 rows=58824 width=15) (actual time=6.338..6.339 rows=33333 loops=3)
Buffers: shared hit=1031
-> Parallel Seq Scan on customers c (cost=0.00..1619.24 rows=58824 width=15) (actual time=0.007..1.625 rows=33333 loops=3)
Buffers: shared hit=1031
Planning Time: 0.336 ms
Execution Time: 28.700 msПланировщик ждал одну строку и выбрал Nested Loop: для каждой строки заказа — поиск клиента по индексу. На деле строк 60 000, и узел Index Scan выполнился 60 000 раз (loops=60000), прочитав 180 000 страниц. После ANALYZE оценка стала близкой к реальной, и план переключился на Hash Join: таблица клиентов читается один раз.
Опыт 2: планировщик по умолчанию считает условия независимыми и перемножает их селективности. Если регион однозначно определяется городом, оценка занижается примерно в 10 раз. Расширенная статистика CREATE STATISTICS … (dependencies) сообщает ему о зависимости.
-- Оценки и реальность 2: зависимые столбцы (регион определяется городом)
\echo '=== ДО: планировщик считает условия независимыми'
EXPLAIN (ANALYZE)
SELECT id FROM customers WHERE city = 'city-7' AND region = 'region-7';
CREATE STATISTICS customers_city_region (dependencies) ON city, region FROM customers;
ANALYZE customers;
\echo '=== ПОСЛЕ: расширенная статистика о зависимости'
EXPLAIN (ANALYZE)
SELECT id FROM customers WHERE city = 'city-7' AND region = 'region-7';$ docker exec -i kb-pg-explain psql -U postgres -f - < 06-extended-stats.sql
=== ДО: планировщик считает условия независимыми
Seq Scan on customers (cost=0.00..2531.00 rows=194 width=8) (actual time=0.014..4.145 rows=1991 loops=1)
Filter: ((city = 'city-7'::text) AND (region = 'region-7'::text))
Rows Removed by Filter: 98009
Planning Time: 0.415 ms
Execution Time: 4.213 ms
=== ПОСЛЕ: расширенная статистика о зависимости
Seq Scan on customers (cost=0.00..2531.00 rows=1930 width=8) (actual time=0.010..3.743 rows=1991 loops=1)
Filter: ((city = 'city-7'::text) AND (region = 'region-7'::text))
Rows Removed by Filter: 98009
Planning Time: 0.175 ms
Execution Time: 3.790 ms- Оценка отличается от реальности в разы на нижних узлах — сначала
ANALYZEтаблицы, затем проверка autovacuum (pg_stat_user_tables.last_autoanalyze). - Неравномерное распределение — увеличить детализацию статистики столбца:
ALTER TABLE … ALTER COLUMN … SET STATISTICS 500(по умолчанию 100) и сноваANALYZE. - Связанные столбцы в одном условии или
GROUP BY—CREATE STATISTICSсdependenciesилиndistinct. - После массовой загрузки —
ANALYZEсразу, не дожидаясь autovacuum; документация советует это в разделе о наполнении базы.
N+1 из ORM и pg_stat_statements
N+1 — когда код сначала получает список (1 запрос), а потом для каждого элемента отдельно догружает связанные данные (N запросов). ORM делает это незаметно при «ленивой» загрузке связей. Каждый такой запрос быстрый, поэтому в логе медленных запросов его не видно, зато в pg_stat_statements он выдаёт себя числом вызовов.
-- Как ORM порождает N+1 и как это видно в pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT pg_stat_statements_reset() IS NOT NULL AS reset;
-- N+1: сначала список клиентов, потом по запросу на каждого (100 запросов)
\o /dev/null
SELECT format('SELECT id, total FROM orders WHERE customer_id = %s', id)
FROM customers ORDER BY id LIMIT 100 \gexec
\o
-- То же одним запросом
\o /dev/null
SELECT o.customer_id, o.id, o.total
FROM orders o
WHERE o.customer_id = ANY (ARRAY(SELECT id FROM customers ORDER BY id LIMIT 100));
\o
SELECT calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 3) AS mean_ms,
rows,
regexp_replace(query, '\s+', ' ', 'g') AS query
FROM pg_stat_statements
WHERE query LIKE '%FROM orders%' AND query NOT LIKE '%pg_stat_statements%'
ORDER BY calls DESC;$ docker exec -i kb-pg-explain psql -U postgres -f - < 07-n-plus-one.sql
calls | total_ms | mean_ms | rows | query
-------+----------+---------+------+------------------------------------------------------------------------------------------------------------------------------------
100 | 4.17 | 0.042 | 1254 | SELECT id, total FROM orders WHERE customer_id = $1
1 | 1.08 | 1.083 | 1254 | SELECT o.customer_id, o.id, o.total FROM orders o WHERE o.customer_id = ANY (ARRAY(SELECT id FROM customers ORDER BY id LIMIT $1))
(2 rows)Внутри базы сто коротких запросов заняли несколько миллисекунд, и это обманчиво: pg_stat_statements не учитывает сетевые задержки, а каждый из ста запросов — это отдельный круг «приложение — база». Исправление — загрузить связи заранее: JOIN, WHERE id = ANY($1) или режим жадной загрузки в ORM (например, include, select_related, JOIN FETCH — в зависимости от библиотеки).
SELECT calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows,
100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0) AS hit_percent,
left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;Типовые исправления и порядок работы
Найти
Топ по
total_exec_timeвpg_stat_statementsили логlog_min_duration_statement. Оптимизируют то, что суммарно дороже, а не самый медленный одиночный запрос.Воспроизвести
Тот же запрос с реальными параметрами и на данных близкого объёма: документация предупреждает, что план на игрушечной таблице не переносится на большую.
Прочитать план
EXPLAIN (ANALYZE, BUFFERS): самый дорогой узел (время ×loops, буферы) и расхождениеrowsсactual rows.Исправить одну причину
Индекс, переписывание или статистика — по одному изменению за раз, чтобы видеть эффект каждого.
Проверить
Снова план и снова
pg_stat_statementsчерез время: среднее время и буферы должны снизиться, а запись — не пострадать.
Seq Scan и большое Rows Removed by Filter
- Вероятная причина
- Нет индекса под условие
- Исправление
- Индекс; см. урок об индексах
Hash Join с Seq Scan большой таблицы ради нескольких строк
- Вероятная причина
- Нет индекса на столбец соединения (часто FK)
- Исправление
- Индекс на внешний ключ
Индекс есть, но Filter: (func(col) = …)
- Вероятная причина
- Функция или приведение типа над столбцом
- Исправление
- Диапазон без функции или индекс по выражению
rows=1, а actual rows — тысячи; Nested Loop с огромным loops
- Вероятная причина
- Устаревшая статистика
- Исправление
ANALYZE
Оценка занижена при нескольких условиях на связанные столбцы
- Вероятная причина
- Независимость условий
- Исправление
CREATE STATISTICS
SubPlan в фильтре и стоимость в миллионах
- Вероятная причина
NOT INили коррелированный подзапрос- Исправление
NOT EXISTS,JOIN
Sort Method: external merge Disk
- Вероятная причина
- Сортировке не хватило
work_mem - Исправление
- Индекс под
ORDER BY, меньше строк илиwork_memдля сессии — с замером
Упражнения
Дальше по теме — транзакции и изоляция: долгие запросы удлиняют транзакции, держат блокировки и увеличивают число конфликтов.
Как проверено4 · пройденосверено 11 октября 2026 г.
- запущено, работает
01-seed.sql … 08-exercises.sql (RU и EN варианты комментариев)
docker postgres:17-alpine (17.11) с -c shared_preload_libraries=pg_stat_statements -c log_min_duration_statement=200, свежий контейнер на каждый язык, psql -f по порядку; 04-not-in намеренно содержит ошибку statement_timeout (ожидаемый результат). Выводы и фрагмент серверного лога вставлены в статью скриптом сборки из файлов out-ru/out-en
- запущено, работает
утверждения о планах: Hash Join + Parallel Seq Scan → Nested Loop + Bitmap (FK-индекс); ::date → Seq Scan, диапазон → Bitmap Heap Scan, результаты равны; NOT IN — стоимость ~6,25e8 и таймаут 10 с, NOT EXISTS → Parallel Hash Right Anti Join; rows=1 vs 60000 и Nested Loop loops=60000 → Hash Join после ANALYZE; CREATE STATISTICS: оценка ≈190–210 → ≈1890–2100 при 1991 реальных (зависит от выборки ANALYZE); N+1: calls=100 против 1
сверено с выводом трёх прогонов (отладочный и два финальных); время — как пример на этой машине
- запущено, работает
work_mem: внешняя сортировка при 4MB против quicksort при 64MB (утверждение в callout)
extra/08-work-mem.sql, три прогона: external merge ≈ 600–690 мс, quicksort ≈ 780–920 мс; в статью вынесен только вывод
- запущено, работает
SQL-запрос «топ-10 по total_exec_time»
extra/09-top.sql на отдельном свежем контейнере с pg_stat_statements после 01-seed, 02-join-fk, 07-n-plus-one: 5 строк, без ошибок; extversion 1.11
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.