6 Punkte von GN⁺ 5 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • Das Bestandsreservierungssystem ist eine zentrale Infrastruktur, die Overselling verhindert, also dass derselbe Artikel während der Zahlungsabwicklung zweimal verkauft wird; Shopify betrieb es über Jahre auf Redis-Basis
  • Mithilfe der SKIP LOCKED-Funktion von MySQL 8 wurde das System von einer Mengen-Spalte pro Artikel auf eine Struktur mit 1 Zeile pro Verkaufseinheit umgestellt und erreichte auch ohne Redis hohe Performance
  • Durch die Kombination von MySQL-Optimierungen wie zusammengesetztem Primärschlüssel, Isolationsniveau READ COMMITTED, konsistenter Lock-Reihenfolge und Batch-Verarbeitung mit UNION ALL wurden Lock-Contention und Deadlocks beseitigt
  • Der tatsächliche Flaschenhals lag nicht bei den Reservierungsabfragen, sondern bei der Belegung von Verbindungen; durch Instrumentierung des gesamten Checkout-Pfads wurden 50 % weniger DB-Lesezugriffe und 33 % weniger Transaktionen erreicht
  • Beim Black Friday 2025 wurden auf dem Spitzenwert Umsätze von 5,1 Mio. US-Dollar pro Minute verarbeitet, während die Writer-CPU unter 50 % und die Reader-CPU unter 16 % blieb und damit das angestrebte Durchsatzniveau übertroffen wurde

Hintergrund: Anforderungen an ein System zur Verhinderung von Overselling

  • Es wird ein System zur Oversell Protection benötigt, das garantiert, dass zum Zeitpunkt des Checkout-Abschlusses tatsächlich noch Bestand vorhanden ist
    • Reserve: Beim Start der Zahlung wird der betreffende Artikel für einige Minuten temporär gesperrt
    • Claim: Nach Abschluss der Zahlung wird die Menge dauerhaft aus dem Bestandsledger abgezogen
  • In beide Richtungen ist keine Fehlerakzeptanz möglich
    • Andernfalls könnten entweder zwei Personen denselben Artikel kaufen, oder ein Artikel würde trotz vorhandenen Bestands als ausverkauft markiert, was zu Umsatzverlusten führt
  • Skalierungsanforderung: Shopify verantwortet mehr als 14 % des US-E-Commerce; am Black Friday 2025 wurde ein Umsatz von 5,1 Mio. US-Dollar pro Minute erzielt, 11 % mehr als im Vorjahr
  • Zentrale Anforderungen sind Multi-location inventory, ACID-Garantien, hoher Durchsatz und Priorität auf Korrektheit

Grenzen des bisherigen Redis-Modells

  • In Redis besitzt jeder Artikel einen Mengenschlüssel; Reservierungen werden mit DECR, Freigaben mit INCR verarbeitet
  • Kernproblem: Reservierungsdaten (Redis) und Bestandsledger (MySQL) liegen in unterschiedlichen Systemen
    • Im Claim-Schritt konnten das MySQL-Update und die Redis-Bereinigung nicht in einer einzigen atomaren Transaktion zusammengefasst werden
    • Je nach Ausführungsreihenfolge konnte es zu Oversell (Artikel verkauft, aber nicht aus dem Ledger abgezogen) oder Undersell (Ledger reduziert, aber weiterhin reserviert) kommen
  • Es fehlte außerdem an Unterstützung für standortübergreifende Bestände, hinzu kamen die Betriebskosten eines separaten Redis-Clusters

Kernlösung: MySQL-Neudesign auf Basis von SKIP LOCKED

