Кеш 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:

  1. Считает хеш по тексту запроса и OID типов параметров.
  2. Ищет хеш в pool-level кеше (общий между всеми клиентами пула). При miss выделяет новое имя DOORMAN_<counter> и регистрирует запись Arc<Parse>.
  3. Записывает в клиентский кеш ключ Anonymous(hash), чтобы следующий Bind нашёл тот же DOORMAN_<N>.
  4. Отправляет Parse на backend с переписанным именем.
  5. На соответствующем 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, libpq PQexecParams, JDBC с serverPreparedStatementType=NONE. Без подмены они каждый раз перепланируют.
  • Смешанные пулы, где named и anonymous соседствуют. Анонимные получают тот же выигрыш от кеш планов, что и именованные, без раздувания клиентский кеш.

Когда подмена не помогает

  • Разовый / OLAP-трафик. Каждый запрос уникален: pool cache постоянно вытесняет записи, на каждом insert идёт O(N) скан. Отключите через prepared_statements_cache_size = 0.
  • Скрипты с одним statement. Паттерн connect → Parse → 1 Bind → 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_size8192Размер 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_MEMORYpool_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_bytes
  • pg_doorman_clients_prepared_cache_entries
  • pg_doorman_clients_prepared_cache_bytes
  • pg_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 показывает устойчивую ненулевую скорость.

Справочник