АйТи Фреш
Главная / Статьи / 1С и базы данных
1С и базы данных

Отчёт был быстрым, а потом завис: проверяем generic plan

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~27 мин чтения
Отчёт был быстрым, а потом завис: проверяем generic plan
Иллюстрация к статье «Отчёт был быстрым, а потом завис: проверяем generic plan».

Знакомая картина: аналитик запускает отчёт — результат приходит за секунду. Позже тот же отчёт с теми же параметрами упирается в тайм-аут. Разработчик подставляет литералы и выполняет SQL в psql — запрос снова быстрый. После пересоздания соединений проблема на время исчезает. В такой ситуации я прежде всего проверяю, не выбрал ли PostgreSQL generic plan для подготовленного выражения. Ниже покажу воспроизводимую диагностику для PostgreSQL 18, безопасный временный обход и исправление причины без глобального отключения prepared statements.

Симптомы, по которым я проверяю кэш планов

Эта история часто приходит ко мне с формулировкой «база постепенно деградирует». Команда проверяет блокировки, autovacuum, заполнение диска, bloat и давление на память. Эти проверки полезны, но наблюдение «после пересоздания соединений всё сразу стало быстро» заставляет отдельно посмотреть на подготовленные выражения. Само по себе оно ещё ничего не доказывает: соединение могло держать временные объекты, изменённые параметры сеанса или неудачную транзакцию. Нужны план и счётчики.

У перехода на generic plan есть характерное сочетание признаков. Первые выполнения одного параметризованного выражения быстрые, затем время меняется скачком. Запрос с литералами получает другой план. На разных значениях одного параметра скорость отличается на порядок. При этом ожиданий блокировок нет, нагрузка на сервер до запуска отчёта обычная, а плохое время стабильно воспроизводится в конкретных долгоживущих соединениях.

Причина различия между psql и приложением проста. Для условия org_id = 4471 планировщик знает значение во время планирования. Он может найти его в most_common_vals, учесть частоту из most_common_freqs и подобрать индексный либо последовательный доступ. В условии org_id = $1 generic plan не знает будущего значения параметра. Ему приходится строить один план, пригодный для всех вызовов, на основе усреднённой оценки. При сильном перекосе распределения этот усреднённый арендатор может не быть похож ни на одного настоящего.

Custom plan, напротив, создаётся заново для конкретного набора параметров. Это стоит дополнительного времени планирования, зато позволяет различать организацию с тысячей строк и организацию со ста миллионами строк. Поэтому одинаковый текст запроса на уровне приложения не означает одинаковых условий для оптимизатора: момент, когда сервер узнаёт параметры, принципиально важен.

Без PgBouncer кэш подготовленного выражения находится в серверном процессе PostgreSQL и ограничен соединением. Закрытие соединения удаляет доступные в нём prepared statements. С PgBouncer в режиме transaction pooling картина сложнее: при ненулевом max_prepared_statements прокси отслеживает протокольные именованные выражения и при необходимости подготавливает их на выбранном серверном соединении. История выбора планов всё равно существует отдельно в каждом backend PostgreSQL, поэтому перезапуск только приложения не гарантирует очистку всех серверных кэшей.

Перезапуск приложения — только улика. Диагноз подтверждают счётчики `generic_plans` и `custom_plans` вместе со сравнением фактических планов.

Что означает правило пяти выполнений в PostgreSQL 18

При plan_cache_mode = auto PostgreSQL выполняет первые пять вызовов параметризованного prepared statement с custom plans и накапливает их среднюю оценочную стоимость. Затем сервер создаёт generic plan и сравнивает его оценочную стоимость со средней стоимостью custom plans. В следующих вызовах generic plan выбирается, если его оценка не настолько выше, чтобы затраты на повторное планирование custom plan выглядели оправданными.

Это не обещание безусловного перехода на шестом вызове. Если generic plan по оценке существенно хуже, сервер продолжит строить custom plans. И наоборот, ошибочно оптимистичная оценка generic plan может победить, хотя фактическое выполнение окажется гораздо медленнее. Механизм сравнивает единицы стоимости планировщика, а не измеренные секунды предыдущих запусков.

