Справочник DBA: базы данных изнутри читается по порядку

Статья 10.3 · раздел 10 «Мониторинг и диагностика» · 73 из 109

pg_stat_statements: нормализация, queryid, разность снимков и ловушка среднего

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18 (--memory 1500m), pg_stat_statements 1.12, compute_query_id = on, track_io_timing = on; pgbench scale 20 (2 млн строк в pgbench_accounts, база 307 МБ), окно нагрузки 60 с; pg_stat_kcache не запускался; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Сервер «тормозит», а top и логи не говорят, какой запрос приложения виноват. pg_stat_statements ведёт накопительные счётчики по каждому типу запроса: вызовы, время, блоки, WAL, временные файлы. Три вещи делают их пригодными для разбора. Запросы с разными константами склеиваются в одну строку, и её ключ queryid виден и в плане, и в списке активных сессий. Счётчики растут с момента сброса, поэтому смотреть надо разность двух снимков, а не итог. И среднее время скрывает редкие выбросы, видные только в max_exec_time и stddev_exec_time. Расширение уже встречалось при поиске регрессий планов; читать сам план помогает EXPLAIN ANALYZE BUFFERS. Здесь все три механизма проверены на нагрузке pgbench с одним испорченным запросом.

DBA / 01

Два снимка, разность и пять топов

Два снимка, разность и пять топов Снимок 2 в 00:52:21 и снимок 6 в 00:53:21 вычитаются друг из друга. Разность даёт пять топов за 60 секунд: по времени первым идёт UPDATE pgbench_branches, 16 913 мс; по вызовам BEGIN, 97 642; по чтению блоков и по временным файлам первым идёт SELECT по bid без индекса, 1 307 995 блоков и 609 МБ; по WAL первым идёт UPDATE pgbench_accounts, 14 МБ. Максимум и стандартное отклонение не вычитаются, их окно получают сбросом minmax_only. снимок 2 · 00:52:21 счётчики после сброса снимок 6 · 00:53:21 те же счётчики через 60 с Δ снимков 6 и 2 calls, время, блоки, WAL max и stddev не вычитаются: окно — reset с minmax_only по времени UPDATE branches: 16 913 мс по вызовам BEGIN: 97 642 по чтению блоков SELECT по bid: 1 307 995 по WAL UPDATE accounts +: 14 МБ по temp SELECT по bid: 609 МБ
Топ по времени выбирает тяжёлые, но нормальные запросы нагрузки; испорченный SELECT без индекса стоит на первом месте только по чтению блоков и по временным файлам. Максимум за окно получают отдельным сбросом, а не вычитанием.

Стенд

Контейнер запускается с библиотекой в shared_preload_libraries; без перезапуска сервера её не подключить. compute_query_id = on включает расчёт queryid в ядре, track_io_timing = on добавляет время чтения блоков:

C=dba-pg-stat-statements-workload-analysis
docker run -d --name $C --memory 1500m -e POSTGRES_PASSWORD=pg postgres:18 \
  -c shared_preload_libraries=pg_stat_statements -c compute_query_id=on -c track_io_timing=on
docker exec $C psql -U postgres -c "CREATE EXTENSION pg_stat_statements"
docker exec $C pgbench -U postgres -i -s 20 -q postgres

SELECT version() вернул PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2), расширение — версия 1.12. В pgbench_accounts 2 000 000 строк, вся база 307 МБ при shared_buffers = 128MB и work_mem = 4MB.

Опыт 1: константы уходят, queryid остаётся

Три запроса отличаются только значением aid:

SELECT pg_stat_statements_reset();
SELECT abalance FROM pgbench_accounts WHERE aid = 17;
SELECT abalance FROM pgbench_accounts WHERE aid = 250000;
SELECT abalance FROM pgbench_accounts WHERE aid = 1999999;
SELECT queryid, query, calls, rows FROM pg_stat_statements WHERE query LIKE '%pgbench_accounts WHERE aid%' AND query NOT LIKE '%pg_stat%';

вывод (в psql включён \x on):

-[ RECORD 1 ]-------------------------------------------------
queryid | 5409612748292285639
query   | SELECT abalance FROM pgbench_accounts WHERE aid = $1
calls   | 3
rows    | 3

Одна строка, три вызова. Тот же идентификатор печатает EXPLAIN (VERBOSE):

EXPLAIN (VERBOSE, COSTS OFF) SELECT abalance FROM pgbench_accounts WHERE aid = 17;

вывод:

                            QUERY PLAN                             
-------------------------------------------------------------------
 Index Scan using pgbench_accounts_pkey on public.pgbench_accounts
   Output: abalance
   Index Cond: (pgbench_accounts.aid = 17)
 Query Identifier: 5409612748292285639
