iGamingSep 11, 202610 Min. Lesezeit

Sportsbook-Postgres unter Spitzenlast bei Spielen: Hot Rows, Abrechnungsverzug und eine Wettannahme, die nicht wartet

Sportsbook-EngineeringPostgreSQLWettabrechnungIdempotenz
Bild konnte nicht geladen werden

Ein Sportsbook, dessen Postgres bei großen Spielen langsamer wird und Wetten erst lange nach Ende des Events abrechnet, schickt zwei Workloads durch dieselben Zeilen: die Wettannahme, einen kurzen Schreibvorgang pro Request, und die Abrechnung, einen Burst, den ein einziges Ergebnis auslöst. Hier steht, wie die beiden Pfade getrennt werden, woher die Contention kommt und wie Guthaben korrekt bleiben, während die Abrechnung hinterherläuft.

Ein Sportsbook, dessen Postgres während eines großen Spiels zum Engpass wird, hat meist ein Symptom und zwei Ursachen. Wettannahme und Abrechnung konkurrieren um dieselben Zeilen, Locks und Verbindungen genau dann, wenn der Traffic seine Spitze erreicht, und die Abrechnung läuft als Arbeit, die diese Zeilen hält, statt als Queue, die warten kann, bis sie an der Reihe ist.

Mehr Hardware hebt das Traffic-Niveau an, bei dem das passiert, ohne die Ursache zu beseitigen. Was folgt, trennt die beiden Pfade, lokalisiert die Contention und hält Guthaben korrekt, während die Abrechnung hinterherläuft. Das unten zitierte PostgreSQL-Verhalten stammt aus der Dokumentation zu Version 18.

Die kurze Antwort ist strukturell. Wettannahme und Abrechnung teilen keine Transaktionen mehr: Die Wettannahme schreibt die Wette, eine Guthabenreservierung und eine Outbox-Zeile in einer kurzen Transaktion unter einem Idempotenz-Schlüssel, und die Abrechnung konsumiert Ergebnis-Events in kleinen Batches, deren Effekte ebenfalls einen Schlüssel tragen, sodass eine erneut zugestellte Nachricht kein Geld bewegt. Was amBrain öffentlich belegen kann, ist Casino-Plattform-Engineering, und eine Zahl, die wir dort als gemessen veröffentlichen, ist 12 Betreiber im Live-Betrieb. Das Design unten ergibt sich aus der Mechanik des Problems, nicht aus einem unserer Fälle, und keine Zahl darin ist an einem System von uns gemessen.

Wettannahme und Abrechnung sind zwei Workloads, die sich Zeilen teilen

Die Wettannahme ist eine Request, auf die ein Mensch wartet: Marktzustand lesen, ein Guthaben prüfen, eine Wette schreiben, antworten. Die Abrechnung geht von einem Ergebnis aus und fächert sich auf einmal auf jede offene Wette in den betroffenen Märkten auf. Ein großes Spiel endet, während andere Events noch offen sind, dieser Burst trifft also die Guthabenzeilen von Konten, die bereits wieder Wetten platzieren.

Schreiben beide Pfade diese Zeilen in eigenen Transaktionen, wird die Latenz der Wettannahme zu einer Funktion der längsten Abrechnungstransaktion auf demselben Konto. Entkopplung ist ein Satz von Zusagen über Locks und Zeit:

  • Die Abrechnung hält eine Guthabenzeile nicht länger als eine Annahmetransaktion
  • Die Abrechnung darf in Rückstand geraten, und ihr Backlog ist eine Queue mit einem Alter und nicht ein Haufen offener Transaktionen
  • Jeder Effekt auf Geld tritt genau einmal ein, egal wie oft die Nachricht dahinter zugestellt wird
  • Lesezugriffe, die den Primary nicht brauchen, berühren ihn nicht

Eine Guthabenzeile ist ein Lock, ob Sie einen entworfen haben oder nicht

Das Locking-Kapitel von PostgreSQL sagt, dass Row-Level-Locks nur Schreiber und Sperrende derselben Zeile blockieren, nicht Leser, und dass eine Transaktion, die einen Lock anfordert, unbegrenzt wartet, sofern kein Deadlock erkannt wird. Eine Zeile pro Konto, die bei jeder Wettannahme aktualisiert wird, ist deshalb eine Queue, und zu Recht: Der Lock verhindert, dass zwei Wettannahmen dasselbe Geld ausgeben. Entscheidend ist, wie lange jeder Halter ihn behält.