Параметр plan_cache_mode имеет ровно три допустимых значения: auto, force_custom_plan и force_generic_plan. Значение по умолчанию — auto. Документация отдельно уточняет, что параметр учитывается в момент исполнения кэшированного плана, а не в момент подготовки выражения. Поэтому режим можно переключить перед EXECUTE и сравнить два варианта одного prepared statement.

SHOW plan_cache_mode;

SET plan_cache_mode = force_custom_plan;
SET plan_cache_mode = force_generic_plan;

RESET plan_cache_mode;

Я не называю prepared statement только результатом явной команды PREPARE. pgJDBC использует протокольные Parse и Bind, а после достижения порога создаёт именованное серверное выражение. PL/pgSQL через SPI также подготавливает и повторно использует выполняемые SQL-команды и выражения; для параметризованных команд SPI может выбирать между custom и generic plans. Динамическая команда PL/pgSQL EXECUTE, напротив, планируется при каждом выполнении и не использует сохранённый план этой команды.

Кэшированный план не вечен. Перед очередным использованием PostgreSQL выполняет повторный анализ и планирование, если изменилось определение задействованного объекта или была обновлена его статистика планировщика. Изменение search_path между вызовами также вызывает повторный разбор. Поэтому после ANALYZE или DDL план может измениться сам; это ещё не доказывает, что первые пять вызовов начали считаться заново, и не заменяет проверку счётчиков.

Версия 18.6 в исходном кейсе реальна: она выпущена 13 августа 2026 года, а 18.5 не выпускалась из-за найденной перед публикацией регрессии. Само правило выбора generic plan не является новшеством 18.6 и не исчезает после установки минорного обновления. Обновляться до актуального минорного выпуска всё равно нужно ради исправлений ошибок и безопасности, но использовать обновление как лечение перекошенной селективности нельзя.

Фраза «тормозит после пяти запусков» относится к пяти исполнениям одного и того же серверного prepared statement в конкретном backend. Порог драйвера и пул соединений могут заметно сдвинуть момент проявления.
Отчёт был быстрым, а потом завис: проверяем generic plan — схема
Схема к статье. Открыть схему в полном размере

Диагностика: счётчики, generic plan и очная ставка

Сначала я воспроизвожу запрос в отдельном сеансе. Представление pg_prepared_statements содержит колонки типа int8generic_plans и custom_plans. Они показывают, сколько раз сервер выбирал каждый вид плана. Представление видит только prepared statements текущего сеанса, поэтому из произвольного окна psql нельзя посмотреть кэш соединения приложения.

Для чистого эксперимента я явно подготавливаю выражение и выполняю его пять раз. Перед шестым вызовом полезно снять счётчики, а затем выполнить EXPLAIN (ANALYZE) на шестом. В PostgreSQL 18 опция ANALYZE автоматически включает статистику BUFFERS; это изменение именно восемнадцатой мажорной версии. Команда с ANALYZE действительно выполняет запрос, поэтому на изменяющих данные выражениях её запускают только в безопасной транзакции с последующим откатом.

PREPARE r(integer, date, date) AS
SELECT w.code, sum(l.qty)
FROM doc_line AS l
JOIN warehouse AS w ON w.id = l.warehouse_id
WHERE l.org_id = $1
  AND l.doc_date BETWEEN $2 AND $3
GROUP BY w.code;

EXECUTE r(4471, DATE '2026-08-01', DATE '2026-08-31');
EXECUTE r(4471, DATE '2026-08-01', DATE '2026-08-31');
EXECUTE r(4471, DATE '2026-08-01', DATE '2026-08-31');
EXECUTE r(4471, DATE '2026-08-01', DATE '2026-08-31');
EXECUTE r(4471, DATE '2026-08-01', DATE '2026-08-31');

SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements
WHERE name = 'r';

EXPLAIN (ANALYZE, SETTINGS)
EXECUTE r(4471, DATE '2026-08-01', DATE '2026-08-31');

