We vervangen Redis door MySQL voor voorraadreserveringen — en het schaalde (2026)
Gepubliceerd op 12 mei 2026
Tijdens het afrekenproces, wanneer een koper op "Aankoop voltooien" klikt, moeten we garanderen dat de artikelen die ze kopen nog beschikbaar zijn. Als we dit in de ene richting fout doen, kopen twee kopers hetzelfde laatste exemplaar: de verkoper moet dan een bestelling annuleren, een excuses-mail sturen en de kosten voor de klantenservice dragen. Als we het in de andere richting fout doen, vertellen we een koper dat iets is uitverkocht terwijl dat niet zo is, waardoor de verkoper een verkoop mist die hij had kunnen maken.
Op de schaal van Shopify stapelen deze fouten zich snel op. Tijdens Black Friday 2025 bereikten verkopers op ons platform een recordbedrag van 5,1 miljoen dollar aan verkopen per minuut op het piekmoment. Elke transactie raakt de voorraad aan.
Ons systeem voor bescherming tegen oververkoop regelt dit door voorraad te reserveren tijdens de betalingsverwerking — een korte blokkering die voorkomt dat twee gelijktijdige checkouts hetzelfde exemplaar claimen. Jarenlang draaide dit op Redis. Toen we overgingen naar een uniforme databasestrategie, moesten we een moeilijke vraag beantwoorden: kon MySQL dezelfde schaal aan?
Eerdere pogingen waren mislukt. Een enkele rij met een kolom voor het aantal kon de concurrentie (contention) niet aan. De SKIP LOCKED-functie van MySQL 8 introduceerde een ander ontwerp: één rij per voorraadeenheid in plaats van één rij per item. Geïnspireerd door de aanpak van 37signals voor database-gestuurde load distribution, hebben we reserveringen opnieuw gebouwd op MySQL en onze throughput-doelstellingen behaald tijdens de piekverkeersperiode van 2025.
Maar de belangrijkste les ging niet over databasestructuren. Het was de ontdekking dat de werkelijke bottleneck niet was wat we observeerden en maten. Dit artikel beschrijft de oplossing en wat we onderweg hebben ontdekt.
De uitdaging
Wat is bescherming tegen oververkoop?
Bescherming tegen oververkoop bestaat uit twee hoofdbewerkingen:
- Reserveren: Wanneer de betaling start, markeren we artikelen als gereserveerd (een korte hold, bijvoorbeeld van enkele minuten).
- Claimen: Wanneer de betaling slaagt, trekken we de hoeveelheid permanent af van het voorraadregister (de source of truth).
Het voltooien van de checkout is afhankelijk van het feit dat dit snel en correct gebeurt. Trage reserveringen triggeren throttling en zorgen voor een slechtere koperervaring. Fouten leiden tot oververkoop (boze klanten) of onderverkoop (verlies van omzet).
Eisen aan schaalbaarheid en correctheid
Schaal is hier geen abstract begrip: Shopify drijft meer dan 14% van de Amerikaanse e-commerce aan, en tijdens Black Friday 2025 zagen we een stijging van 11% in verkopen per minuut op het piekmoment ten opzichte van het jaar ervoor. Reserveringen worden uitgevoerd bij elke checkout die voorraad raakt, dus het systeem moet deze burst kunnen verwerken zonder verzoeken te verliezen of de consistentie te verbreken.
We moesten:
- De high-performance throughput-doelstellingen van het platform ondersteunen tijdens piekverkeer.
- Rekening houden met voorraad op meerdere locaties (alleen reserveren van locaties die kunnen leveren).
- ACID-garanties behouden tussen reserveringen en het voorraadregister.
- Correctheid prioriteren: geen oververkoop en geen verloren reserveringen.
Het Redis-model en de beperkingen ervan
Het vorige systeem sloeg reserveringen op in Redis. Elk item had een quantity-sleutel; reserveren betekende DECR, vrijgeven betekende INCR. Redis ging goed om met concurrency, maar de reserveringen en het voorraadregister bevonden zich in twee verschillende systemen.
De claimstap (betaling verwerkt, voorraad permanent aftrekken) vereiste een update van MySQL en het opschonen van Redis. Deze twee bewerkingen konden niet in één enkele atomaire stap worden verpakt. Afhankelijk van de volgorde kon dit leiden tot oververkoop (artikel verkocht maar nooit afgetrokken uit het register) of onderverkoop (artikel afgetrokken maar nog steeds als gereserveerd gemarkeerd).
Daarnaast had het Redis-model geen inzicht in meerdere locaties en bracht het de operationele kosten van een apart cluster met zich mee. Door reserveringen naar dezelfde MySQL-database als het register te verplaatsen, konden we alles in ACID-transacties verpakken en deze foutmodi volledig elimineren.
De oplossing: SKIP LOCKED
Kernidee: één rij per eenheid, begrensd door ontwerp
In plaats van één rij per item met een kolom voor de hoeveelheid, gebruiken we één rij per verkoopbare eenheid. Een artikel met 10 eenheden heeft 10 rijen. Het reserveren van drie eenheden betekent het selecteren en verplaatsen van drie rijen in een enkele transactie. Door reserveringen en het voorraadregister in dezelfde database te houden, krijgen we ACID over reserve en claim — wat klassen van bugs oplost die mogelijk waren met Redis (bijv. betaling slaagt maar voorraad wordt niet geclaimd, of andersom).
Een vereenvoudigde reserve-flow ziet er als volgt uit:
- Selecteer beschikbare eenheden met
SELECT ... FOR UPDATE SKIP LOCKED. - Markeer deze als gereserveerd.
SKIP LOCKED is wat dit schaalbaar maakt: als een andere transactie enkele rijen heeft vergrendeld, slaat MySQL deze over en retourneert andere beschikbare rijen. Er wordt niet gewacht op dezelfde rij, waardoor er minder concurrentie is.
Maar één rij per eenheid voor alle voorraad zou bij grote aantallen vastlopen — een artikel met 50.000 eenheden over 10 locaties zou betekenen dat er 500.000 rijen zijn, en de reserve-query zou vertragen door het scannen hiervan. In plaats daarvan onderhouden we een begrensde pool van beschikbare rijen, gemaximeerd op 1.000 per combinatie van item/locatie. Reserveringen verbruiken rijen uit deze pool; een aanvullingsproces (replenishment) vult deze aan vanuit het voorraadregister.
Waarom 1.000? De limiet moet groot genoeg zijn om bursts op te vangen zonder droog te lopen, maar klein genoeg om de tabel compact en de SKIP LOCKED-scan snel te houden. We hebben dit bepaald op basis van geobserveerde piekreserveringssnelheden per item/locatie tijdens flash-sales: 1.000 geeft ons voldoende ruimte zodat het aanvullingsproces kan bijhouden bij aanhoudende belasting zonder dat de tabel zo groot wordt dat de queryperformance verslechtert.
Wat gebeurt er als de pool leeg is? Tijdens een extreme flash-sale kan de pool voor een populair item tijdelijk uitgeput raken. Wanneer dat gebeurt, triggert het reserve-pad direct een aanvulling. Een lock zorgt ervoor dat slechts één transactie tegelijk aanvult; andere gelijktijdige reserveringen voor hetzelfde item wachten tot dit is voltooid in plaats van allemaal tegelijk rijen te proberen in te voegen (thundering herd). Zodra de aanvulling is voltooid, gaan de wachtende transacties verder met een volle pool. De koper ziet het artikel nooit als niet-beschikbaar (tenzij dat echt zo is). Dit voegt latentie toe aan die specifieke reservering, maar behoudt de correctheid: een koper met beschikbare voorraad wordt nooit weggestuurd.
Belangrijke technische beslissingen
1. Samengestelde primaire sleutel: minder locks per rij
Ons eerste prototype gebruikte een auto-increment ID als primaire sleutel. Wanneer we het lockgedrag observeerden (bijv. met SHOW ENGINE INNODB STATUS), zagen we twee row locks per reservering in plaats van één.
Met een auto-increment primaire sleutel vergrendelde InnoDB zowel de secundaire index die werd gebruikt in de WHERE-clausule als de clustered index (primaire sleutel). We zijn overgestapt op een samengestelde primaire sleutel (shopid, inventoryitemid, inventorygroup_id, id), zodat de kolommen waarop we filteren deel uitmaken van de primaire sleutel. Dat reduceerde het aantal locks naar één per rij, wat cruciaal was bij veel reserveringen per seconde.
Les: Op deze schaal hebben index- en primaire sleutelontwerpen direct invloed op het aantal locks en de throughput.
2. READ COMMITTED: voorkomen van gap (supremum) locks
Wanneer we SELECT ... FOR UPDATE SKIP LOCKED uitvoerden op een lege tabel die aangevuld moest worden, zagen we gap locks (inclusief op het "supremum" pseudo-record). Deze locks blokkeerden de aanvullingstransactie bij het invoegen van nieuwe rijen en konden leiden tot deadlocks.
We hebben het transactie-isolatieniveau voor deze transacties gewijzigd van REPEATABLE READ (het standaardniveau van MySQL) naar READ COMMITTED. Onder READ COMMITTED neemt InnoDB geen gap locks op dezelfde manier aan, waardoor de aanvulling kon doorgaan. Dit was ons eerste gebruik van een niet-standaard isolatieniveau in deze codebase; dit vereiste kleine framework-ondersteuning om isolatie per transactie in te stellen.
3. Consistente volgorde van locks: voorkomen van deadlocks
We stuitten op deadlocks wanneer 'reserve' en 'claim' twee tabellen in verschillende volgordes raakten. Reserve voerde een INSERT uit in reservedquantities en daarna een DELETE uit reservationunits; claim voerde een DELETE uit in reserved_quantities. Verschillende transacties konden de twee tabellen in verschillende volgordes vergrendelen, wat een cyclus vormde.
De oplossing was om de volgorde te standaardiseren: reserve voert altijd eerst de DELETE uit op de units-tabel, en daarna de INSERT in reservedquantities. Claim raakt alleen reservedquantities. Omdat beide paden nu locks in dezelfde volgorde verwerven, kan geen van beiden een lock vasthouden waar de ander op wacht — geen circulaire wachtrijen meer.
4. Batching met UNION ALL
Elke round trip naar de database brengt kosten met zich mee. Voor winkelwagentjes met meerdere artikelen groeperen we reserveringsqueries met UNION ALL, zodat we alle benodigde eenheden in één round trip ophalen. Dit verminderde het totaal aantal round trips en hielp de latentie onder belasting.
De werkelijke bottleneck: verbindingen, niet CPU
In productie stuitten we op een throughput-plafond dat ruim onder ons doel lag. De reserveringslatentie (bijv. P90) was acceptabel, de CPU was niet maximaal belast en de queries waren al geoptimaliseerd. Dus keken we verder.
We probeerden reserveringen over meerdere checkouts te batchten in een enkele SKIP LOCKED-query om minder verbindingen te gebruiken. Dat hielp in loadtests, maar voegde complexiteit toe. We verplaatsten ook een deel van de leeslast naar replica's. Toch klopte er iets niet.
Symptomen volgen
Tijdens loadtests zagen we:
- Threads die in een wachtrij stonden in MySQL.
- CPU-spikes wanneer het werk uit de wachtrij werd verwerkt.
- Uitputting van verbindingen naar de MySQL-backends op de ProxySQL-laag.
Daarom voegden we zichtbaarheid toe aan welke bedrijfsprocessen databaseverbindingen vasthielden en voor hoe lang. Weten dat verbindingen uitgeput zijn, vertelt je niet wie ze vasthoudt; we hadden toeschrijving per aanroeper nodig.
Aan de applicatiezijde voegden we een comment-tag toe aan elke SQL-statement die het bedrijfsproces identificeerde, zoals / conntag:checkoutcompletion /. Op de ProxySQL-laag voegden we tracking toe die de tag parseert en meet hoe lang elke aanroeper een verbinding vasthoudt. Het resultaat was de totale verbindingstijd, uitgesplitst per bedrijfsproces.
Dit toonde direct aan welke aanroepers de meeste verbindingstijd verbruikten. Niet welke queries traag waren, maar welke processen verbindingen vasthielden over lange transacties. Als je tegen verbindingslimieten aanloopt en niet weet waarom, is dit patroon (tag op applicatielaag, aggregatie op proxy) eenvoudig te implementeren en direct actiegericht.
Wat we ontdekten
Zodra we het verbruiksbeheer van verbindingen konden zien, bleek dat reserveringen niet de enige zware gebruiker waren. Andere delen van het checkout-pad hielden verbindingen langer vast dan nodig was. Deze waren niet geoptimaliseerd omdat ze niet als eerste de limiet hadden geraakt. Verbindingen zijn eindig: bij een hoge throughput hebben we veel korte transacties per seconde nodig. Wanneer andere code verbindingen langer vasthoudt, waren de reserveringen de druppel die de emmer deed overlopen — niet omdat de reserveringen traag waren, maar omdat de pool al bijna uitgeput was.
Het opschonen van het checkout-pad verwijderde 50% van de leesacties en 33% van de transacties op de primaire database. Ook herbezochten we de MySQL-configuratie. De InnoDB thread concurrency was jaren geleden conservatief ingesteld en nooit opnieuw geëvalueerd, terwijl onze workload was veranderd. Na het verhogen van de thread concurrency waar we ruimte hadden, verwijderden we een bottleneck die we pas zagen toen we verbindings- en CPU-metrieken naast elkaar zetten.
Gecombineerd verwijderden de opschoning en configuratiewijzigingen het plafond. We konden schalen voorbij onze vorige limiet en onze doelstellingen behalen. Tijdens flash-sales met een hoog volume bleef de schrijver-CPU onder 50% en de lezer-CPU onder 16%, met voldoende reserve.
De overgang (Cutover)
We zijn niet simpelweg van Redis naar MySQL overgestapt. We lieten beide systemen parallel draaien in wat we "shadow mode" noemden: elke reservering werd naar zowel Redis als MySQL geschreven, waarbij Redis de source of truth bleef. Dit stelde ons in staat om de twee systemen naast elkaar te vergelijken en te valideren dat MySQL de juiste zakelijke resultaten produceerde en voldeed aan de prestatie-eisen op echt productieverkeer. Omdat beide systemen live waren, hoefden er geen lopende reserveringen gemigreerd te worden. Redis-reserveringen bleven worden gerespecteerd terwijl MySQL zijn eigen status opbouwde.
Zodra we tevreden waren over de correctheid en prestaties, schakelden we de source of truth om naar MySQL. Als er iets misging, konden we met een kill-switch terugkeren naar Redis; het dual-write pad was nog actief, dus had Redis altijd een volledig beeld van de reserveringen. De uitrol gebeurde geleidelijk, pod per pod, beginnend bij pods met weinig verkeer tot onze verkopers met het hoogste volume.
Wat we hebben geleerd
We hebben veel geleerd van dit project, maar de twee belangrijkste lessen zijn:
1. Herbezoek oude beslissingen Wat vijf jaar geleden niet mogelijk was (bijv. MySQL voor deze workload), kan vandaag wel mogelijk zijn dankzij nieuwe functies zoals SKIP LOCKED. Hetzelfde geldt voor configuraties: thread-limieten en andere "vuistregels" zijn de moeite waard om opnieuw te controleren wanneer workload en hardware evolueren. Als de cijfers niet kloppen (bijv. lage CPU maar wachtrijen), graaf dan dieper.
2. Begin klein en observeer We haalden veel waarde uit een minimaal prototype: een klein Ruby-script en MySQL, zonder een volledig framework zoals Rails. Het observeren van de database (bijv. lockgedrag in een tweede terminal) leerde ons meer dan theorie alleen. Eenvoudige tooling en een korte feedbackloop zijn superieur aan grote, ondoorzichtige systemen bij exploratie.
MySQL kan nu workloads aan waarvan we voorheen aannamen dat ze gespecialiseerde infrastructuur vereisten. Als je overweegt Redis, Kafka of een aangepaste coördinatielaag te gebruiken voor high-throughput mutual exclusion, kan je bestaande database wellicht al voldoende zijn.
De bottleneck zat niet waar we verwachtten. We optimaliseerden wekenlang queries en locks; de werkelijke limiet was het verbruik van verbindingen in code waar we niet eens naar keken. Als de cijfers niet kloppen — lage CPU maar hoge wachtrijvorming — instrumenteer dan het volledige pad. Het antwoord ligt vaak in de leidingen, niet in de motor.
Cruciaal is dat dit niet ging over het maken van snelle reserveringen. Het ging erom ze "veilige buren" te maken. Reserveringen delen een database met winkelwagen-updates, betalingsverwerking en ordercreatie. Een systeem dat verbindingen verzadigt of locks te lang vasthoudt, brengt al deze processen in gevaar. De echte lat was het behouden van throughput zonder de gezondheid van de database voor alle andere functies aan te tasten.
Voor ons is het resultaat concreet: betrouwbaardere reserveringen betekenen geen oververkoop en meer succesvolle aankopen voor onze verkopers.
Groetjes,