iGamingSep 11, 202610 мин чтения

Postgres букмекерской платформы на пиках матчей: горячие строки, отставание расчёта и приём ставок, который не ждёт

Инженерия букмекерских платформPostgreSQLРасчёт ставокИдемпотентность
Ошибка загрузки изображения

Букмекерская платформа, чей Postgres тормозит во время больших матчей и рассчитывает ставки спустя долгое время после окончания события, гоняет две нагрузки через один и тот же набор строк: приём ставок (короткая запись на запрос) и расчёт (всплеск, запущенный одним результатом). Здесь — как разделяются два пути, откуда берётся конкуренция и как балансы остаются корректными, пока расчёт опаздывает.

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

Больше железа поднимает уровень трафика, при котором это происходит, но не устраняет причину. Дальше два пути разделены, место конкуренции найдено, а балансы остаются корректными, пока расчёт опаздывает. Поведение PostgreSQL, которое цитируется ниже, взято из документации версии 18.

Короткий ответ структурный. Приём и расчёт ставок перестают делить транзакции: приём пишет ставку, резервирование баланса и строку outbox в одной короткой транзакции под ключом идемпотентности, а расчёт потребляет события результатов небольшими батчами, эффекты которых тоже снабжены ключами, поэтому повторно доставленное сообщение не двигает деньги. Что amBrain может подтвердить публично — это инженерия казино-платформ, и цифра, которую мы публикуем в этой области как измеренную, — 12 операторов в проде. Схема ниже выведена из механики задачи, а не из нашего кейса, и ни одна цифра в ней не измерена ни на одной из наших систем.

Приём и расчёт ставок — две нагрузки на общих строках

Приём ставки — это запрос, ответа на который ждёт человек: прочитать состояние рынка, проверить баланс, записать одну ставку, ответить. Расчёт начинается с одного результата и расходится сразу на все открытые ставки по затронутым рынкам. Большой матч заканчивается, пока другие события ещё открыты, поэтому этот всплеск приходится на строки балансов тех счетов, с которых снова делают ставки.

Если оба пути пишут эти строки в своих транзакциях, задержка приёма ставок становится функцией самой долгой транзакции расчёта на том же счёте. Развязка — это набор обещаний о блокировках и времени:

  • Расчёт держит строку баланса не дольше, чем транзакция приёма ставки
  • Расчёт может отставать, и его бэклог — это очередь, измеряемая возрастом самого старого элемента, а не груда открытых транзакций
  • Каждый денежный эффект происходит один раз, сколько бы раз ни было доставлено стоящее за ним сообщение
  • Чтения, которым не нужен основной сервер, его не трогают

Строка баланса — это блокировка, проектировали вы её или нет

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

Read Committed, уровень изоляции по умолчанию, делает резервирование простым. UPDATE, который находит строку, уже обновлённую параллельной транзакцией, ждёт, пока та закоммитится или откатится, и, если она закоммитилась, заново проверяет своё условие WHERE на обновлённой версии. Условное обновление, которое вычитает сумму, только если доступный баланс её покрывает, не может списать больше доступного и не требует SELECT FOR UPDATE.

  • Блокировку баланса берите последней и коммитьте сразу после неё; проверки, которым блокировка не нужна, выполняются раньше
  • Несколько счетов блокируйте в одном и том же порядке — глава о блокировках называет это способом избежать взаимоблокировок
  • Не держите экспозицию по рынку в одной строке, которую обновляет каждый приём ставки, иначе один популярный рынок выстроит приём своих ставок в очередь за одной блокировкой; распределите счётчик по фиксированному набору строк
  • Задайте lock_timeout на пути приёма ставок, чтобы бесконечное ожидание стало учтённой ошибкой, которую повторяют с тем же ключом идемпотентности
  • Не включайте столбцы баланса в индексы: глава о хранении допускает HOT-обновление, только если не меняется ни один индексированный столбец и на странице со старой строкой есть место, а пониженный fillfactor делает это вероятнее