В текстовом плане generic plan сохраняет обозначения $1, $2 и $3. В custom plan вместо них видны переданные значения. Это официальный и удобный визуальный признак. Но смотреть нужно весь план: параметры могут находиться в узле, который не попал в вырезанный фрагмент. После EXPLAIN (ANALYZE) EXECUTE я повторно читаю pg_prepared_statements, потому что выполненный запрос увеличит соответствующий счётчик.

Отдельный инструмент — EXPLAIN (GENERIC_PLAN), добавленный в PostgreSQL 16 и доступный в PostgreSQL 18. Он разрешает написать параметры непосредственно в запросе и получить план, который не зависит от их значений. Опция несовместима с ANALYZE, поскольку значений для выполнения нет. Если тип параметра нельзя вывести из контекста, требуется явное приведение.

EXPLAIN (GENERIC_PLAN, COSTS, SETTINGS)
SELECT w.code, sum(l.qty)
FROM doc_line AS l
JOIN warehouse AS w ON w.id = l.warehouse_id
WHERE l.org_id = $1::integer
  AND l.doc_date BETWEEN $2::date AND $3::date
GROUP BY w.code;

Такой вывод показывает структуру и оценку generic plan, но не его реальное время. Поэтому следующий шаг — очная ставка. Я принудительно выполняю один prepared statement в двух режимах, сохраняю полный вывод и сравниваю Execution Time, Planning Time, оценочные и фактические строки, чтение блоков, временные файлы и порядок соединений.

SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, SETTINGS)
EXECUTE r(4471, DATE '2026-08-01', DATE '2026-08-31');

SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, SETTINGS)
EXECUTE r(4471, DATE '2026-08-01', DATE '2026-08-31');

RESET plan_cache_mode;

Перед сравнением я прогреваю оба варианта или учитываю разницу между shared hit и shared read. Иначе первым планом можно случайно прогреть данные для второго. Одного запуска мало: фоновые контрольные точки, конкурирующие запросы и состояние файлового кэша дают шум. При этом разница в десятки или сотни раз обычно видна без сложной статистики.

В проде до чужого сеанса чаще всего не добраться, поэтому я подключаю auto_explain. Модуль можно добавить в shared_preload_libraries, что требует перезапуска сервера, либо в session_preload_libraries, что не требует перезапуска, но начинает действовать только для новых соединений. После правки postgresql.conf конфигурацию нужно перечитать и пересоздать нужные подключения.

# postgresql.conf
session_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '3s'
auto_explain.log_analyze = on
auto_explain.log_timing = off
auto_explain.log_buffers = on
auto_explain.log_settings = on
auto_explain.log_nested_statements = on
auto_explain.log_parameter_max_length = 1024
auto_explain.sample_rate = 0.1

Порог auto_explain.log_min_duration измеряется в миллисекундах, но допускает единицы времени в строковом значении; 3s корректно. Значение по умолчанию -1 отключает логирование. Для auto_explain.log_parameter_max_length значение -1 означает полное логирование параметров, 0 отключает их, а положительное число задаёт предел в байтах для каждого значения. Параметры могут содержать персональные или секретные данные, поэтому объём и доступ к журналу согласуют до включения.

Самая опасная настройка здесь — auto_explain.log_analyze. При ней инструментирование выполняется для всех рассматриваемых запросов, даже если итоговая длительность не достигнет порога. Документация особо предупреждает о стоимости измерения времени каждого узла; поэтому я обычно начинаю с log_timing = off, ненулевого порога и sample_rate меньше единицы. Полную выборку включаю лишь на ограниченный срок и после оценки нагрузки.

Не ставьте `auto_explain.log_min_duration = 0` и `auto_explain.log_analyze = on` на всю нагруженную базу без предварительной оценки. Начните с секундного порога, отключённого узлового тайминга и небольшой выборки.
Цифры и версии: Диагностика: счётчики, generic plan и очная ставка — схема
Цифры и версии: Диагностика: счётчики, generic plan и очная ставка. Открыть схему в полном размере

Практика: консалтинговая компания «Бизнес-Ресурс», 21 РМ

