La race condition typique en PHP, ce n’est pas “un bug de concurrence compliqué”. C’est deux workers qui tombent sur la même entité, au même moment, et qui exécutent la même transition d’état en parallèle. Et toi, tu as “mis un lock”, donc tu dors tranquille. Sauf que ton lock a un détail que tu n’as pas vraiment choisi : son TTL. Et ce TTL implicite peut expirer pile au milieu de la transition, laissant un deuxième worker rentrer comme dans un moulin.
On va parler d’un scénario de prod ultra classique (workers + workflow/état), et d’un pattern de verrouillage par ressource qui tient debout. Pas juste “mettre un lock”, mais le rendre prouvable, observable, et surtout cohérent avec la durée réelle de ton traitement.
Le scénario qui casse tout : deux workers sur la même entité, même transition
Imagine une entité “Commande” avec un statut, et une transition “paid → fulfilled”. Tu as des jobs asynchrones qui se déclenchent (webhook, cron, retry, import, ou juste deux messages du broker). En temps normal, un seul job passe, la transition est appliquée, et tout va bien.
En prod, ce n’est pas “normal”. Tu as des retries, des latences réseau, des workers qui se relancent, des messages dupliqués, et des timings qui se recouvrent. Résultat, deux jobs peuvent tenter la même transition quasiment en même temps. Si ta logique ressemble à “je lis l’état, je vérifie, j’applique, je persiste”, tu as une fenêtre où l’autre worker peut faire pareil. Et si l’action derrière la transition a des effets de bord (facturation, email, inventaire, call API), tu viens de créer un incident.
Le truc pervers, c’est que ça arrive rarement en local. Ça sort sous charge, sur un pic, ou quand un service externe ralentit. Donc tu te retrouves avec des symptômes chelous : double exécution, erreurs “transition not allowed”, états incohérents, ou pire, tout “réussit” mais tu as deux factures.
Sponsorisé par Le Scribouillard
Besoin de contenu optimisé SEO ?
Utilisez la meilleure plateforme française de création de contenu assistée par IA ! Et générez des articles pour moins de 1€ !
Le lock “évident” : bonne intention, mauvaise garantie
La première réaction est saine : “je vais verrouiller la commande pendant la transition”. Oui. Mais encore faut-il que ton lock soit réellement exclusif pendant toute la section critique.
Les deux erreurs que je vois le plus souvent sont opposées et pourtant cousines. Soit tu n’as pas de TTL (ou un TTL à 0 selon la lib/driver), et si le worker crash, tu te crées un deadlock qui va bloquer la ressource potentiellement indéfiniment. Soit tu as un TTL par défaut qui te paraît “raisonnable”, sauf qu’il est inférieur à la durée de ton traitement quand ça ralentit. Dans ce deuxième cas, tu crois être protégé… et en fait tu as juste déplacé le problème. Le lock expire, un autre worker acquiert, et les deux sont maintenant dans la section critique, chacun persuadé d’être seul.
Dans Symfony Lock par exemple, il existe un TTL par défaut (souvent 300 secondes selon la configuration / les stores). 300 secondes, c’est long… jusqu’au jour où ton traitement prend 6 minutes parce que l’API d’en face rame, que tu fais un retry interne, ou que ta base est sous pression. Et là, tu viens d’ouvrir une porte exactement au pire moment.
Le bon pattern : un lock par ressource, clé stable, TTL explicite
Le verrou doit représenter la ressource “unique” que tu protèges. Pas “un lock global de workflow”, pas “un lock par worker”. Un lock par entité ou par agrégat. Typiquement : order:{id}, invoice:{id}, user:{id}:billing. Une clé stable, déterministe, sans ambiguïté. C’est ce point qui transforme un lock en outil, plutôt qu’en gri-gri.
Ensuite, tu choisis un TTL explicite. Pas parce que “300 c’est nul” ou parce que “0 c’est mieux”. Parce que tu sais ce que tu protèges. Tu estimes une durée de traitement “normale”, tu ajoutes une marge (réseau, GC, pic CPU, petite latence), et tu assumes. Et tu logges la durée réelle, pour vérifier dans la vraie vie.
En pratique, ça te donne un code qui fait trois choses sans mentir : acquisition, section critique, release, avec un finally. Si tu ne fais pas le finally, tu vas le payer.
Exemple Symfony Lock : TTL choisi et acquisition bloquante
<?php
use Symfony\Component\Lock\LockFactory;
use Psr\Log\LoggerInterface;
final class FulfillOrderHandler
{
public function __construct(
private LockFactory $lockFactory,
private LoggerInterface $logger,
) {}
public function __invoke(int $orderId): void
{
$lockKey = sprintf('order:%d:fulfill', $orderId);
$ttlSeconds = 600; // choisis, pas subi
$lock = $this->lockFactory->createLock($lockKey, $ttlSeconds);
$holder = bin2hex(random_bytes(8));
$startedAt = microtime(true);
$this->logger->info('Lock: acquiring', [
'key' => $lockKey,
'ttl' => $ttlSeconds,
'holder' => $holder,
]);
$lock->acquire(true); // bloquant
$this->logger->info('Lock: acquired', [
'key' => $lockKey,
'holder' => $holder,
'wait_ms' => (int) ((microtime(true) - $startedAt) * 1000),
]);
try {
// Section critique : lecture état + transition + persistance + effets de bord
// ...
} finally {
$lock->release();
$this->logger->info('Lock: released', [
'key' => $lockKey,
'holder' => $holder,
'held_ms' => (int) ((microtime(true) - $startedAt) * 1000),
]);
}
}
}Deux détails importants ici. D’abord, le TTL est un paramètre de prod, pas une constante “au hasard”. Ensuite, les logs te donnent une preuve : combien de temps tu as attendu pour l’acquérir, et combien de temps tu l’as tenu. C’est exactement ce qui te manque quand tu dois expliquer “pourquoi ça double-traitait”.
Acquisition bloquante vs try+retry : ça change ton comportement d’incident
L’acquisition bloquante, c’est confortable parce que ton code “attend son tour”. Mais en prod, ça veut dire que tu peux augmenter la latence, empiler des workers en attente, et parfois créer une sorte de congestion. Ce n’est pas forcément mauvais, mais il faut être conscient de ce que tu es en train de faire : tu convertis un problème de concurrence en problème de débit.
L’approche try+retry est souvent plus saine côté système, parce qu’elle te force à décider quoi faire quand la ressource est déjà en cours de traitement. Sur une API HTTP, ça ressemble souvent à un 409 “Conflict” (ou 423 “Locked” si tu veux être explicite), avec un message clair. Sur un worker, ça ressemble à un requeue avec backoff, ou à un abandon propre si ton job est idempotent et que le travail est déjà en cours ailleurs.
Mon avis terrain : en asynchrone, try+retry avec backoff est rarement un mauvais choix. En synchrone (requête utilisateur), bloquer longtemps est un cadeau empoisonné. Tu masques le problème jusqu’au jour où ça devient visible… sous forme de timeouts et d’expérience utilisateur qui se dégrade.
Try+retry simple : quand le lock est pris, tu choisis une stratégie
<?php
$lock = $lockFactory->createLock($lockKey, 600);
for ($attempt = 1; $attempt <= 5; $attempt++) {
if ($lock->acquire(false)) {
try {
// section critique
} finally {
$lock->release();
}
return;
}
// backoff très simple (à adapter)
usleep(50_000 * $attempt);
}
throw new \RuntimeException('Resource busy: could not acquire lock');Ce code n’est pas “la solution universelle”. L’idée, c’est que tu rends explicite ton comportement quand le lock est pris. Et c’est ce choix qui évite les décisions implicites faites par ton infra ou tes timeouts.
Le TTL : choisis-le pour la durée de ton traitement, pas pour te rassurer
Un TTL n’est pas une constante “safe”. C’est une hypothèse sur la durée max de la section critique. Si tu mets 30 secondes parce que “ça doit aller vite”, et que ton job dépend d’une API externe qui peut passer à 2 minutes, ton lock va expirer et tu recrées la course. Si tu mets 24 heures “pour être tranquille”, tu crées une arme de destruction massive si un worker se plante et ne release pas correctement, ou si tu as un lock qui reste bloqué suite à une panne.
La bonne approche est pragmatique : tu mesures. Tu ajoutes des métriques ou, à défaut, des logs structurés sur la durée réelle. Tu regardes le P95/P99 en prod. Tu choisis un TTL qui couvre les queues days “pas propres” (pic, ralentissement externe, cold start), sans pour autant figer la ressource trop longtemps. Et tu réévalues quand le système change. C’est vivant.
Et si ta section critique peut dépasser le TTL dans certains cas (paiement, appels partenaires, traitements lourds), tu as un vrai sujet de conception : soit tu réduis la section critique (tu verrouilles juste l’état et tu externalises le reste), soit tu mets en place un lock extensible/renouvelable. Mais ignorer le problème en espérant que « ça ne dépassera pas » finit mal.
Crashes, timeouts, workers tués : ton lock doit survivre à la vraie vie
Si tu n’as pas de TTL, un crash peut figer une ressource pour toujours. Si tu as un TTL trop court, un crash devient presque “transparent” mais tu risques de laisser rentrer un concurrent alors que le premier worker n’est pas vraiment mort, juste lent. Dans les deux cas, tu peux perdre.
Ce qui rend le lock robuste, c’est d’assumer les deux scénarios. Le TTL évite la prise d’otage après crash. Le finally évite de compter sur le TTL “parce que ça ira”. Et ton traitement doit être le plus idempotent possible, parce qu’en distribué, même avec un lock, tu n’auras jamais 0 doublon sur toute l’histoire de ton produit.
Autre piège : croire que “le lock empêche tout”. Non. Il empêche deux exécutions concurrentes sur la même clé. Mais si ta clé n’est pas la bonne (clé instable, clé trop large, ou clé qui ne couvre pas la vraie ressource), tu peux avoir une concurrence déguisée. Typiquement, verrouiller “le workflow” au lieu de verrouiller “l’entité” ne sert à rien, et verrouiller “l’entité” avec un identifiant mal normalisé te donne des locks parallèles par accident.
Observabilité : comment prouver une race condition (au lieu de la “sentir”)
En incident, le problème n’est pas seulement de corriger. C’est d’être certain que tu as corrigé le bon truc. Les race conditions donnent des symptômes flous et sporadiques, donc si tu n’as pas un minimum d’observabilité, tu vas “patcher” au pif et attendre.
Moi, je veux trois preuves. Je veux voir quand le lock est acquis, quand il est relâché, et combien de temps il a été tenu. Je veux un identifiant de “holder” (instance/job id, ou un token) pour relier les logs. Je veux aussi le contexte métier, parce que “order:123” sans l’action, ça ne m’aide pas à reconstruire l’histoire.
Et surtout, je veux repérer le cas le plus dangereux : un lock tenu plus longtemps que prévu (proche du TTL), ou des acquisitions qui attendent longtemps. Ce sont les signaux faibles qui te disent “un jour, le TTL va expirer pendant l’exécution” ou “un jour, ça va faire bouchon”. C’est là que tu ajustes avant que ça te réveille.
Les mauvais arbitrages que je vois souvent (et qui finissent en bug fantôme)
Le premier, c’est le TTL implicite. Ça marche des semaines, et le jour où le traitement est plus long, tu as une course exactement au moment où le système est déjà sous stress. C’est le combo parfait pour perdre du temps en debug.
Le deuxième, c’est de mettre un lock global “par sécurité”. Tu règles une race condition… en détruisant ton throughput. Et après on te dit “le système est lent”. Oui, forcément, tu as serialisé tout le monde sur une clé unique.
Le troisième, c’est d’oublier que lock ou pas, ton job doit rester raisonnablement idempotent. Parce que des doublons, tu en auras (au moins une fois). Réessais, messages dupliqués, redelivery après timeout, exécutions partielles. Le lock réduit le risque, il ne supprime pas la réalité du distribué.
Conclusion : un lock, ce n’est pas un talisman, c’est un contrat
Un verrouillage solide en PHP, ce n’est pas “j’ai appelé une méthode lock()”. C’est une clé par ressource, un TTL explicite aligné sur la durée réelle de traitement, un comportement clair quand le lock est déjà pris, et des logs qui te permettent de prouver ce qui s’est passé.
Si tu prends le réflexe de traiter le TTL comme un paramètre de prod, tu vas éviter une catégorie entière de bugs vicieux. Et surtout, tu vas arrêter d’être surpris par des incidents qui ne sont, au fond, qu’une histoire de temps.