Read Committed, die Standard-Isolationsstufe, hält die Reservierung einfach. Ein UPDATE, das eine Zeile vorfindet, die bereits von einer nebenläufigen Transaktion aktualisiert wurde, wartet auf deren Commit oder Rollback und wertet, falls sie committet hat, seine WHERE-Klausel gegen die aktualisierte Version neu aus. Ein bedingtes Update, das den Betrag nur abzieht, wo das verfügbare Guthaben ihn deckt, kann nicht überbuchen und braucht kein SELECT FOR UPDATE.

  • Nehmen Sie den Guthaben-Lock zuletzt und committen Sie direkt danach; Validierung, die keinen Lock braucht, läuft vorher
  • Sperren Sie mehrere Konten in einer einheitlichen Reihenfolge, die das Locking-Kapitel als Weg zur Vermeidung von Deadlocks nennt
  • Halten Sie das Markt-Exposure von einer einzelnen Zeile fern, die jede Wettannahme aktualisiert, sonst serialisiert ein populärer Markt seine Wettannahmen hinter einem Lock; verteilen Sie den Zähler auf eine feste Menge von Zeilen
  • Setzen Sie lock_timeout auf dem Annahmepfad, damit aus einem unbegrenzten Warten ein gezählter Fehler wird, der unter demselben Idempotenz-Schlüssel wiederholt wird
  • Halten Sie Guthabenspalten aus Indizes heraus: Das Storage-Kapitel erlaubt ein HOT-Update nur, wenn sich keine indizierte Spalte ändert und die Seite mit der alten Zeile Platz hat, was ein niedrigerer fillfactor wahrscheinlicher macht

Lange Transaktionen und Autovacuum halten den Burst auf der Platte

Eine Abrechnung, die jede offene Wette eines Markts in einem Statement markiert, hält diese Zeilen-Locks bis zum Commit und hinterlässt von jeder Zeile eine tote Version. Das Kapitel zu Vacuum sagt, dass eine alte Version nicht entfernt werden darf, solange andere Transaktionen sie noch sehen könnten, also hält eine lange Abrechnung oder ein Report, der idle in einer Transaktion steht, den ganzen Burst auf der Platte.

Autovacuum kommt konstruktionsbedingt spät. PostgreSQL 18 führt VACUUM auf einer Tabelle aus, sobald die seit dem letzten VACUUM aktualisierten oder gelöschten Zeilen den kleineren Wert von autovacuum_vacuum_max_threshold und autovacuum_vacuum_threshold plus autovacuum_vacuum_scale_factor mal Zeilenzahl überschreiten. Mit den Defaults 100.000.000, 50 und 0,2 wartet eine Wettentabelle mit 50 Millionen Zeilen auf etwa zehn Millionen aktualisierte oder gelöschte Zeilen.

  • Überschreiben Sie diese Schwellen pro Tabelle für Guthaben und offene Wetten, was das Kapitel zu Vacuum über Storage-Parameter erlaubt
  • Setzen Sie idle_in_transaction_session_timeout, in dessen Dokumentation gewarnt wird, dass eine offene Transaktion das Vacuum kürzlich toter Tupel verhindert und zu Table Bloat beitragen kann
  • Hängen Sie Abrechnungszeilen an, statt eine indizierte Statusspalte umzuschalten, denn ein Update, das eine indizierte Spalte ändert, kann nicht HOT sein
  • Mustern Sie Historie aus, indem Sie Partitionen abhängen oder löschen, was laut Kapitel zur Partitionierung weit schneller ist als eine Massenoperation und ohne den VACUUM-Overhead eines Massen-DELETE auskommt

Queue-Tabellen machen den Effekt gut sichtbar. In einem Beitrag von 2015 auf brandur.org, „Postgres Job Queues & Failure By MVCC“, ließ eine Transaktion, die neben einer Job-Queue idle gelassen wurde, die Zeit zum Sperren eines Jobs von unter 0,01 Sekunden auf Spitzen des 15-Fachen dieses Niveaus steigen, weil tote Job-Zeilen noch nicht entfernt werden konnten.

Verbindungen und Replicas gehören zur selben Spitze

