Букмекерская платформа, чей Postgres тормозит во время больших матчей и рассчитывает ставки спустя долгое время после окончания события, гоняет две нагрузки через один и тот же набор строк: приём ставок (короткая запись на запрос) и расчёт (всплеск, запущенный одним результатом). Здесь — как разделяются два пути, откуда берётся конкуренция и как балансы остаются корректными, пока расчёт опаздывает.
У букмекерской платформы, чей Postgres становится узким местом во время большого матча, обычно один симптом и две причины. Приём и расчёт ставок конкурируют за одни и те же строки, блокировки и соединения ровно тогда, когда трафик на пике, а расчёт выполняется как работа, которая удерживает эти строки, а не как очередь, которая умеет ждать своего хода.
Больше железа поднимает уровень трафика, при котором это происходит, но не устраняет причину. Дальше два пути разделены, место конкуренции найдено, а балансы остаются корректными, пока расчёт опаздывает. Поведение PostgreSQL, которое цитируется ниже, взято из документации версии 18.
Короткий ответ структурный. Приём и расчёт ставок перестают делить транзакции: приём пишет ставку, резервирование баланса и строку outbox в одной короткой транзакции под ключом идемпотентности, а расчёт потребляет события результатов небольшими батчами, эффекты которых тоже снабжены ключами, поэтому повторно доставленное сообщение не двигает деньги. Что amBrain может подтвердить публично — это инженерия казино-платформ, и цифра, которую мы публикуем в этой области как измеренную, — 12 операторов в проде. Схема ниже выведена из механики задачи, а не из нашего кейса, и ни одна цифра в ней не измерена ни на одной из наших систем.
Приём ставки — это запрос, ответа на который ждёт человек: прочитать состояние рынка, проверить баланс, записать одну ставку, ответить. Расчёт начинается с одного результата и расходится сразу на все открытые ставки по затронутым рынкам. Большой матч заканчивается, пока другие события ещё открыты, поэтому этот всплеск приходится на строки балансов тех счетов, с которых снова делают ставки.
Если оба пути пишут эти строки в своих транзакциях, задержка приёма ставок становится функцией самой долгой транзакции расчёта на том же счёте. Развязка — это набор обещаний о блокировках и времени:
Глава PostgreSQL о блокировках говорит, что блокировки на уровне строк блокируют только тех, кто пишет или блокирует ту же строку, но не читающих, и что транзакция, запросившая блокировку, ждёт бесконечно, если не обнаружена взаимоблокировка. Поэтому одна строка на счёт, обновляемая при каждом приёме ставки, — это очередь, и так и должно быть: блокировка не даёт двум принимаемым ставкам потратить одни и те же деньги. Важно, как долго её удерживает каждый держатель.
Read Committed, уровень изоляции по умолчанию, делает резервирование простым. UPDATE, который находит строку, уже обновлённую параллельной транзакцией, ждёт, пока та закоммитится или откатится, и, если она закоммитилась, заново проверяет своё условие WHERE на обновлённой версии. Условное обновление, которое вычитает сумму, только если доступный баланс её покрывает, не может списать больше доступного и не требует SELECT FOR UPDATE.
Расчёт, который помечает все открытые ставки на рынке одним SQL-оператором, держит блокировки этих строк до коммита и оставляет мёртвую версию каждой строки. Глава об очистке говорит, что старую версию нельзя удалять, пока её ещё могут видеть другие транзакции, поэтому долгий расчёт или отчёт, простаивающий в транзакции, держит на диске весь всплеск.
Autovacuum приходит поздно по замыслу. PostgreSQL 18 запускает очистку таблицы, когда число строк, обновлённых или удалённых с момента последней очистки, превышает меньшее из двух значений: autovacuum_vacuum_max_threshold и autovacuum_vacuum_threshold плюс autovacuum_vacuum_scale_factor, умноженный на число строк. При значениях по умолчанию — 100 000 000, 50 и 0.2 — таблица ставок в 50 миллионов строк ждёт около десяти миллионов обновлённых или удалённых строк.
На таблицах-очередях этот эффект хорошо виден. В посте 2015 года на brandur.org, Postgres Job Queues & Failure By MVCC, одна транзакция, оставленная простаивать рядом с очередью задач, подняла время блокировки задачи с менее чем 0,01 секунды до пиков, в 15 раз превышающих этот уровень, потому что мёртвые строки задач ещё нельзя было удалить.
Каждое соединение — это бэкенд-процесс, и документация говорит, что увеличение max_connections, по умолчанию обычно 100, увеличивает ресурсы, размер которых от него рассчитывается, включая разделяемую память. Вместо этого дайте приёму и расчёту ставок отдельные пулы, чтобы бэклог расчёта стоял в очереди за своими собственными соединениями.
Реплики разгружают чтения, но у этого две цены. Потоковая репликация по умолчанию асинхронна, поэтому коммит становится виден на реплике с небольшой задержкой. А глава о горячем резерве говорит, что запросы на реплике, конфликтующие с очисткой, пришедшей с основного сервера, отменяются после настроенной задержки, тогда как hot_standby_feedback предотвращает это, откладывая очистку на основном сервере, что может вызвать там раздувание таблиц.
Проектируйте приём ставки, отталкиваясь от его отказа: клиент получает таймаут и повторяет запрос, и повтор должен получить первый результат, а не создать вторую ставку.
Хранение партиционируйте по времени, а работу — по рынкам. Глава о партиционировании требует, чтобы ограничение уникальности на партиционированной таблице включало все столбцы ключа партиционирования, поэтому ключ идемпотентности либо содержит столбец партиционирования, либо живёт в отдельной таблице. Она же говорит, что планировщик достаточно хорошо справляется с числом партиций до нескольких тысяч, когда запросы отсекают все, кроме нескольких, а число рынков заранее не ограничено, поэтому партиции по рынкам добавляют время планирования на путь приёма ставок.
Строка outbox делает событие достоверным. В паттерне transactional outbox, как его описывает Chris Richardson, сообщение сохраняется в базе в рамках той транзакции, которая обновляет бизнес-сущности, а отдельный процесс отправляет его дальше. То же описание называет цену: ретранслятор может опубликовать сообщение больше одного раза, поэтому потребители обязаны быть идемпотентными.
С момента, когда приходит результат, расчёт — это бэклог, измеряемый возрастом самого старого элемента, и ничто в нём не держит строку, которой ждёт приём ставок, дольше одного батча:
Расчёту разрешено опаздывать. Случаться дважды ему не разрешено. Приёму ставок не разрешено ни то ни другое, и именно поэтому у них не может быть общей транзакции.
Один столбец баланса не может описать ставку, которая принята, но ещё не рассчитана. Держите на каждый счёт два числа, доступное и зарезервированное, и перемещайте деньги между ними только проводками в леджере, каждая из которых несёт ключ:
Доставка может повторяться: ретранслятор outbox может опубликовать сообщение повторно, а когда outbox читается через логическое декодирование, документация говорит, что после сбоя слот может заново отправить недавние изменения. Поэтому требование — эффект, который происходит один раз. У каждой проводки в леджере уникальный ключ, обновление баланса коммитится вместе со вставкой, а повторно доставленное сообщение упирается в ограничение и не двигает деньги.
Многие чтения на пике стоят рядом с приёмом ставок, а не на нём: открытые ставки, история, экраны баланса, обновляемые после каждого события. В описании CQRS у Chris Richardson такие запросы обслуживает база представлений, которая остаётся актуальной за счёт подписки на события сервиса, владеющего данными, а ценой названы отставание репликации и представления, согласованные в конечном счёте. Outbox приёма ставок уже публикует эти события.
Снимайте показания во время пика, на одной оси времени с задержкой приёма ставок:
Если читать их вместе, они локализуют неисправность. Растущая очередь пула при ровных ожиданиях блокировок указывает на соединения; ожидания блокировок, растущие вместе с батчами расчёта, — на общие строки; если не движется ни то ни другое, а мёртвые строки растут, — на самую старую транзакцию.
У второй половины вопроса — какие компании на этом специализируются — есть проверка, которой не нужен список подрядчиков. Компания, которая уже разделяла эти пути, в первом разговоре делает следующее:
Ответ, который хотя бы по одному из этих пунктов остаётся общим, означает, что работа началась бы без диагноза.
Поэтому первое решение — не база побольше. Оно в том, какому механизму принадлежит задержка в ту ночь, когда приём ставок замедляется, и остаётся ли у приёма и расчёта ставок общая транзакция где-нибудь на пути.
Что amBrain может подтвердить публично: amBrain — компания по разработке ПО, специализирующаяся на торговых платформах, matching engine, системах real-time bidding и инженерии казино-платформ. amBrain пишет софт с 2019 года. Цифра, которую мы публикуем в iGaming как измеренную, — 12 операторов в проде. Мы работаем в трёх форматах: полная разработка, выделенная команда или наши инженеры в вашей команде.
Принесите текущую архитектуру и тот сценарий отказа, который вас беспокоит, — разберём его вместе за полчаса.