(4 rows)

У работающей сессии идентификатор лежит в pg_stat_activity.query_id. В одной сессии выполняется SELECT pg_sleep(4), abalance FROM pgbench_accounts WHERE aid = 42, из другой читается query_id (вывод: -551396630635601600), после завершения по нему ищется строка статистики:

SELECT queryid, query, calls, round(total_exec_time::numeric) AS total_ms FROM pg_stat_statements WHERE queryid = -551396630635601600;

вывод:

       queryid       |                               query                                | calls | total_ms 
---------------------+--------------------------------------------------------------------+-------+----------
 -551396630635601600 | SELECT pg_sleep($1), abalance FROM pgbench_accounts WHERE aid = $2 |     1 |     4003

Идентификатор считается не по тексту, а по дереву разбора. В структуре Const значение отмечено атрибутом query_jumble_ignore, а комментарий над ней в REL_18_6 говорит: «Only the constant type OID is relevant for the query jumbling». Вместо значения хеш получает тип, а положение константы в тексте запоминает RecordConstLocation (queryjumblefuncs.c, строки 387–398):

/*
 * Record the location of some kind of constant within a query string.
 * These are not only bare constants but also expressions that ultimately
 * constitute a constant, such as those inside casts and simple function
 * calls; if extern_param, then it corresponds to a PARAM_EXTERN Param.
 *
 * If length is -1, it indicates a single such constant element.  If
 * it's a positive integer, it indicates the length of a squashable
 * list of them.
 */
static void
RecordConstLocation(JumbleState *jstate, bool extern_param, int location, int len)

Положения нужны затем, чтобы вместо каждой константы в тексте подставить $n. Это делает pgss_store (pg_stat_statements.c, строки 1348–1355), и только при создании записи (блок if (!entry), строка 1334):

		if (jstate)
		{
			LWLockRelease(pgss->lock);
			norm_query = generate_normalized_query(jstate, query,
												   query_location,
												   &query_len);
			LWLockAcquire(pgss->lock, LW_SHARED);
		}

Отсюда следует, что query — текст первого вызова с подставленными $n. Список констант в IN в PostgreSQL 18 тоже сворачивается: после сброса ... WHERE aid IN (1, 2, 3) и ... IN (1, 2, 3, 4, 5, 6, 7) дали одну строку с calls = 2 и текстом SELECT count(*) FROM pgbench_accounts WHERE aid IN ($1 /*, ... */).

Опыт 2: таблица снимков и разность

Счётчики копятся с момента stats_since, а не за последнюю минуту. Чтобы увидеть, что происходило в окне, нужны два среза. Таблица и функция, которые их пишут, — это всё, что нужно:

CREATE TABLE pgss_snap AS
  SELECT 0 AS snap_id, now() AS ts, s.* FROM pg_stat_statements s WITH NO DATA;
CREATE INDEX ON pgss_snap (snap_id, queryid);
CREATE SEQUENCE pgss_snap_seq;
CREATE FUNCTION pgss_take_snapshot() RETURNS int LANGUAGE sql AS $$
  WITH id AS (SELECT nextval('pgss_snap_seq')::int AS n)
  INSERT INTO pgss_snap SELECT id.n, now(), s.* FROM id, pg_stat_statements s
  RETURNING snap_id
$$;

Разность двух снимков — представление, в которое подставлены номера 2 и 6:

CREATE VIEW pgss_delta AS
SELECT b.queryid, b.query, b.calls - COALESCE(a.calls, 0) AS calls,
       b.total_exec_time - COALESCE(a.total_exec_time, 0) AS total_ms,
       b.shared_blks_read - COALESCE(a.shared_blks_read, 0) AS blks_read,
       b.shared_blks_hit - COALESCE(a.shared_blks_hit, 0) AS blks_hit,
       b.shared_blks_dirtied - COALESCE(a.shared_blks_dirtied, 0) AS blks_dirtied,
       b.wal_bytes - COALESCE(a.wal_bytes, 0) AS wal_bytes,
       b.temp_blks_written - COALESCE(a.temp_blks_written, 0) AS temp_written
FROM pgss_snap b
LEFT JOIN pgss_snap a ON a.queryid = b.queryid AND a.snap_id = 2
WHERE b.snap_id = 6;

LEFT JOIN с COALESCE нужен для запросов, которых не было в первом снимке. Разностью можно получить только аддитивные счётчики. Среднее восстанавливается как Δ total_exec_time / Δ calls, а min, max и stddev вычитанием не получить.

Нормальную нагрузку даёт tpcb-like, а испорченный запрос лежит в собственном скрипте bad.pgb:

\set bid random(1, 20)
SELECT * FROM pgbench_accounts WHERE bid = :bid ORDER BY filler;

По bid индекса нет, а 100 000 строк одного bid с сортировкой по filler не помещаются в work_mem. Запуск на 62 секунды, снимки каждые 15 секунд (скрипты сначала копируются docker cp в /tmp контейнера):

docker exec -d $C bash -c "printf 'SELECT pgss_take_snapshot();\n\\\\watch i=15 c=5\n' | psql -U postgres -q"
docker exec -d $C pgbench -U postgres -n -f /tmp/bad.pgb -c 1 -R 1 -T 62 postgres
docker exec $C pgbench -U postgres -n -b tpcb-like -c 4 -j 2 -T 62 postgres

pgbench показал 100 620 транзакций и 1622,9 tps. Снимки 1 и 2 сделаны одновременно (первый запуск запроса и первая итерация \watch), поэтому окно считается от снимка 2 до снимка 6: с 00:52:21 до 00:53:21, ровно 60 секунд. Пять топов:

SELECT left(query,50) q, calls, round(total_ms) total_ms FROM pgss_delta WHERE calls>0 ORDER BY total_ms DESC LIMIT 5;
SELECT left(query,50) q, calls FROM pgss_delta WHERE calls>0 ORDER BY calls DESC LIMIT 5;
SELECT left(query,50) q, blks_read, blks_hit FROM pgss_delta ORDER BY blks_read DESC LIMIT 5;
SELECT left(query,50) q, pg_size_pretty(wal_bytes) wal FROM pgss_delta ORDER BY wal_bytes DESC LIMIT 5;
SELECT left(query,50) q, temp_written, pg_size_pretty(temp_written*8192) temp FROM pgss_delta ORDER BY temp_written DESC LIMIT 5;

вывод (по одному на метрику, Δ за окно):

=== по времени
 UPDATE pgbench_branches SET bbalance = bbalance +  | 97617 |    16913
 SELECT pg_sleep($1)                                |    24 |     7205
 SELECT * FROM pgbench_accounts WHERE bid = $1 ORDE |    60 |     3446
=== по вызовам
 BEGIN                                              | 97642
=== по чтению блоков (read | hit)
 SELECT * FROM pgbench_accounts WHERE bid = $1 ORDE |   1307995 |   685132
 UPDATE pgbench_accounts SET abalance = abalance +  |     91721 |   687119
=== по WAL
 UPDATE pgbench_accounts SET abalance = abalance +  | 14 MB
 UPDATE pgbench_branches SET bbalance = bbalance +  | 7663 kB
=== по temp (блоков | размер)
 SELECT * FROM pgbench_accounts WHERE bid = $1 ORDE |        77892 | 609 MB

(у каждого топа оставлены первые строки). Что видно:

  • По времени испорченный SELECT только третий, 3446 мс против 16 913 мс у UPDATE pgbench_branches. Вторая строка, pg_sleep, — не запрос приложения, а сон скрипта-блокировщика из опыта 3 внутри транзакции.
  • По вызовам лидирует служебный BEGIN (97 642): этот топ отвечает на другой вопрос, чем время, — что приложение гоняет чаще всего.
  • По чтению блоков испорченный запрос первый с большим отрывом: 1 307 995 блоков за 60 вызовов, около 21 800 блоков на вызов, а hit у него всего 685 132 из 1 993 127. Это shared_blks_read, то есть блоки не из shared_buffers; из диска они пришли или из кэша файловой системы, счётчик не различает. Различает shared_blk_read_time при track_io_timing.
  • По WAL первой идёт нормальная запись: UPDATE pgbench_accounts оставил 14 МБ за 97 618 вызовов.
  • По temp только он: 77 892 блока, 609 МБ за окно, около 10 МБ на вызов. План подтверждает причину:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT * FROM pgbench_accounts WHERE bid = 7 ORDER BY filler;

вывод (отфильтрован grep -E 'Sort Method|Query Identifier|Seq Scan|Buffers', повторы рабочих процессов убраны):

   Buffers: shared hit=12552 read=20838 dirtied=11, temp read=1313 written=1316
         Sort Method: external merge  Disk: 3208kB
         ->  Parallel Seq Scan on public.pgbench_accounts (actual rows=33333.33 loops=3)
 Query Identifier: 2631783761524797523

Query Identifier: 2631783761524797523 — queryid, под которым этот запрос стоял в pg_stat_statements до сброса. Что значат external merge и Disk, объясняет спилл сортировки.

Опыт 3: ловушка среднего

