Guide technique

Concurrence et intégrité des données : les patterns qui ont sauvé notre production

Patterns de concurrence en production pour systèmes d'entreprise. Field ownership, verrouillage optimiste, baux coopératifs, stores d'idempotence, gestion des versions, et couches de gouvernance transactionnelle.

24 janvier 202616 min de lectureÉquipe d'Ingénierie Oronts

La condition de course que tu ne vois pas avant la production

Les conditions de course sont invisibles en développement. Ta machine locale exécute un seul processus. Ta suite de tests s'exécute séquentiellement. Tout fonctionne. Puis tu déploies en production avec 4 pods web et 3 pods worker, et deux processus modifient le même enregistrement en même temps. L'un écrase les changements de l'autre. Les données sont silencieusement corrompues. Personne ne s'en aperçoit jusqu'à ce qu'un client se plaigne.

Nous avons corrigé des conditions de course sur plusieurs systèmes d'entreprise : des plateformes CMS avec 20 éditeurs et des workers en arrière-plan, des plateformes de commerce avec traitement concurrent des commandes, et des systèmes IA avec des workflows d'agents parallèles. Les patterns dans cet article sont ceux qui ont survécu.

Pour un contexte plus large, consulte notre guide d'architecture système et notre guide d'architecture event-driven. Pour la concurrence spécifique aux CMS, notre guide de workflows Pimcore couvre ces patterns en détail.

Field Ownership : qui peut écrire quoi

La cause fondamentale de la plupart des bugs de concurrence en entreprise : plusieurs écrivains modifient le même enregistrement via le même chemin de sauvegarde sans coordination.

Un CMS a des éditeurs qui rédigent des descriptions de produits et des workers qui génèrent des miniatures. Les deux appellent save(). Les deux persistent l'objet entier. Si le worker sauvegarde après avoir chargé mais avant que l'éditeur ne sauvegarde, la sauvegarde de l'éditeur écrase la miniature du worker. Si l'éditeur sauvegarde en premier, la sauvegarde du worker écrase la description de l'éditeur.

La solution : assigner chaque champ à un propriétaire.

field_ownership:
    Product:
        editor_owned:
            - name
            - description
            - images
        system_owned:
            - thumbnail
            - searchIndex
            - checksum
            - lastSyncTimestamp
        shared:
            - categories
            - price
            - availability
DomainePropriétaireChemin de mutationStratégie de conflit
Editor-ownedUtilisateurs adminSauvegarde standardPas de conflit (seuls les éditeurs écrivent)
System-ownedWorkers/intégrationsCouche transactionnelle avec verrousRéessai en cas de conflit
SharedLes deuxCouche transactionnelle avec résolution de conflitsConfigurable : réessai, skip, fusion

Les champs editor-owned passent par le chemin de sauvegarde standard. Les champs system-owned passent par une couche transactionnelle avec verrous et vérifications de version. Les champs partagés utilisent des stratégies de résolution de conflits explicites.

Sans field ownership, tu comptes sur la chance. Avec, le système impose qui peut écrire quoi et résout les conflits de manière déterministe.

Verrouillage optimiste avec vérification de version

Le verrouillage optimiste suppose que les conflits sont rares. Au lieu de verrouiller avant la modification, il vérifie si l'enregistrement a changé entre le chargement et la sauvegarde.

async function updateWithOptimisticLock(
    productId: string,
    updateFn: (product: Product) => void,
    maxRetries: number = 3,
): Promise<Product> {
    for (let attempt = 0; attempt < maxRetries; attempt++) {
        const product = await productRepo.findById(productId, { force: true });
        const versionBefore = product.versionCount;

        updateFn(product);

        // Vérifie que la version n'a pas changé avant de sauvegarder
        const currentVersion = await productRepo.getVersionCount(productId);
        if (currentVersion !== versionBefore) {
            if (attempt === maxRetries - 1) {
                throw new ConcurrencyError(
                    `Product ${productId} was modified concurrently (version ${versionBefore} -> ${currentVersion})`
                );
            }
            continue; // Réessaie avec des données fraîches
        }

        await product.save();
        return product;
    }
}