Jede Verbindung ist ein Backend-Prozess, und laut Dokumentation erhöht ein höheres max_connections, standardmäßig typischerweise 100, die Ressourcen, die davon abhängig dimensioniert werden, darunter Shared Memory. Geben Sie Wettannahme und Abrechnung stattdessen getrennte Pools, damit ein Abrechnungs-Backlog auf seine eigenen Verbindungen wartet.

  • Das Transaction Pooling von PgBouncer weist eine Server-Verbindung nur für die Dauer einer Transaktion zu, sodass sich viele Clients weniger Backends teilen
  • Session-Features funktionieren in diesem Modus nicht: PgBouncer führt SET und RESET, LISTEN, WITH-HOLD-Cursor und Advisory Locks auf Session-Ebene als nicht unterstützt auf
  • Benannte Prepared Statements auf Protokollebene funktionieren dort seit PgBouncer 1.21.0, veröffentlicht im Oktober 2023, wenn max_prepared_statements ungleich null ist

Replicas entlasten Lesezugriffe zu zwei Preisen. Streaming-Replikation ist standardmäßig asynchron, ein Commit wird auf dem Standby also nach einer kleinen Verzögerung sichtbar. Und das Kapitel zu Hot Standby sagt, dass Abfragen auf dem Standby, die mit der Vacuum-Bereinigung vom Primary kollidieren, nach einer konfigurierten Verzögerung abgebrochen werden, während hot_standby_feedback das verhindert, indem es die Bereinigung auf dem Primary verzögert, was dort Table Bloat verursachen kann.

Die Wettannahme ist eine kurze Transaktion, ihr Schlüssel steht vor dem ersten Retry fest

Entwerfen Sie die Wettannahme rückwärts von ihrem Fehlerfall aus: Ein Client läuft ins Timeout und versucht es erneut, und der Retry muss das erste Ergebnis erhalten, nicht eine zweite Wette erzeugen.

  • Der Client oder die Edge, die die Request als Erste empfängt, erzeugt einen Idempotenz-Schlüssel pro Einreichung, und jeder Retry trägt ihn unverändert mit
  • Eine Transaktion schreibt den Wettdatensatz, die Reservierung als bedingtes Guthaben-Update und eine Outbox-Zeile für die angenommene Wette
  • Wettdatensätze sind append-only: Abrechnung, Annullierungen und Korrekturen sind neue Zeilen, die auf die Wette verweisen, nie Änderungen
  • Ein Unique Constraint macht aus einem Retry einen Konflikt: INSERT mit ON CONFLICT DO NOTHING fügt nichts ein, RETURNING liefert nur eingefügte Zeilen, und der Pfad liest das gespeicherte Ergebnis zurück
  • Stripe dokumentiert denselben Vertrag für seine API: Das erste Ergebnis zu einem Schlüssel wird gespeichert und späteren Requests zurückgegeben, egal ob es erfolgreich war oder fehlgeschlagen ist, und ein wiederverwendeter Schlüssel mit anderen Parametern wird abgelehnt

Partitionieren Sie den Storage nach Zeit und die Arbeit nach Markt. Das Kapitel zur Partitionierung verlangt, dass ein Unique Constraint auf einer partitionierten Tabelle alle Spalten des Partitionsschlüssels enthält, der Idempotenz-Schlüssel trägt also entweder die Partitionsspalte oder liegt in einer eigenen Tabelle. Es sagt außerdem, dass der Planer bis zu einigen Tausend Partitionen recht gut bewältigt, wenn Abfragen alle bis auf wenige ausschließen, und die Menge der Märkte ist nach oben offen, Partitionen pro Markt legen also Planungszeit auf den Annahmepfad.

Die Outbox-Zeile macht das Event vertrauenswürdig. Im Transactional-Outbox-Pattern, wie Chris Richardson es beschreibt, wird die Nachricht in der Datenbank innerhalb der Transaktion gespeichert, die die Business-Entitäten aktualisiert, und ein separater Prozess sendet sie weiter. Dieselbe Beschreibung nennt den Preis: Das Relay kann eine Nachricht mehr als einmal veröffentlichen, Consumer müssen also idempotent sein.

Die Abrechnung ist eine Queue, die spät sein darf

