Performance e-commerce : prévenir la concurrence sur les stocks avec Redis

Guide pratique PrestaShop : réserver les stocks via Redis (DECR/INCR, Lua, TTL), assurer l’idempotence, monitorer et prévoir un fallback pour éviter la survente.

Écran d'ordinateur avec code, icônes de serveur Redis, et texte sur l'optimisation des stocks.

Table des matières :

  1. La concurrence sur les stocks : là où PrestaShop vous laisse (encore) seul
  2. Redis pour la cohérence de stock : opérations atomiques, TTL et scripts Lua
  3. Réservation de stock “overlay” : un design compatible PrestaShop 8.2 / 9.1
  4. Verrous distribués : utiles dans 10% des cas, dangereux dans 90%
  5. Observabilité, tests de charge et runbook : la partie qui fait la différence en prod

La concurrence sur les stocks : là où PrestaShop vous laisse (encore) seul

Sur PrestaShop (8.x et 9.x), le cœur ne fournit pas de mécanisme de réservation de stock natif au niveau panier. Le stock “réel” est principalement matérialisé dans ps_stock_available (et éventuellement “advanced stock management” selon votre contexte), et la décrémentation est typiquement finalisée lors de la validation de commande (validateOrder), pas lors de l’ajout au panier. Résultat : deux clients peuvent simultanément passer le tunnel et “croire” qu’un article est disponible, jusqu’au moment où l’un des deux écrit en base avant l’autre.

Dans la vraie vie, le problème n’est pas uniquement “deux clients”. C’est aussi : concurrence front (AJAX panier), jobs asynchrones (ERP/PIM), multi-canal (marketplaces), et modules de paiement qui confirment plus tard. À charge élevée (soldes, drops, ventes flash), le point de contention se déplace de la couche HTTP vers MySQL : une rafale de UPDATE ps_stock_available SET quantity = quantity - X et de lectures du stock affiché peut déclencher du lock/wait, augmenter le TTFB et faire exploser les timeouts applicatifs. Pour la partie pure performance, vous avez déjà des leviers serveurs (OPcache, caches HTTP, etc.) mais ils ne résolvent pas la cohérence des stocks : voir notamment votre base de référence sur les couches cache côté serveur dans Cache PrestaShop : Varnish, Redis, Memcached et OPcache côté serveur.

Le second angle est plus vicieux : même si vous “bloquez” l’écriture en base dans une transaction SQL, vous ne pouvez pas verrouiller durablement un stock pour un panier pendant 5–15 minutes sans impacter fortement la concurrence (verrous longs, risques de deadlocks, scalabilité médiocre). La bonne approche, en e‑commerce, est une réservation courte avec TTL (time-to-live) et une confirmation atomique au paiement/validation. Autrement dit : un mécanisme de concurrence côté applicatif, rapide, non bloquant, et observable.

Pour cadrer le risque, gardez en tête ce micro-scénario très courant (et particulièrement visible sur des boutiques à audience France/UE pendant les périodes de pics type soldes/Black Friday) :

  • SKU “limité” (ex. 30 unités) affiché comme “en stock”.
  • 60 sessions arrivent sur la fiche produit en quelques secondes (campagne e-mail, push, ou trafic social).
  • 15 paniers ajoutent 2 unités quasi simultanément via Cart::updateQty() (souvent via appels AJAX).
  • 6 paiements redirigent vers un PSP et reviennent en asynchrone (3DS, latence, double callback).
  • 4 paniers sont abandonnés (et restent “ouverts” en base), 2 sont repris plus tard.

Sans réservation, vous avez deux issues désagréables : survente (mauvaise expérience + service client + annulation) ou blocage de stock (perte de conversion), et souvent les deux en alternance selon les modules et les délais de confirmation.

Enfin, note “pratique” PrestaShop : l’affichage “stock” peut être agrégé (produit vs déclinaison), dépendre de la config “commande possible hors-stock” (PS_ORDER_OUT_OF_STOCK) et être recalculé sur des pages très consultées (catégorie, recherche, fiche produit). Toute incohérence entre ce que l’utilisateur voit, ce que le panier accepte, et ce que la commande valide finit par se transformer en tickets “bug stock” difficiles à diagnostiquer.