La vérification de version n'est pas atomique dans cet exemple. Pour une vraie atomicité, utilise le support au niveau de la base de données :

-- PostgreSQL : vérification atomique de la version + mise à jour
UPDATE products
SET name = $1, version_count = version_count + 1
WHERE id = $2 AND version_count = $3;

-- Si 0 lignes affectées : modification concurrente détectée
// TypeORM : @VersionColumn pour le verrouillage optimiste automatique
@Entity()
class Product {
    @VersionColumn()
    version!: number;
    // TypeORM vérifie automatiquement la version à la sauvegarde
    // Lance OptimisticLockVersionMismatchError en cas de conflit
}

Le verrouillage optimiste fonctionne bien quand les conflits sont rares (< 5% des écritures). Pour les scénarios à haute contention (plusieurs workers traitant le même enregistrement), utilise plutôt les verrous coopératifs.

Baux coopératifs basés sur des leases

Quand plusieurs workers se disputent la même ressource, un verrou coopératif sérialise l'accès. Contrairement aux mutex distribués, les verrous coopératifs utilisent une sémantique de bail : le verrou expire après un TTL, empêchant les deadlocks causés par des workers crashés.

// Redis : SET NX EX atomique avec propriété basée sur un token
class RedisLockProvider {
    async acquire(key: string, ttlSeconds: number = 30): Promise<Lock | null> {
        const token = crypto.randomBytes(16).toString('hex');
        const acquired = await this.redis.set(key, token, 'NX', 'EX', ttlSeconds);
        return acquired ? new Lock(key, token, ttlSeconds) : null;
    }

    async release(lock: Lock): Promise<void> {
        // Vérification et suppression atomiques via script Lua
        const script = `
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
        `;
        await this.redis.eval(script, 1, lock.key, lock.token);
    }

    async extend(lock: Lock, ttlSeconds: number): Promise<boolean> {
        const script = `
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('expire', KEYS[1], ARGV[2])
            else
                return 0
            end
        `;
        return !!(await this.redis.eval(script, 1, lock.key, lock.token, ttlSeconds));
    }
}

Heartbeat du verrou

Le TTL du verrou ne suffit pas. Si une opération dure plus longtemps que prévu, le verrou expire et un autre worker l'acquiert. Deux workers tournent alors en parallèle.

async function executeWithLock(key: string, operation: () => Promise<void>) {
    const lock = await lockProvider.acquire(key, 30);
    if (!lock) throw new LockError(`Could not acquire lock: ${key}`);

    // Heartbeat : prolonge le verrou si l'opération prend trop de temps
    const heartbeat = setInterval(async () => {
        const extended = await lockProvider.extend(lock, 30);
        if (!extended) {
            clearInterval(heartbeat);
            // Le verrou a été volé, on abandonne l'opération
            throw new LockError(`Lock stolen during operation: ${key}`);
        }
    }, 15000); // Prolonge toutes les 15s (50% du TTL)

    try {
        await operation();
    } finally {
        clearInterval(heartbeat);
        await lockProvider.release(lock);
    }
}

Hiérarchie de portée des verrous

Différentes opérations nécessitent différentes granularités de verrouillage :

PortéePattern de cléCas d'usage
Élémentlock:product:123Sauvegarde complète de l'objet
Groupe de champslock:product:123:generatedAssetsMise à jour partielle (miniatures uniquement)
Opérationlock:product:123:thumbnail:enOpération spécifique unique
Même produit + même groupe de champs  -> attente/réessai (sériel)
Même produit + groupes différents     -> parallèle (sûr)
Produits différents                   -> toujours parallèle

Des portées plus étroites permettent plus de parallélisme. Un worker de miniatures et un indexeur de recherche peuvent traiter le même produit simultanément s'ils verrouillent des groupes de champs différents.

Stores d'idempotence