Условный клиент из этого разбора — консалтинговая компания «Бизнес-Ресурс», 21 РМ. Стек: самописный Java-бэкенд, PostgreSQL 18.6 на Debian 12, 16 vCPU, 64 ГБ RAM и база объёмом 340 ГБ на NVMe. Перед PostgreSQL работал PgBouncer в transaction pooling, клиентские соединения держал HikariCP. Тайм-аут отчёта в приложении составлял 30 секунд. Утром отчёт по остаткам за месяц открывался быстро, а позже начинал вращать индикатор и завершался ошибкой.

Виновником оказался запрос к doc_line: 184 млн строк и индекс (org_id, doc_date). В pg_stats для org_id значение n_distinct было равно 341, а первая частота в most_common_freqs — 0,91. Около 91 % строк принадлежало одной крупной организации, остальные значения делили примерно 9 %. Для месячного диапазона обычная организация возвращала 1 204 строки, а объём тяжёлой организации на всей таблице приближался к 167 млн строк.

Generic plan оценивал результирующий узел в 541 208 строк. Для неизвестного параметра это выглядело достаточно правдоподобно, но не описывало фактический месячный срез обычной организации. План выбрал Bitmap Heap Scan, прочитал большой разбросанный фрагмент таблицы и применил условие по дате после получения строк по организации. Полный отчёт занял около 24 секунд. Custom plan использовал обе колонки индекса, а итоговый запрос вместе с соединением и агрегацией завершался примерно за 180 мс.

Ниже приведён сокращённый до двух значимых ветвей, но не подменённый тезисами фрагмент зафиксированных планов. Числа Buffers означают обращения к блокам: hit обслужен из shared buffers, read потребовал чтения. При стандартном размере блока 8 КиБ сумма 42 117 попаданий и 331 904 чтений соответствует примерно 2,9 ГиБ обращений к блокам, но называть весь этот объём физическим чтением с NVMe было бы неверно.

-- generic plan, проблемная ветвь
Bitmap Heap Scan on doc_line l
  (cost=8214.02..1104233.71 rows=541208 width=24)
  (actual time=21044.882..23861.117 rows=1204 loops=1)
  Recheck Cond: (org_id = $1)
  Filter: ((doc_date >= $2) AND (doc_date <= $3))
  Rows Removed by Filter: 1214488
  Buffers: shared hit=42117 read=331904
  ->  Bitmap Index Scan on doc_line_org_date_idx
        (cost=0.00..8078.72 rows=1215692 width=0)
        (actual time=183.411..183.411 rows=1215692 loops=1)
        Index Cond: (org_id = $1)

-- custom plan, быстрая ветвь
Index Scan using doc_line_org_date_idx on doc_line l
  (cost=0.57..4412.06 rows=1188 width=24)
  (actual time=0.061..2.914 rows=1204 loops=1)
  Index Cond: ((org_id = 4471)
               AND (doc_date >= '2026-08-01'::date)
               AND (doc_date <= '2026-08-31'::date))
  Buffers: shared hit=1093

В воспроизведённом серверном сеансе pg_prepared_statements показал custom_plans = 5 и generic_plans = 41. Вместе с параметрами в плохом плане и фактической разницей времени это закрыло диагноз. Одной оценки rows=541208 было бы недостаточно: неверная оценка встречается и в custom plan, а медленный запрос может упираться в I/O, блокировки или spill.

Задержку до второй половины дня объяснил второй счётчик. У pgJDBC свойство prepareThreshold по умолчанию равно 5: драйвер начинает использовать именованное серверное prepared statement на пятом выполнении того же PreparedStatement. После этого PostgreSQL на каждом backend накапливает собственную историю custom plans. HikariCP распределял вызовы между клиентскими соединениями, а PgBouncer — между серверными, поэтому устойчивое проявление занимало часы. Складывать два порога в универсальную формулу «плохим всегда будет десятый вызов» нельзя: переиспользование объектов, клиентский кэш и маршрутизация меняют последовательность.

В тот же вечер режим ограничили ролью отчётного приложения. После изменения ролевого значения полностью пересоздали её пул, потому что существующие сеансы сохраняют прежние стартовые настройки. Отчёт вернулся к 180 мс. Время планирования выросло примерно на 2,8 мс на вызов. При пике 40 тысяч вызовов в час это 112 секунд процессорного времени за час, то есть около 0,2 % совокупной часовой ёмкости 16 ядер без учёта прочей нагрузки. Это была приемлемая цена за предсказуемый SLA.