Redis pour la cohérence de stock : opérations atomiques, TTL et scripts Lua

Redis est utile ici non pas “comme cache”, mais comme couche de coordination à faible latence. Le point clé : Redis exécute les commandes de manière séquentielle (modèle single-thread/event loop), ce qui rend les opérations unitaires atomiques, et les scripts Lua (EVAL) permettent d’enchaîner plusieurs opérations comme un seul bloc atomique. En pratique, c’est exactement ce qu’on cherche pour un “check-and-set” (vérifier puis réserver) sans round-trips et sans condition de course. Référence : documentation officielle Redis sur le scripting (EVAL).

Le pattern qui tient la route en production est :

  • Stock “durable” : MySQL (source de vérité comptable, intégrations ERP).
  • Stock “réservable” : Redis (compteur, overlay de réservation, TTL).
  • Réconciliation : événements (streams/queue) ou jobs batch pour consolider et détecter les dérives.

Concrètement, vous maintenez un compteur available et un compteur reserved, ou un seul compteur “disponible” géré en DECRBY/INCRBY au moment des réservations. Les deux modèles existent :

  • Modèle 1 (simple) : un compteur available que l’on décrémente à la réservation et que l’on ré-incrémente à l’expiration/annulation. Avantage : lecture immédiate du “réservable”. Inconvénient : il faut être carré sur les chemins de restitution (update panier à la baisse, suppression panier, expiration).
  • Modèle 2 (overlay) : available reflète le stock MySQL, et Redis stocke des réservations par panier ; le “réservable” = available - sum(reservations) (calculé via script / structures). Avantage : réconciliation plus intuitive. Inconvénient : calcul potentiellement plus coûteux si mal modélisé.

La structure de clé doit être strictement déterministe (multi-boutique, déclinaisons, éventuellement entrepôts). Exemple de nommage :

  • stock:{shopId}:{productId}:{attributeId}:available
  • stock:{shopId}:{productId}:{attributeId}:reserved
  • reserve:{cartId}:{shopId} (set/hash des items réservés par panier, TTL)

Deux précautions “production” souvent oubliées :

1) Redis Cluster : si vous êtes en cluster, un script Lua atomique ne peut opérer que sur des clés dans le même hash slot. Il faut alors utiliser des hash tags Redis pour forcer des clés dans le même slot (par exemple stock:{shop:1}:product:123:attr:0:available et reserve:{shop:1}:cart:456 partagent la portion {shop:1} dans les accolades, ce qui les place dans le même hash slot). Si vous êtes sur un Redis standalone/Sentinel, le problème est moindre, mais le design de clé reste utile.
2) Politique mémoire : sur un mécanisme de stock, une éviction “silencieuse” est toxique. Évitez de mélanger “cache best-effort” et “coordination stock” dans la même instance Redis avec une politique d’éviction agressive. Pour un stock critique : dimensionnement + alerting + politique évitant les évictions inattendues (et/ou instance dédiée).

Le TTL est un détail qui devient vite l’élément central : sans TTL, vous créez des “fuites” de stock (abandon de panier = stock bloqué). Avec TTL, vous acceptez une cohérence éventuellement relâchée (un panier expiré libère la stock) mais contrôlée. En pratique, un TTL de 10–20 minutes fonctionne pour la plupart des tunnels ; il doit être ajusté en fonction des modules de paiement (3DS, redirections, latence PSP). Sur un site qui a un TTFB bien tenu (voir TTFB PrestaShop : réduire le Time To First Byte sous 200 ms), vous pouvez réduire ce TTL sans pénaliser l’expérience.

Checklist courte pour choisir un TTL “raisonnable” (à adapter à votre réalité) :

Contexte checkout Symptôme typique TTL indicatif
Paiement on-site (CB intégrée) latence faible, peu de retours async 8–12 min
Redirection PSP + 3DS retours différés / double callback 12–20 min
Paiement “virement / différé” confirmation tardive réserver uniquement à la création de commande (pas au panier), ou TTL très court + re-check strict

Enfin, petite touche “RGPD pragmatique” : évitez de stocker des identifiants personnels inutiles dans Redis. Un cartId technique + shopId + identifiant produit/déclinaison suffit généralement ; le TTL vous aide aussi à minimiser la conservation.