Les retransmissions réseau, les redélivrances de messages et les replays de workflows font exécuter la même opération plusieurs fois. Sans idempotence, tu obtiens des enregistrements en double, des double facturations, ou des emails répétés.

interface IdempotencyEntry {
    key: string;           // Clé à sens métier
    scope: string;         // Catégorie d'opération
    status: string;        // PENDING | COMPLETED | FAILED
    requestHash: string;   // SHA-256 de l'input normalisé
    resultId?: string;     // ID de la ressource créée
    expiresAt: Date;       // TTL pour le nettoyage
    createdAt: Date;
}

class IdempotencyStore {
    async checkAndAcquire(key: string, scope: string, requestHash: string): Promise<IdempotencyResult> {
        try {
            await this.db.insert('idempotency_keys', {
                key, scope, requestHash,
                status: 'PENDING',
                expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),
            });
            return { acquired: true };
        } catch (error) {
            if (isDuplicateKeyError(error)) {
                const existing = await this.db.findOne({ key, scope });
                if (existing.status === 'COMPLETED') {
                    return { acquired: false, cached: true, resultId: existing.resultId };
                }
                if (existing.status === 'PENDING' && isStale(existing)) {
                    // PENDING périmé : la tentative précédente a planté, on autorise le réessai
                    await this.db.update({ key, scope }, { status: 'PENDING', createdAt: new Date() });
                    return { acquired: true };
                }
                return { acquired: false, inProgress: true };
            }
            throw error;
        }
    }

    async complete(key: string, scope: string, resultId: string): Promise<void> {
        await this.db.update({ key, scope }, { status: 'COMPLETED', resultId });
    }
}

Conception des clés

La clé d'idempotence doit avoir un sens métier :

OpérationComposants de la cléExemple
Ajout à la wishlistcustomerId + productVariantId + wishlistIdwish:cust_123:var_456:wl_789
Soumettre un aviscustomerId + productIdreview:cust_123:prod_456
Envoyer une notificationrecipient + category + entityRef + dayBucketnotify:sara@beispiel.de:stock:var_456:2026-04-20
Utiliser des points fidélitécustomerId + orderId + pointsredeem:cust_123:ord_789:500
Import ERPsourceRecordId + importBatchIdimport:erp_456:batch_20260420

Deux modèles d'idempotence distincts :

  • Idempotence API : pour les mutations initiées par l'utilisateur. Le client fournit la clé ou elle est générée à partir du hash de l'input. La réponse en cache est rejouée en cas de doublon.
  • Idempotence de jobs : pour le traitement en arrière-plan. Clé de déduplication dans le payload du job. Utilise des contraintes DB, des marqueurs de complétion, ou des vérifications par clé métier.

Ne les mélange jamais. Ils ont des cycles de vie et des stratégies de nettoyage différents.

Le problème de l'explosion des versions

Dans les systèmes où les workers en arrière-plan appellent save(), chaque sauvegarde crée une version. Avec 6 workers traitant chaque modification de produit, une seule sauvegarde éditeur génère 6+ versions inutiles. En quelques mois, les produits accumulent des milliers de versions qui consomment du stockage, ralentissent l'interface de l'historique des versions, et rendent les vrais changements éditoriaux impossibles à retrouver.

La solution : des gardes de version scopés qui suppriment la création de versions pendant les opérations système tout en préservant les versions pour les sauvegardes éditeur.

// Garde de version à compteur de références
class ScopedVersionGuard {
    private static refCount = 0;

    suppress(): void {
        ScopedVersionGuard.refCount++;
        if (ScopedVersionGuard.refCount === 1) {
            VersionManager.disable();
        }
    }

    restore(): void {
        ScopedVersionGuard.refCount--;
        if (ScopedVersionGuard.refCount === 0) {
            VersionManager.enable();
        }
    }
}