Grundstruktur: eine Zeile pro Einheit (One Row Per Unit)

  • Statt einer Mengen-Spalte pro Artikel wurde eine Struktur mit 1 Zeile pro verkaufbarer Einheit eingeführt
    • Artikel mit Bestand 10 → 10 Zeilen; bei 3 Reservierungen werden in einer einzigen Transaktion 3 Zeilen ausgewählt und verschoben
  • Reservierungen und Bestandsledger liegen in derselben MySQL-Datenbank, sodass Reserve und Claim als ACID-Transaktionen verarbeitet werden können; damit entfallen die zuvor in Redis auftretenden Fehlertypen
  • SKIP LOCKED: Von anderen Transaktionen gesperrte Zeilen werden übersprungen, verfügbare Zeilen sofort zurückgegeben → weniger Contention, ohne auf dieselben Zeilen warten zu müssen

Begrenzung der Pool-Größe: maximal 1.000 Zeilen pro Standort

  • Die verfügbaren Zeilen pro Artikel-/Standort-Kombination wurden auf maximal 1.000 begrenzt, um Tabellengröße und Scan-Performance beherrschbar zu halten
    • Beispiel: Verhindert Situationen wie 50.000 Bestand × 10 Standorte = 500.000 Zeilen
  • Wenn der Pool erschöpft ist, wird ein Inline-Replenishment ausgelöst; per Lock wird sichergestellt, dass nur eine einzige Transaktion nachfüllt, damit nicht viele Transaktionen gleichzeitig Zeilen einfügen und einen Thundering Herd verursachen
  • Ist der Pool vollständig leer, betrifft die Verzögerung nur diese Reservierung; Käufer mit tatsächlich vorhandenem Bestand sehen den Artikel dadurch nicht als ausverkauft

Vier zentrale technische Entscheidungen

1. Weniger Locks durch zusammengesetzten Primärschlüssel

  • Im ersten Prototyp führte ein Auto-Increment-ID-Primärschlüssel dazu, dass InnoDB sowohl den Sekundärindex als auch den Clustered Index sperrte, also 2 Zeilen-Locks pro Reservierung
  • Einführung eines zusammengesetzten Primärschlüssels aus shop_id, inventory_item_id, inventory_group_id, id → weil die Filterspalten Teil des Primärschlüssels sind, sank die Zahl der Locks auf 1
  • In einer Umgebung mit Tausenden Reservierungen pro Sekunde beeinflussen Index- und Primärschlüssel-Design direkt die Anzahl der Locks und den Durchsatz

2. Gap Locks mit READ COMMITTED entfernen

  • Bei SELECT ... FOR UPDATE SKIP LOCKED auf einer leeren Tabelle entstanden Gap Locks (einschließlich supremum), die INSERT-Operationen der Replenishment-Transaktion blockierten und Deadlocks auslösten
  • Das Isolationsniveau wurde vom MySQL-Standard REPEATABLE READ auf READ COMMITTED umgestellt → dadurch änderte sich das Verhalten der Gap Locks, sodass Replenishment-Transaktionen normal ablaufen konnten
  • Es war das erste nicht standardmäßige Isolationsniveau in dieser Codebasis, weshalb eine kleine Framework-Unterstützung zum Setzen transaktionsspezifischer Isolationsniveaus nötig war

3. Deadlocks durch konsistente Lock-Reihenfolge vermeiden

  • Reserve und Claim griffen in unterschiedlicher Reihenfolge auf zwei Tabellen zu und verursachten so Deadlocks
    • Reserve: reserved_quantities INSERTreservation_units DELETE
    • Claim: reserved_quantities DELETE
  • Lösung: Reserve standardisiert die Reihenfolge so, dass immer zuerst DELETE in der Units-Tabelle und danach INSERT in reserved_quantities ausgeführt wird → dadurch wird Circular Wait eliminiert

4. Weniger Roundtrips durch Batch-Verarbeitung mit UNION ALL

  • Wenn ein Warenkorb mehrere Line Items enthält, werden die Reservierungsabfragen per UNION ALL in einem einzigen Roundtrip gebündelt
  • Weniger Roundtrips verbessern die Latenz unter Last

Der eigentliche Flaschenhals: nicht die Query, sondern die Verbindungsbelegung