ALTER ROLE report_app SET plan_cache_mode = force_custom_plan;

Через три недели режим вернули в auto, но не после одного лишь увеличения statistics target. Сначала уточнили статистику, затем разделили два физически разных сценария. Для доминирующей организации приложение отправляло отдельный SQL с доверенной константой org_id = 118; для остальных — выражение с параметром и явным условием org_id <> 118. Под эти две формы создали разные индексы. Это важно: generic plan с условием org_id = $1 не может использовать частичный индекс WHERE org_id = 118, потому что во время планирования неизвестно, чему равен параметр.

После разделения тяжёлая организация больше не участвовала в выборе плана для обычных организаций. Расширенная статистика по действительно совместно фильтруемым колонкам улучшила оценки custom plans, а повышенная детализация стабилизировала сведения о распределении. Разница между generic и custom plan для оставшейся однородной ветви снизилась примерно со 130 до 1,4 раза. Только после повторных замеров и недели наблюдения ролевой обход сняли.

Нюанс PgBouncer тоже подтвердился. При max_prepared_statements = 200, а это текущее значение по умолчанию, прокси отслеживает именованные протокольные prepared statements в transaction и statement pooling. Одинаковый текст получает внутреннее имя, а PgBouncer обеспечивает его подготовку на выбранном серверном соединении. Поэтому клиент может попасть на backend, где выражение уже использовалось другим клиентом. Перезапуск Java-процесса сам по себе не обязан сбрасывать серверную историю.

Частичный индекс с предикатом на конкретную организацию не спасёт generic plan вида `org_id = $1`. Чтобы индекс стал применим, предикат должен следовать из текста запроса уже во время планирования.

Исправляем оценки, но признаём предел одного плана

Назвать любой неудачный generic plan только «проблемой статистики» было бы неточно. У планировщика действительно могут быть устаревшие или слишком грубые сведения, и их нужно исправить. Но при распределении 91 к 9 один универсальный план иногда не существует в принципе. Для маленькой организации выгоден точный индексный доступ, для доминирующей — последовательное чтение, параллельный план или отдельная физическая структура. Неизвестный параметр не позволяет generic plan угадать, какой сценарий пришёл.

Первый шаг — проверить не только n_distinct, most_common_vals и most_common_freqs, но и расхождение Plan Rows с Actual Rows в каждом важном узле. Повышение цели статистики увеличивает выборку ANALYZE, предельную длину MCV-списка и гистограммы. В PostgreSQL 18 допустимый диапазон для колоночного SET STATISTICS — от 0 до 10000, а default_statistics_target по умолчанию равен 100. Значение 1000 корректно, но выбирать его нужно измерением, а не привычкой.

ALTER TABLE doc_line
  ALTER COLUMN org_id SET STATISTICS 1000;

ANALYZE doc_line;

SELECT n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE schemaname = 'public'
  AND tablename = 'doc_line'
  AND attname = 'org_id';

Повышенный target улучшает статистику, но не сообщает generic plan будущее значение $1. Поэтому после ANALYZE я снова строю EXPLAIN (GENERIC_PLAN) и отдельно выполняю custom plans для тяжёлого, среднего и малого значений. Если generic plan по-прежнему опасен хотя бы для значимой доли вызовов, оставлять auto только потому, что MCV-список стал длиннее, нельзя.

Расширенная статистика полезна, когда несколько условий одной таблицы коррелируют. dependencies, ndistinct и mcv — реальные допустимые виды CREATE STATISTICS. Однако у них разные назначения: dependencies помогают простым равенствам с константами, ndistinct — оценке числа групп, mcv — совместным распределениям значений. В PostgreSQL 18 расширенная статистика не применяется для оценки селективности соединений таблиц. Поэтому объект на org_id и warehouse_id не поможет лишь от того, что warehouse_id участвует в JOIN; на обе колонки должны приходиться подходящие условия той же таблицы.

