iGamingSep 11, 2026読了10分

試合のピークにおけるスポーツブックの Postgres ― ホットな行、精算の遅れ、そして待たないベット受付

スポーツブックのエンジニアリングPostgreSQLベット精算冪等性
画像を読み込めませんでした

大きな試合のあいだに Postgres が遅くなり、イベント終了からずっと後になってベットを精算するスポーツブックは、一組の行に二つのワークロードを通している。リクエストごとの短い書き込みである受付と、一つの結果が引き金になるバーストである精算だ。ここでは、二つの経路をどう分けるか、競合がどこから来るか、そして精算が遅れているあいだ残高をどう正しく保つかを扱う。

大きな試合のあいだに Postgres がボトルネックになるスポーツブックには、たいてい症状が一つと原因が二つある。受付と精算は、トラフィックがピークに達するまさにそのときに、同じ行、ロック、接続を奪い合う。そして精算は、順番を待てるキューとしてではなく、それらの行を握り続ける仕事として走る。

ハードウェアを増やすと、これが起きるトラフィックの水準は上がるが、原因は取り除かれない。以下では二つの経路を切り分け、競合の場所を突き止め、精算が遅れているあいだも残高を正しく保つ。以下で引く PostgreSQL の挙動は、バージョン 18 のドキュメントによる。

短い答えは構造にある。受付と精算は、トランザクションの共有をやめる。受付は、冪等キーのもとで、ベット、残高の引き当て、アウトボックス行を一つの短いトランザクションで書く。精算は結果イベントを小さなバッチで消費し、その効果にもキーを付ける。だから再配信されたメッセージはお金を一切動かさない。amBrain が公に裏付けられるのはカジノプラットフォームのエンジニアリングであり、そこで実測として公開している数字の一つが、本番稼働中のオペレーター12社である。以下の設計は、私たちの事例からではなく問題の仕組みから来ており、その中のどの数字も、私たちのシステムで計測したものではない。

受付と精算は、行を共有する二つのワークロードである

受付は、人が待っているリクエストである。マーケットの状態を読み、残高を確認し、ベットを一件書き、応答する。精算は一つの結果から始まり、影響を受けるマーケット上のすべての未確定ベットへ一斉に広がる。大きな試合が終わっても他のイベントはまだ開いているので、そのバーストは、再びベットしているアカウントの残高行に降りかかる。

両方の経路がそれぞれのトランザクションでそれらの行に書き込むなら、受付のレイテンシは、同じアカウント上で最も長い精算トランザクションの関数になる。切り離しとは、ロックと時間についての約束の集まりである:

  • 精算が残高行を握る時間は、受付のトランザクションより長くない
  • 精算は遅れてもかまわない。そしてそのバックログは、開いたトランザクションの山ではなく、経過時間を持つキューである
  • お金に対する効果は、その裏にあるメッセージが何度配信されようと、一度だけ起きる
  • プライマリを必要としない読み取りは、プライマリに触れない

残高行は、ロックとして設計したかどうかにかかわらずロックである

PostgreSQL のロックの章によれば、行レベルロックがブロックするのは同じ行への書き込みとロック取得だけで、読み取りはブロックしない。また、ロックを求めるトランザクションは、デッドロックが検出されない限り無期限に待つ。したがって、受付のたびに更新されるアカウントごとの一行はキューであり、それで正しい。そのロックが、二つの受付が同じお金を使うのを止めているからだ。問題は、それぞれの保持者がどれだけ長くロックを握るかである。

既定の分離レベルである Read Committed なら、引き当ては単純に保てる。並行するトランザクションがすでに更新した行に出会った UPDATE は、そのトランザクションのコミットかロールバックを待ち、コミットされていれば、更新後のバージョンに対して WHERE 句を評価し直す。利用可能残高で賄える場合にだけ金額を差し引く条件付き更新は、利用可能残高を超えて引き当てることがありえず、SELECT FOR UPDATE も要らない。

  • 残高のロックは最後に取り、その直後にコミットする。ロックの要らない検証は先に走らせる
  • 複数のアカウントは、一貫した一つの順序でロックする。ロックの章は、これをデッドロックを避ける方法として挙げている
  • マーケットのエクスポージャーを、すべての受付が更新する単一の行に置かない。さもなければ、人気のマーケット一つが、その受付を一つのロックの後ろで直列化してしまう。カウンタは固定数の行に分散させる
  • 受付の経路に lock_timeout を設定する。そうすれば無期限の待ちは、カウントされるエラーになり、同じ冪等キーのもとでリトライされる
  • 残高の列はインデックスに含めない。ストレージの章によれば、HOT 更新が可能なのは、インデックス付きの列が一つも変わらず、かつ古い行を持つページに空きがある場合だけであり、fillfactor を下げるとその可能性が高まる

