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
6 Kommentare
Endlich erscheint mal wieder ein Artikel, der sich nach echter Entwicklung anfühlt.
So einen Beitrag habe ich auch schon lange nicht mehr gesehen, das fühlt sich irgendwie anders an.
Ich scheine von AI ziemlich erschöpft zu sein.
Sehe ich genauso, haha. Wir leben wirklich in einer Zeit, in der solche Artikel willkommen sind!
Ich war also offenbar nicht der Einzige, der die AI-Slop-Werbeposts satt hatte, haha.
Bravo!
Gab es bei PostgreSQL oder Oracle nicht schon seit Langem
skip locked? Andererseits sind bei Oracle die Lizenzkosten erst einmal eine Belastung, und bei Pg ist wegen der MVCC-Implementierung wohl mit einer ziemlich hohen Update-Last zu rechnen, daher liegt die Vermutung nahe, dass man es erst nach dem Hinzukommen der Funktion in MySQL 8 eingeführt hat.