pg_stat_statements: находим и оптимизируем медленные запросы в PostgreSQL
Логи медленных запросов — это хорошо, но они показывают только то, что уже взорвалось. А что если нужно найти запросы, которые по отдельности быстрые, но вызываются 100 000 раз в минуту и суммарно съедают 80% CPU? Для этого есть pg_stat_statements — расширение, которое собирает агрегированную статистику по всем запросам в кластере.
Установка и настройка
Расширение входит в стандартную поставку PostgreSQL, но требует подключения через shared_preload_libraries:
-- postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all -- top, all или none
pg_stat_statements.max = 10000 -- макс. количество отслеживаемых запросов
-- После изменения shared_preload_libraries нужен рестарт:
-- sudo systemctl restart postgresql
-- Создаём расширение в нужной базе:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
Параметр track = all отслеживает запросы внутри функций и процедур. Если нужны только top-level запросы — ставьте top. Параметр max определяет, сколько уникальных запросов хранить — при переполнении самые редкие вытесняются.
Ключевые метрики
Представление pg_stat_statements содержит десятки колонок. Самые важные:
total_exec_time— суммарное время выполнения (мс). Главная метрика для поиска «тяжёлых» запросов.calls— сколько раз запрос выполнялся.mean_exec_time— среднее время одного вызова (total_exec_time / calls).rows— суммарное количество возвращённых/затронутых строк.shared_blks_hit/shared_blks_read— попадания в кеш vs чтение с диска.queryid— хеш нормализованного запроса.
TOP-10 запросов по времени
Готовый запрос, который покажет главных потребителей ресурсов:
SELECT
queryid,
substring(query, 1, 80) AS short_query,
calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS avg_ms,
rows,
round(
100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0), 1
) AS cache_hit_pct
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Этот запрос сразу показывает: какой запрос суммарно сжирает больше всего времени, как часто вызывается, и какой у него процент попадания в кеш. Низкий cache_hit_pct — сигнал, что данные не помещаются в shared_buffers.
Вариации для других задач:
-- TOP по количеству вызовов (найти N+1)
SELECT query, calls, mean_exec_time
FROM pg_stat_statements
ORDER BY calls DESC LIMIT 10;
-- TOP по чтению с диска (I/O-bound запросы)
SELECT query, shared_blks_read, calls
FROM pg_stat_statements
ORDER BY shared_blks_read DESC LIMIT 10;
Нормализация запросов: queryid и параметры
PostgreSQL автоматически нормализует запросы — заменяет литералы на $1, $2 и т.д. Это позволяет группировать одинаковые по структуре запросы:
-- Эти два запроса будут одной записью в pg_stat_statements:
SELECT * FROM users WHERE id = 42;
SELECT * FROM users WHERE id = 999;
-- В pg_stat_statements они отобразятся как:
-- SELECT * FROM users WHERE id = $1
Поле queryid — хеш нормализованного запроса. Используйте его для трекинга во времени.
Автоматизация: reset и мониторинг
Статистика накапливается с момента последнего сброса. Для трендов нужно периодически снимать снапшоты и сбрасывать:
-- Сбросить всю статистику
SELECT pg_stat_statements_reset();
Для мониторинга через Grafana: cron каждые 5 минут снимает снапшот в отдельную таблицу, после чего вызывает reset. Grafana строит графики по дельтам.
-- Снапшот (вызывать по cron каждые 5 минут)
INSERT INTO pgss_snapshots (queryid, query, calls, total_exec_time, rows)
SELECT queryid, query, calls, total_exec_time, rows
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database());
Реальный кейс: нашли N+1 через pg_stat_statements
Ситуация: API отвечает медленно, но отдельные запросы в логах быстрые (1-3 мс). Смотрим TOP по calls:
SELECT substring(query, 1, 60), calls, mean_exec_time, total_exec_time
FROM pg_stat_statements ORDER BY calls DESC LIMIT 5;
-- query | calls | avg_ms | total_ms
-- SELECT * FROM products WHERE id = $1 | 847293 | 1.2 | 1016752
-- SELECT * FROM categories WHERE id = $1 | 847293 | 0.8 | 677834
-- SELECT * FROM users WHERE id = $1 | 12045 | 0.9 | 10840
847 тысяч вызовов одного и того же запроса за период! Это классический N+1: ORM загружает список заказов, потом для каждого отдельно тянет product и category. Суммарно эти два запроса съедают 1.7 миллиона мс — почти 30 минут чистого CPU-времени.
Решение: добавили JOIN в ORM-запрос (eager loading), количество вызовов упало с 847K до 12K, суммарное время — с 1.7M мс до 45K мс.
Вывод
pg_stat_statements — must-have расширение для любого PostgreSQL в проде. Оно находит проблемы, которые не видны в логах: частые лёгкие запросы, деградацию кеша, N+1 паттерны. Настройте снапшоты, подключите Grafana — и вы будете видеть проблемы до того, как пользователи начнут жаловаться.