長いトランザクションと autovacuum が、バーストをディスクに残し続ける

マーケット上の未確定のベットをすべて一つのステートメントでマークする精算は、コミットまでそれらの行ロックを保持し、各行にデッドな行バージョンを残す。バキュームの章は、古いバージョンは他のトランザクションからまだ見える可能性があるあいだは削除してはならないと述べている。だから長い精算や、トランザクション内でアイドルのまま止まっているレポートは、バースト全体をディスク上に残し続ける。

autovacuum は設計上、遅れてやって来る。PostgreSQL 18 は、前回のバキューム以降に更新または削除された行の数が、autovacuum_vacuum_max_threshold と、autovacuum_vacuum_threshold に autovacuum_vacuum_scale_factor と行数の積を足した値のうち、小さいほうを超えた時点でテーブルをバキュームする。既定値の 100,000,000、50、0.2 では、5,000万行のベットテーブルは、約1,000万行が更新または削除されるまで待つ。

  • 残高と未確定ベットのテーブルでは、それらのしきい値をテーブルごとに上書きする。バキュームの章は、ストレージパラメータによってこれを認めている
  • idle_in_transaction_session_timeout を設定する。そのドキュメントは、開いたままのトランザクションが、直近にデッドになったタプルのバキュームを妨げ、テーブルの肥大化の一因になりうると警告している
  • インデックス付きのステータス列を書き換えるのではなく、精算の行を追記する。インデックス付きの列を変える更新は HOT にならないからだ
  • 履歴は、パーティションを切り離すか削除することで退役させる。パーティショニングの章は、これを一括操作よりはるかに速く、一括 DELETE に伴う VACUUM のオーバーヘッドもないと述べている

キューテーブルでは、この影響が見えやすい。brandur.org の2015年の投稿「Postgres Job Queues & Failure By MVCC」では、ジョブキューの横でアイドルのまま放置された一つのトランザクションが、ジョブをロックするまでの時間を0.01秒未満から、その水準の15倍のピークにまで押し上げた。デッドなジョブ行を、まだ削除できなかったからである。

接続とレプリカは同じピークに属する

接続は一本ごとにバックエンドプロセスであり、ドキュメントによれば、既定値が通常 100 である max_connections を引き上げると、共有メモリを含め、その値をもとに確保される資源も増える。代わりに、受付と精算に別々のプールを与える。そうすれば精算のバックログは、自分用の接続を待つ列に並ぶ。

  • PgBouncer のトランザクションプーリングは、サーバー接続をトランザクションのあいだだけ割り当てる。だから多数のクライアントが、より少ないバックエンドを共有する
  • そのモードではセッションの機能が壊れる。PgBouncer は、SET と RESET、LISTEN、WITH HOLD カーソル、セッションレベルのアドバイザリロックを非対応として挙げている
  • プロトコルレベルの名前付きプリペアドステートメントは、2023年10月にリリースされた PgBouncer 1.21.0 以降、max_prepared_statements がゼロでなければ、そのモードでも動く

レプリカは読み取りの負荷を肩代わりするが、代償が二つある。ストリーミングレプリケーションは既定で非同期なので、コミットはわずかな遅れの後にスタンバイで見えるようになる。そしてホットスタンバイの章によれば、プライマリからのバキュームによるクリーンアップと衝突するスタンバイ上のクエリは、設定された遅延の後にキャンセルされる。一方 hot_standby_feedback は、プライマリでのクリーンアップを遅らせることでそれを防ぐが、そのせいでプライマリにテーブルの肥大化が起きうる。

受付は、最初のリトライより前にキーが付いた、短いトランザクション一つである