Долгие транзакции и autovacuum держат всплеск на диске

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

Autovacuum приходит поздно по замыслу. PostgreSQL 18 запускает очистку таблицы, когда число строк, обновлённых или удалённых с момента последней очистки, превышает меньшее из двух значений: autovacuum_vacuum_max_threshold и autovacuum_vacuum_threshold плюс autovacuum_vacuum_scale_factor, умноженный на число строк. При значениях по умолчанию — 100 000 000, 50 и 0.2 — таблица ставок в 50 миллионов строк ждёт около десяти миллионов обновлённых или удалённых строк.

  • Переопределите эти пороги на уровне таблиц для балансов и открытых ставок — глава об очистке разрешает это через параметры хранения
  • Задайте idle_in_transaction_session_timeout: его документация предупреждает, что открытая транзакция не даёт очистке убрать недавно ставшие мёртвыми кортежи и может способствовать раздуванию таблиц
  • Добавляйте строки расчёта, а не переключайте индексированный столбец статуса, потому что обновление, меняющее индексированный столбец, не может быть HOT
  • Выводите историю из оборота, отсоединяя или удаляя партиции: глава о партиционировании называет это гораздо более быстрым, чем массовая операция, и свободным от накладных расходов VACUUM, которые влечёт массовый DELETE

На таблицах-очередях этот эффект хорошо виден. В посте 2015 года на brandur.org, Postgres Job Queues & Failure By MVCC, одна транзакция, оставленная простаивать рядом с очередью задач, подняла время блокировки задачи с менее чем 0,01 секунды до пиков, в 15 раз превышающих этот уровень, потому что мёртвые строки задач ещё нельзя было удалить.

Соединения и реплики — часть того же пика

Каждое соединение — это бэкенд-процесс, и документация говорит, что увеличение max_connections, по умолчанию обычно 100, увеличивает ресурсы, размер которых от него рассчитывается, включая разделяемую память. Вместо этого дайте приёму и расчёту ставок отдельные пулы, чтобы бэклог расчёта стоял в очереди за своими собственными соединениями.

  • Режим transaction pooling в PgBouncer выделяет серверное соединение только на время транзакции, поэтому множество клиентов делят меньшее число бэкендов
  • Сессионные возможности в этом режиме ломаются: PgBouncer перечисляет SET и RESET, LISTEN, курсоры WITH HOLD и рекомендательные блокировки уровня сессии как неподдерживаемые
  • Именованные подготовленные операторы на уровне протокола работают в этом режиме начиная с PgBouncer 1.21.0, вышедшего в октябре 2023, если max_prepared_statements не равен нулю

Реплики разгружают чтения, но у этого две цены. Потоковая репликация по умолчанию асинхронна, поэтому коммит становится виден на реплике с небольшой задержкой. А глава о горячем резерве говорит, что запросы на реплике, конфликтующие с очисткой, пришедшей с основного сервера, отменяются после настроенной задержки, тогда как hot_standby_feedback предотвращает это, откладывая очистку на основном сервере, что может вызвать там раздувание таблиц.

Приём ставки — одна короткая транзакция с ключом, заданным до первого повтора

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

  • Клиент или эдж, который первым принимает запрос, создаёт ключ идемпотентности на каждую отправку, и каждый повтор несёт его без изменений
  • Одна транзакция пишет запись о ставке, резерв в виде условного обновления баланса и строку outbox о принятой ставке
  • Записи ставок только дописываются (append-only): расчёт, аннулирования и исправления — это новые строки со ссылкой на ставку и никогда не правки
  • Ограничение уникальности превращает повтор в конфликт: INSERT с ON CONFLICT DO NOTHING ничего не вставляет, RETURNING возвращает только вставленные строки, и путь перечитывает сохранённый результат
  • Stripe документирует тот же контракт для своего API: первый результат по ключу сохраняется и возвращается последующим запросам независимо от того, был он успешным или нет, а повторно использованный ключ с другими параметрами отклоняется