Wie das Problem entdeckt wurde

  • In der Produktionsumgebung wurde schon unterhalb des Zieldurchsatzes eine Obergrenze erreicht; P90-Latenz war unauffällig, CPU nicht ausgelastet und auch die Queries waren bereits optimiert
  • In Lasttests wurden folgende Symptome beobachtet:
    • Thread-Queuing innerhalb von MySQL
    • Starker CPU-Anstieg, sobald aufgestaute Arbeit abgearbeitet wurde
    • Erschöpfung der MySQL-Backend-Verbindungen in der ProxySQL-Schicht

Sichtbarkeit der Verbindungen schaffen

  • Anwendungsschicht: Zu jedem SQL-Statement wurden Business-Prozess-Kommentare in der Form /* conn_tag:checkout_completion */ hinzugefügt
  • ProxySQL-Schicht: Ergänzung um Tag-Parsing und Aggregation der Verbindungsbelegungszeit pro Aufrufer
  • Ergebnis: Es war sofort erkennbar, welcher Prozess Verbindungen wie lange belegte

Erkenntnisse und Lösung

  • Andere Teile des Checkout-Pfads außerhalb der Reservierung belegten Verbindungen deutlich länger als nötig
    • Diese Bereiche waren zuvor nicht als Optimierungsziel identifiziert worden, weil sie nicht zuerst an ihre Grenze stießen
  • Nach Bereinigung des Checkout-Pfads: 50 % weniger Primär-DB-Lesezugriffe, 33 % weniger Transaktionen
  • Zusätzliche Bottlenecks wurden durch Anpassung der InnoDB-Thread-Concurrency beseitigt, die vor Jahren konservativ gesetzt und nie erneut überprüft worden war
  • Nach den Verbesserungen blieb die Writer-CPU selbst bei Flash-Sales mit hohem Volumen unter 50 %, die Reader-CPU unter 16 %

Migrationsansatz: Shadow Mode

  • Statt eines sofortigen Wechsels von Redis zu MySQL wurde ein Shadow Mode verwendet, in dem beide Systeme parallel liefen
    • Alle Reservierungen wurden gleichzeitig in Redis und MySQL geschrieben, wobei Redis die Source of Truth blieb
    • Korrektheit und Performance von MySQL konnten so mit echtem Produktionstraffic parallel validiert werden
  • Der Wechsel war ohne Migration bereits laufender Reservierungen möglich, weil beide Systeme gleichzeitig aktiv waren
  • Auch nach dem Wechsel der Source of Truth auf MySQL blieb ein Kill Switch erhalten; über den Dual-Write-Pfad blieb Redis jederzeit aktuell
  • Das Rollout erfolgte schrittweise auf Pod-Ebene, von Pods mit niedrigem Traffic bis zu den Merchants mit dem höchsten Volumen

Erkenntnisse

1. Alte Entscheidungen regelmäßig hinterfragen

  • Was vor 5 Jahren mit MySQL nicht möglich war, ist heute dank neuer Funktionen wie SKIP LOCKED machbar
  • Konfigurationen nach dem Motto „Faustregel“, etwa Thread-Limits, sollten bei veränderter Workload und Hardware neu bewertet werden
  • Wenn die CPU niedrig ist, aber dennoch Queuing auftritt, muss die Ursache konsequent untersucht werden

2. Klein anfangen und beobachten

  • Statt mit dem vollständigen Rails-Framework wurde ein Minimalprototyp mit einem kleinen Ruby-Skript und MySQL aufgebaut
  • Das direkte Beobachten des Lock-Verhaltens in einem zweiten Terminal lehrte mehr als reine Theorie
  • Das Instrumentierungsmuster für Verbindungsbelegung (App-Layer-Tags + Proxy-Aggregation) ist einfach umzusetzen und sofort praktisch einsetzbar

1 Kommentare

 
hso2341 30 분 전

Endlich erscheint mal wieder ein Artikel, der sich nach echter Entwicklung anfühlt.