Ab dem Moment, in dem ein Ergebnis eintrifft, ist die Abrechnung ein Backlog mit einem Alter, und nichts darin hält eine Zeile, auf die die Wettannahme wartet, länger als einen Batch lang:

  • Reihenfolge pro Markt, nicht global: Kafka schreibt Events mit demselben Schlüssel in dieselbe Partition und dokumentiert, dass Consumer eine Partition in Schreibreihenfolge lesen, Ergebnis-Events mit dem Markt als Schlüssel bleiben also in Reihenfolge
  • Eine Queue-Tabelle funktioniert innerhalb von Grenzen: Die Dokumentation nennt SKIP LOCKED ungeeignet für allgemeine Zwecke, aber brauchbar, um Lock-Contention zwischen Consumern einer queue-artigen Tabelle zu vermeiden
  • Jeder Batch rechnet eine begrenzte Zahl von Wetten ab, schreibt deren Ledger-Buchungen, aktualisiert Guthabenzeilen in Kontoreihenfolge und committet
  • Der Fortschritt wird zusammen mit den Effekten committet, sodass ein Worker, der mitten im Batch stirbt, ab seinem letzten committeten Batch weitermacht
  • Ein korrigiertes Ergebnis ist ein neues Event: Stornobuchungen, dann neue Abrechnungsbuchungen, nie Änderungen an alten

Die Abrechnung darf spät sein. Sie darf nicht zweimal passieren. Die Wettannahme darf beides nicht, und deshalb können die beiden keine Transaktion teilen.

Guthaben brauchen zwei Zahlen und Buchungen, die genau einmal landen

Eine einzige Guthabenspalte kann eine Wette nicht abbilden, die angenommen und noch nicht abgerechnet ist. Führen Sie zwei Zahlen pro Konto, verfügbar und reserviert, und bewegen Sie Geld zwischen ihnen nur über Ledger-Buchungen, die jeweils einen Schlüssel tragen:

  • Die Wettannahme verschiebt den Betrag in ihrem bedingten Update von verfügbar nach reserviert
  • Die Abrechnung gibt die Reservierung frei und bucht die endgültige Belastung und eine etwaige Gutschrift in einer Transaktion, mit Wette, Buchungsart und Abrechnungsversion als Schlüssel
  • Eine Annullierung gibt die Reservierung frei, und eine Reservierung, deren Abrechnung nie eintrifft, hat einen namentlich benannten Verantwortlichen und eine Frist
  • Die Guthabenzeile ist eine Projektion des Ledgers, und ein geplanter Abgleich, der die Buchungen pro Konto summiert, meldet Drift als Incident, statt ihn stillschweigend zu korrigieren

Zustellung kann sich wiederholen: Das Outbox-Relay kann erneut veröffentlichen, und wird die Outbox über Logical Decoding gelesen, kann laut Dokumentation ein Slot nach einem Absturz jüngste Änderungen erneut senden. Die Anforderung ist also ein Effekt, der genau einmal eintritt. Jede Ledger-Buchung hat einen eindeutigen Schlüssel, das Guthaben-Update wird mit dem Insert committet, und eine erneut zugestellte Nachricht läuft auf den Constraint und bewegt kein Geld.

Die Wetthistorie gehört in ein Lesemodell, nicht auf den Schreibpfad

Viele Lesezugriffe in einer Spitze liegen neben der Wettannahme statt auf ihr: offene Wetten, Historie, Guthaben-Screens, die nach jedem Event aktualisiert werden. Chris Richardsons Beschreibung von CQRS bedient solche Abfragen aus einer View-Datenbank, die aktuell gehalten wird, indem sie Events des Service abonniert, dem die Daten gehören, und nennt Replikationsverzögerung und eventually consistent Views als Preis. Die Outbox der Wettannahme veröffentlicht diese Events bereits.

  • Die Antwort der Wettannahme liefert die angenommene Wette zurück, sodass der Client sie anzeigt, ohne sie aus einer View zurückzulesen, die hinterherhinken kann
  • Screens, die den neuesten Stand brauchen, lesen explizit vom Primary, und diese Liste bleibt kurz
  • synchronous_commit auf remote_apply lässt jeden Commit warten, bis synchrone Standbys ihn eingespielt haben: Read-your-writes auf dem Replica, bezahlt mit Latenz der Wettannahme

Was zu messen ist, solange das Spiel noch läuft

