Stratégies de gestion des données incohérentes pour les systèmes de vente flash à haute charge

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 atomique DECR.
  • 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.

Étiquettes: Redis MySQL Cohérence des données Architecture Logicielle haute disponibilité

Publié le 21 juin à 02h36