// Utilisation : les opérations imbriquées fonctionnent correctement
const outerGuard = new ScopedVersionGuard();
outerGuard.suppress();
try {
    product.setThumbnail(asset);
    product.save(); // Pas de version créée

    const innerGuard = new ScopedVersionGuard();
    innerGuard.suppress();
    try {
        product.setChecksum(hash);
        product.save(); // Toujours pas de version
    } finally {
        innerGuard.restore(); // refCount passe de 2 à 1, toujours supprimé
    }
} finally {
    outerGuard.restore(); // refCount passe de 1 à 0, versions réactivées
}

Le résultat : les sauvegardes éditeur créent des versions (piste d'audit préservée). Les sauvegardes des workers créent des entrées de log opérationnel (observabilité sans explosion de versions).

Pour voir comment nous implémentons cela spécifiquement dans Pimcore, consulte notre guide de workflows Pimcore qui détaille le garde de version de PimTx.

Pièges courants

  1. Pas de field ownership. Sans cela, chaque écrivain entre en compétition pour chaque champ via le même chemin de sauvegarde. Définis qui possède quoi avant d'écrire la première ligne de code concurrent.

  2. Verrouillage optimiste sans réessai. Détecter le conflit ne suffit pas. L'opération doit réessayer avec des données fraîches. Définis un nombre maximum de tentatives et gère l'épuisement proprement.

  3. Désactivation globale du verrou. Un flag global disable() casse quand plusieurs opérations tournent en parallèle. Utilise des gardes scopés à compteur de références.

  4. Clés d'idempotence sans sens métier. Un UUID aléatoire comme clé d'idempotence n'empêche rien. La clé doit encoder l'opération métier : qui, quoi, quand.

  5. Pas de heartbeat sur les verrous longue durée. Si l'opération dépasse le TTL, le verrou expire et un autre worker entre. Prolonge le verrou à 50% du TTL.

  6. Ignorer les entrées PENDING périmées. Si un worker plante alors qu'il détient une clé d'idempotence en PENDING, l'opération est bloquée définitivement. Détecte et récupère les entrées périmées.

  7. Verrouillage à la mauvaise granularité. Les verrous au niveau de l'élément sérialisent tout. Les verrous au niveau du groupe de champs permettent le parallélisme là où c'est sûr. Choisis la portée la plus étroite qui reste sûre.

  8. Pas de stratégie de gestion des versions. Chaque save() crée une version par défaut. Dans les systèmes avec des workers, cela crée des milliers de versions inutiles. Supprime les versions pour les opérations système.

Points clés à retenir

  • Le field ownership prévient la condition de course la plus courante. Définis quels champs appartiennent aux éditeurs, lesquels aux workers, et lesquels sont partagés. Le registre de propriété détermine la stratégie de verrouillage et la résolution des conflits.

  • Le verrouillage optimiste pour les écritures à faible contention. Vérifie le compteur de version avant de sauvegarder. Réessaie en cas de conflit. Utilise le support au niveau de la base de données (TypeORM @VersionColumn, mise à jour conditionnelle PostgreSQL) pour l'atomicité.

  • Les baux coopératifs pour les ressources à haute contention. Redis SET NX EX avec propriété par token. Heartbeat à 50% du TTL. Scripts Lua pour les opérations atomiques. N'utilise jamais de mutex distribués sans TTL.

  • Les clés d'idempotence doivent avoir un sens métier. Encode la sémantique de l'opération (qui + quoi + quand) dans la clé. Sépare l'idempotence API de l'idempotence de jobs.

  • Les gardes de version préservent la piste d'audit sans explosion. Suppression à compteur de références pour les opérations système. L'imbrication fonctionne correctement. Les sauvegardes éditeur créent toujours des versions.

Nous appliquons ces patterns dans nos projets de développement logiciel sur mesure et nos pipelines d'ingénierie de données. Si tu rencontres des problèmes de concurrence en production, parle à notre équipe ou demande un devis.

Sujets couverts

patterns de concurrenceconditions de courseverrouillage optimisteintégrité des donnéesverrous distribuésfield ownershipidempotencegestion des versions

Prêt à construire des systèmes IA prêts pour la production ?

Notre équipe est spécialisée dans les systèmes IA prêts pour la production. Discutons de comment nous pouvons aider.

Démarrer une conversation