Dans les architectures de vente flash (seckill) à forte affluence, la structure classique repose sur une couche Redis pour absorber le trafic massif et une base de données MySQL pour la persistance asynchrone. Bien que performant, ce modèle expose le système à des incohérences de données (données "sales"), telles que la survente ou des écarts de stock entre le cache et le disque.
L'objectif n'est pas d'atteindre une cohérence forte immédiate, ce qui paralyserait les performances, mais de garantir une cohérence à terme (eventual consistency) tout en protégeant l'intégrité métier.
1. Garantir l'atomicité au niveau de la persitsance (MySQL)
Pour éviter qu'un stock soit déduit sans qu'une commande ne soit créée, ou inversement, il est impératif d'encapsuler ces deux opérations dans une transaction SQL unique. En utilisant les propriétés ACID, on s'assure que l'état de la base de données reste intègre même en cas de crash du processus de consommation.
// Exemple de traitement d'une commande via un worker de file d'attente
public function processOrder(array $payload)
{
Db::startTrans();
try {
$skuId = $payload['item_id'];
$userId = $payload['user_id'];
$ref = $payload['order_ref'];
// Mise à jour du stock avec une condition de sécurité
$affected = Db::name('inventory_items')
->where('id', $skuId)
->where('quantity', '>', 0)
->update(['quantity' => Db::raw('quantity - 1')]);
if ($affected === 0) {
throw new \Exception("Stock épuisé en base de données pour l'article : {$skuId}");
}
// Insertion de la commande
Db::name('orders')->insert([
'reference' => $ref,
'user_id' => $userId,
'item_id' => $skuId,
'status' => 'pending_payment',
'created_at' => date('Y-m-d H:i:s')
]);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
// Log d'erreur et logique de réexécution
throw $e;
}
}
2. Synchronisation par compensation périodique
Les écarts entre Redis et MySQL sont inévitables lors de pics de trafic (latence réseau, échecs de file d'attente). Un script de réalignement doit s'exécuter périodiquement pour comparer les états et corriger Redis en se basant sur MySQL, qui reste la source de vérité.
// Script de synchronisation Redis vs MySQL
public function syncInventory(int $promotionId)
{
$items = Db::name('promotion_skus')
->where('promo_id', $promotionId)
->column('quantity', 'sku_id');
foreach ($items as $id => $actualStock) {
$cacheKey = "stock:sku:{$id}";
$cachedStock = Cache::store('redis')->get($cacheKey);
if ($cachedStock !== $actualStock) {
Cache::store('redis')->set($cacheKey, $actualStock);
// Journalisation de la correction
Console::log("Réalignement SKU {$id} : {$cachedStock} -> {$actualStock}");
}
}
}
3. Résilience via les mécanismes de retry des files d'attente
Si MySQL est temporairement indisponible, les messages de création de commande ne doivent pas être perdus. L'utilisation d'une file d'attente avec un mécanisme de "Backoff" (tentatives espacées) permet d'assurer que la donnée finira par être écrite dès que le service sera rétabli.
// Configuration de la file d'attente (exemple générique)
return [
'default' => 'redis',
'failed' => [
'type' => 'database',
'table' => 'failed_jobs',
],
'retry_strategy' => [
'max_attempts' => 5,
'delay' => 10, // secondes entre chaque tentative
]
];
4. Optimisation du niveau d'isolation des transactions
Le niveau d'isolation par défaut peut parfois permettre des "lectures sales" (dirty reads). Pour les systèmes de reporting ou les back-offices consultant les stocks durant une vente flash, il est recommandé de configurer le niveau READ COMMITTED. Cela garantit qu'une transaction ne voit que les données confirmées par d'autres transactions.
-- Configuration MySQL
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
5. Double vérificattion et jetons d'idempotence
Pour contrer les requêtes dupliquées et la survente, une stratégie à deux niveaux est nécessaire :
- Niveau Redis : Utilisation d'un drapeau d'unicité (ex:
user_id:item_id) et de l'opération atomiqueDECR. - Niveau MySQL : Verrouillage de ligne (
FOR UPDATE) lors de la vérification finale avant l'écriture.
public function handleRequest(int $userId, int $skuId)
{
$lockKey = "user_lock:{$userId}:{$skuId}";
// 1. Vérification d'unicité (Idempotence)
if (Cache::store('redis')->has($lockKey)) {
return ['status' => 'error', 'msg' => 'Participation déjà enregistrée'];
}
// 2. Décrémentation atomique dans le cache
$remaining = Cache::store('redis')->decr("stock_cache:{$skuId}");
if ($remaining < 0) {
Cache::store('redis')->incr("stock_cache:{$skuId}"); // Rollback cache
return ['status' => 'error', 'msg' => 'Rupture de stock'];
}
// 3. Pose du verrou utilisateur
Cache::store('redis')->set($lockKey, 1, 3600);
// 4. Envoi vers la file d'attente asynchrone
Queue::push(OrderProcessor::class, ['user_id' => $userId, 'sku_id' => $skuId]);
return ['status' => 'success'];
}
Synthèse des stratégies
| Méthode | Rôle principal | Impact Performance |
|---|---|---|
| Atomicité SQL | Empêche les données orphelines en base | Moyen |
| Compensation | Corrige les dérives Cache/DB | Très faible |
| Retry MQ | Garantit l'écriture finale | Faible |
| Idempotence | Évite les doublons et la survente | Moyen |
En combinant ces couches de protection, on construit un système capable d'absorber des millions de requêtes tout en maintenant une base de données saine et cohérente, même face à des pannes partielles de l'infrastructure.