Кеш Parse для анонимных prepared statements
PgDoorman кеширует анонимный Parse как производительную
оптимизацию для transaction pooling. Многие драйверы отправляют
короткие параметризованные запросы как Parse с пустым именем; без
подмены PostgreSQL заново выполняет работу планировщика на каждом
Bind, а горячий OLTP-путь платит за это при каждом обращении.
PgDoorman прозрачно переписывает каждый анонимный Parse в
служебное имя DOORMAN_<N> на бэкенде. План попадает в реестр
именованных statement бэкенда и переиспользуется между Bind'ами
одного клиента и между разными клиентами одного пула. Главный эффект
— меньше CPU на планирование и меньше повторных Parse на бэкенде
для одинаковых форм запроса.
Подмена прозрачна для драйвера: клиент шлёт и получает пустые имена точно так же, как при работе с обычным PostgreSQL.
Это уникальная возможность PgDoorman. PgBouncer (1.21+) и Odyssey
поддерживают prepared statements в transaction mode, но только для
именованных statement; анонимный Parse проходит без
изменений и планируется заново при каждом обращении. Ограничения кеша,
LRU, TTL и observability нужны не вместо производительности, а чтобы
эта оптимизация оставалась управляемой под динамическим SQL.
Что ускоряется
Кеш анонимного Parse убирает повторную работу на горячих
параметризованных запросах:
- бэкенд PostgreSQL не получает одинаковый
Parseпри каждом повторном обращении к уже известной форме запроса; - PostgreSQL может использовать prepared statement, который уже создан на этом серверном соединении;
- разные клиенты одного пула используют одну пуловую запись, а не прогревают одинаковый запрос независимо друг от друга;
- при попадании в кеш бэкенда PgDoorman синтезирует
ParseCompleteбез обращения к PostgreSQL.
Поэтому это в первую очередь оптимизация производительности для OLTP-нагрузок с повторяющимися формами запросов. Ограничение размеров кеша, TTL и интернер нужны затем, чтобы выигрыш не превращался в бесконтрольный рост памяти на пулере или бэкенде.
Базовая модель PostgreSQL
В сообщении Parse имя prepared statement задаёт его тип: пустое
имя соответствует анонимному statement, любое непустое —
именованному:
Время жизни в PG Кеширование плана
───────────────────── ───────────────── ──────────────────
Анонимный (name="") До следующего Нет: планировщик
анонимного Parse запускается на
или конца сессии каждый Bind
Именованный До Close / Generic вначале,
(name="stmt_42") DEALLOCATE / переключается в
конца сессии custom после 5
наблюдений
Большинство современных драйверов по умолчанию используют
анонимные prepared для разовых параметризованных запросов:
lib/pq (Go), libpq PQexecParams (C), часть режимов в pgjdbc и
psycopg. Прикладной код выглядит как обычный параметризованный
запрос, но в wire protocol уходит пустое имя.
Почему это проблема в transaction-mode
В transaction pooling один backend по очереди обслуживает разных
клиентов. Если пулер отправляет пустой Parse как есть, каждый
Bind клиента приходит на backend, у которого плана для этого
запроса нет. Горячие OLTP-пути платят CPU планировщика на каждом
обращении.
Именованные prepared решают проблему производительности планирования, но перекладывают учёт на пулер:
- Пулер обязан помнить именованные statement каждого клиента до его отключения, даже если pool-level shared cache уже выселил запись.
- На каждом
Bindпулер обязан проверить, знает ли текущий backend это имя, и при необходимости заново сделатьParse. - При дисконнекте клиента пулер обязан отправить
CloseилиDEALLOCATEна правильный backend. - Драйверы, которые генерируют отдельное имя
stmt_<seq>под каждый уникальный запрос, раздувают клиентский кеш: сотни записей на клиента, при тысячах подключений превращающиеся в миллионы записей в памяти.
Остаются два варианта: отказаться от кеширования плана для анонимных или взять на себя полную стоимость учёта именованных. PgDoorman выбирает третий путь.
Что делает PgDoorman
На каждый анонимный Parse от клиента PgDoorman:
- Считает хеш по тексту запроса и OID типов параметров.
- Ищет хеш в pool-level кеше (общий между всеми клиентами
пула). При miss выделяет новое имя
DOORMAN_<counter>и регистрирует записьArc<Parse>. - Записывает в клиентский кеш ключ
Anonymous(hash), чтобы следующийBindнашёл тот жеDOORMAN_<N>. - Отправляет
Parseна backend с переписанным именем. - На соответствующем
Bind(с пустым именем) переписывает имя statement вDOORMAN_<N>и проверяет, что текущий backend уже держит запись; если нет, отправляетParseповторно.
Клиент никогда не видит DOORMAN_<N>: имя живёт только на участке
между PgDoorman и backend. Когда нужный backend уже держит запись,
PgDoorman синтезирует ParseComplete сам и не делает round-trip.
Пример wire-protocol
Go-приложение, выполняющее
db.Query("SELECT * FROM t WHERE name = $1", "vasya")
через lib/pq, отправляет такой обмен:
Клиент PgDoorman Backend
────── ───────── ───────
Parse("", q) ───►│ hash, miss → DOORMAN_42
│ pool_cache[hash] = Arc<Parse>
│ client_cache[Anon(hash)] = ...
│ Parse("DOORMAN_42") ─────►
│ ◄── ParseComplete
◄────│ ParseComplete
Bind("", "vasya") ───►│ rewrite "" → "DOORMAN_42"
│ Bind("DOORMAN_42") ──────►
│ Execute, Sync ───────────►
│ ◄── BindComplete, ...
│ ◄── ReadyForQuery
◄────│ BindComplete, ...
Второй клиент с тем же запросом в том же пуле попадает в pool cache
и не отправляет Parse на backend:
Клиент B PgDoorman Backend (тот же)
──────── ───────── ────────────────
Parse("", q) ───►│ hash hit → DOORMAN_42
│ server_cache содержит "DOORMAN_42"
◄────│ синтетический ParseComplete (на backend ничего)
Bind("", v) ───►│ rewrite "" → "DOORMAN_42"
│ Bind("DOORMAN_42") ────►
│ ...
Слои кеша
PgDoorman держит состояние prepared statements на трёх уровнях:
Pool-level DashMap<hash, CacheEntry>
Один на пул. Хранит Arc<Parse> с именем DOORMAN_N.
Размер: prepared_statements_cache_size (по умолчанию 8192).
Выселение: приближённый LRU.
Client-level Named: AHashMap<String, CachedStatement>, без лимита.
Anonymous: LruCache<u64, CachedStatement> ограничен
client_anonymous_prepared_cache_size (если не задан,
наследует prepared_statements_cache_size),
или AHashMap при размере 0.
Выселение Anonymous локальное: Arc<Parse> отбрасывается,
DOORMAN_<N> на бэкенде остаётся.
Server-level LruCache<String, ()>, на backend-соединение.
Запоминает, какие DOORMAN_N этот backend уже держит.
точный LRU; при выселении отправляет Close на backend.
При вытеснении записи из Anonymous LRU PgDoorman отбрасывает локальную
ссылку и не отправляет Close на бэкенд. Соответствующий
DOORMAN_<N> будет переиспользован server-level LRU или закроется по
server_lifetime (default 20 минут) — что наступит раньше.
Текст запроса интернируется через Arc<str>: десять клиентов с
одним и тем же анонимным запросом делят одну аллокацию в памяти.
Когда подмена помогает
- API-нагрузки с малым набором горячих запросов. Десяток
уникальных форм
SELECT/INSERTна тысячи клиентов. Hit rate в pool cache близок к 100 %, планировщик работает один раз на backend на каждую форму запроса, а следующие обращения идут черезBindк уже подготовленному statement. - Драйверы, привязанные к анонимным prepared.
lib/pq,libpqPQexecParams, JDBC сserverPreparedStatementType=NONE. Без подмены они каждый раз перепланируют. - Смешанные пулы, где named и anonymous соседствуют. Анонимные получают тот же выигрыш от кеш планов, что и именованные, без раздувания клиентский кеш.
Когда подмена не помогает
- Разовый / OLAP-трафик. Каждый запрос уникален: pool cache
постоянно вытесняет записи, на каждом insert идёт O(N) скан.
Отключите через
prepared_statements_cache_size = 0. - Скрипты с одним statement. Паттерн connect →
Parse→ 1Bind→ disconnect не накапливает достаточно hits, чтобы окупить учёт. Накладные расходы наParse~700 нс — небольшие, но измеримые. - Асинхронные драйверы в режиме pipeline. Каждая сессия получает
уникальное имя
DOORMAN_async_<N>, чтобы избежать коллизий между одновременными незавершёнными операциями. Server cache между сессиями не переиспользуется. Pool-level кеш по-прежнему делит текст запроса между сессиями; планировщик на backend срабатывает раз в сессию.
Эффективность этого ускорения измеряйте по Prometheus-счётчикам
pg_doorman_servers_prepared_hits и
pg_doorman_servers_prepared_misses. Устойчивый miss rate выше
30 % означает, что подмена расходует CPU и память, а
переиспользования плана не происходит. Тогда либо отключайте
подмену, либо увеличивайте prepared_statements_cache_size.
Как это устроено у других пулеров
| Пулер | Кеш Parse/плана для анонимного prepared statement |
|---|---|
| PgDoorman | Да: прозрачная подмена на DOORMAN_<N> |
| PgBouncer 1.21+ | Нет: только named, анонимный проходит as-is |
| Odyssey | Нет: только named, pool_reserve_prepared_statement |
| PgCat | Нет: только named |
В PgBouncer поддержка prepared statements появилась в 1.21, но
ограничена именованными: анонимный Parse проходит без
изменений, и каждый Bind запускает планировщик. Флаг
pool_reserve_prepared_statement в Odyssey требует именованных
statement; на анонимный трафик он не влияет. PgCat ведёт себя
так же.
Кешировать план анонимных prepared сегодня умеет только PgDoorman.
Конфигурация
| Параметр | Default | Эффект |
|---|---|---|
prepared_statements_cache_size | 8192 | Размер pool-level кеша в записях. 0 отключает подмену. |
client_anonymous_prepared_cache_size | наследует prepared_statements_cache_size | Размер per-client Anonymous LRU. 0 снимает потолок. Named всегда без лимита. |
Часть Named клиентский кеша всегда без лимита и не зависит от
client_anonymous_prepared_cache_size.
Полностью отключить подмену анонимных (редко, для OLAP-only):
general:
prepared_statements_cache_size: 0
Отличия от семантики PostgreSQL
Подмена меняет несколько протокольных деталей, на которые могут полагаться строгие приложения:
- Один и тот же анонимный
Parse, отправленный дважды, не стирает предыдущий. Каждая пара(query, param_types)живёт независимо в pool cache под своимDOORMAN_<N>. Closeс пустым именем — no-op для кешей PgDoorman. СоответствующийDOORMAN_<N>живёт до выселения pool-level LRU или до закрытия пула.- План остаётся generic дольше. В чистом PostgreSQL именованный
statement переключается с generic на custom после пяти
наблюдений; если разные клиенты делят один
DOORMAN_<N>и каждый даёт по одному-двумBind'ам, порог достигается быстрее. Но получившийся общий план может оказаться неудачным для клиента с перекошенными данными.
Приложения, которые опираются на PG-семантику "анонимный Parse
стирает предыдущий", должны переключиться на именованные statement
с явным Close.
Тюнинг
Размер кеша
Кеш prepared statements в PgDoorman состоит из трёх слоёв, и управляют ими два связанных параметра:
prepared_statements_cache_size(по умолчанию8192) задаёт размер общего pool-level кеша — одна хеш-таблица на пул, ключом служит хеш запроса. Это верхняя граница на число различных query shape, которые пул помнит сразу для всех клиентов. Приближённый LRU: вытеснение проходит за O(N) по всему кешу и не отправляетCloseна бэкенд (другие клиенты могут ещё держатьArc).server_prepared_statements_cache_size(по умолчанию наследуетprepared_statements_cache_size) задаёт размер per-backend кеша — отдельный LRU на каждое серверное соединение, ключом служит имяDOORMAN_<N>. Это верхняя граница на число prepared statements, которое PgDoorman позволит держать одному бэкенду PostgreSQL. Точный LRU за O(1); при выселении в очередь бэкенда кладётсяClose, который отправляется ближайшим Sync или Flush — представлениеpg_prepared_statementsможет временно показывать больше строк, чем потолок, пока не придёт следующий Sync.client_anonymous_prepared_cache_size(по умолчанию наследуетprepared_statements_cache_size) задаёт размер per-client Anonymous LRU.0отключает LRU и снимает потолок: кеш растёт без ограничения. Любое положительное число задаёт независимый от пулового размера потолок клиентский кеша.
Оба параметра принимают per-pool override:
general:
prepared_statements_cache_size: 8192
server_prepared_statements_cache_size: 1024 # потолок per-backend жёстче
pools:
oltp:
# наследует оба значения из general
pool_mode: "transaction"
reporting:
# у этого пула шире разнообразие запросов; пусть per-backend кеш
# вмещает больше
server_prepared_statements_cache_size: 4096
pool_mode: "transaction"
prepared_statements_cache_size: 0 отключает подмену целиком и
заодно обнуляет server-level LRU. Указать
server_prepared_statements_cache_size: 0 при положительном
pool-size допустимо, но смысла мало: per-backend кеш превратится
в pass-through, и каждое попадание на чужой бэкенд приведёт к
повторному Parse.
Когда уменьшать server_prepared_statements_cache_size ниже размера
pool-level кеша:
- На бэкендах копится слишком много строк
DOORMAN_<N>(pg_prepared_statementsупирается в потолок, память планов растёт). - Хочется ускорить вытеснение через
Close, не урезая попадания в pool-level кеш.
Когда оставить значения равными (поведение по умолчанию):
- Нет измеренной проблемы с памятью на бэкендах. Достаточно наследования.
Размер client_anonymous_prepared_cache_size
Если параметр не задан, per-client Anonymous LRU наследует
вычисленный prepared_statements_cache_size пула (по умолчанию 8192).
Явное значение перекрывает наследование: 0 отключает LRU, и кеш
растёт без ограничения; любое положительное число задаёт потолок
LRU.
Каждая запись хранит лёгкую структуру
(hash, async_name?, Arc<Parse>) — сам Arc<Parse> делится с
pool-level кешем, поэтому накладные расходы per-client ≈ 80 байт
на запись. На 10 000 подключённых клиентах × 256 записей × ~80 байт
получаем около 200 МБ предсказуемого потолка на пулере.
Поднимайте лимит, когда:
- ORM или генератор SQL выдаёт
stmt_<seq>под каждый запрос и Anonymous LRU постоянно вытесняет записи (видно по устойчиво ненулевой скоростиpg_doorman_clients_prepared_anonymous_evictions_total). - Приложение заведомо имеет широкое рабочее множество в одной сессии и скорость вытеснений соответствует этой нагрузке.
Уменьшайте лимит или поднимайте max_memory_usage при очень больших
числах подключений (50 000+ клиентов): на таком масштабе
clients × cache_size × 80 байт учётной памяти на пулере может
пересечь 1 ГБ, и урезание лимита уполовинивает её.
Named всегда без лимита
Часть Named клиентский кеша не ограничена. PgDoorman держит
Arc<Parse> для каждого именованного statement, который создал
клиент, до его отключения или явного DEALLOCATE /
DEALLOCATE ALL. Это согласуется с собственным контрактом
PostgreSQL — именованные prepared живут до конца сессии — и
исключает сценарий, при котором вытеснение Named-записи под нагрузкой
ломает следующий Bind ошибкой prepared statement does not exist.
Обратная сторона: драйверы, генерирующие отдельное имя под каждый
запрос (часть режимов pgjdbc и Hibernate, отдельные конфигурации
.NET Npgsql), могут раздуть Часть Named без потолка. PgDoorman не
может ограничить её безопасно; ответственность за переиспользование
имён или явный DEALLOCATE лежит на приложении.
Сигнал давления есть только для Anonymous LRU — счётчик вытеснений
pg_doorman_clients_prepared_anonymous_evictions_total. Для Named
такого сигнала нет: следите за колонкой client_named_count в
SHOW POOLS_MEMORY и метрикой pg_doorman_clients_prepared_named_entries
на предмет неожиданного роста.
Окно утечки памяти на бэкенде
При вытеснении записи из Anonymous LRU на стороне клиента PgDoorman
отбрасывает только локальный Arc<Parse>. Соответствующий
DOORMAN_<N> остаётся живым на каждом backend, который когда-либо
его обслуживал. Очищают его две силы:
- Server-level LRU. На каждом backend ведётся свой
LruCache<String, ()>имёнDOORMAN_<N>, ограниченныйprepared_statements_cache_size(по умолчанию 8192). При достижении лимита backend отправляетCloseна наименее давно использованное имя и освобождает план. - Ротация backend. Backend достигает
server_lifetime(default 20 мин), и pg_doorman закрывает его; новый backend стартует с пустым кеш планов.
Худший случай по памяти на одном backend — это
prepared_statements_cache_size × ~100 КБ плана ≈ 800 МБ на стороне
PostgreSQL. Чтобы сжать окно:
- Снизьте
prepared_statements_cache_size, чтобы server-level LRU быстрее вытесняла планы. - Снизьте
server_lifetime, чтобы backend ротировались чаще.
Системное представление pg_prepared_statements в PostgreSQL
показывает имена, которые держит текущий backend. Подсчёт строк там
показывает, насколько близко backend подошёл к лимиту.
Мониторинг
Команды администратора:
-
SHOW PREPARED_STATEMENTS— pool, hash, name, query,count_used,kind. Топ записей поcount_usedпоказывает горячие запросы, на которых кеш окупается. Колонкаkind— последняя в наборе и принимает значенияnamed,anonymousилиmixedв зависимости от того, как клиенты использовали запись за её жизнь.Пример:
pool | hash | name | query | count_used | kind --------------+--------------------+-------------+-------------------+------------+----------- sharded.user | 1234567890123456 | DOORMAN_1 | SELECT * FROM t1 | 150234 | anonymous sharded.user | 2345678901234567 | DOORMAN_2 | INSERT INTO t2 .. | 87654 | named sharded.user | 3456789012345678 | DOORMAN_3 | SELECT * FROM t3 | 45678 | mixed -
SHOW POOLS_MEMORY—pool_prepared_count,client_prepared_count,pool_prepared_bytes,client_prepared_bytesплюс разбивка по kind:client_named_count,client_anonymous_count,client_anonymous_evictions_alive. Последняя колонка суммирует счётчики вытеснений только по подключённым сейчас клиентам — отключившиеся клиенты выпадают из суммы, поэтому колонка не монотонна. Для накопительного счётчика читайте Prometheus-метрикуpg_doorman_clients_prepared_anonymous_evictions_total.
Prometheus-метрики (полный список в Prometheus):
pg_doorman_pool_prepared_cache_entries{user, database}pg_doorman_pool_prepared_cache_bytespg_doorman_clients_prepared_cache_entriespg_doorman_clients_prepared_cache_bytespg_doorman_clients_prepared_named_entries{user, database}pg_doorman_clients_prepared_anonymous_entries{user, database}pg_doorman_clients_prepared_anonymous_evictions_total{user, database}pg_doorman_servers_prepared_hits{user, database}pg_doorman_servers_prepared_misses{user, database}pg_doorman_async_clients_count
Алертинг
Скорость вытеснений в Anonymous LRU
Устойчиво ненулевая скорость на счётчике вытеснений означает, что LRU вытесняет записи быстрее, чем приложение переиспользует их. Шаблон алерта:
rate(pg_doorman_clients_prepared_anonymous_evictions_total[5m]) > 10
for 10m
Порог в 10 вытеснений/с на пул — отправная точка, реальное значение
зависит от формы трафика и числа подключений. Срабатывание алерта
читайте как "лимит слишком мал или рабочее множество приложения
шире, чем ожидалось"; решение — либо поднять
client_anonymous_prepared_cache_size, либо разобраться, не
генерирует ли приложение уникальные запросы на горячем пути.
Интерпретация kind = mixed
Каждая запись pool-level кеша помнит, использовали ли её клиенты под
именованным statement, под анонимным, или и так и так. kind = mixed
означает, что одна и та же пара (query, param_types) была
обработана хотя бы одним клиентом как named и хотя бы одним другим
как anonymous за её текущую жизнь. У большинства нагрузок строк
mixed нет; если в пуле их большинство, у клиентов разные драйверы
или разные конфигурации драйверов против одной БД, и эту разнородность
стоит проверить — иногда она задумана, иногда сигналит, что один из
клиентов настроен неверно.
Число prepared statements на бэкенде
PostgreSQL отдаёт pg_prepared_statements для текущего backend.
Если память пулера в норме, но RSS PostgreSQL backend растёт,
посчитайте строки на каждом backend:
SELECT count(*) FROM pg_prepared_statements;
Цифры около prepared_statements_cache_size (по умолчанию 8192)
означают, что server-level LRU работает на потолке и единственный
способ освободить память планов — ротация. Если server_lifetime
велик, планы копятся долго. Снижение любого из этих параметров
ослабляет давление на память планов ценой более частых перепарсингов
на backend.
Ограниченный query interner
Глобальный интернер, в котором дедуплицируются тексты Parse, разделён на две независимые хеш-таблицы.
- NAMED — тексты именованных prepared. Запись жива, пока
пуловой или клиентский кеш держит ссылку через
Arc<str>. Сборщик мусора убирает запись, когда её перестали удерживать снаружи интернера; двухтактовая отсрочка защищает запись, на которую сослались между двумя циклами обхода. - ANON — тексты анонимных prepared. Запись истекает по
query_interner_anon_idle_ttl_secondsбездействия (60 секунд по умолчанию).0отключает TTL и возвращает поведение pg_doorman до 3.7 — оставлено для существующих установок.
Когда интернер и пуловой/клиентский кеши одновременно перестали
держать текст анонимного prepared, pg_doorman на следующий Bind
отвечает ERROR: unnamed prepared statement does not exist
(SQLSTATE 26000). Это та же ошибка, что PostgreSQL отдаёт
напрямую в аналогичной ситуации; стандартные драйверы реагируют
повторным Parse.
Бинарное обновление (SIGUSR2) переносит и NAMED, и ANON в новый
процесс. Анонимные записи попадают в новый ANON-интернер со
свежим last_used, и TTL отсчитывается заново с момента
обновления.
Команды для оператора
SHOW INTERNER (через admin-сессию) выводит количество и объём
текста для каждой половины:
kind | entries | bytes
named | 420 | 87654
anonymous | 1337 | 234567
SHOW INTERNER N показывает N самых тяжёлых записей: hash,
kind, bytes, idle_ms (-1 для named — там нет
последнего-использования, вместо него отслеживается состояние
сборщика) и 120 первых символов текста запроса.
RESET INTERNER чистит обе половины. Активные клиенты заново
сделают Parse при следующем использовании. Команда
диагностическая.
Метрики Prometheus отражают SHOW INTERNER плюс гистограмму
длительности обхода и счётчик синтетических 26000. Поднимайте
query_interner_anon_idle_ttl_seconds, когда
pg_doorman_query_interner_synthetic_misses_total показывает
устойчивую ненулевую скорость.
Справочник
- Режимы пула — transaction mode, где работает подмена prepared statements.
- Общие настройки —
prepared_statements_cache_size,client_anonymous_prepared_cache_size,query_interner_gc_interval_seconds,query_interner_anon_idle_ttl_seconds. - Команды администратора —
SHOW PREPARED_STATEMENTS,SHOW POOLS_MEMORY,SHOW INTERNER,RESET INTERNER. - Prometheus — полный список метрик.