CREATE STATISTICS doc_line_org_date_mcv (mcv)
  ON org_id, doc_date
  FROM doc_line;

ANALYZE doc_line;

Для конкретного отчёта статистика по org_id и doc_date улучшила оценки custom plan, где значения известны. Но я не обещаю такого же эффекта generic plan: параметр организации остаётся неизвестным, а условие по дате является диапазоном. Результат проверяется только планами. Ненужный объект расширенной статистики стоит удалить, потому что он увеличивает время ANALYZE и планирования.

Частичные индексы требуют ещё большей аккуратности. PostgreSQL применяет такой индекс, только если во время планирования может доказать, что условие запроса влечёт предикат индекса. Параметризованная проверка этого не гарантирует. В кейсе были введены две формы запроса, после чего индексы стали осмысленными.

CREATE INDEX CONCURRENTLY doc_line_bigorg_date_idx
  ON doc_line (doc_date)
  WHERE org_id = 118;

CREATE INDEX CONCURRENTLY doc_line_other_org_date_idx
  ON doc_line (org_id, doc_date)
  WHERE org_id <> 118;

Команда CREATE INDEX CONCURRENTLY не выполняется внутри обычного транзакционного блока. Для тяжёлой ветви запрос содержит доверенную константу org_id = 118. Для остальных он содержит одновременно org_id <> 118 и org_id = $1, поэтому предикат второго индекса виден generic planner. Значение организации нельзя принимать из пользовательской строки и склеивать с SQL: ветвь выбирает код приложения, а переменные даты остаются параметрами.

Если сценарии продолжают различаться на два порядка, я выбираю разделение SQL или секционирование. Это не поражение тюнинга, а честное отражение модели данных. Один огромный арендатор и сотни маленьких арендаторов создают разные задачи доступа. Секционирование стоит дороже в разработке и эксплуатации, зато планировщик понимает границы секций, а обслуживание и архивирование можно вести раздельно.

Статистика исправляет неверные оценки, но не отменяет неизвестность параметра. При сильном перекосе надёжное решение часто начинается с разделения тяжёлой и обычной ветвей.
Порядок действий: Исправляем оценки, но признаём предел одного плана — схема
Порядок действий: Исправляем оценки, но признаём предел одного плана. Открыть схему в полном размере

Временные обходы и их реальная цена

Пока структурное исправление проходит тестирование, нужен обратимый обход. Мой первый выбор — plan_cache_mode на отдельной роли, роли в конкретной базе либо только внутри транзакции отчёта. Это ограничивает перепланирование нужным трафиком и не лишает остальные запросы преимуществ generic plans.

Ролевое значение начинает действовать как сеансовое значение по умолчанию в новых подключениях. Для действующего пула нужен контролируемый recycle соединений. Если отчёт можно обернуть одной транзакцией, ещё уже работает SET LOCAL: настройка откатится в конце транзакции, включая случай ошибки и ROLLBACK.

BEGIN;
SET LOCAL plan_cache_mode = force_custom_plan;

-- Здесь приложение выполняет подготовленный отчёт.

COMMIT;

Второй обход — настройка pgJDBC prepareThreshold=0, которая отключает серверные prepared statements. Значение по умолчанию равно 5, а значение 0 официально используется для отключения. Глобально применять его дорого: драйвер потеряет серверное повторное использование планов и часть преимуществ бинарной передачи. Если API приложения позволяет, лучше изменить порог только у конкретного PreparedStatement через PGStatement.setPrepareThreshold(int).

У pgJDBC есть и клиентский кэш: preparedStatementCacheQueries по умолчанию хранит до 256 запросов на соединение, а preparedStatementCacheSizeMiB ограничивает его пятью мебибайтами на соединение. Оба параметра реальны, но менять их ради generic plan обычно бессмысленно. Уменьшение кэша лишь изменяет частоту вытеснения и маскирует проблему, не гарантируя хороший план.