Réservation de stock “overlay” : un design compatible PrestaShop 8.2 / 9.1

Pré-requis raisonnables (2026) : PrestaShop 8.2+ ou 9.1+, PHP 8.2/8.3, Redis 7.x, extension phpredis (ou Predis, mais pires perfs à charge). Côté serveur, évitez un Redis exposé : ACL, bind local/VPC, TLS si traversée réseau. Le point sécurité n’est pas optionnel : Redis = accès direct à des primitives de concurrence, donc surface d’attaque. Pour une approche plus globale sécurité, votre base est Sécurité PrestaShop : protéger API backoffice, WAF et journalisation SIEM.

Le flux minimaliste (qui marche) :

1) Sync initiale : à intervalle court (cron) ou sur évènement (MAJ ERP), poussez available dans Redis depuis MySQL.
2) Add/update cart : avant d’accepter une quantité, faites une réservation Redis atomique ; si échec, renvoyez un message “stock insuffisant” côté front.
3) Checkout : au moment de créer la commande, “consommez” la réservation (débitez MySQL) et supprimez l’état Redis associé au panier.
4) Expire TTL : si le panier meurt, le TTL supprime la réservation, et un job de cleanup (optionnel) corrige les compteurs si vous maintenez reserved séparément.

Dans PrestaShop, les points d’accroche dépendent de votre stratégie. Pour une implémentation module sans chirurgie lourde : vous interceptez les modifications de quantité panier côté Cart::updateQty() (via override, ou via module/hook selon votre architecture), et vous sécurisez l’étape finale dans actionValidateOrder (hook) pour refuser une commande si la réservation n’est plus valide. Oui, c’est un contournement : le cœur ne fournit pas une API “ReservationManager” standard. Si vous travaillez sur des orchestrations plus larges (ERP, MCP, jobs), vous pouvez aussi externaliser la logique via un service dédié ; pour l’outillage et l’orchestration, voir Automatisation PrestaShop : orchestrer commandes, stocks et prix via MCP.

Ce qui fait souvent échouer une première implémentation n’est pas la “réservation initiale”, mais la gestion correcte des diffs quand le panier évolue :

  • Si l’utilisateur passe de 1 → 3 : il faut réserver +2 (et refuser si indisponible).
  • S’il passe de 3 → 1 : il faut libérer 2 immédiatement (sinon vous “perdez” du stock réservable).
  • S’il supprime la ligne : libérer tout.
  • Si le panier est fusionné (connexion compte, multi-device) : décider si vous migrez les réservations (plus complexe) ou si vous expirez/revalidez (souvent plus robuste).

Dans une boutique très “temps réel” (stocks faibles, drops), une stratégie robuste consiste à ne jamais faire confiance au stock affiché côté front : on affiche le stock informatif, mais la vérité opérationnelle est “réservation OK / KO” au clic. Ça évite les listes produits “optimistes” qui se transforment en erreurs au checkout.

Exemple de script Lua (une seule opération atomique) : “réserver N unités si disponible ≥ N, et enregistrer la réservation par panier”.

-- KEYS[1] = stock available key
-- KEYS[2] = cart reservation hash key
-- ARGV[1] = productKey (ex: "123:0")
-- ARGV[2] = qty
-- ARGV[3] = ttlSeconds

local available = tonumber(redis.call('GET', KEYS[1]) or '0')
local qty = tonumber(ARGV[2])

if available < qty then
  return {err="OUT_OF_STOCK"}
end

redis.call('DECRBY', KEYS[1], qty)
redis.call('HINCRBY', KEYS[2], ARGV[1], qty)
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))

return {ok="RESERVED"}

Côté PHP (exemple phpredis), vous exécutez le script et vous gérez le TTL sur la clé panier. Sur validation de commande, vous relisez reserve:{cartId}:{shopId} et vous effectuez l’écriture MySQL dans une transaction (commandes + lignes + décrément stock), puis vous supprimez la réservation et, si nécessaire, vous ajustez le compteur Redis (idempotence obligatoire en cas de retry PSP).

$redis = new Redis();
$redis->connect('127.0.0.1', 6379, 0.5);

