English: see README_EN.md.
- Это не универсальная замена advisory locks. Сначала поднимите
max_locks_per_transaction(4096..16384). Для большинства нагрузок этого достаточно.- Не используйте, если
LWLock:LockManagerне входит в топ-3 wait-event'ов под реальной нагрузкой на вашем проде. Это и есть узкое место, под которое pg_xclaim сделан. Без него расширение дает только лишние операционные накладные расходы.- Запустите бенчмарк альтернатив на зеркале своей системы. Если любая из альтернатив 1-6 быстрее или сравнима по стабильности - берите ее.
Экспериментальное PostgreSQL-расширение: альтернативный механизм
хранения транзакционных claim'ов в собственной партиционированной
shared-memory hashtable. Сделан как прототип под конкретную нагрузку:
унаследованная кодобаза с сотнями мест вызова pg_try_advisory_xact_lock,
операции по 100k+ claim'ов в одной транзакции, стандартные
альтернативы не подошли (см. Альтернативы).
Это не замена pg_try_advisory_xact_lock и не позиционируется
как таковая. Сигнатуры функций намеренно совпадают, чтобы можно было точечно
мигрировать старый код в узких случаях - не как общий рецепт. Перед
использованием обязательно прочитайте Альтернативы
и FAQ.
Операционная сложность. Claim'ы pg_xclaim не видны в
pg_locks,pg_stat_activity, EXPLAIN. DBA на инциденте смотрит в два места - стандартные инструменты PostgreSQL плюсxclaim.stats(). Под это написано отдельное руководство по инцидентам (docs/incident-decision-tree.md). У детальногоxclaim.debug_snapshot()есть оговорка по числу партиций (см. FAQ Q4).
Контекст. В одной конкретной системе долгоживущие транзакции
удерживают pg_try_advisory_xact_lock на 100k+ ключей одновременно.
На такой нагрузке LWLock:LockManager становится доминирующим
wait-event'ом. Не из-за max_locks_per_transaction (это лечится
GUC), а из-за конкуренции на partition-LWLock'ах самого LockManager.
Стандартные альтернативы (см. Альтернативы) в конкретной кодобазе не подошли. Слишком много мест вызова advisory, нельзя декомпозировать транзакции, сложно менять схему.
pg_xclaim - эксперимент: что если положить эти claim'ы в отдельную
партиционированную shmem-структуру с теми же транзакционными
семантиками, но мимо LockManager? Получился рабочий прототип с
тестами, ранбуками и perf-бюджетами.
По стоимости: захват одного ключа - один цикл partition LWLock.
Очистка амортизированно <= num_partitions циклов на всю транзакцию
(групповая по партициям, не per-key). На горячем пути в C -
memset + memcmp на 16-байтном ключе.
Перегрузки xclaim.try(int4, int4) и xclaim.try(int8) имеют
идентичные атрибуты функций с pg_try_advisory_xact_lock
(volatility, parallel mode, а strictness отличается намеренно, см.
ниже). Это нужно, чтобы можно было мигрировать старый код через
текстовую замену.
Прежде чем рассматривать pg_xclaim, оцените стандартные подходы. Для ~95% нагрузок одна из строк 1-6 закрывает задачу.
| # | Решение | Когда выбирать | Компромиссы |
|---|---|---|---|
| 1 | Поднять max_locks_per_transaction (4096..16384) |
По умолчанию 64 не хватает, LWLock:LockManager НЕ топ-событие ожидания |
Shared lock tables растут линейно: PostgreSQL резервирует их под max_locks_per_transaction x (MaxBackends + max_prepared_transactions) блокировок (записи LOCK и вдвое больше PROCLOCK, плюс 10%). При 4096 и max_connections=200 это ~909k блокировок и на ~334 MB больше shmem, чем при 64 по умолчанию (PG 16 и 17), в PG 18 сверх этого идут fast-path массивы (~5 MB). Под high-cardinality к этому добавляется локальная память бэкенда: ~34 MB на бэкенд при 100k удерживаемых locks, примерно поровну LOCALLOCK hash и массивы владельцев блокировок (lockOwners) в TopMemoryContext. Обычно дешевле любого расширения если кардинальность транзакции невысокая. |
| 2 | Батчинг + идемпотентность через document_id UNIQUE |
Операцию можно декомпозировать на 100-500 счетов, есть outbox/saga для атомарности | Теряется атомарность всей операции в одной транзакции, нужен идемпотентный дизайн |
| 3 | SELECT ... FOR UPDATE NOWAIT / SKIP LOCKED на самих счетах |
Accounts - полноценные строки в таблице, доступны для row-lock, конкуренция умеренная | WAL и грязные страницы на каждую залоченную строку (блокировка пишется в заголовок tuple), возможны MultiXact'ы, взаимодействие с vacuum, partitioning по другому ключу = непредсказуемая стоимость |
| 4 | Claim-table INSERT ... ON CONFLICT DO NOTHING RETURNING |
Простое, портируемое, инспектируемое стандартными средствами PG. Кардинальность умеренная, конкурентность низкая | Под высокой конкурентностью ловит deadlock'и на UNIQUE-индексе (см. бенчмарк: на 8 параллельных бэкендах с пересекающимися ключами несортированные варианты теряют на deadlock'ах 7 воркеров из 8, сортированные обходят deadlock'и ожиданием на блокировках и при K=100k дают 2.5-5.4 tx/sec с p95 3.6-8.1 с). Требует DDL и миграции схемы. Для LOGGED-таблиц - heap+index WAL и нагрузка на autovacuum |
| 5 | Шардинг на уровне приложения по hash(account_id) % N |
Можно переделать приложение, есть N PG-подключений | Конфликтов нет по построению, но требует переделки приложения - неприменимо к старому коду |
| 6 | Оптимистическая конкурентность (version column + CAS) |
Конфликты редкие (<5%) | Лавина повторов если конфликты частые |
| 7 | pg_xclaim | Альтернативы 3-4 (B/C/D) проигрывают в синтетическом бенчмарке (см. ограничение методики), а 1, 2 исключены по архитектурным причинам, специфичным для исходной системы (см. FAQ Q1, Q3). Self-hosted PG, сотни мест вызова advisory в старом коде, есть ресурс на поддержку расширения | Операционная непрозрачность, managed-облака отрезаны, привязка к ABI |
Если строите новую систему - почти наверняка нужна одна из 1-6, а не это расширение. См. также FAQ, там разобраны частые возражения.
| Платформа | Версия | CI |
|---|---|---|
| PostgreSQL | 16.x последний минорный | GitHub Actions |
| PostgreSQL | 17.x последний минорный | GitHub Actions (основной) |
| PostgreSQL | 18.x последний минорный | GitHub Actions |
| ABI-совместимый форк PG | 16/17/18 | локально вручную |
Флаги компиляции: -Wall -Wextra -Werror (HARD invariant).
- Кластер PG 16 / 17 / 18 (тестировано на upstream PG, ABI-совместимые форки могут потребовать локальной верификации).
pg_configна сборочной машине.- macOS для разработки:
brew install postgresql@17(или 16 / 18).
set -euo pipefail
make PG_CONFIG=/opt/homebrew/opt/postgresql@17/bin/pg_config
sudo make PG_CONFIG=/opt/homebrew/opt/postgresql@17/bin/pg_config installОтредактируйте postgresql.conf:
# Поставьте pg_xclaim ПОСЛЕДНИМ в списке - на коммите его cleanup
# отработает раньше всех (PG вызывает xact-callback'и в LIFO-порядке).
shared_preload_libraries = 'citus,timescaledb,pg_xclaim'
pg_xclaim.max_claims = 4194304 # 4M (default, ~360 MB shmem)
pg_xclaim.num_partitions = 128
pg_xclaim.expected_claims_per_backend = 16384
pg_xclaim.enabled = true
pg_xclaim.capacity_warn_pct = 80
pg_xclaim.on_capacity_exhaustion = errorЗамечание о размере памяти.
expected_claims_per_backend = 16384- это размер, до которого расширение заранее наращивает backend-локальныйsimplehash. Если один бэкенд держит больше 16k одновременных claim'ов (например, цель - 750k на бэкенд), поднимите GUC до ожидаемого пика (~750000). Без этого тоже все будет работать, но в середине пиковой нагрузки бэкенд будет тратить время на rehash хеш-таблицы.Чтобы не следить за этим вручную, расширение само пишет в server log строку
crossed 75% of pg_xclaim.expected_claims_per_backend, как только бэкенд впервые подходит к пределу. Это готовый сигнал "пора поднять GUC". Подробнее вdocs/runbook.md§3.Формула общего бюджета памяти. Каждый удерживаемый claim занимает 48-байтную
XClaimLocalEntry. С учетом округления simplehash до степени двойки и load factor ~75% эффективная стоимость - ~63 байта на claim при 100k, что эмпирически дает ~50.3 MB на бэкенд при 750k (см.docs/runbook.md§9).Локальная память одного бэкенда (при 750k claim'ов) ~ 50.3 MB Локальная память кластера (суммарно) ~ 50.3 MB x max_connectionsПример. При
max_connections=200иexpected_claims_per_backend=750000это ~10 GB суммарно в TopMemoryContext'ах бэкендов (~50.3 MB x 200), плюс ~360 MB shmem dynahash изmax_claims=4194304. Это нужно учитывать при расчете RAM кластера: pre-grow per-backend - самая большая статья расхода в общем бюджете.
Перезапустите кластер, затем:
CREATE EXTENSION pg_xclaim;Расширение не выдает claim'ы на hot-standby репликах: пока
RecoveryInProgress() возвращает true, SQL-вызовы поднимают
ERRCODE_FEATURE_NOT_SUPPORTED. Загрузка через
shared_preload_libraries на реплике безопасна, и после promotion
вызовы работают без рестарта. Оставляйте pg_xclaim в preload-списке
на репликах, иначе новый primary после failover не выдаст claim'ы до
рестарта. См. docs/runbook.md §10.3.
Все функции живут в выделенной схеме xclaim.
| Функция | Назначение |
|---|---|
xclaim.try(int4, int4) RETURNS boolean |
Двухаргументная функция, сигнатура совпадает с pg_try_advisory_xact_lock(int4, int4) для удобной миграции старого кода. |
xclaim.try(int8) RETURNS boolean |
Одноаргументная функция, сигнатура совпадает с pg_try_advisory_xact_lock(bigint). Отдельное пространство ключей. |
xclaim.try_many(int4, int4[]) RETURNS boolean[] |
Пакетный вариант, пред-сортирует по партиции (<= num_partitions циклов LWLock на весь массив). |
xclaim.try_many(int8[]) RETURNS boolean[] |
Пакетный вариант одноаргументной формы. |
xclaim.count() RETURNS int8 |
Число claim'ов, удерживаемых текущей top-level транзакцией. |
xclaim.stats() RETURNS TABLE(...) |
Снимок атомарных счетчиков + метрики емкости. Доступ pg_monitor. |
xclaim.debug_snapshot() RETURNS TABLE(...) |
Детальный снимок shared-state под shared-lock'ами всех партиций. Доступ pg_monitor, требует pg_xclaim.num_partitions <= 192. |
xclaim.debug() RETURNS TABLE(...) |
Полный консистентный снимок по всем партициям. Только superuser. |
xclaim.debug_inject_stale(int4, int4) RETURNS void |
Только для тестов. Инъектор stale-записи (имитация владельца, не пережившего crash). Только superuser, REVOKE-нут от PUBLIC. В production не вызывается. |
xclaim.session_reset() RETURNS void |
Опциональный хук для аварийной очистки. Принудительно очищает локальное состояние и ротирует owner_token. При штатной работе не нужен: claim'ы освобождаются автоматически через xact-callback на каждом COMMIT/ABORT. Нужен только сессии, которая держит claims вне транзакции (xclaim.count() > 0 в autocommit). Штатная работа такого состояния не дает, см. раздел 6 в docs/runbook.md. Не ставьте в server_reset_query пулера - это лишний RTT. |
xclaim.try совпадает с pg_try_advisory_xact_lock по volatility и
parallel-режиму, а strict-режим намеренно отличается. Таблица ниже -
точное описание инварианта API, не маркетинг:
| Атрибут | pg_try_advisory_xact_lock |
xclaim.try |
Совпадение |
|---|---|---|---|
provolatile |
v (VOLATILE) |
v (VOLATILE) |
да |
proparallel |
r (PARALLEL RESTRICTED) |
r (PARALLEL RESTRICTED) |
да |
proisstrict |
t (STRICT) |
f (CALLED ON NULL INPUT) |
намеренно отличается |
Замечание.
pg_try_advisory_xact_lockобъявлен STRICT (pg_proc.proisstrict = t), то есть PostgreSQL молча возвращает NULL при любом NULL-аргументе, не вызывая C-тело.xclaim.tryведет себя иначе: ловит NULL и явно бросаетERRCODE_NULL_VALUE_NOT_ALLOWED. Программные ошибки видны сразу, а не маскируются "тихим NULL".Что это значит для миграции. Вызывающий код не должен передавать NULL как ключ. В большинстве production-мест вызова это уже так. Если конкретный вызов полагался на тихий NULL-проход - добавьте
WHERE key IS NOT NULLпередxclaim.try(key).
SELECT xclaim.try(NULL, 1); -- поднимает ERRCODE_NULL_VALUE_NOT_ALLOWED
SELECT xclaim.try(NULL); -- поднимает ERRCODE_NULL_VALUE_NOT_ALLOWED
SELECT xclaim.try_many(NULL::int8[]); -- поднимает ERRCODE_NULL_VALUE_NOT_ALLOWED
SELECT xclaim.try_many(NULL, ARRAY[1]); -- поднимает ERRCODE_NULL_VALUE_NOT_ALLOWED
SELECT xclaim.try_many(ARRAY[1, NULL, 3]); -- поднимает ERRCODE_NULL_VALUE_NOT_ALLOWEDОбработка NULL симметрична между скалярной и пакетной формами:
xclaim.try_manyподнимает ERROR на любом NULL-аргументе И на любом NULL-элементе внутри валидного массива, так же как иxclaim.try. Тихий NULL-возврат скрывает программные ошибки (например, массив, построенный из подзапроса, который выдал NULL): рекомендованный идиомbool_and(unnest)молча трактовал бы частично-NULL результат как успешный, потому чтоbool_andигнорирует NULL по SQL-семантике. Отфильтровывайте NULL-элементы черезarray_remove(arr, NULL)(илиWHERE x IS NOT NULLв подзапросе) перед вызовомxclaim.try_many.
BEGIN;
SELECT xclaim.try(1, 100); -- true (claim взят)
SELECT xclaim.try(1, 100); -- true (быстрый путь повторного входа, без LWLock)
SELECT xclaim.try(1, 100), xclaim.count(); -- (true, 1)
COMMIT; -- claim освобождается xact-callback'ом-- Пакетный захват:
SELECT bool_and(ok)
FROM unnest(xclaim.try_many(1, ARRAY[100, 200, 300, 400])) AS ok;Семантика результата
try_many. Элемент результатаiсоответствует входному элементуi(порядок сохраняется, несмотря на внутреннюю пере-сортировку по партиции). Это best-effort, не "все или ничего": при частичной неудаче захваченное подмножество остается удержанным до конца транзакции. Если нужна атомарность "все или ничего", проверяйтеbool_and(...)и делайтеROLLBACK, когда хоть один элемент вернулfalse.
Claim'ы, взятые внутри SAVEPOINT (или PL/pgSQL-блока
BEGIN ... EXCEPTION, который PostgreSQL реализует как неявный
savepoint), переживают ROLLBACK TO SAVEPOINT и освобождаются
только top-level COMMIT'ом или ABORT'ом. Это намеренное
расхождение с pg_try_advisory_xact_lock, у которого advisory-локи
освобождаются subxact-rollback'ом. Более простой контракт
"top-level lifetime" позволяет pg_xclaim'у обойтись одним xact-callback
(никакого per-subxact учета в shared или local памяти). Если ваш
workload рассчитывает на освобождение per-savepoint - либо
реструктурируйте код, чтобы держать claim внутри той xact-границы,
которую вы хотите освободить, либо для этого конкретного call site
оставайтесь на pg_try_advisory_xact_lock.
| GUC | Тип | По умолчанию | Описание |
|---|---|---|---|
pg_xclaim.max_claims |
int4 | 4194304 | Емкость shared dynahash. Требует перезапуска. Footprint масштабируется линейно от max_claims. Sizing по умолчанию - ~360 MB, удвоение max_claims -> удвоение footprint. |
pg_xclaim.num_partitions |
int4 | 128 | Количество защищенных LWLock'ом партиций. Требует перезапуска. |
pg_xclaim.expected_claims_per_backend |
int4 | 16384 | Заранее наращивает локальный simplehash до этого размера, чтобы избежать rehash'ей под нагрузкой. Требует перезапуска. |
pg_xclaim.enabled |
bool | true | Активный "выключатель". Off = xclaim.try безусловно возвращает true. PGC_SUSET. |
pg_xclaim.capacity_warn_pct |
int4 | 80 | Порог watermark для лога (LOG-строка на 80/90/95%). |
pg_xclaim.on_capacity_exhaustion |
enum | error | error (по умолчанию, ERRCODE 53400) / warn (log + return false). |
См. docs/runbook.md - рекомендации по настройке.
Если вы только начинаете проект, не используйте pg_xclaim. Возьмите одну из альтернатив 1-6 из таблицы выше. Раздел ниже - для случая, когда уже есть сотни мест вызова
pg_try_advisory_xact_lockв унаследованной кодобазе и вы прошли через все альтернативы.
xclaim.try(k)иpg_try_advisory_xact_lock(k)живут в разных пространствах ключей и не исключают друг друга: ключk, взятый через advisory, и тот жеk, взятый через xclaim, - это два независимых claim'а. Если часть кода уже мигрирована наxclaim.try, а часть все еще зоветpg_try_advisory_xact_lockна тот же ключ, оба "успешно" захватят его одновременно - взаимное исключение сломано. Поэтому все места вызова одного keyspace должны мигрировать одновременно, одним изменением. Частичная миграция keyspace - ошибка корректности, не оптимизация.
Замените pg_try_advisory_xact_lock(a, b) на xclaim.try(a, b) и
pg_try_advisory_xact_lock(c) на xclaim.try(c) в каждом месте
вызова. Сигнатуры совпадают (volatility и parallel-mode идентичны -
см. таблицу паритета выше). Поведение на NULL отличается намеренно:
advisory молча возвращает NULL, xclaim.try бросает ERROR.
То есть API совместим по сигнатуре, но не по поведению. Для большинства production-мест это не проблема, никто не передает NULL как ключ. Но проверьте свой код перед миграцией.
pg_xclaim/
├── Makefile # PGXS
├── pg_xclaim.control # extension metadata
├── sql/
│ ├── pg_xclaim--1.0.0-rc1.sql # install script
│ └── pg_xclaim--1.0.0-rc1--1.0.0-rc2.sql # update script
├── src/
│ ├── pg_xclaim.c # _PG_init + GUC'ы + glue
│ ├── pg_xclaim_compat.h # PG 16/17/18 ABI shim
│ └── ... # acquire / cleanup / bulk
├── test/
│ ├── sql/, expected/ # pg_regress
│ └── concurrency/ # shell-driven concurrency suite
├── scripts/
│ ├── find_pg_config.sh
│ ├── run_temp_cluster.sh
│ ├── smoke_gate.sh # preload smoke gate
│ ├── smoke_gate_no_preload.sh # no-preload smoke gate
│ ├── run_regress_matrix.sh
│ ├── bench_try_many.sh # self perf-budget gate
│ └── bench_alternatives.sh # comparison vs row locks / claim-table
├── docs/
│ ├── runbook.md # DBA-ранбук (русская)
│ ├── runbook_en.md # DBA runbook (English)
│ ├── incident-decision-tree.md # дерево решений на дежурстве (русская)
│ ├── incident-decision-tree_en.md # oncall triage (English)
│ └── perf/
│ ├── hot-path-analysis.md # русская версия
│ ├── hot-path-analysis_en.md # English version
│ ├── flamegraphs/ # SVG flamegraphs
│ └── *.csv # bench + baseline measurements
├── .github/workflows/ci-pg-matrix.yml
└── README.md
Числа ниже - синтетические измерения, не универсальные утверждения о производительности. Сверяйтесь с
актуальным CSV в docs/perf/ и измеряйте на своей нагрузке.
| Нагрузка | Наблюдается | Бюджет | Контроль |
|---|---|---|---|
| 750k одиночный бэкенд, скалярный API | ~378 ms end-to-end | 2000 ms | bench_try_many.sh |
750k пакетный xclaim.try_many |
~288 ms | 500 ms | bench_try_many.sh |
| 50k acquire+COMMIT (групповая очистка) | < 100 ms | 100 ms | grouped_cleanup_50k.sh |
Числа из последнего прогона на macOS PG 17.10 / Apple M-series:
docs/perf/bench-20260525-pg17.csv
(накопительный CSV, новая строка на каждый прогон).
Сравнивать "advisory vs pg_xclaim" по памяти честно можно только при
оговорке: чтобы advisory вообще удержал 100k locks на бэкенд, нужен
max_locks_per_transaction, во много раз превышающий default - а это
само по себе раздувает shared lock table на весь кластер. То есть
конфигурация "до" либо нежизнеспособна, либо платит свою цену в
shmem. Поэтому здесь приводится не одно "выигрышное" число, а формула
и порядок величины - считайте под свой max_connections и пик claim'ов.
pg_xclaim добавляет фиксированный shmem-пул (max_claims, default
~360 MB) и per-backend локальную память:
shmem (общий пул) ~ выбранный max_claims (default 4M -> ~360 MB)
per-backend local ~ 50.3 MB при 750k claim'ов
~ 6.3 MB при 100k claim'ов
cluster-wide local ~ per-backend x число активных бэкендов
Пример (high-cardinality сценарий, под который сделано расширение):
100k claim'ов в одной транзакции x 200 параллельных бэкендов - это
20M одновременных claim'ов в общем пуле. Дефолтных 4M хватает на ~41
такой бэкенд, поэтому нужен pg_xclaim.max_claims не меньше 20M,
например 25165824 (24M, ~2.2 GB shmem). Локальная память -
~6.3 MB x 200 = ~1.26 GB. Взамен снимается локальная память
advisory-блокировок (~34 MB/бэкенд при 100k удерживаемых locks:
LOCALLOCK hash и массивы владельцев блокировок) и убирается
потребность в раздутом max_locks_per_transaction: чтобы advisory
удержал те же 20M блокировок, нужен max_locks_per_transaction
~91000, это ~7.6 GB lock tables.
Эмпирически замерено на PG 16 и PG 18 (CPU/shmem структуры backend-local,
числа идентичны между версиями). Полная разбивка с pre-grown floor и
ростом simplehash - в docs/runbook.md §9.
Под легкой нагрузкой (10-100 locks на транзакцию) переход только
добавит ~360 MB shmem без видимой экономии - эта нагрузка изначально
не нуждается в pg_xclaim.
На этом графике B и D в режиме overlap представлены одним воркером из восьми, который дошел до конца (см. вторую сноску к краткой сводке ниже).
Репозиторий содержит бенчмарк pg_xclaim против пяти стандартных
альтернатив, образующих полную 2x2 матрицу {UNLOGGED, LOGGED} x {UNSORTED, SORTED} для claim-table:
| unsorted INSERT | sorted INSERT (ORDER BY k) |
|
|---|---|---|
| UNLOGGED | B | F |
| LOGGED | D | E |
Плюс C: блокировка строк через FOR UPDATE NOWAIT. Бенчмарк гоняется
в двух режимах: overlap (общий пул ключей, ~52% попарного
пересечения на N=8 K=100k) и disjoint (каждому бэкенду свой шард
ключей без пересечений).
Полные числа, методология и оговорки о справедливости - в
docs/perf/COMPARISON.md. Сырые данные:
bench-alternatives-20260524-overlap-pg17.csv
и bench-alternatives-20260524-disjoint-pg17.csv.
Железо: macOS, Apple M-series, PG 17.10. Важная оговорка: macOS по
умолчанию не использует F_FULLFSYNC, поэтому обычный fsync()
возвращается до физического сброса на диск - это льстит WAL-тяжелым
вариантам (C/D/E). На боевом Linux с честным fsync ранжирование может
быть другим. Читайте числа как порядок величины, а не как точные
коэффициенты.
Ограничение методики. В scripts/bench_alternatives.sh каждая
итерация идет в подтранзакции внутри одной транзакции воркера.
Состояние B-F (строки claim-table, блокировки строк C) исчезает при
откате подтранзакции, а claim'ы pg_xclaim переживают его и копятся до
конца транзакции, поэтому с каждой итерацией растет доля захватов A,
которые повторно входят в уже взятые ключи или получают конфликт с
claim'ами других воркеров. Воркер B или D, упавший на deadlock,
выбывает без замеров, а throughput считается на полное число
итераций. Поэтому числа A и B-F не сравнимы напрямую: это порядок
величины для этой методики, а не отношение стоимостей захвата.
Краткая сводка (PG 17, ITERS=200, fsync=on, log_lock_waits=on, ACCOUNT_POOL=1M):
| Сценарий | A pg_xclaim | B UNLOGGED | C row locks | D LOGGED | E LOGGED+sorted | F UNLOGGED+sorted |
|---|---|---|---|---|---|---|
| N=1, K=100k overlap - tx/sec | 38 | 2.3 | 9.6 | 1.7 | 2.5 | 5.1 |
| N=1, K=100k overlap - WAL | 0 | 723 KB | 1.2 GB | 3.6 GB | 3.4 GB | 9 KB |
| N=8, K=100k overlap - tx/sec | 209 | 18** | 223* | 14** | 2.5 | 5.4 |
| N=8, K=100k overlap - p95 ms | 29 | 478** | 23 | 669** | 8124 | 3571 |
| N=8, K=100k overlap - deadlocks (per scenario, cumulative) | 0 | 7 | 0 | 7 | 0 | 0 |
| N=8, K=100k disjoint - tx/sec | 194 | 13.6 | 17.8 | 4.3 | 5.2 | 15.2 |
| N=8, K=100k disjoint - p95 ms | 32 | 719 | 721 | 2367 | 1964 | 580 |
| N=8, K=100k disjoint - WAL | 0 | 73 KB | 6.8 GB | 20.5 GB | 20.2 GB | 72 KB |
* Видимая "победа" C на overlap - артефакт fail-fast семантики NOWAIT: C падает на первом конфликте за микросекунды и не захватывает ни одного ключа за транзакцию. На
disjoint, где NOWAIT не может "сжульничать", тот же C дает 18 tx/sec. Полная оговорка и наблюдения - вdocs/perf/COMPARISON.md.
** У B и D на N=8 overlap до конца доходит один воркер из восьми (вывод из логики скрипта, см. абзац про deadlock'и ниже). tx/sec делит 8 x 200 итераций на время работы этого воркера и завышает выполненную работу примерно в 8 раз: выполнено ~2.2 (B) и ~1.8 (D) tx/sec. p95 посчитан по этому одному воркеру.
Deadlocks замерены напрямую (pg_stat_database.deadlocks): на N=8
overlap unsorted claim-table'ы (B и D) ловят по 7 deadlock'ов за
сценарий при каждом K. Deadlock обрывает всю транзакцию одного
воркера (весь его DO-блок), поэтому при 7 deadlock'ах до конца
доходит один воркер из восьми. Это вывод из логики
scripts/bench_alternatives.sh, а не прямое наблюдение. Косвенно его
подтверждают WAL D при K=100k (3.63 GB, почти как у одного воркера
при N=1, 3.57 GB) и tx/sec B и D при K=100k, примерно в 8 раз выше,
чем при N=1 (17.8 против 2.3, 14.1 против 1.7). Sorted-варианты
(E и F) дают 0 deadlock'ов. Sort действительно их устраняет, но
превращает в wait-on-lock сериализацию (см. COMPARISON.md).
Что показывает полная матрица B/D/E/F:
- На overlap при K=100k F (без WAL) дает 5.4 tx/sec, E (с WAL) - 2.5 tx/sec, у обоих p95 > 3 секунд. WAL снижает throughput примерно вдвое, а без WAL F на overlap почти втрое медленнее, чем на disjoint (15.2 tx/sec): вероятно, воркеры ждут друг друга на пересекающихся ключах. Сравнить sorted- и unsorted-варианты на overlap этот бенчмарк не дает: у B/D до конца доходит один воркер из восьми.
- На disjoint, где deadlock'ов нет, F (15.2) > B (13.6) и F >> E (5.2). Без пересечений sort нейтрален или дает небольшой выигрыш (вероятно cache locality в btree), а главная стоимость E/D - это WAL (LOGGED таблица генерирует ~20 GB WAL за прогон N=8 K=100k - D ~20.5 GB, E ~20.2 GB - против ~72 KB у F).
По результатам этого бенчмарка pg_xclaim оправдан, когда K >= 10k, параллелизм >= 8 и нагрузка не может полагаться на NOWAIT fail-fast. Иначе выбор должен делаться по операционной простоте (B/C/D/E/F не требуют расширения и видны в стандартных инструментах). Перед выбором запустите бенчмарк на зеркале своей системы.
| Документ | Назначение |
|---|---|
docs/runbook.md |
DBA-ранбук - установка, мониторинг, откат. |
docs/incident-decision-tree.md |
Дерево решений на дежурстве (P0-P3). |
docs/perf/hot-path-analysis.md |
Профиль горячего пути с flamegraph'ами. |
Компактный список того, чего pg_xclaim намеренно не делает. Подробности
по каждому пункту - в docs/runbook.md.
PREPARE TRANSACTIONотвергается (SQLSTATE0A000,ERRCODE_FEATURE_NOT_SUPPORTED). Это явное расхождение с advisory: advisory-локи переживаютPREPARE, claim'ы pg_xclaim - нет. См.docs/runbook.md.- Try-only API: нет блокирующего захвата, нет очереди ожидания, нет детектора deadlock'ов. Порядок захвата в retry-цикле - ответственность вызывающего. Deadlock-детектор PostgreSQL claim'ы pg_xclaim не видит.
- Hot-standby: на реплике в recovery вызовы поднимают
ERRCODE_FEATURE_NOT_SUPPORTED. После promotion они работают без рестарта, еслиpg_xclaimостался вshared_preload_libraries. - Время жизни в подтранзакциях отличается от advisory: claim
переживает
ROLLBACK TO SAVEPOINT, освобождается только top-level COMMIT/ABORT (см. раздел выше). - Не больше 134217727 claim'ов в одном бэкенде. При большем числе
очистка в конце транзакции падает (PostgreSQL не выделяет больше 1 ГБ
за раз), и кластер уходит в PANIC. Для этого нужен
max_claims >= 134217728, с дефолтом 4M предел недостижим. См.docs/runbook.md§9. - Windows не тестировался и не поддерживается.
- Transaction-pooling (pgbouncer/odyssey в режиме
transaction): claim'ы привязаны к транзакции и освобождаются на ее границе, поэтому не ставьтеsession_resetвserver_reset_queryпулера.
pg_xclaim.max_claims - это один фиксированный пул на весь кластер,
общий для всех баз данных. Дефолтные GRANT EXECUTE ... TO PUBLIC
зеркалят дефолты advisory и несут ту же экспозицию: любой грантополучатель
может намеренно исчерпать пул и заставить on_capacity_exhaustion=error
падать у других сессий. На кластерах с недоверенными ролями выполните
REVOKE EXECUTE (точные сигнатуры - в
sql/pg_xclaim--1.0.0-rc1.sql):
REVOKE EXECUTE ON FUNCTION xclaim.try(int8) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION xclaim.try(int4, int4) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION xclaim.try_many(int8[]) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION xclaim.try_many(int4, int4[]) FROM PUBLIC;Полная модель угроз и порядок раскрытия уязвимостей -
.github/SECURITY.md.
Вопросы, которые задаст любой вдумчивый PG-инженер.
В большинстве случаев это правильное решение, рекомендуется первым (см. строку 1 в Альтернативы).
В исходном сценарии оно не сработало по одной причине: под высокой
конкурентностью partition-LWLock'и самого LockManager сами становятся
горячей точкой среди wait-event'ов. Поднять max_locks_per_transaction
это не выход, конкуренция остается.
Если у вас обычное исчерпание max_locks_per_transaction и
LWLock:LockManager не в топе wait-events - поднимите GUC и не
ставьте pg_xclaim. Это дешевле и проще.
Для новых систем - часто правильный выбор. В исходной системе
accounts - интенсивно обновляемая таблица с десятками столбцов,
partitioning по другому ключу. FOR UPDATE на тысячах строк пишет
блокировку в заголовок каждого tuple: это значительный WAL, грязные
страницы и возможные MultiXact'ы. Row-level блокировки на тысячах
счетов задевают десятки партиций с непредсказуемой стоимостью. См.
Сравнение с альтернативами для измеренных цифр.
На этом графике B и D при каждом K представлены одним воркером из восьми, который дошел до конца (см. вторую сноску к краткой сводке в Сравнении).
Семантически вариант работает, мы его проверили. Основная проблема под нагрузкой: на UNIQUE-индексе возникают deadlock'и (см. Сравнение с альтернативами).
Конкретные цифры на 8 параллельных бэкендах с пересекающимися ключами (overlap, K=100k) - это разные точки, не один диапазон:
- B (UNLOGGED): 7 deadlock'ов за сценарий, до конца доходит 1 воркер из 8, выполнено ~2.2 tx/sec
- D (LOGGED): 7 deadlock'ов, до конца доходит 1 воркер из 8, выполнено ~1.8 tx/sec
- E (LOGGED+sorted): 2.5 tx/sec, p95 8124 мс, 0 deadlock'ов
- F (UNLOGGED+sorted): 5.4 tx/sec, p95 3571 мс, 0 deadlock'ов
- pg_xclaim в той же конфигурации: 209 tx/sec, p95 29 мс
Точные числа зависят от K и от того, отсортирован ли batch (см.
таблицы и ограничение методики в
Сравнении). Сгладить можно через
lock_timeout + повторы в приложении, но это лишняя сложность.
Мы гоняем бенчмарк в четырех вариантах claim-table:
- B (UNLOGGED) - без WAL, как и xclaim
- D (LOGGED) - ближе к production
- E (LOGGED + ORDER BY) - production-прием против deadlock'ов
- F (UNLOGGED + ORDER BY) - в паре с E отделяет стоимость сортировки от стоимости WAL
LOGGED-варианты дополнительно создают heap+index WAL: ~18 MB WAL на итерацию со 100k claim'ов (~3.6 GB за 200 итераций одного воркера, у pg_xclaim 0). Это напрямую давит на лаг репликации и autovacuum. Deadlock'и на UNIQUE-индексе при пересечении ключей возникают у unsorted-вариантов (B/D по 7 за сценарий), а sorted-варианты (E/F) устраняют их за счет wait-on-lock сериализации. Throughput E и B/D под overlap в этом бенчмарке несравним: у B/D до конца доходит один воркер из восьми.
Еще claim-table требует DDL: отдельная таблица, миграция, привязка к
схеме приложения. Главный аргумент в исходном сценарии - миграция
без DDL и сотни мест вызова старого advisory-кода: замена через
sed pg_try_advisory_xact_lock -> xclaim.try без правки схемы.
Если у вас новый проект с низкой кардинальностью и умеренной конкурентностью - claim-table проще и понятнее.
Да, это реальный недостаток, и в проекте он открыто признается.
pg_locks не покажет xclaim'ы. Базовая видимость идет через
xclaim.stats(), а детальный SELECT * FROM xclaim.debug_snapshot()
работает только при pg_xclaim.num_partitions <= 192. Документы
docs/runbook.md и
docs/incident-decision-tree.md
существуют именно потому, что новый примитив с состоянием = новое
руководство по инцидентам. Если у вас нет ресурса на обучение
DBA-команды дополнительному инструменту - не используйте pg_xclaim.
Не гарантируется. Каждый major-релиз PG требует патчей совместимости (PG 16->17->18 уже обкатаны).
Upstream PG движется в сторону меньшей конкуренции на LockManager.
Например, в PG 18 Tomas Vondra расширил fast-path locking
(release notes,
коммит c4d5cb71d22),
сняв узкое место LWLock:LockManager для query-heavy нагрузок с
большим числом relation-locks.
Этот механизм не переносится на advisory locks. Fast-path требует
доминирования слабых режимов (mode < ShareUpdateExclusiveLock в
EligibleForRelationFastPath), а advisory locks эксклюзивны по
дизайну: взаимное исключение это их основная семантика.
Другие пути уменьшения конкуренции на LockManager существуют:
увеличение NUM_LOCK_PARTITIONS, отдельный пул для advisory. Если
upstream сделает такой шаг - ниша pg_xclaim сожмется.
Очень узкий сегмент:
- Self-hosted PG (managed-облака отпадают по
shared_preload_libraries). - Измеренный
LWLock:LockManagerкак топовый wait-event под реалистичной нагрузкой. - Нагрузка удерживает 100k+ claim'ов в одной транзакции.
- Альтернативы 3-4 исключены по измеренным причинам (бенчмарк), а 1, 2 - по архитектурным причинам, специфичным для системы (не "показалось").
- У команды есть ресурс на поддержку расширения и обучение DBA.
Если хотя бы один из пунктов (1)-(5) не выполнен - берите альтернативы.
Нет. Никакого отношения к Redis-команде XCLAIM. Имя расшифровывается
как transactional (xact) claims - claim'ы, привязанные к жизненному
циклу транзакции PostgreSQL. Схема xclaim фиксированная
(relocatable = false) и не может быть переименована при
CREATE EXTENSION - проверьте, что имя xclaim не занято в вашей базе,
до установки.
PostgreSQL License - те же либеральные условия, что у самой PostgreSQL. Copyright (c) 2026, E. Pavlichenko.
v1.0.0-rc2 - второй кандидат в релиз экспериментального прототипа. Документация и тесты на месте, матрица сборки зеленая на PG 16/17/18, код покрыт трехуровневым набором тестов и контролируется бюджетами производительности.
Почему 1.0.0-rc2, а не 0.x, при статусе "экспериментальный": публичный
SQL-API заморожен (сигнатуры функций, имена GUC, схема), и расширение
проходит полную тестовую матрицу на PG 16/17/18. "Экспериментальность"
относится к узости ниши и операционной зрелости, а не к нестабильности
API. История изменений - в CHANGELOG.md.




