В плане стоит rows=20.00 loops=3, а рядом Buffers: shared hit=44 и Rows Removed by Filter: 980: часть этих чисел усреднена на один запуск узла, часть сложена за все запуски, а буферы родителя уже содержат буферы детей. Базовое чтение дерева, оценок и BUFFERS разобрано в статье про EXPLAIN ANALYZE BUFFERS; здесь продолжение с того места, где она остановилась: какое число умножать, какое вычитать, что показывают WAL, SETTINGS и SERIALIZE и сколько стоит сам замер времени.
Откуда взялось каждое число в плане
Стенд
Все опыты идут на одном контейнере. shared_buffers и параллельные воркеры зафиксированы, чтобы числа буферов повторялись; auto_explain подгружается заранее для последнего опыта:
docker run -d --name dba-explain-advanced-reading --memory 1500m -e POSTGRES_PASSWORD=pg \
postgres:18 -c shared_buffers=256MB -c max_parallel_workers_per_gather=0 \
-c shared_preload_libraries=auto_explain
docker exec -i dba-explain-advanced-reading psql -U postgres
CREATE TABLE customers AS SELECT g AS id, g % 10 AS region FROM generate_series(1,1000) g;
ALTER TABLE customers ADD PRIMARY KEY (id);
CREATE TABLE orders AS
SELECT g AS id, 1 + (g - 1) / 1000 AS customer_id,
CASE WHEN g % 50 = 0 THEN 'failed' ELSE 'ok' END AS status,
(g % 997) AS amount, md5(g::text) AS note
FROM generate_series(1,1000000) g;
ALTER TABLE orders ADD PRIMARY KEY (id);
CREATE INDEX orders_customer_idx ON orders(customer_id);
VACUUM ANALYZE customers, orders;
В orders 1 млн строк, по 1000 на клиента подряд; таблица занимает 82 МБ, или 10 368 страниц.
Опыт 1: Nested Loop и loops больше единицы
EXPLAIN (ANALYZE) SELECT c.id, o.id
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE c.id <= 3 AND o.status = 'failed';
Вывод, второй запуск (первый прогрел кэш); опущены Index Cond, Heap Fetches и строки Planning:
Nested Loop (cost=0.70..210.23 rows=62 width=8) (actual time=0.008..0.169 rows=60.00 loops=1)
Buffers: shared hit=47
-> Index Only Scan using customers_pkey on customers c (cost=0.28..4.33 rows=3 width=4) (actual time=0.001..0.001 rows=3.00 loops=1)
Index Searches: 1
Buffers: shared hit=3
-> Index Scan using orders_customer_idx on orders o (cost=0.42..68.42 rows=21 width=8) (actual time=0.005..0.054 rows=20.00 loops=3)
Filter: (status = 'failed'::text)
Rows Removed by Filter: 980
Index Searches: 3
Buffers: shared hit=44
Внутренний Index Scan, число за числом:
rows=20.00 loops=3— среднее за запуск. Внешняя сторона отдала трёх клиентов, внутренний узел запускался по разу на каждого, итог20 × 3 = 60, и ровноrows=60.00вышло изNested Loop. Оценкаrows=21тоже на один запуск: сравнивать её надо с20.00, не с 60.actual time=0.005..0.054— тоже на запуск: три запуска заняли около0.054 × 3 = 0.16мс из0.169мс родителя.Rows Removed by Filter: 980— среднее на запуск: отброшено980 × 3 = 2 940, а прочитано по20 + 980 = 1 000на клиента, что совпадает с данными.Index Searches: 3— не среднее, а сумма: три запуска, три поиска по индексу.Buffers: shared hit=44— сумма за три запуска, без деления. У родителяhit=47, и3 + 44 = 47: собственных страницNested Loopне читает.
Деление на loops видно в коде: ExplainNode берёт накопленные за все запуски ntuples и время и делит на nloops (explain.c, строки 1832-1835):
double nloops = planstate->instrument->nloops;
double startup_ms = 1000.0 * planstate->instrument->startup / nloops;
double total_ms = 1000.0 * planstate->instrument->total / nloops;
double rows = planstate->instrument->ntuples / nloops;
Rows Removed by Filter делится так же и печатается без дробной части (строки 3983-3984): 980 × 3 может отличаться от настоящего итога на единицы. Буферы не делятся: bufusage узла печатается как есть (строки 2285-2286). Дробные rows появились в PostgreSQL 18: в примечаниях к выпуску есть строка «Modify EXPLAIN to output fractional row counts», поэтому 20.00 вместо 20.
Опыт 2: Seq Scan с фильтром и буферы родителя
EXPLAIN (ANALYZE)
SELECT c.region, count(*), sum(o.amount)
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'failed' GROUP BY c.region;
В выводе опущены строки Group Key, Batches, Buckets и Planning:
HashAggregate (cost=23107.66..23107.76 rows=10 width=20) (actual time=31.836..31.839 rows=10.00 loops=1)
Buffers: shared hit=3282 read=7093
-> Hash Join (cost=29.50..22952.16 rows=20733 width=8) (actual time=0.133..30.287 rows=20000.00 loops=1)
Buffers: shared hit=3282 read=7093
-> Seq Scan on orders o (cost=0.00..22868.00 rows=20733 width=8) (actual time=0.007..28.078 rows=20000.00 loops=1)
Filter: (status = 'failed'::text)
Rows Removed by Filter: 980000
Buffers: shared hit=3275 read=7093
-> Hash (cost=17.00..17.00 rows=1000 width=8) (actual time=0.111..0.111 rows=1000.00 loops=1)
Buffers: shared hit=7
-> Seq Scan on customers c (cost=0.00..17.00 rows=1000 width=8) (actual time=0.005..0.041 rows=1000.00 loops=1)
Buffers: shared hit=7
Seq Scan по orders с loops=1: отдано 20 000, отброшено 980 000, прочитано 20 000 + 980 000 = 1 000 000 строк, фильтр пропустил 2 %. Буферы 3275 + 7093 = 10 368 равны числу страниц таблицы: узел прошёл её всю. Деление на hit и read зависит от кэша, сумма не зависит.
Буферы родителя включают детей, поэтому собственную работу узла находят вычитанием. HashAggregate читает 3282 + 7093 = 10 375, ровно столько же у ребёнка Hash Join: собственных 0. У Hash Join те же 10 375 против 10 368 + 7 у детей Seq Scan orders и Hash: снова 0. У Hash 7 и у его ребёнка 7. Если сложить все строки дерева, получится 10 375 + 10 375 + 10 368 + 7 + 7 = 31 132 против реальных 10 375: страницы посчитаны по три раза. Механизм виден в InstrStopNode: при выходе из узла к его счётчику прибавляется разница общих счётчиков между входом и выходом, а в этом интервале исполнялись и дочерние узлы (instrument.c, строки 104-107):
/* Add delta of buffer usage since entry to node's totals */
if (instr->need_bufusage)
BufferUsageAccumDiff(&instr->bufusage,
&pgBufferUsage, &instr->bufusage_start);
Время узла устроено так же: actual time родителя включает детей. Как выбирается вид соединения, разобрано в статье про алгоритмы соединения.
Опыт 3: запрос с записью и опция WAL
EXPLAIN ANALYZE выполняет UPDATE по-настоящему, поэтому запуск обёрнут в транзакцию с откатом. Перед ним CHECKPOINT, чтобы первая запись в страницу после контрольной точки несла полный образ (статья про full page writes):
CHECKPOINT;
BEGIN;
EXPLAIN (ANALYZE, WAL, COSTS OFF, SUMMARY OFF)
UPDATE orders SET amount = amount + 1 WHERE customer_id <= 10;
ROLLBACK;
Update on orders (actual time=33.254..33.255 rows=0.00 loops=1)
Buffers: shared hit=100838 read=111 dirtied=298 written=90
WAL: records=40414 fpi=150 bytes=4408495
-> Index Scan using orders_customer_idx on orders (actual time=0.005..1.435 rows=10000.00 loops=1)
Buffers: shared hit=106 read=66
Updateпоказываетrows=0.00: безRETURNINGузел ничего не отдаёт наверх, обновлённые 10 000 строк видны только в дочернемIndex Scan(rows=10000.00).- Буферы родителя:
100 838 + 111 = 100 949; ребёнка:106 + 66 = 172. Разность100 949 − 172 = 100 777— собственная работаUpdate: страницы таблицы и двух индексов с новыми версиями. В отличие от соединений в опытах 1 и 2, здесь у родителя есть собственные буферы. WAL: records=40414 fpi=150 bytes=4408495— число записей журнала, полных образов страниц (fpi) и байтов, сгенерированных узлом. Эти записи сгенерированы во время выполнения, доROLLBACK.
Опыт 4: BUFFERS по умолчанию, SETTINGS и SERIALIZE
BUFFERS. В примечаниях к PostgreSQL 18 есть строка «Automatically include BUFFERS output in EXPLAIN ANALYZE». Проверка: BUFFERS не указан, а строки Buffers есть:
EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF) SELECT count(*) FROM customers;
Aggregate (actual time=0.057..0.057 rows=1.00 loops=1)
Buffers: shared hit=7
-> Seq Scan on customers (actual time=0.005..0.030 rows=1000.00 loops=1)
Buffers: shared hit=7
SETTINGS печатает параметры планирования, отличающиеся от встроенных значений; он нужен, когда план с одного сервера не воспроизводится на другом:
CREATE INDEX orders_amount_idx ON orders(amount);
VACUUM ANALYZE orders;
EXPLAIN (SETTINGS, COSTS OFF) SELECT * FROM orders WHERE amount < 400;
SET random_page_cost = 1.1;
SET work_mem = '64MB';
EXPLAIN (SETTINGS, COSTS OFF) SELECT * FROM orders WHERE amount < 400;
RESET work_mem;
EXPLAIN (SETTINGS, COSTS OFF) SELECT * FROM orders WHERE amount < 400;
Из каждого плана оставлены корневой узел и строка Settings:
Bitmap Heap Scan on orders
Settings: max_parallel_workers_per_gather = '0'
Index Scan using orders_amount_idx on orders
Settings: max_parallel_workers_per_gather = '0', random_page_cost = '1.1', work_mem = '64MB'
Index Scan using orders_amount_idx on orders
Settings: max_parallel_workers_per_gather = '0', random_page_cost = '1.1'
max_parallel_workers_per_gather = '0' попал в вывод из флага запуска контейнера: SETTINGS видит и конфигурацию сервера, не только SET сессии. План сменился с Bitmap Heap Scan на Index Scan; в третьем запуске без work_mem план тот же, значит причина — random_page_cost, а work_mem просто присутствует в списке. Как параметр входит в стоимость, показано в статье про модель стоимости, а как work_mem ограничивает узлы — в статье про сортировки и спилл.
SERIALIZE (с PostgreSQL 17) считает время и объём преобразования результата в формат отправки. Таблица docs: 4 000 строк по 20 000 байт (length и pg_column_size равны, сжатия нет), основная часть 208 кБ, вместе с TOAST 87 МБ (статья про TOAST):
CREATE TABLE docs AS
SELECT g AS id, (SELECT string_agg(md5(random()::text || g::text || i::text), '')
FROM generate_series(1, 625) i) AS body
FROM generate_series(1, 4000) g;
VACUUM ANALYZE docs;
EXPLAIN (ANALYZE, COSTS OFF) SELECT * FROM docs;
EXPLAIN (ANALYZE, SERIALIZE, COSTS OFF) SELECT * FROM docs;
Seq Scan on docs (actual time=0.013..0.200 rows=4000.00 loops=1)
Buffers: shared hit=26
Execution Time: 0.332 ms
Seq Scan on docs (actual time=0.001..0.193 rows=4000.00 loops=1)
Buffers: shared hit=26
Serialization: time=18.093 ms output=78179kB format=text
Buffers: shared hit=22001
Execution Time: 18.525 ms
Без SERIALIZE запрос «стоит» 0,332 мс: значения из TOAST не читались, 26 страниц кучи. С ним добавилось 18,093 мс, 78 179 кБ и 22 001 страница TOAST. Тот же SELECT * FROM docs в psql с выводом в /dev/null занял по \timing 39,6; 30,0; 30,1; 30,0 и 32,2 мс: порядок тот же, что у SERIALIZE, а не у 0,3 мс обычного плана. Если запрос «быстрый в EXPLAIN, медленный в приложении», первым делом пробуй SERIALIZE.
Опыт 5: сколько стоит TIMING
Таблица big: 10 млн строк, 346 МБ. Один и тот же SELECT count(*) FROM big тремя способами, по пять запусков, время клиента из \timing (вывод в /dev/null):
CREATE TABLE big AS SELECT g AS id FROM generate_series(1, 10000000) g;
VACUUM ANALYZE big;
Запросы: SELECT count(*) FROM big;, он же под EXPLAIN (ANALYZE, TIMING on) и под EXPLAIN (ANALYZE, TIMING off).
| Режим | Пять запусков, мс | Медиана, мс |
|---|---|---|
| без EXPLAIN | 167,7; 153,7; 153,2; 149,7; 150,1 | 153,2 |
TIMING off | 238,8; 237,3; 237,3; 236,8; 237,7 | 237,3 |
TIMING on | 475,5; 482,8; 471,8; 475,2; 472,5 | 475,2 |
Замер замедлил запрос в 475,2 / 153,2 = 3,1 раза: TIMING on добавил 475,2 − 153,2 = 322,0 мс на 10 млн строк, 32 нс на строку. TIMING off убирает большую часть: 237,3 − 153,2 = 84,1 мс, 8 нс на строку (подсчёт строк остаётся, а времени узлов в плане нет). Цена одного обращения к часам в этом контейнере:
$ docker exec dba-explain-advanced-reading pg_test_timing -d 3
Per loop time including overhead: 13.01 ns
Два обращения по 13 нс — 26 нс из 32 нс на строку. Они видны в InstrStopNode: при включённом таймере узел читает часы на каждом вызове (instrument.c, строки 93-99), а Seq Scan вызывается по разу на каждую отданную строку. Узел, который отдаёт мало строк, платит мало; узел с десятками миллионов вызовов платит за каждый. Число 13 нс относится к этому хосту: на другом его надо мерить своим pg_test_timing.
Опыт 6: auto_explain и вложенные запросы
Функция выполняет запрос внутри, снаружи виден только вызов:
CREATE FUNCTION orders_total(cid int) RETURNS bigint LANGUAGE plpgsql AS $$
BEGIN
RETURN (SELECT sum(amount) FROM orders WHERE customer_id = cid);
END $$;
SET auto_explain.log_min_duration = 0;
SET auto_explain.log_analyze = on;
SET auto_explain.log_buffers = on;
SET auto_explain.log_nested_statements = on;
SELECT orders_total(7);
В логе сервера (docker logs dba-explain-advanced-reading) две записи: сначала запрос внутри функции, затем вызов. С выключенным log_nested_statements в логе была только вторая, один узел Result:
LOG: duration: 0.113 ms plan:
Query Text: (SELECT sum(amount) FROM orders WHERE customer_id = cid)
Result (cost=38.37..38.38 rows=1 width=8) (actual time=0.110..0.111 rows=1.00 loops=1)
Buffers: shared hit=18
InitPlan 1
-> Aggregate (cost=38.36..38.37 rows=1 width=8) (actual time=0.109..0.110 rows=1.00 loops=1)
CONTEXT: PL/pgSQL expression "(SELECT sum(amount) FROM orders WHERE customer_id = cid)"
PL/pgSQL function orders_total(integer) line 3 at RETURN
LOG: duration: 0.214 ms plan:
Query Text: SELECT orders_total(7)
Result (cost=0.00..0.26 rows=1 width=8) (actual time=0.212..0.213 rows=1.00 loops=1)
Buffers: shared hit=21
Строка CONTEXT называет функцию и номер строки. Вызов hit=21 включает вложенные hit=18, как родитель включает детей. log_analyze на всех запросах дорог по тем же причинам, что TIMING on: в документации auto_explain для этого есть log_timing = off.
Что из этого следует
- Сначала определи, что в числе усреднено.
rows,actual time,Rows Removed by Filterумножай наloops;BuffersиIndex Searchesне трогай. Оценку планировщика сравнивай со средним, не с итогом. - Буферы и время узла читай как разность с детьми. У
Hash Joinсобственных страниц не нашлось, уUpdateнашлись 100 777; сумму по дереву не бери. - Запись видна в
WAL, а не вBuffers: дляUPDATEиDELETEсмотриrecords,fpiиbytes; откат не освобождает журнал. - План с другого сервера просит
SETTINGS, запрос из приложения проситSERIALIZE. Замер времени тоже стоит:TIMING onутроил время запроса на 10 млн строк.
Чек-лист
- В каждом узле с
loops > 1умножьrowsиRows Removed by Filterнаloopsи не умножайBuffers. - Чтобы найти узел, который сам читает страницы, вычти буферы детей из буферов родителя.
- Для DML запускай
EXPLAIN (ANALYZE, WAL)внутриBEGIN ... ROLLBACKи сравнивайrecords,fpiиbytes. - Сохраняй план вместе с
SETTINGS, а для широких значений TOAST добавляйSERIALIZE. - На узлах с миллионами строк сверяй
TIMING off, а для функций включайauto_explain.log_nested_statements. - Контейнер опыта убирается командой
docker rm -f -v dba-explain-advanced-reading.