Erheben Sie die Messwerte während der Spitze, auf einer Zeitachse mit der Latenz der Wettannahme:

  • Lock-Wartezeiten: pg_stat_activity in Abständen auf den Wait-Event-Typ Lock abfragen und Blocker mit pg_blocking_pids finden, vor der die Dokumentation warnt, dass sie bei häufigem Aufruf die Performance beeinträchtigen kann
  • log_lock_waits ist standardmäßig aus und meldet nur Wartezeiten, die länger als deadlock_timeout sind, standardmäßig eine Sekunde, das Log zeigt also keine der kürzeren Wartezeiten
  • Die älteste Transaktion, aus xact_start in pg_stat_activity, und jede Session im Zustand idle in transaction
  • Bereinigung auf den heißen Tabellen: n_dead_tup, last_autovacuum und n_tup_hot_upd gegenüber n_tup_upd
  • Pool-Druck: cl_waiting und maxwait aus SHOW POOLS, wobei PgBouncer ein steigendes maxwait als Pool deutet, der nicht hinterherkommt
  • Abrechnungs-Backlog als Alter, denn eine Anzahl kann eine große Queue nicht von einer festgefahrenen unterscheiden
  • replay_lag pro Standby sowie wal_status und safe_wal_size für logische Replikationsslots

Zusammen gelesen lokalisieren sie den Fehler. Eine wachsende Pool-Queue bei flachen Lock-Wartezeiten zeigt auf die Verbindungen; Lock-Wartezeiten, die mit den Abrechnungs-Batches steigen, zeigen auf gemeinsam genutzte Zeilen; bewegt sich keines von beiden, während die toten Zeilen zunehmen, zeigt das auf die älteste Transaktion.

Woran man erkennt, welche Engineering-Firmen diese Arbeit tatsächlich machen

Die zweite Hälfte der Frage, welche Unternehmen darauf spezialisiert sind, hat einen Test, der ohne Anbieterliste auskommt. Eine Firma, die diese Pfade schon einmal getrennt hat, tut in einem ersten Gespräch Folgendes:

  • Fragt nach Latenz der Wettannahme und Abrechnungs-Backlog aus einer echten Spitze auf einer Zeitachse, bevor sie nach dem Schema fragt
  • Benennt den Mechanismus, den sie als Besitzer der Latenz erwartet, und den Messwert, der diese Erwartung widerlegen würde
  • Behandelt Geld als Testsuite: doppelte Zustellung, ein mitten im Batch gekillter Worker, ein korrigiertes Ergebnis
  • Testet einen Ergebnis-Burst unter Last, während der Traffic der Wettannahme weiterläuft, statt einen der beiden Pfade allein
  • Benennt Exit-Kriterien vorab: ein Perzentil der Wettannahme während des Bursts und ein akzeptables Backlog-Alter danach
  • Kann in der Nacht des Finales jemanden in Bereitschaft für Abrechnungs-Worker, Replikationsslots und Connection-Pools stellen

Eine Antwort, die bei einem dieser Punkte allgemein bleibt, heißt, dass die Arbeit ohne Diagnose beginnen würde.

Die erste Entscheidung ist also keine größere Datenbank. Sie lautet, welcher Mechanismus in der Nacht, in der die Wettannahme langsamer wird, die Latenz besitzt, und ob Wettannahme und Abrechnung irgendwo auf dem Pfad noch eine Transaktion teilen.

Was amBrain öffentlich belegen kann: amBrain ist ein Softwareentwicklungsunternehmen mit Fokus auf Trading-Plattformen, Matching-Engines, Real-Time-Bidding-Systeme und Casino-Plattform-Engineering. amBrain baut seit 2019 Software. Eine Zahl, die wir im iGaming als gemessen veröffentlichen, ist 12 Betreiber im Live-Betrieb. Wir arbeiten in drei Formaten: vollständige Umsetzung, dediziertes Team oder Engineers, die in Ihrem Team mitarbeiten.

Liegt ein solcher Entwurf bei Ihnen auf dem Tisch?

Bringen Sie Ihre aktuelle Architektur und den Fehlerfall mit, der Sie beunruhigt - wir gehen ihn in einer halben Stunde gemeinsam durch.

Verwandte Artikel

Bild konnte nicht geladen werden
iGaming
Feb 28, 20266 Min. Lesezeit

iGaming-Plattformen skalieren: Lehren aus 10M gleichzeitigen Nutzern

Beitrag lesen
Bild konnte nicht geladen werden
iGaming
Feb 7, 20265 Min. Lesezeit

Responsible-Gaming-Funktionen bauen: ein technischer Deep Dive

Beitrag lesen
Bild konnte nicht geladen werden
iGaming
Jan 15, 20267 Min. Lesezeit

Live-Betting-Architektur: Quotenupdates in unter 50ms verarbeiten

Beitrag lesen