$stockKey = sprintf('stock:%d:%d:%d:available', $shopId, $productId, $attrId);
$cartKey  = sprintf('reserve:%d:%d', $cartId, $shopId);
$productKey = sprintf('%d:%d', $productId, $attrId);

$lua = file_get_contents(__DIR__.'/reserve.lua');
try {
    $res = $redis->eval($lua, [$stockKey, $cartKey, $productKey, $qty, 900], 2);
} catch (RedisException $e) {
    // Fail-safe : si Redis est KO, ne “survend” pas.
    // Choix dur : refuser l'ajout panier, ou basculer sur une lecture MySQL + marge de sécurité.
    throw $e;
}

Deux points opérationnels à ajouter ici (souvent décisifs) :

  • Consommation finale : au moment actionValidateOrder, vous devez encore vérifier que la réservation correspond bien aux lignes finales (packs, quantités modifiées juste avant paiement, changement de déclinaison). Si vous détectez une divergence, mieux vaut annuler proprement (message + réouverture panier) que “forcer” un débit partiel.
  • Idempotence bout en bout : pas seulement sur la décrémentation MySQL. Pensez aussi aux callbacks paiement : un même cartId peut générer plusieurs tentatives, et un même orderId peut être notifié deux fois. Une clé de déduplication Redis (ex. order_consumed:{shopId}:{orderId} via SET ... NX) ou une contrainte SQL côté table de log technique évite les doubles consommations.

Le point qui évite 80% des bugs : idempotence. Une validation de commande peut être rappelée (timeouts, retours PSP), et votre code doit pouvoir dire “commande déjà consommée” sans re-décrémenter. Typiquement : stocker un order_consumed:{orderId} avec SET NX + TTL long, ou utiliser une clé de déduplication sur cartId + paymentIntentId. Les architectures multi-canal (ERP, marketplaces) ont exactement les mêmes problèmes ; si vous êtes dans ce cas, la lecture utile est Inventaire unifié : synchronisation temps réel et routage par canal.

Verrous distribués : utiles dans 10% des cas, dangereux dans 90%

Vous serez tenté d’implémenter un verrou Redis “global” par produit (SET lock:product:{id} NX PX 2000) autour d’une lecture MySQL + update MySQL. Techniquement, ça peut marcher si vous avez un seul Redis, une latence faible et des timeouts stricts. Mais vous ne devez pas confondre “un lock Redis” et une garantie forte en présence de partitions réseau, de réplication asynchrone ou de failover. Un lock est un mécanisme de coordination ; ce n’est pas une transaction distribuée.

Si vous avez besoin d’un lock, limitez-le à :

  • des sections critiques très courtes (< 50–100 ms),
  • un unique Redis primaire (pas un cluster multi-primary),
  • un fencing token (monotonic counter) quand l’effet doit être protégé contre un “lock expiré mais détenteur encore actif”.

Sur ce dernier point : un fencing token est souvent un simple INCR lock_token:product:{id} à chaque prise de lock ; vous propagez ce token jusqu’à l’écriture “réelle”, et vous refusez une opération si son token est inférieur au dernier token connu côté ressource protégée. C’est plus verbeux, mais c’est précisément ce qui vous protège des scénarios “lock expiré + traitement encore en cours”.

En pratique, pour le stock, les compteurs atomiques + Lua sont presque toujours un meilleur choix qu’un verrou. Vous évitez les lock convoys (tout le monde attend), vous ne dépendez pas d’horloges parfaitement alignées, et vous conservez des opérations O(1).

Sur Redlock, le sujet est documenté et controversé. La critique la plus citée est celle de Martin Kleppmann (“How to do distributed locking”, 2016), qui détaille pourquoi la correction d’un algorithme de verrou distribué est difficile en systèmes répliqués soumis aux partitions. Le point actionnable : si votre design nécessite un lock distribué “fort” pour ne pas survendre, c’est souvent le symptôme que vous essayez de faire porter à Redis un rôle de consensus qu’il n’a pas vocation à tenir. Pour un e‑commerce, préférez un mécanisme de réservation explicite, expirante, et une validation finale transactionnelle côté base.