Третий путь — динамический SQL, но я не советую собирать произвольные литералы конкатенацией. Если известны два доверенных сценария, безопаснее иметь два заранее написанных текста: один с фиксированным идентификатором доминирующей организации, второй с параметром и явным исключением этой организации. Пользовательские даты и остальные значения остаются bind-параметрами. В PL/pgSQL динамический EXECUTE ... USING планируется при каждом вызове и потому может дать custom-подобное поведение без ручного экранирования значений.

Следует учитывать мониторинг: pg_stat_statements нормализует константы. Множество запросов, различающихся только литеральными значениями, может попасть в одну статистическую запись, и это не всегда удобно. Две структурно разные ветви, как org_id = 118 и сочетание org_id <> 118 AND org_id = $1, обычно легче различить по query ID и метрикам приложения.

Четвёртый режим — force_generic_plan — применяется, когда планирование само дороже короткого выполнения и один план безопасен для всех параметров. Такое встречается у сложных запросов, множества соединений и секционированных таблиц. Решение принимают по данным pg_stat_statements: mean_plan_time, mean_exec_time, числу plans и calls.

SET pg_stat_statements.track_planning = on;

SELECT query, plans, calls, mean_plan_time, mean_exec_time
FROM pg_stat_statements
WHERE calls > 0
ORDER BY total_plan_time DESC
LIMIT 20;

pg_stat_statements.track_planning по умолчанию выключен. Документация предупреждает о заметном штрафе, особенно когда много соединений одновременно обновляют небольшое число одинаковых записей статистики. Я включаю параметр на измерительный интервал, фиксирую базовый и контрольный периоды, а затем выключаю. Сам модуль должен быть заранее загружен через shared_preload_libraries, для чего при первом подключении модуля требуется перезапуск сервера.

Не отключайте prepared statements для всего приложения ради одного отчёта. Сначала ограничьте изменение ролью, транзакцией или конкретным JDBC-выражением.

Порядок действий во время инцидента и после него

Во время инцидента я сначала фиксирую факты, а не меняю стоимость диска и соединений. В отдельном сеансе воспроизвожу PREPARE, пять custom-выполнений и следующий выбор плана. Сохраняю счётчики до и после EXPLAIN (ANALYZE). Затем принудительно сравниваю custom и generic plan на малом, типичном и тяжёлом значениях. Если фактическая разница подтверждена, применяю узкий force_custom_plan и пересоздаю только нужный пул.

Параллельно проверяю продовый план через осторожно настроенный auto_explain, потому что лабораторное выражение может отличаться типами параметров, search_path, ролью, GUC или текстом от вызова приложения. В логе нужны полный план, настройки, параметры в допустимом объёме и время. Если параметры содержат персональные данные, доступ к журналу и срок его хранения ограничивают заранее.

После стабилизации читаю pg_stats, сравниваю оценки с фактом и проверяю актуальность ANALYZE. Повышаю статистическую цель только нужным колонкам. Расширенную статистику создаю для доказанной корреляции условий, а не для каждой пары полей. Любое изменение проверяю отдельно: иначе невозможно понять, что помогло — новая статистика, индекс, прогрев кэша или смена плана после DDL.

Если один SQL обслуживает организацию со 167 млн строк и организацию с месячной выборкой 1 204 строки, заранее проверяю разделение. Частичный индекс проектирую вместе с текстом запроса, поскольку неизвестный параметр не доказывает его предикат. CREATE INDEX CONCURRENTLY планирую как отдельную операцию, слежу за фазами построения и не прячу её внутрь транзакции миграционного фреймворка.

После возврата в auto наблюдаю не только среднее время, но и хвост распределения: p95, p99, тайм-ауты и планы по разным классам организаций. Среднее легко скрывает редкий 24-секундный вызов среди тысяч быстрых. Обход снимаю, когда generic plan либо безопасен для всех значимых классов, либо тяжёлый класс окончательно отделён.

На что я не трачу первые часы: на смену мажорной версии, случайный REINDEX, глобальную правку random_page_cost и увеличение RAM без признаков нехватки. Эти действия могут быть нужны по другим причинам, но не исправляют неизвестный параметр и перекос арендаторов. Минорное обновление до PostgreSQL 18.6 важно для безопасности, однако правило пяти custom plans после него остаётся.