受付は、その失敗から逆算して設計する。クライアントがタイムアウトしてリトライしたとき、そのリトライは二つ目のベットを作るのではなく、最初の結果を受け取らなければならない。

  • クライアント、またはリクエストを最初に受け取るエッジが、送信ごとに冪等キーを作り、すべてのリトライはそれを変えずに運ぶ
  • 一つのトランザクションで、ベットの記録、条件付きの残高更新としての引き当て、そして受け付けたベットのアウトボックス行を書く
  • ベットの記録は追記専用である。精算、無効化、訂正は、そのベットを参照する新しい行であり、決して編集ではない
  • 一意制約はリトライを競合に変える。ON CONFLICT DO NOTHING 付きの INSERT は何も挿入せず、RETURNING は挿入された行だけを返す。そして経路は保存済みの結果を読み戻す
  • Stripe は自社の API について同じ契約を文書化している。キーに対する最初の結果は、成功でも失敗でも保存されて後続のリクエストに返され、異なるパラメータで再利用されたキーは拒否される

ストレージは時間で、仕事はマーケットで分割する。パーティショニングの章は、パーティションテーブル上の一意制約にパーティションキーの列をすべて含めることを求めている。だから冪等キーは、パーティション列を含むか、専用のテーブルに置くかのどちらかになる。同じ章は、クエリがごく少数を除くすべてのパーティションを刈り込めるなら、プランナは数千程度までのパーティションをかなりうまく扱えるとも述べている。マーケットの数には上限がないので、マーケットごとのパーティションは、受付の経路にプランニング時間を載せることになる。

アウトボックス行が、イベントを信頼できるものにする。Chris Richardson が説明するトランザクショナルアウトボックスパターンでは、メッセージはビジネスエンティティを更新するトランザクションの中でデータベースに保存され、別のプロセスがそれを送り出す。同じ説明は代償も挙げている。リレーはメッセージを複数回パブリッシュしうるので、コンシューマは冪等でなければならない。

精算は、遅れることが許されたキューである

結果が届いた瞬間から、精算は経過時間を持つバックログであり、その中のどれも、受付が待っている行を1バッチより長く握らない:

  • 順序はグローバルではなくマーケットごとに保つ。Kafka は同じキーのイベントを同じパーティションに書き込み、コンシューマはパーティションを書き込み順に読むと文書化している。だからマーケットをキーにした結果イベントは順序どおりに並ぶ
  • キューテーブルは限度内でなら機能する。ドキュメントは SKIP LOCKED を、汎用の作業には向かないが、キューのようなテーブルのコンシューマ間でロック競合を避けるためには使えるとしている
  • 各バッチは上限のある件数のベットを精算し、それらの台帳エントリを書き、アカウント順に残高行を更新してコミットする
  • 進捗は効果と一緒にコミットする。だからバッチの途中で落ちたワーカーは、最後にコミットしたバッチから再開する
  • 訂正された結果は新しいイベントである。打ち消しのエントリ、続いて新しい精算エントリを書き、古いエントリを編集することは決してない

精算は遅れてもよい。二度起きてはならない。受付にはそのどちらも許されない。だから二つはトランザクションを共有できない。

残高には二つの数と、一度だけ反映されるエントリが要る

残高の列が一つでは、受け付け済みでまだ精算されていないベットを表せない。アカウントごとに利用可能額と引当額という二つの数を持ち、両者のあいだでお金を動かすのは、それぞれがキーを持つ台帳エントリを通すときだけにする:

  • 受付は、条件付き更新の中で、金額を利用可能額から引当額へ移す
  • 精算は、一つのトランザクションで引き当てを解放し、最終的なデビットと、発生する場合はクレジットを記帳する。キーはベット、エントリ種別、精算バージョンである
  • ベットの無効化は引き当てを解放する。そして精算がいつまでも届かない引き当てには、名指しされた担当者と期限がある
  • 残高行は台帳のプロジェクションであり、アカウントごとにエントリを合計する定期的な照合は、ずれを黙って直すのではなく、インシデントとして報告する

配信は繰り返されうる。アウトボックスのリレーは再パブリッシュすることがあり、アウトボックスをロジカルデコーディングで読む場合、ドキュメントによれば、スロットはクラッシュ後に直近の変更を再送しうる。だから要件は、一度だけ起きる効果である。台帳エントリはそれぞれ一意のキーを持ち、残高の更新は挿入と一緒にコミットされ、再配信されたメッセージは制約に当たって、お金を一切動かさない。

ベット履歴は書き込み経路ではなく、読み取りモデルに属する

