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.
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
| Domaine | Propriétaire | Chemin de mutation | Stratégie de conflit |
|---|---|---|---|
| Editor-owned | Utilisateurs admin | Sauvegarde standard | Pas de conflit (seuls les éditeurs écrivent) |
| System-owned | Workers/intégrations | Couche transactionnelle avec verrous | Réessai en cas de conflit |
| Shared | Les deux | Couche transactionnelle avec résolution de conflits | Configurable : 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ée | Pattern de clé | Cas d'usage |
|---|---|---|
| Élément | lock:product:123 | Sauvegarde complète de l'objet |
| Groupe de champs | lock:product:123:generatedAssets | Mise à jour partielle (miniatures uniquement) |
| Opération | lock:product:123:thumbnail:en | Opé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ération | Composants de la clé | Exemple |
|---|---|---|
| Ajout à la wishlist | customerId + productVariantId + wishlistId | wish:cust_123:var_456:wl_789 |
| Soumettre un avis | customerId + productId | review:cust_123:prod_456 |
| Envoyer une notification | recipient + category + entityRef + dayBucket | notify:sara@beispiel.de:stock:var_456:2026-04-20 |
| Utiliser des points fidélité | customerId + orderId + points | redeem:cust_123:ord_789:500 |
| Import ERP | sourceRecordId + importBatchId | import: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
-
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.
-
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.
-
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. -
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.
-
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.
-
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.
-
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.
-
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
Guides connexes
Architecture Event-Driven en pratique : ce qui tourne vraiment mal
Patterns réels d'architecture event-driven en production. Event storms, boucles de sync bidirectionnelle, dead letters, stores d'idempotence, et choix entre Kafka, RabbitMQ, BullMQ et Symfony Messenger.
Lire le guideGuide Entreprise des Systèmes d'IA Agentiques
Guide technique des systemes d'IA agentiques en entreprise. Decouvre l'architecture, les capacites et les applications des agents IA autonomes.
Lire le guideCommerce Agentique : Comment laisser les agents IA acheter en toute securite
Comment concevoir un commerce agentique gouverne. Moteurs de politiques, portes d'approbation HITL, reçus HMAC, idempotence, isolation multi-tenant et le protocole Agentic Checkout complet.
Lire le guidePrê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