Dernier piège : le lock “par produit” ne résout pas les combinaisons (déclinaisons), les packs, ni les contraintes multi-entrepôts. Si vous avez des requêtes SQL déjà lourdes sur ps_product_attribute, ps_stock_available et des jointures de listing, commencez par nettoyer le plan d’exécution et les index (sinon vous déplacerez juste le problème). Référence directe : Index MySQL : optimiser WHERE, JOIN et ORDER BY avec EXPLAIN.

Observabilité, tests de charge et runbook : la partie qui fait la différence en prod

Mettre Redis au milieu du stock sans observabilité est une erreur classique : vous créez un système à deux sources (MySQL + Redis) et vous perdez la capacité à expliquer un écart. Il faut instrumenter : latence Redis (p95/p99), taux de hit/miss, nombre de clés reserve:*, taux d’échec OUT_OF_STOCK, et surtout métriques de dérive (diff entre MySQL et Redis, par produit). Redis expose ces infos via INFO, LATENCY DOCTOR, SLOWLOG. Mettez des seuils : un p99 > 5–10 ms en LAN est déjà suspect pour un mécanisme de réservation.

Ajoutez aussi des métriques “métier” (sinon on ne sait pas si la perf sert le stock) :

  • Taux de refus à l’ajout panier sur SKU sous tension (si ça grimpe, soit vous êtes vraiment en rupture, soit votre sync Redis/MySQL est en retard).
  • Taux de paniers expirés avec réservation (proxy d’abandon sur stock réservé).
  • Temps moyen entre réservation et consommation (utile pour calibrer TTL et repérer les PSP “lents”).

Côté test, ne vous contentez pas d’un test “charge HTTP”. Vous devez simuler un comportement réaliste : beaucoup de updateQty concurrentiels sur le même SKU, puis des validations de commande, puis des abandons (TTL). Un test k6/JMeter doit vérifier :

  • taux de survente = 0,
  • cohérence finale MySQL = cohérence attendue,
  • absence d’embalement des expirations (CPU Redis),
  • absence de contention MySQL lors de la phase de consommation.

Une pratique simple qui évite les “fausses victoires” : pendant vos tests, loggez systématiquement (en debug contrôlé) un identifiant de corrélation cartId + sku + qty + résultat de réservation. En cas d’écart, vous pouvez reconstruire la timeline sans supposer.

Pour cadrer la méthode (monitoring + tests + runbooks période de soldes), vous avez une base solide dans PrestaShop performance : monitoring, tests de charge et runbooks soldes.

Enfin, prévoyez un plan de rollback explicite. Redis n’est pas une base “magique” : vous pouvez perdre des clés (éviction mémoire), vous pouvez redémarrer un nœud, vous pouvez avoir une réplication en retard. Décidez à l’avance du mode dégradé :

  • Mode strict : Redis indisponible ⇒ on refuse les ajouts panier sur produits à stock limité (zéro survente, mais pertes de conversion).
  • Mode tolérant : Redis indisponible ⇒ fallback MySQL avec marge de sécurité (ex. considérer quantity - safety_buffer), au prix d’un risque de survente contrôlé.
  • Mode asynchrone : on continue, mais on logge tout et on réconcilie (rarement acceptable sur stock critique).

Pour que ce rollback soit actionnable “à 9h un lundi de soldes”, votre runbook doit préciser au minimum :

  • comment détecter Redis “dégradé” (latence, erreurs connexion, mémoire),
  • comment basculer de mode (feature flag / configuration module),
  • quel message front afficher (éviter le 500 générique),
  • comment réconcilier après incident (job qui recalcule available depuis MySQL + purge réservations).

Si votre système de stock est couplé à un ERP (Odoo, Dolibarr, etc.), la décision doit être alignée avec la stratégie d’inventaire et de synchronisation. Une lecture utile pour cadrer la bi‑directionnalité est Intégration e-commerce Odoo : architecture bi-directionnelle et synchronisation stocks.

En résumé opérationnel : Redis est un excellent outil pour prévenir la concurrence sur les stocks en e‑commerce, mais uniquement si vous l’utilisez comme un moteur d’atomicité (compteurs + Lua + TTL), avec idempotence, instrumentation, et une consommation finale transactionnelle côté MySQL. Tout le reste (verrous long terme, “on verra en prod”, absence de métriques) finit généralement par des tickets “survente”, “stock bloqué” ou “site lent” au pire moment.


À lire aussi