Shopify remplace son système de réservation d’inventaire de Redis par MySQL
(shopify.engineering)- Le système de réservation d’inventaire est une infrastructure critique qui empêche la survente d’un même produit pendant le traitement du paiement, et Shopify l’exploitait depuis des années sur Redis
- En s’appuyant sur la fonctionnalité
SKIP LOCKEDde MySQL 8, Shopify a repensé l’architecture en passant d’une colonne de quantité par article à une structure d’une ligne par unité vendable, obtenant de hautes performances sans Redis - En combinant des techniques d’optimisation MySQL comme une clé primaire composite, le niveau d’isolation
READ COMMITTED, un ordre de verrouillage cohérent et un traitement par lot viaUNION ALL, Shopify a résolu la contention sur les verrous et les interblocages - Le véritable goulot d’étranglement n’était pas la requête de réservation mais l’occupation des connexions ; en instrumentant tout le parcours de checkout, Shopify a réduit de 50 % les lectures DB et de 33 % les transactions
- Lors du pic du Black Friday 2025, avec 5,1 millions de dollars de ventes par minute traités, Shopify est resté sous 50 % de CPU sur le writer et sous 16 % sur les readers, dépassant l’objectif de débit visé
Contexte : exigences d’un système anti-survente
- Il faut un système de protection contre la survente (Oversell Protection) qui garantisse qu’au moment de la finalisation du checkout, le stock est bien encore disponible
- Reserve : verrouille temporairement l’article pendant quelques minutes au début du paiement
- Claim : déduit définitivement la quantité du grand livre d’inventaire une fois le paiement terminé
- Aucune erreur n’est tolérable dans un sens comme dans l’autre
- Sinon, deux personnes peuvent acheter le même produit, ou un article peut être marqué en rupture alors qu’il est disponible, entraînant une perte de chiffre d’affaires
- Exigences d’échelle : Shopify représente plus de 14 % du e-commerce américain et, lors du Black Friday 2025, a enregistré 5,1 millions de dollars de ventes par minute, soit 11 % de plus que l’année précédente
- Inventaire multi-emplacements (multi-location inventory), garanties ACID, haut débit et priorité absolue à l’exactitude sont les exigences clés
Limites de l’ancien modèle Redis
- Dans Redis, chaque article possède une clé de quantité ; la réservation se fait avec
DECRet la libération avecINCR - Problème central : les données de réservation (Redis) et le grand livre d’inventaire (MySQL) vivent dans deux systèmes distincts
- À l’étape de claim, il était impossible d’englober dans une seule transaction atomique la mise à jour MySQL et le nettoyage Redis
- Selon l’ordre d’exécution, cela pouvait provoquer une survente (produit vendu mais non déduit du grand livre) ou une sous-vente (grand livre décrémenté mais article toujours réservé)
- Absence de prise en charge de l’inventaire multi-emplacements et coût opérationnel d’un cluster Redis dédié
Solution clé : refonte MySQL basée sur SKIP LOCKED
Structure de base : une ligne par unité
- Au lieu d’une colonne de quantité par article, Shopify a adopté une structure avec une ligne par unité vendable
- Un article avec 10 unités en stock → 10 lignes ; pour en réserver 3, une seule transaction sélectionne et déplace 3 lignes
- En plaçant réservation et grand livre d’inventaire dans la même base MySQL, reserve et claim sont traités comme des transactions ACID, ce qui élimine les classes de bugs présentes avec Redis
SKIP LOCKED: les lignes verrouillées par d’autres transactions sont ignorées et les lignes disponibles sont renvoyées immédiatement → moins de contention sans attente sur une même ligne
Limitation de la taille du pool : максимум 1 000 lignes par emplacement
- Le nombre de lignes disponibles est limité à 1 000 maximum par combinaison article/emplacement afin de maîtriser la taille de table et les performances de scan
- Exemple : éviter qu’un stock de 50 000 unités × 10 emplacements ne produise 500 000 lignes
- Quand le pool est épuisé, un réapprovisionnement inline (replenishment) est déclenché ; un verrou garantit qu’une seule transaction effectue ce réapprovisionnement afin d’éviter un thundering herd où plusieurs transactions inséreraient des lignes en même temps
- Si le pool se vide complètement, seul ce checkout subit un délai ; un acheteur pour lequel il reste réellement du stock ne sera pas faussement mis en rupture
Quatre décisions techniques majeures
1. Réduire le nombre de verrous grâce à une clé primaire composite
- Dans le prototype initial, l’usage d’un identifiant auto-incrémenté comme clé primaire amenait InnoDB à verrouiller à la fois l’index secondaire et l’index clusterisé, provoquant 2 verrous de ligne par réservation
- Application d’une clé primaire composite composée de
shop_id, inventory_item_id, inventory_group_id, id→ les colonnes de filtrage étant incluses dans la clé primaire, le nombre de verrous tombe à 1 - Dans un environnement avec des milliers de réservations par seconde, la conception des index et de la clé primaire affecte directement le nombre de verrous et le débit
2. Éliminer les gap locks avec READ COMMITTED
- Lors de l’exécution de
SELECT ... FOR UPDATE SKIP LOCKEDsur une table vide, des gap locks (y compris sur le supremum) apparaissaient, bloquant lesINSERTde réapprovisionnement et provoquant des interblocages - Le niveau d’isolation a été modifié du défaut MySQL,
REPEATABLE READ, versREAD COMMITTED→ le comportement des gap locks change et les transactions de réapprovisionnement peuvent se dérouler normalement - C’était le premier usage d’un niveau d’isolation non par défaut dans cette base de code, ce qui a nécessité un petit support framework pour définir le niveau d’isolation par transaction
3. Éviter les interblocages avec un ordre de verrouillage cohérent
- Reserve et claim accédaient à deux tables dans un ordre différent, ce qui provoquait des interblocages
- reserve :
reserved_quantitiesINSERT →reservation_unitsDELETE - claim :
reserved_quantitiesDELETE
- reserve :
- Solution : standardiser l’ordre afin que reserve commence toujours par le DELETE dans la table units, puis fasse l’INSERT dans
reserved_quantities→ suppression de l’attente circulaire
4. Réduire les aller-retours grâce au batching avec UNION ALL
- Quand le panier contient plusieurs lignes d’articles, Shopify regroupe les requêtes de réservation avec
UNION ALLpour les traiter en un seul aller-retour - La réduction du nombre total d’aller-retours améliore la latence sous charge
Le véritable goulot d’étranglement : l’occupation des connexions, pas les requêtes
Comment le problème a été identifié
- En production, un plafond était atteint avant le débit cible, alors même que la latence P90 restait correcte, que le CPU n’était pas saturé et que les requêtes étaient déjà optimisées
- Symptômes observés lors des tests de charge :
- Mise en file d’attente des threads dans MySQL
- Pic brutal du CPU lorsque les tâches en attente s’exécutent
- Épuisement des connexions backend MySQL au niveau de la couche ProxySQL
Rendre visibles les connexions
- Couche applicative : ajout, à toutes les instructions SQL, d’un commentaire d’identification du processus métier sous la forme
/* conn_tag:checkout_completion */ - Couche ProxySQL : ajout du parsing des tags et d’un agrégat du temps d’occupation des connexions par appelant
- Résultat : il devenait immédiatement possible de voir quel processus occupait les connexions et pendant combien de temps
Ce qui a été découvert et la solution
- D’autres portions du code du parcours de checkout, en dehors de la réservation, occupaient les connexions plus longtemps que nécessaire
- Elles avaient échappé à l’optimisation parce qu’elles n’avaient pas été les premières à atteindre leurs limites
- Après nettoyage du parcours de checkout : 50 % de lectures sur la DB primaire en moins et 33 % de transactions en moins
- Ajustement supplémentaire du paramètre InnoDB thread concurrency, défini de manière conservatrice il y a des années puis jamais réévalué, ce qui a supprimé un autre goulot d’étranglement
- Après amélioration, lors de flash sales à très fort volume : CPU writer sous 50 % et CPU reader sous 16 %
Méthode de transition : Shadow Mode
- Au lieu d’un basculement immédiat de Redis vers MySQL, Shopify a exploité les deux systèmes en parallèle via le Shadow Mode
- Toutes les réservations étaient écrites simultanément dans Redis et MySQL, Redis restant la source de vérité
- Cela a permis de valider en parallèle l’exactitude et les performances de MySQL sur le trafic réel de production
- Le basculement a été possible sans migration des réservations en vol (puisque les deux systèmes restaient actifs en même temps)
- Même après que MySQL est devenu la source de vérité, un kill switch a été conservé, et le chemin d’écriture double a maintenu Redis en permanence à jour
- Le rollout s’est fait progressivement, pod par pod, des pods à faible trafic jusqu’aux marchands au plus gros volume
Enseignements
1. Réexaminer les anciennes décisions
- Ce qui n’était pas possible avec MySQL il y a 5 ans l’est devenu aujourd’hui grâce à de nouvelles fonctionnalités comme
SKIP LOCKED - Les réglages « empiriques » comme les limites de threads doivent être revus lorsque la charge de travail et le matériel évoluent
- Si le CPU reste bas alors qu’il y a de la mise en file d’attente, il faut absolument en chercher la cause
2. Commencer petit et observer
- Le prototype minimal a été construit avec un petit script Ruby et MySQL, sans framework Rails complet
- Observer directement le comportement des verrous dans un second terminal a apporté plus d’enseignements que la théorie
- Le pattern d’instrumentation de l’occupation des connexions (tags côté application + agrégation côté proxy) est simple à mettre en place et immédiatement exploitable
1 commentaires
Cela faisait longtemps qu’un article qui ressemble à du vrai développement n’avait pas été publié.