Такие инциденты лучше предупреждать на уровне модели. Мультиарендная таблица с одним арендатором, который хранит 91 % строк, должна попасть в архитектурный риск-лист. Для неё заранее тестируют generic plans, проектируют отдельную ветвь запроса, секционирование или индексы с доказуемыми предикатами. Это дешевле, чем во время сбоя выяснять, почему один и тот же отчёт то укладывается в 180 мс, то работает 24 секунды.

Главный критерий завершения — не один быстрый запуск после `ANALYZE`, а предсказуемое время для малого, обычного и доминирующего значений после повторного выбора generic plan.
Порядок действий: Порядок действий во время инцидента и после него — схема
Порядок действий: Порядок действий во время инцидента и после него. Открыть схему в полном размере

Частые вопросы

Всегда ли prepared statement переходит на generic plan после пяти выполнений?

Нет. При `plan_cache_mode = auto` первые пять выполнений получают custom plans, после чего PostgreSQL сравнивает среднюю оценочную стоимость этих планов со стоимостью generic plan. Generic plan выбирается для последующих вызовов только тогда, когда его оценка не настолько выше, чтобы постоянное перепланирование выглядело выгодным.

Как отличить generic plan от custom plan?

В текстовом generic plan остаются параметры `$1`, `$2` и далее, а в custom plan видны переданные значения. Дополнительное прямое доказательство — колонки `generic_plans` и `custom_plans` в `pg_prepared_statements`. Представление показывает только prepared statements текущего сеанса.

Можно ли посмотреть generic plan без PREPARE?

Да. Начиная с PostgreSQL 16 используется `EXPLAIN (GENERIC_PLAN)` с параметрами непосредственно в тексте запроса. Опция доступна в PostgreSQL 18 и несовместима с `ANALYZE`. Если тип параметра не выводится из контекста, добавляют явное приведение, например `$1::integer`.

Почему EXPLAIN ANALYZE в PostgreSQL 18 показывает BUFFERS без отдельной опции?

В PostgreSQL 18 `ANALYZE` автоматически включает информацию `BUFFERS`. Её можно явно отключить через `BUFFERS OFF`. Сам запрос при `EXPLAIN (ANALYZE)` выполняется, поэтому для изменяющих данные команд нужны транзакция и последующий `ROLLBACK` либо безопасная тестовая среда.

Стоит ли установить force_custom_plan на всю базу?

Обычно нет. Начните с роли отчётного сервиса, сочетания роли и конкретной базы либо `SET LOCAL` внутри транзакции. Generic plans экономят планирование на запросах, для которых значения параметров не меняют оптимальную стратегию. Глобальный `force_custom_plan` заставит перепланировать и эти безопасные запросы.

Поможет ли повышение STATISTICS до 1000?

Оно увеличит выборку и детализацию статистики, что часто улучшает оценки custom plans и общее сравнение стоимостей. Но generic plan всё равно не знает значение `$1`. После `ANALYZE` необходимо заново сравнить generic и custom plans на нескольких классах параметров; автоматической гарантии исправления нет.

Почему partial index WHERE org_id = 118 не используется с org_id = $1?

Во время создания generic plan сервер не знает значение параметра и не может доказать, что условие запроса влечёт предикат частичного индекса. Нужен отдельный текст с доверенной константой либо дополнительное постоянное условие, из которого предикат следует уже при планировании.

Почему перезапуск приложения иногда не помогает при PgBouncer?

При ненулевом `max_prepared_statements` PgBouncer отслеживает именованные протокольные prepared statements и обеспечивает их наличие на серверных соединениях. Перезапуск клиента не обязательно закрывает backend PostgreSQL, где уже накопилась история выбора планов. Для чистого эксперимента нужно точно понимать, какие серверные соединения были пересозданы.

Что проверять, если custom plan тоже медленный?

Тогда переход на generic plan не объясняет основную задержку. Проверьте ожидания и блокировки, расхождение оценочных и фактических строк, актуальность статистики, подходящие индексы, spill в временные файлы, объём чтения и порядок соединений. Диагноз из этой статьи требует существенной воспроизводимой разницы между двумя режимами.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи