pg_stat_statements выглядит как полный журнал того, что выполняла база: строка на форму запроса, счётчики вызовов, время, строки, буферы. На практике это не журнал, а агрегат по ключу из четырёх полей, и именно устройство ключа, нормализацияЗамена констант в тексте запроса на нумерованные параметры $1..$N, чтобы запросы одной формы получали одинаковый queryId и попадали в одну запись., вытеснения и разделяемой памяти определяет, что вы увидите, а что исчезнет. Ниже — механика по шагам: как формируется запись, что в неё попадает, при каких условиях она вытесняется или не создаётся вовсе, и почему запросы агента в этой картине либо сливаются с запросами приложения, либо пропадают.
Что хранит запись и под какими блокировками
Статистика каждой формы запроса лежит в структуре Counters внутри записи pgssEntry. Значительная часть полей — массивы размером PGSS_NUMKINDконстанта, задающая число видов учёта в записи; её индексы различают планирование и выполнение: calls (число планирований и выполнений), total_time (суммарное время планирования и выполнения в миллисекундах), min_time, max_time, mean_time и sum_var_time. Отдельно хранятся rows — общее число полученных или затронутых строк, счётчики буферов (shared_blks_hit/read/dirtied/written, local_blks_*, temp_blks_read/written), времена чтения и записи блоков, generic_plan_calls/custom_plan_calls (сколько раз использовался обобщённый и сколько раз специализированный план).
Разделение блокировок отражает разделение задач. Поиск или создание записи в хэше и удержание блокировки на время обновления выполняются под LWLockлегковесная блокировка, допускающая параллельное чтение и монопольную запись pgss->lock. Само обновление счётчиков идёт под спинлокомкороткая блокировка без засыпания, удерживаемая на время нескольких инструкций entry->mutex. То есть пока один процесс пишет числа в запись, другой может параллельно искать другие записи под разделяемым локом, но не может переписать ту же запись.
Текст запроса живёт вне разделяемой памяти
В хэш-таблице pgss_hash для каждой записи хранятся только счётчики и метаданные текста — смещение query_offset и длина query_len. Сам текст лежит в отдельном файле PGSS_TEXT_FILE (PG_STAT_TMP_DIR "/pgss_query_texts.stat"). При сериализации на диск текст извлекается по этим координатам через qtext_fetch, и запись пишется в файл подряд: сначала структура записи, сразу за ней текст с завершающим нулём.
Вынесение текста из разделяемой памяти позволяет держать в общей памяти только компактные счётчики, а строки запросов — в файле, который при остановке сервера удаляется через unlink(PGSS_TEXT_FILE). Внешний файл читается целиком в буфер: если файл прочитать не удаётся, ошибка не выбрасывается, а работа продолжается без данных о текстах.
Как формируется ключ: queryId и нормализация
Запись создаётся только при ненулевом queryId. Если queryId равен нулю, функция сохранения немедленно возвращается, и запись не появляется. Ненулевой идентификатор означает, что его вычислили либо при compute_query_id = on, либо другой модуль, вызвавший EnableQueryId() при compute_query_id = auto. При compute_query_id = off идентификатор не вычисляется, при on — вычисляется всегда, а при auto — только если его включил какой-либо модуль.
Дальше queryId кладётся в ключ вместе с идентификатором пользователя, базой данных и признаком верхнего уровня вложенности. Если записи нет, при наличии состояния нормализации создаётся нормализованный текст, текст сохраняется в файл, и только затем под исключительной блокировкой создаётся новая запись. Если запись в файл не удалась, создание записи прекращается.
Нормализация заменяет константы на $1..$N. Список местоположений констант заполняет RecordConstLocation, а длины вычисляет ComputeConstantLengths. В цикле пропускаются константы, пришедшие извне текста запроса (если нет сжатых списков), и элементы с отрицательной длиной — дубликаты. Номер параметра вычисляется как число уже заменённых констант плюс единица плюс наибольший номер внешнего параметра, уже присутствующего в исходном тексте запроса. Для сжатого списка констант — списка, который нормализатор сворачивает в один параметр, — к номеру добавляется пометка /*, ... */.
Нумерация начинается со следующего номера после наибольшего $n в исходном тексте, или с $1, если такого не было. Это нужно, чтобы не конфликтовать со скрытыми параметрами — например, оператор PL/pgSQL SELECT i + 1 INTO j получает представительный текст вида SELECT i + $2.
EXECUTE, PREPARE и utility-команды
Для EXECUTE идентификатор обнуляется, чтобы статистика накапливалась на нижележащем PREPARE. Это делается только при включённом отслеживании utility-команд, чтобы не мешать другому расширению. Сам PREPARE тоже не отслеживается: иначе появилась бы одна запись с хешем от строки запроса и другая — с хешем от дерева запроса для последующих EXECUTE.
Utility-команды принудительно получают queryId = 0, даже если внутри есть оптимизируемый запрос (например, EXPLAIN или DECLARE CURSOR), потому что учёт уже ведётся на уровне utility и хуки исполнителя не должны ничего делать. Для группировки это означает, что EXECUTE не учитывается отдельно и не увеличивает уровень вложенности, а его циклы относятся к PREPARE.
Что сжимается и что сливается
Списки констант (IN-списки) сжимаются: если список подлежит сжатию и узел является ArrayExpr с положительными list_start и list_end, вместо перечисления всех элементов записывается одна позиция с длиной (list_end - list_start) - 1. Запросы, отличающиеся только числом элементов такого списка, получают одинаковый query_id.
В параметрах джамблятВычисление хеша queryId по дереву разобранного запроса, при котором часть полей (например, paramtypmod, paramcollid, список имён столбцов в eref) игнорируется, а списки констант сжимаются.ся вид, идентификатор и тип, а paramtypmod и paramcollid игнорируются. Записи о местоположениях сортируются по позиции, дубликаты (кроме первого) получают длину -1 и игнорируются, а сжатые списки пропускаются.
Путь запроса через хуки
_PG_init сначала проверяет, что модуль загружается через shared_preload_libraries: если нет — функция возвращается и хуки не устанавливаются. Затем вызывается EnableQueryId(), определяются GUC-переменные, регистрируются потребности в разделяемой памяти и подменяются хуки: pgss_post_parse_analyze, pgss_planner, pgss_ExecutorStart, pgss_ExecutorRun, pgss_ExecutorFinish, pgss_ExecutorEnd, pgss_ProcessUtility.
pgss_ExecutorStart при ненулевом queryId и включённом трекинге добавляет INSTRUMENT_ALL в опции инструментирования, затем вызывает предыдущий или стандартный ExecutorStart. pgss_ExecutorRun и pgss_ExecutorFinish только увеличивают уровень вложенности вокруг вызова и уменьшают его в PG_FINALLY. pgss_ExecutorEnd при ненулевом queryId, наличии инструментирования и включённом трекинге вызывает сохранение с видом PGSS_EXEC, после чего вызывает предыдущий или стандартный ExecutorEnd.
Для PGSS_PLAN сохранение вызывается в хуке планировщика после завершения планирования, когда включён pgss_track_planning: передаются время в миллисекундах, rows = 0, использования буферов и WAL, нулевые счётчики воркеров. Для PGSS_EXEC передаются rows = es_total_processed, использования буферов и WAL из инструментирования, JIT-статистика, счётчики параллельных воркеров. Дельты буферов считаются обнулением структуры и вызовом BufferUsageAccumDiff, дельты WAL — аналогично через WalUsageAccumDiff. Вид (PGSS_PLAN или PGSS_EXEC) используется как позиция массива в Counters.
Дисперсия по методу Уэлфорда
При первом вызове, когда calls[kind] == 1, значения min_time, max_time и mean_time сразу устанавливаются равными total_time. При повторных вызовах среднее обновляется инкрементально: сохраняется старое значение, к среднему прибавляется (total_time - old_mean) / calls[kind], а в sum_var_time[kind] накапливается произведение (total_time - old_mean) * (total_time - mean_time). При выводе стандартное отклонение вычисляется как sqrt(sum_var_time[kind] / calls[kind]) при calls[kind] > 1, иначе 0.0. Используется именно population varianceдисперсия по всей совокупности, без поправки Бесселя: данные есть для всей популяции, поэтому деления на calls - 1 нет.
Вложенные вызовы и видимость текста
При track = 'top' (значение по умолчанию) учитываются только операторы верхнего уровня, отправленные клиентом напрямую. Вложенные операторы внутри функций не попадают в представление, и клиент видит только сам вызов: при вызове SELECT orders_for(42) в представлении остаётся строка с текстом SELECT orders_for($1), а SELECT внутри тела функции отсутствует. DO-блок сохраняет весь текст как один utility-оператор и скрывает содержимое. При track = 'all' внутренние операторы возвращаются как строки с признаком toplevel = false: для функции текст читается как WHERE customer_id = cid, а для DO-блока — = $1, потому что переменная PL/pgSQL не является константой для нормализатора.
Текст может быть не виден клиенту, если роль не имеет pg_read_all_stats: строки других ролей присутствуют со счётчиками вызовов, но их текст редактируется, и в колонке query отображается <insufficient privilege>.
Вытеснение и сборка мусора
При переполнении таблицы вызывается вытеснение. Под исключительной блокировкой собираются все записи, к каждой применяется коэффициент уменьшения usage, массив сортируется по возрастанию usage, и удаляется доля самых малоиспользуемых записей. Число жертв — максимум из 10 и доли в процентах от числа записей, но не больше общего числа записей. Жертвами становятся первые nvictims элементов, для каждой выполняется удаление из хэша. Счётчик числа деаллокаций увеличивается под спинлоком pgss->mutex.
Тексты хранятся во внешнем файле: qtext_store резервирует место, увеличивая pgss->extent на длину запроса плюс один, и записывает строку с завершающим нулём. При сборке мусора gc_qtexts перечитывает файл, заново записывает только тексты живых записей, обновляет смещения и обрезает файл через ftruncate. Если чтение текста не удаётся, запись помечается недействительной (query_offset = 0, query_len = -1) и не учитывается при вычислении средней длины запроса.
Сборка мусора запускается только тогда, когда файл разросся примерно вдвое больше ожидаемого объёма:
if ((uint64) extent < (uint64) pgss->mean_query_len * pgss_max * 2)
return false;Вызов происходит под эксклюзивным локом pgss->lock, потому что gc_qtexts переписывает файл на месте и меняет смещения у каждой записи, а также сбрасывает общий указатель extent. Пока держится эксклюзивный лок, читатели представления не могут взять разделяемый лок и ждут завершения сборки мусора — это и есть вклад в задержку читателей.
Сброс статистики
pg_stat_statements_reset вызывает сброс с нулевыми параметрами, то есть сбрасывает все записи: перебираются все элементы хэша, и каждый сбрасывается. Если сброшены не все записи, глобальные счётчики не трогаются. Если удалены все, то под спинлоком обнуляется счётчик деаллокаций и записывается время сброса. Дополнительно создаётся пустой файл текстов, выполняется ftruncate до нуля, pgss->extent = 0 и вызывается запись факта сборки мусора. Если все статистики в представлении сброшены, сбрасываются и статистики в pg_stat_statements_info, а возвращаемое время сохраняется в поле stats_reset.
Сериализация на диск и восстановление
При завершении хук не берёт блокировку, потому что при вызове не должно быть других процессов. Сначала проверяется, что это не крах, что структуры существуют и что сохранение включено. Затем открывается временный файл и пишется заголовок: сначала PGSS_FILE_HEADER, затем PGSS_PG_MAJOR_VERSION, затем число записей. Далее загружаются тексты, и в цикле по хэшу для каждой записи пишется структура, а сразу за ней текст запроса — осиротевшие тексты тем самым исключаются. Записи с некорректным текстом пропускаются. После цикла пишется глобальная статистика, файл закрывается, атомарно переименовывается, а файл текстов удаляется.
При старте читаются заголовок, версия и число записей; при несовпадении заголовка или версии — переход на обработку ошибки данных. Для каждой записи проверяется только кодировка — это единственное поле, которое легко проверить. При неверной кодировке пишется сообщение в лог и битый файл удаляется.
Как читается внешний файл
qtext_load_file открывает файл только для чтения; если открыть не удалось, об этом пишется сообщение в лог — кроме случая, когда файла просто нет. Размер берётся через fstat, буфер выделяется с флагами MCXT_ALLOC_HUGE | MCXT_ALLOC_NO_OOM. Чтение идёт сегментами по 1 ГБ. При коротком чтении с ненулевым errno пишется сообщение в лог, буфер освобождается, файл закрывается, и текст запроса остаётся недоступным. При коротком чтении без errno жалоба не логируется, потому что причина, вероятно, в том, что сборка мусора усекла файл после fstat(), но текст всё равно не отдаётся как вероятно повреждённый конкурентной записью.
qtext_fetch файл не читает, а ищет текст в уже загруженном буфере: текст не отдаётся, если буфер пуст, если длина отрицательна или смещение с длиной выходят за границы, либо если за текстом нет завершающего нуля.
Что видит клиент и почему это ломает наблюдаемость
Представление не содержит колонки application_name. Из пятидесяти двух колонок ключом группировки являются первые четыре — userid, dbid, toplevel и queryid, — а среди остальных сорока восьми нет ничего, что идентифицировало бы соединение, сессию, адрес клиента или application_name. Поэтому два соединения под ролью app, одно из которых объявляет себя payments-api, а другое claude-agent, при одинаковом операторе попадают в одну строку, тогда как роль agent, выполняющая тот же текст, открывает вторую запись. Роль — единственная ось разделения, которую предлагает представление, и это имеет грубое последствие, когда агент переиспользует строку подключения приложения: числа складываются в момент завершения каждого оператора, разбивка не сохраняется, и ничто потом не разделяет этих двух вызывающих. Роль должна существовать до появления трафика, поскольку агент, одолживший логин приложения, навсегда остаётся внутри чисел приложения.
При чтении представления ролью без pg_read_all_stats строки других ролей присутствуют со счётчиками вызовов, но их текст редактируется, и в колонке query отображается <insufficient privilege>.
Ошибки не попадают в счётчики, потому что представление хранит строку на форму запроса, а не строку на каждый выполненный оператор. Провалившиеся операторы не были учтены. Единственный способ увидеть такие запросы — включить логирование: log_min_duration_statement вместе с log_line_prefix, содержащим %u и %a, даёт строку, которую представление хранить не может. Лог пишет по строке на каждый оператор, а не по строке на форму, и именно поэтому %u (роль) и %a (application_name) разделяют вызывающих: роль, база, объявленное имя клиента и литеральное значение оказываются в одной строке.
Отдельная ловушка — расхождение каталога и разделяемой памяти. CREATE EXTENSION pg_stat_statements; проходит успешно, потому что модуль позволяет создавать свои функции даже когда он не активен: при отсутствии загрузки через shared_preload_libraries выполняется ранний выход, и ошибка не бросается, чтобы разрешить создание функций. При этом чтение представления падает, так как реальные данные лежат в разделяемой памяти, которую модуль запрашивает только при загрузке через shared_preload_libraries на старте postmaster. Пока shared_preload_libraries не назовёт библиотеку и сервер не будет перезапущен, чтения представления продолжают падать. Это же объясняет, почему повышение pg_stat_statements.max требует перезапуска: параметр имеет контекст postmaster, а записи живут в области разделяемой памяти, размер которой задаётся при старте и не может быть изменён при reload.
Как сравнивали и что получилось
Стенд задаётся в postgresql.conf: модуль подключается через shared_preload_libraries = 'pg_stat_statements', включается compute_query_id = on, задаётся pg_stat_statements.max = 10000 и pg_stat_statements.track = all. pg_stat_statements.max — максимальное число отслеживаемых операторов (строк в представлении); при превышении информация о наименее исполняемых операторах отбрасывается, а число таких отбрасываний видно в pg_stat_statements_info. pg_stat_statements.track управляет тем, какие операторы считаются: top — только операторы верхнего уровня, all — также вложенные, none — отключение сбора.
Сценарий с flooding воспроизводится массовым прохождением множества различных форм операторов: обход по 400 таблицам с шестнадцатью формами чтения каждая, два из которых на большинстве таблиц совпадают. Перед первым потоком в представлении находился горячий запрос приложения с 500 вызовами. Затем прошло 5 120 операторов по 320 таблицам с шестнадцатью формами.
Результаты по сценариям:
| Сценарий | entries | dealloc |
|---|---|---|
| Обход 400 таблиц, 6 400 операторов | 4805 | 0 |
| Обход 6 400 операторов рядом с горячим запросом и ночным отчётом | 4755 | 5 |
| Двойной обход схемы, 12 800 операторов чтения | 4895 | 20 |
В первом сценарии ничего не было вытеснено: из 4 805 записей 4 801 создал сам обход — по пятнадцать на таблицу, кроме одной с шестнадцатью, — а четыре уже были в представлении до его начала. Во втором сценарии выжили горячий запрос с 500 вызовами (SELECT count(*) FROM orders WHERE customer_id = $1) и отчёт, выполненный ровно один раз (SELECT status, sum(amount) FROM orders GROUP BY stat). В третьем сценарии вытеснение сработало, но не забрало ни одной записи приложения, включая отчёт, выполненный ровно один раз. Вместо этого вытеснение уничтожило собственные записи агента о записях: сорок операторов DELETE, по одному на таблицу, больше не присутствовали, хотя каждый был записан в момент выполнения.
Причина, по которой эти сорок удалений не слились в одну запись, — разные имена таблиц: каждый оператор читал DELETE FROM t### WHERE id = 1 с одинаковым значением, и джамбл объединил бы их по этому признаку, но сорок разных имён таблиц удержали их порознь. В контрольном прогоне сорок удалений по одной таблице, различающиеся только литералами, нормализовались в одну запись с 40 вызовами, и она пережила тот же поток из 12 800 операторов.
Отдельные измерения — про split и слияние записей. Три запроса с IN-списками разной длины дают три отдельные строки: IN ($1), IN ($1,$2,$3) и IN ($1,$2,$3,$4,$5,$6,$7); список из одного элемента не считается списком и сохраняет собственную строку. Запросы SELECT count(*) FROM invoices WHERE id > 5 под search_path = tenant_a и search_path = tenant_b сливаются в одну строку с queryid 9114093936963880505 и calls = 2. Строки SET app.tenant = 'alpha' и SET app.tenant = 'beta' давали отдельные строки, но имя схемы и значение SET одновременно убрали арендатора из ответа, поэтому оператор, читающий только статистику, потерял эту возможность.
Оговорки методологии. Счётчики записей в представлении не равны числу исчезнувших записей приложения: из 4 805 записей 4 801 создал сам обход, а четыре уже были в представлении до его начала. Вытеснение удаляет наименее исполняемые записи, и запрос с 500 вызовами не находится у этого края списка, поэтому не был вытеснен. После двух обходов схемы (12 800 читающих записей) счётчик деаллокаций стал 20, а сорок операторов DELETE, записанных в начале прогона, исчезли. Из этих измерений нельзя делать вывод, что представление покажет, что именно пытался сделать агент: для оператора, который приходит после инцидента и хочет узнать, что пытался сделать агент, представление молчит по построению. Также нельзя считать, что число деаллокаций равно числу исчезнувших записей приложения.
Что из этого следует на практике
- Ключ группировки —
userid,dbid,toplevel,queryid. Всё, что не различается этими четырьмя полями, складывается в одну строку.application_name, адрес клиента и сессия в ключ не входят, поэтому разделить приложение и агента по ним нельзя. - Роль — единственная ось разделения. Если агент переиспользует строку подключения приложения, его запросы навсегда остаются внутри чисел приложения, и разбивка не сохраняется. Отдельная роль должна существовать до появления трафика.
- Ошибки не учитываются вовсе: представление хранит строку на форму, а не на каждый оператор, и провалившиеся операторы не были посчитаны. Увидеть их можно только через логирование с
%uи%aв префиксе. - Вытеснение удаляет наименее исполняемые записи. Разовые записи агента — самые уязвимые: в измерениях сорок операторов
DELETE, записанных в начале прогона, исчезли после потока чтений, тогда как горячий запрос с 500 вызовами и отчёт с одним вызовом выжили. - Текст запроса живёт во внешнем файле, а не в разделяемой памяти. Сборка мусора переписывает этот файл под эксклюзивным локом и блокирует читателей представления; запускается она только при разрастании файла примерно вдвое больше ожидаемого объёма.
CREATE EXTENSIONи чтение представления — разные вещи: каталог создаётся командой в любой момент, а разделяемая память выделяется только при старте с предзагруженной библиотекой. Покаshared_preload_librariesне назовёт библиотеку и сервер не перезапущен, чтения падают. По той же причинеpg_stat_statements.maxтребует перезапуска.- Нормализация решает, что сольётся. Списки констант сжимаются, имена схем и значения
SETне различают арендаторов, а разные имена таблиц удерживают записи порознь. Отсюда и split, и слияние, которые оператор не может отменить постфактум.