pg_stat_statements хранит mean_exec_time, max_exec_time и stddev_exec_time. Среднее — единственное, что смотрят в обзорах, и именно оно гасит редкий выброс. Чтобы проверить, скрипт hot.pgb обновляет случайную из тысячи строк:

\set aid random(1, 1000)
UPDATE pgbench_accounts SET abalance = abalance - 1 WHERE aid = :aid;

Параллельно скрипт locker.pgb раз в две секунды открывает транзакцию, правит такую же строку и держит её 0,3 секунды (pg_sleep(0.3), потом COMMIT). Запись, которая попала на эту строку, ждёт блокировку, остальные нет. За 62 секунды hot.pgb вызвал запрос 18 638 раз, блокировщик 24. Сортировка по среднему:

SELECT left(query,40) q, calls, round(mean_exec_time::numeric,3) mean, round(max_exec_time::numeric,1) max,
       round(stddev_exec_time::numeric,2) sd, round((max_exec_time/mean_exec_time)::numeric) max_per_mean
FROM pg_stat_statements WHERE query ~ 'abalance - |abalance \+ |bbalance|tbalance' ORDER BY mean_exec_time;

вывод:

                    q                     | calls  | mean  |  max  |  sd  | max_per_mean 
------------------------------------------+--------+-------+-------+------+--------------
 UPDATE pgbench_tellers SET tbalance = tb | 100620 | 0.023 |  13.1 | 0.17 |          573
 UPDATE pgbench_accounts SET abalance = a | 100620 | 0.029 |   1.3 | 0.02 |           45
 UPDATE pgbench_accounts SET abalance = a |  18638 | 0.031 | 182.5 | 1.49 |         5802
 UPDATE pgbench_branches SET bbalance = b | 100620 | 0.175 |  79.6 | 0.98 |          455

Третья строка — запрос из hot.pgb. Его среднее 0,031 мс неотличимо от соседнего UPDATE pgbench_accounts (0,029 мс), но максимум 182,5 мс: в 5802 раза больше среднего против 45 у соседа. У branches среднее выше (0,175 мс), там 20 строк на четырёх клиентов tpcb-like, и это постоянная очередь, а не редкий выброс. Ранжировать нужно и по max_exec_time / mean_exec_time, и по stddev_exec_time, а не по одному среднему.

Максимум в pg_stat_statements — не окно, а значение с minmax_stats_since. Для окна сбрасывают только его: pg_stat_statements_reset(0, 0, 0, true) обнуляет min и max, не трогая вызовы и время. После этого hot.pgb отработал ещё 20 секунд без блокировщика:

                    q                     | calls  | mean  | max |  sd  | max_per_mean 
------------------------------------------+--------+-------+-----+------+--------------
 UPDATE pgbench_accounts SET abalance = a |  24585 | 0.028 | 0.1 | 1.30 |            2

max стал 0,1 мс, а stddev_exec_time остался 1,30: minmax_only его не трогает, и выброс из первого прогона в нём остался (mean и calls тоже накопительные). Поэтому сброс min/max делают в начале каждого окна вместе с новым снимком, а stddev годится для первого взгляда.

Что из этого следует

  • Строка статистики — тип запроса, а не вызов: константы там не хранятся. Конкретный медленный вызов ищут в логе (log_min_duration_statement) и сопоставляют по queryid.
  • Накопительные итоги стареют: снимай статистику в таблицу по расписанию и читай разность, полный сброс стирает историю.
  • Пять топов отвечают на разные вопросы. Испорченный запрос опыта третий по времени и первый по чтению блоков и по temp, по одному времени его не найти.
  • Среднее прячет хвост: нужны max_exec_time, stddev_exec_time и сброс minmax_only в начале окна.
  • pg_stat_kcache (расширение сообщества, отдельное от contrib) по README добавляет к каждому queryid процессорное время и реальные чтения файловой системы. В этом опыте он не запускался, цифр по нему нет.

Чек-лист

  1. shared_preload_libraries = 'pg_stat_statements' и compute_query_id = on (в опыте также track_io_timing = on).
  2. Таблица снимков и разность: снимай раз в минуту-пять, храни недели, pg_stat_statements_reset(0, 0, 0, true) — в начале окна.
  3. Смотри все пять топов, а не один: время, вызовы, shared_blks_read, wal_bytes, temp_blks_written; для хвоста добавь max_exec_time / mean_exec_time.
  4. Следи за ёмкостью: pg_stat_statements.max по умолчанию 5000 записей, счётчик вытеснений dealloc лежит в pg_stat_statements_info (в опыте 0).
  5. Контейнер с данными опыта убери вместе с томом: docker rm -f -v dba-pg-stat-statements-workload-analysis.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru