Shopify ersetzt das Bestandsreservierungssystem von Redis durch MySQL
(shopify.engineering)- 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 mitUNION ALLwurden 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 mitINCRverarbeitet - 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 LOCKEDauf einer leeren Tabelle entstanden Gap Locks (einschließlich supremum), dieINSERT-Operationen der Replenishment-Transaktion blockierten und Deadlocks auslösten - Das Isolationsniveau wurde vom MySQL-Standard
REPEATABLE READaufREAD COMMITTEDumgestellt → 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_quantitiesINSERT→reservation_unitsDELETE - Claim:
reserved_quantitiesDELETE
- Reserve:
- Lösung: Reserve standardisiert die Reihenfolge so, dass immer zuerst
DELETEin der Units-Tabelle und danachINSERTinreserved_quantitiesausgefü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 ALLin 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 LOCKEDmachbar - 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
Endlich erscheint mal wieder ein Artikel, der sich nach echter Entwicklung anfühlt.