ピーク時の読み取りの多くは、受付の上ではなく、その脇にある。未確定のベット、履歴、イベントのたびに再読み込みされる残高画面である。Chris Richardson による CQRS の説明では、そうしたクエリを、データを所有するサービスのイベントを購読して最新に保つビューデータベースから返し、その代償としてレプリケーション遅延と結果整合なビューを挙げている。受付のアウトボックスは、すでにそのイベントをパブリッシュしている。

  • 受付のレスポンスは受け付けたベットを返す。だからクライアントは、遅れているかもしれないビューから読み戻さずにそれを表示する
  • 最新の状態を必要とする画面は、明示的にプライマリから読む。そしてその一覧は短く保つ
  • synchronous_commit を remote_apply にすると、各コミットは同期スタンバイがそれを再生し終えるまで待つ。レプリカ上で read-your-writes が得られ、その代償は受付のレイテンシで払う

試合がまだ続いているあいだに何を測るか

測定値はピークの最中に、受付のレイテンシと同じ一つの時間軸の上で取る:

  • ロック待ち:pg_stat_activity を Lock の待機イベント型でサンプリングし、pg_blocking_pids でブロックしている側を見つける。ドキュメントは、この関数を頻繁に呼ぶと性能に影響しうると警告している
  • log_lock_waits は既定で無効であり、報告するのは deadlock_timeout(既定で1秒)より長い待ちだけである。だからログには、それより短い待ちは一つも出ない
  • pg_stat_activity の xact_start から見た最も古いトランザクションと、idle in transaction 状態のすべてのセッション
  • ホットなテーブルのクリーンアップ:n_dead_tup、last_autovacuum、そして n_tup_upd に対する n_tup_hot_upd
  • プールの逼迫:SHOW POOLS の cl_waiting と maxwait。PgBouncer は、maxwait の上昇を、プールが追いついていないことの表れとして読む
  • 精算のバックログは経過時間として見る。件数では、大きなキューと止まったキューを見分けられないからだ
  • スタンバイごとの replay_lag と、論理レプリケーションスロットの wal_status と safe_wal_size

これらを合わせて読むと、障害の場所が分かる。ロック待ちが横ばいのままプールの待ち行列が伸びるなら、接続を指している。精算バッチとともにロック待ちが増えるなら、共有された行を指している。どちらも動かないのにデッド行が増えていくなら、最も古いトランザクションを指している。

どのエンジニアリング会社が実際にこの仕事をしているかを見分ける

問いの後半、どの会社がこれを専門にしているかには、ベンダー一覧を必要としない試験がある。これらの経路を以前に切り分けたことのある会社は、最初の打ち合わせで次のことをする:

  • スキーマを求める前に、実際のピークでの受付レイテンシと精算バックログを、一つの時間軸に並べて求める
  • レイテンシを握っていると見ている仕組みと、その見立てが誤りだと証明することになる測定値を名指しする
  • お金をテストスイートとして扱う。重複配信、バッチ途中で強制終了されたワーカー、訂正された結果
  • どちらか一方の経路だけではなく、受付のトラフィックが続いている最中の結果のバーストで負荷試験を行う
  • 終了条件をあらかじめ述べる。バースト中の受付のパーセンタイルと、バースト後に許容できるバックログの経過時間である
  • 決勝の夜に、精算ワーカー、レプリケーションスロット、コネクションプールのオンコールに人を就けられる

このうちどれか一つでも答えが一般論にとどまるなら、仕事は診断なしに始まることになる。

だから最初の判断は、より大きなデータベースではない。受付が遅くなるその夜にレイテンシを握っているのはどの仕組みか、そして経路のどこかで受付と精算がまだトランザクションを共有しているかどうか、である。

amBrain が公に裏付けられること:amBrain は、トレーディングプラットフォーム、matching engine、リアルタイムビディングのシステム、そしてカジノプラットフォームのエンジニアリングを専門とするソフトウェア開発会社である。amBrain は2019年からソフトウェアを作ってきた。iGaming で実測として公開している数字の一つは、本番稼働中のオペレーター12社である。働き方は三つの形態がある。フルデリバリー、専任チーム、あるいはあなたのチームに入るエンジニアだ。

手元に似た設計はありますか?

現在のアーキテクチャと気になっている障害シナリオをお持ちください。30 分で一緒に確認します。

関連記事

画像を読み込めませんでした
iGaming
Feb 28, 2026読了6分

iGamingプラットフォームのスケーリング:1,000万同時接続ユーザーを支えた知見

記事を読む
画像を読み込めませんでした
iGaming
Feb 7, 2026読了5分

責任あるゲーミング機能の実装:技術面の詳解

記事を読む
画像を読み込めませんでした
iGaming
Jan 15, 2026読了7分

ライブベッティングのアーキテクチャ:50ms未満でオッズ更新を処理する

記事を読む