Хранение партиционируйте по времени, а работу — по рынкам. Глава о партиционировании требует, чтобы ограничение уникальности на партиционированной таблице включало все столбцы ключа партиционирования, поэтому ключ идемпотентности либо содержит столбец партиционирования, либо живёт в отдельной таблице. Она же говорит, что планировщик достаточно хорошо справляется с числом партиций до нескольких тысяч, когда запросы отсекают все, кроме нескольких, а число рынков заранее не ограничено, поэтому партиции по рынкам добавляют время планирования на путь приёма ставок.

Строка outbox делает событие достоверным. В паттерне transactional outbox, как его описывает Chris Richardson, сообщение сохраняется в базе в рамках той транзакции, которая обновляет бизнес-сущности, а отдельный процесс отправляет его дальше. То же описание называет цену: ретранслятор может опубликовать сообщение больше одного раза, поэтому потребители обязаны быть идемпотентными.

Расчёт — очередь, которой разрешено опаздывать

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

  • Порядок — в пределах рынка, а не глобально: Kafka пишет события с одним и тем же ключом в одну партицию и документирует, что потребители читают партицию в порядке записи, поэтому события результатов с ключом по рынку остаются по порядку
  • Таблица-очередь работает в определённых пределах: документация называет SKIP LOCKED непригодным для задач общего назначения, но применимым, чтобы избежать конкуренции за блокировки между потребителями таблицы, устроенной как очередь
  • Каждый батч рассчитывает ограниченное число ставок, пишет их проводки в леджер, обновляет строки балансов в порядке счетов и коммитит
  • Прогресс коммитится вместе с эффектами, поэтому воркер, упавший посреди батча, продолжает с последнего закоммиченного батча
  • Исправленный результат — это новое событие: сторнирующие проводки, затем новые проводки расчёта и никогда не правка старых

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

Балансам нужны два числа и проводки, которые проходят один раз

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

  • Приём ставки переносит сумму из доступного в зарезервированное своим условным обновлением
  • Расчёт снимает резерв и проводит итоговое списание и зачисление, если оно есть, в одной транзакции с ключом из ставки, типа проводки и версии расчёта
  • Аннулирование ставки снимает резерв, а у резерва, расчёт по которому так и не приходит, есть конкретный ответственный и срок
  • Строка баланса — проекция леджера, а регулярная сверка, суммирующая проводки по каждому счёту, сообщает о расхождении как об инциденте, а не исправляет его тихо

Доставка может повторяться: ретранслятор outbox может опубликовать сообщение повторно, а когда outbox читается через логическое декодирование, документация говорит, что после сбоя слот может заново отправить недавние изменения. Поэтому требование — эффект, который происходит один раз. У каждой проводки в леджере уникальный ключ, обновление баланса коммитится вместе со вставкой, а повторно доставленное сообщение упирается в ограничение и не двигает деньги.

История ставок принадлежит модели чтения, а не пути записи

Многие чтения на пике стоят рядом с приёмом ставок, а не на нём: открытые ставки, история, экраны баланса, обновляемые после каждого события. В описании CQRS у Chris Richardson такие запросы обслуживает база представлений, которая остаётся актуальной за счёт подписки на события сервиса, владеющего данными, а ценой названы отставание репликации и представления, согласованные в конечном счёте. Outbox приёма ставок уже публикует эти события.

  • Ответ на приём ставки возвращает принятую ставку, поэтому клиент показывает её, не перечитывая из представления, которое может отставать
  • Экраны, которым нужно самое свежее состояние, явно читают с основного сервера, и этот список остаётся коротким
  • synchronous_commit со значением remote_apply заставляет каждый коммит ждать, пока синхронные реплики его не применят: read-your-writes на реплике, оплаченный задержкой приёма ставок

Что измерять, пока матч ещё идёт

Снимайте показания во время пика, на одной оси времени с задержкой приёма ставок:

  • Ожидания блокировок: сэмплируйте pg_stat_activity по типу события ожидания Lock и находите блокирующие процессы через pg_blocking_pids — документация предупреждает, что при частых вызовах эта функция может влиять на производительность
  • log_lock_waits по умолчанию выключен и сообщает только об ожиданиях дольше deadlock_timeout (по умолчанию одна секунда), поэтому ни одного более короткого ожидания в логе нет
  • Самая старая транзакция — по xact_start в pg_stat_activity — и каждый сеанс в состоянии idle in transaction
  • Очистка горячих таблиц: n_dead_tup, last_autovacuum и n_tup_hot_upd в сравнении с n_tup_upd
  • Давление на пул: cl_waiting и maxwait из SHOW POOLS, где растущий maxwait PgBouncer трактует как пул, который не справляется
  • Бэклог расчёта в виде возраста самого старого элемента, потому что по количеству не отличить большую очередь от вставшей
  • replay_lag по каждой реплике, а также wal_status и safe_wal_size для слотов логической репликации

Если читать их вместе, они локализуют неисправность. Растущая очередь пула при ровных ожиданиях блокировок указывает на соединения; ожидания блокировок, растущие вместе с батчами расчёта, — на общие строки; если не движется ни то ни другое, а мёртвые строки растут, — на самую старую транзакцию.

Как отличить инженерные компании, которые действительно делают эту работу

У второй половины вопроса — какие компании на этом специализируются — есть проверка, которой не нужен список подрядчиков. Компания, которая уже разделяла эти пути, в первом разговоре делает следующее:

  • Просит задержку приёма ставок и бэклог расчёта с реального пика на одной оси времени раньше, чем просит схему
  • Называет механизм, которому, по его ожиданию, принадлежит задержка, и показание, которое доказало бы, что это не так
  • Рассматривает деньги как набор тестов: повторная доставка, воркер, убитый посреди батча, исправленный результат
  • Нагрузочно тестирует всплеск результатов при продолжающемся трафике приёма ставок, а не каждый путь по отдельности
  • Заранее называет критерии выхода: перцентиль задержки приёма ставок во время всплеска и допустимый возраст самого старого элемента бэклога после него
  • Может выделить дежурного по воркерам расчёта, слотам репликации и пулам соединений на ночь финала

Ответ, который хотя бы по одному из этих пунктов остаётся общим, означает, что работа началась бы без диагноза.

Поэтому первое решение — не база побольше. Оно в том, какому механизму принадлежит задержка в ту ночь, когда приём ставок замедляется, и остаётся ли у приёма и расчёта ставок общая транзакция где-нибудь на пути.

Что amBrain может подтвердить публично: amBrain — компания по разработке ПО, специализирующаяся на торговых платформах, matching engine, системах real-time bidding и инженерии казино-платформ. amBrain пишет софт с 2019 года. Цифра, которую мы публикуем в iGaming как измеренную, — 12 операторов в проде. Мы работаем в трёх форматах: полная разработка, выделенная команда или наши инженеры в вашей команде.

На столе похожая архитектура?

Принесите текущую архитектуру и тот сценарий отказа, который вас беспокоит, — разберём его вместе за полчаса.

Похожие статьи

Ошибка загрузки изображения
iGaming
Feb 28, 20266 мин чтения

Масштабирование iGaming-платформ: уроки обслуживания 10M одновременных пользователей

Читать
Ошибка загрузки изображения
iGaming
Feb 7, 20265 мин чтения

Функции ответственной игры: технический разбор

Читать
Ошибка загрузки изображения
iGaming
Jan 15, 20267 мин чтения

Архитектура live-ставок: обработка обновлений коэффициентов быстрее 50ms

Читать