Le verrou et le problème de la ressource partagée
Sans verrou, deux processus peuvent modifier la même ressource en même temps — le Lock est la garantie d'exclusivité.
Imagine un stock de billets de concert. Deux acheteurs cliquent "Acheter" au même moment. Sans protection, chacun lit stock = 1, chacun décrémente, chacun écrit stock = 0 — mais deux billets ont été vendus pour le même siège. C'est la race condition classique.
Quand le résultat d'une opération dépend de l'ordre d'exécution non contrôlé de plusieurs processus ou threads concurrents. Le comportement devient imprévisible.
Un verrou résout ce problème en imposant un accès séquentiel à la ressource critique : seul le détenteur du lock peut agir, les autres attendent ou échouent proprement.
Un verrou d'exclusion mutuelle garantit qu'une seule unité d'exécution détient l'accès à une ressource à la fois. Symfony Lock est l'implémentation Symfony de ce principe.
Le cycle de vie d'un verrou
acquire()
verrouillée
attend ou échoue
Le verrou nomme la ressource — c'est une chaîne comme "invoice_42" ou "export_users". Deux processus qui utilisent le même nom se coordonnent ; deux noms différents sont indépendants.
LockFactory::createLock('order-42') sur deux workers différents produit deux verrous qui se coordonnent — parce qu'ils partagent le même store ET le même nom.
Essaie par toi-même
Le simulateur ci-dessous montre ce qui se passe sans et avec verrou quand deux processus accèdent à la même ressource en même temps. Clique sur les deux boutons pour comparer.
SELECT FOR UPDATE est plus adaptée. Lock brille pour les opérations longues, multi-étapes, ou cross-services.
LockFactory et createLock
LockFactory est le point d'entrée — elle crée les locks à partir d'un store et d'un nom de ressource.
LockFactory est le seul point d'entrée pour créer des locks. Tu ne crées jamais une instance de Lock directement — tu passes par la factory, qui connaît le store sous-jacent.
Prend un
PersistingStoreInterface à la construction et produit des objets SharedLockInterface via createLock().
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\FlockStore;
// Création manuelle (hors Symfony DI)
$store = new FlockStore('/tmp/locks');
$factory = new LockFactory($store);
// Création d'un lock nommé
$lock = $factory->createLock('export-users');
Signature de createLock
createLock(
string $resource,
?float $ttl = 300.0, // secondes, null = pas de TTL
bool $autoRelease = true, // libère à la destruction de l'objet
): SharedLockInterface
| Paramètre | Défaut | Rôle |
|---|---|---|
$resource | — | Nom de la ressource à verrouiller. Doit être unique par ressource métier. |
$ttl | 300.0 | Durée de vie en secondes. null = pas d'expiration (stores sans TTL seulement). |
$autoRelease | true | Le destructeur PHP appelle release() automatiquement si le lock est encore tenu. |
createLockFromKey — contrôle avancé
Pour les cas où tu veux partager la même Key entre plusieurs locks (par exemple logger des métadonnées sur la clé), utilise createLockFromKey() :
use Symfony\Component\Lock\Key;
$key = new Key('traitement-facture-42');
$lock = $factory->createLockFromKey($key, ttl: 60.0);
Objet final qui encapsule le nom de ressource et les métadonnées d'état (TTL restant, états custom). C'est la Key qui est persistée dans le store — pas l'objet Lock.
Dans le contexte Symfony (injection)
Symfony déclare automatiquement LockFactory comme service si tu configures framework.lock. Tu l'injectes comme n'importe quel service :
use Symfony\Component\Lock\LockFactory;
final class ExportService
{
public function __construct(
private readonly LockFactory $lockFactory,
) {}
public function export(): void
{
$lock = $this->lockFactory->createLock('export-users', ttl: 120.0);
// ...
}
}
$lock entre deux exécutions. L'objet Lock est stateful (il sait s'il est acquis ou non).
acquire, release et auto-release
acquire() tente d'obtenir le verrou, release() le libère — autoRelease=true le libère automatiquement à la destruction.
acquire() tente d'obtenir le verrou et retourne un bool. Si la ressource est déjà verrouillée, il retourne false immédiatement (mode non-bloquant). C'est à toi de décider quoi faire.
$lock = $factory->createLock('traitement-commande-42');
if (!$lock->acquire()) {
// Quelqu'un d'autre traite déjà cette commande
throw new \RuntimeException('Traitement déjà en cours.');
}
try {
// Section critique — tu es le seul à l'exécuter
$this->traiterCommande(42);
} finally {
$lock->release();
}
finally garantit que release() est appelé. Sans ça, le lock reste en place jusqu'à expiration du TTL.
auto-release — le filet de sécurité
Avec autoRelease = true (valeur par défaut), le destructeur PHP appelle release() si l'objet $lock est détruit sans avoir été libéré explicitement. C'est un filet — pas un substitut au try/finally.
$lock = $factory->createLock(
'ma-ressource',
autoRelease: true
);
$lock->acquire();
// Si on oublie release(),
// le destructeur le fait
$lock = $factory->createLock(
'ma-ressource',
autoRelease: false
);
$lock->acquire();
// Lock persiste même après
// destruction de $lock
Si tu crées le lock dans un worker et que tu veux qu'il survive au-delà de l'objet PHP (exemple : lock inter-requêtes géré manuellement via Redis). C'est rare — garde
autoRelease = true par défaut.
L'interface complète
interface LockInterface
{
public function acquire(bool $blocking = false): bool;
public function refresh(?float $ttl = null): void;
public function isAcquired(): bool;
public function release(): void;
public function isExpired(): bool;
public function getRemainingLifetime(): ?float; // null si pas de TTL
}
| Méthode | Rôle |
|---|---|
acquire(false) | Tente immédiatement, retourne false si échoue. |
acquire(true) | Bloque jusqu'à obtenir le lock. Voir section suivante. |
isAcquired() | Vrai si ce processus détient le lock. |
release() | Libère le lock. |
isExpired() | Vrai si le TTL est dépassé. |
getRemainingLifetime() | Secondes restantes, null si pas de TTL. |
refresh() | Renouvelle le TTL (voir section TTL). |
isAcquired() retourne true si tu détiens le lock — il peut être expiré côté store sans que l'objet PHP le sache. Appelle isExpired() avant une opération longue pour vérifier que le TTL n'a pas expiré.
Blocage — attendre la libération
acquire(true) bloque jusqu'à la libération — le mécanisme diffère selon que le store implémente BlockingStoreInterface ou pas.
acquire(true) passe en mode bloquant : le processus attend que le verrou soit disponible. C'est utile quand tu veux être sûr de traiter la ressource, pas juste tenter.
// Bloque jusqu'à obtenir le lock — peut attendre indéfiniment
$lock->acquire(true);
try {
$this->traiter();
} finally {
$lock->release();
}
Comment fonctionne le blocage ?
Symfony Lock adapte son comportement selon le store utilisé :
Le store implémente waitAndSave(Key $key). Le blocage est natif : PostgreSQL advisory locks, FlockStore, SemaphoreStore. Efficace, pas de boucle d'attente.
bloquant natif
Lock boucle avec usleep() jusqu'à succès. L'intervalle est de ~100ms ±10ms (jitter). Redis, PDO, DoctrineDbal : tous passent par cette boucle.
~100ms
usleep(100ms) est légère, mais dans un contexte haute fréquence avec beaucoup de contention, préfère un store avec blocage natif (PostgreSQL, Flock) ou implémente une queue de travail.
Exceptions possibles
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Exception\LockAcquiringException;
try {
$lock->acquire(true);
} catch (LockConflictedException $e) {
// Lock tenu par quelqu'un d'autre (ne se produit pas en bloquant pur)
} catch (LockAcquiringException $e) {
// Erreur lors de la tentative (store indisponible, etc.)
}
Essaie le comportement blocking
Clique sur "acquire(false)" quand le lock est tenu pour voir l'échec immédiat, puis sur "acquire(true)" pour voir l'attente. Utilise "Libérer" pour remettre à zéro.
TTL et refresh
Un lock sans renouvellement expire — refresh() prolonge la durée pour éviter les dead locks.
Un verrou sans TTL est un verrou potentiellement immortel : si le processus meurt sans appeler release(), personne ne peut jamais acquérir la ressource. Le TTL est la garantie anti-deadlock.
Durée de vie du verrou en secondes. Passé ce délai, le store supprime automatiquement l'entrée — même si le détenteur n'a pas appelé
release(). Valeur par défaut : 300 secondes.
Le danger du TTL trop court
Si ton traitement prend plus de temps que le TTL, le lock expire pendant l'opération. Un autre processus peut alors acquérir le même verrou, et tu te retrouves avec deux exécutions concurrentes — exactement ce que tu voulais éviter.
acquire() t=0
t=300s
acquire() t=301s
Les deux processus s'exécutent en même temps. Race condition restaurée.
refresh() — renouveler pendant le traitement
refresh() remet le TTL à zéro depuis maintenant. Appelle-le régulièrement dans les traitements longs :
$lock = $factory->createLock('import-csv', ttl: 60.0);
$lock->acquire(true);
foreach ($this->readLines($csvPath) as $i => $line) {
$this->processLine($line);
// Renouvelle toutes les 100 lignes
if ($i % 100 === 0) {
$lock->refresh(); // Remet à 60s depuis maintenant
}
}
$lock->release();
Tu peux aussi passer un TTL différent à refresh() :
$lock->refresh(120.0); // Prolonge à 120s depuis maintenant
refresh() régulier. Ainsi, si le processus meurt, le lock expire vite. Si le processus vit, il renouvelle à intervalles réguliers.
Surveiller l'expiration
if ($lock->isExpired()) {
// Le TTL a expiré — notre lock n'est peut-être plus valide
throw new \RuntimeException('Lock expiré pendant le traitement.');
}
$remaining = $lock->getRemainingLifetime(); // float ou null si pas de TTL
if ($remaining !== null && $remaining < 10.0) {
$lock->refresh(); // Moins de 10s restantes — on renouvelle
}
refresh()) peuvent lever LockExpiredException si le TTL a déjà expiré côté store. Capte cette exception dans les boucles longues.
Stores sans TTL
FlockStore, SemaphoreStore, PostgreSqlStore n'ont pas de TTL (le lock est lié au processus ou à la connexion). Si tu passes ttl: null avec ces stores, le lock persiste tant que le processus tourne. C'est correct — mais ne pas oublier release() ou autoRelease.
Les stores — choisir la bonne persistance
Chaque store a ses caractéristiques : TTL, blocking, shared locks, dépendances — le tableau de choix.
Le store est l'arrière-plan du système de lock : c'est lui qui persiste "qui tient quoi". Symfony propose une dizaine de stores, chacun avec ses compromis. Le bon choix dépend de ton infrastructure.
Tableau de synthèse
| Store | TTL | Blocking | Shared | Dépendance |
|---|---|---|---|---|
FlockStore |
Non | Oui | Oui | Système de fichiers PHP stdlib |
SemaphoreStore |
Non | Oui | Non | ext-sysvsem |
RedisStore |
Oui | Non | Oui | Redis + scripts Lua |
PdoStore |
Oui | Non | Non | PDO, table lock_keys |
DoctrineDbalStore |
Oui | Non | Non | doctrine/dbal |
MemcachedStore |
Oui | Non | Non | ext-memcached |
MongoDbStore |
Oui | Non | Non | TTL index MongoDB |
PostgreSqlStore |
Non | Oui | Oui | Advisory locks pg_advisory_lock() |
InMemoryStore |
Non | Non | Oui | Tests uniquement, single-process |
NullStore |
Non | Oui | Oui | No-op — désactive le locking |
Focus sur les stores courants
Utilise les verrous de fichiers POSIX (
flock()). Fonctionne sur un seul serveur. Pas de TTL (le lock disparaît quand le process meurt). Idéal pour les workers mono-serveur.
$store = new FlockStore('/var/lock/monapp');
Utilise des scripts Lua atomiques + sorted sets Redis. TTL natif. Supporte les locks partagés. Le choix par défaut pour les architectures multi-serveurs.
use Symfony\Component\Lock\Store\RedisStore;
$redis = new \Redis();
$redis->connect('redis', 6379);
$store = new RedisStore($redis);
Utilise
pg_advisory_lock() / pg_advisory_unlock(). Bloquant natif, supporte les locks partagés. Excellent si tu as déjà PostgreSQL et pas Redis.
$store = new PostgreSqlStore(
'pgsql:host=localhost;dbname=myapp',
['db_username' => 'app', 'db_password' => 'secret']
);
Utilise ta connexion DBAL existante. Crée une table
lock_keys automatiquement (createTable()). Pas de blocking natif, pas de shared locks.
use Symfony\Component\Lock\Store\DoctrineDbalStore;
$store = new DoctrineDbalStore($connection);
$store->createTable(); // Crée lock_keys si inexistante
CombinedStore — verrous distribués
CombinedStore multiplexe sur plusieurs stores avec quorum — pour les architectures multi-nœuds.
Dans une architecture multi-nœuds sans coordination centrale fiable, un seul store peut être indisponible. CombinedStore multiplex le lock sur plusieurs stores en parallèle et applique une stratégie de quorum pour décider si le lock est acquis.
Agrège plusieurs stores. L'acquisition réussit si suffisamment de stores confirment (selon la stratégie). Inspiré de l'algorithme Redlock de Redis.
Mise en place
use Symfony\Component\Lock\Store\CombinedStore;
use Symfony\Component\Lock\Store\RedisStore;
use Symfony\Component\Lock\Strategy\ConsensusStrategy;
use Symfony\Component\Lock\Strategy\UnanimousStrategy;
// Trois instances Redis indépendantes
$redis1 = new \Redis(); $redis1->connect('redis-1');
$redis2 = new \Redis(); $redis2->connect('redis-2');
$redis3 = new \Redis(); $redis3->connect('redis-3');
$store = new CombinedStore(
[
new RedisStore($redis1),
new RedisStore($redis2),
new RedisStore($redis3),
],
new ConsensusStrategy() // ou UnanimousStrategy
);
$factory = new LockFactory($store);
Les deux stratégies
100 % des stores doivent confirmer l'acquisition. Très strict — si un store est indisponible, le lock échoue. À utiliser quand la cohérence absolue prime sur la disponibilité.
new UnanimousStrategy()
// 3/3 stores : OK
// 2/3 stores : FAIL
Plus de 50 % des stores doivent confirmer. Tolérant aux pannes — un store en panne n'empêche pas le lock. C'est l'équivalent de l'algorithme Redlock.
new ConsensusStrategy()
// 3/3 stores : OK
// 2/3 stores : OK
// 1/3 stores : FAIL
✓
✓
✗ down
2/3 ✓
Comportement en cas d'échec partiel
Si CombinedStore arrive à acquérir sur certains stores mais pas assez pour le quorum, il libère automatiquement les stores qui avaient confirmé. Pas de lock orphelin.
BlockingStoreInterface. acquire(true) utilisera la boucle usleep() — acceptable pour des locks peu fréquents.
Différence avec un cluster Redis
| Approche | Cohérence | Disponibilité | Complexité |
|---|---|---|---|
| Redis cluster (1 store) | Moyenne | Haute | Faible |
| CombinedStore (3 Redis) | Haute | Haute | Moyenne |
| UnanimousStrategy (3 Redis) | Très haute | Faible | Moyenne |
Configuration et injection Symfony
lock.yaml, StoreFactory, injection de LockFactory par resource name.
Symfony gère la configuration des stores via le fichier config/packages/lock.yaml et expose automatiquement les LockFactory comme services injectables.
Configuration minimale
# config/packages/lock.yaml
framework:
lock: 'flock://%kernel.project_dir%/var/lock'
Un seul store, une seule factory. Symfony déclare le service LockFactory — tu l'injectes par type.
Plusieurs stores nommés
# config/packages/lock.yaml
framework:
lock:
default_resource: 'default'
resources:
default: 'flock://%kernel.project_dir%/var/lock'
payment: 'redis://redis:6379'
invoice: 'pgsql+advisory://user:pass@localhost/mydb'
Chaque ressource nommée crée une LockFactory dédiée. L'injection utilise le nom de l'argument pour résoudre la bonne factory :
use Symfony\Component\Lock\LockFactory;
final class PaymentService
{
public function __construct(
// Injecte la factory "payment" grâce au nom de l'argument
private readonly LockFactory $paymentLockFactory,
) {}
}
final class InvoiceService
{
public function __construct(
// Injecte la factory "invoice"
private readonly LockFactory $invoiceLockFactory,
) {}
}
StoreFactory — DSN vers store
StoreFactory::createStore() transforme un DSN en store. C'est ce que Symfony utilise en interne pour interpréter le YAML. Tu peux l'utiliser directement dans du code maison :
use Symfony\Component\Lock\Store\StoreFactory;
$store = StoreFactory::createStore('redis://redis:6379');
$store = StoreFactory::createStore('flock:///var/lock');
$store = StoreFactory::createStore('pgsql+advisory://localhost/mydb');
$store = StoreFactory::createStore('semaphore');
$store = StoreFactory::createStore('in-memory'); // Tests
DSN disponibles
| DSN | Store |
|---|---|
flock://chemin | FlockStore |
semaphore | SemaphoreStore |
redis://host:port | RedisStore |
rediss://host:port | RedisStore (TLS) |
pgsql+advisory://... | PostgreSqlStore |
mysql://... / pgsql://... | PdoStore |
in-memory | InMemoryStore (tests) |
null | NullStore |
Configuration par environnement
# config/packages/lock.yaml
framework:
lock: '%env(LOCK_DSN)%'
# .env
LOCK_DSN=flock://%kernel.project_dir%/var/lock
# .env.prod
LOCK_DSN=redis://redis:6379
composer require symfony/lock. Si tu utilises Symfony Flex, le fichier config/packages/lock.yaml est créé automatiquement avec une configuration FlockStore par défaut.
Synthèse — guide de choix
Quel store, quel TTL, blocking ou non — les patterns de production.
Symfony Lock est un outil ciblé : il résout les problèmes de concurrence inter-processus pour des opérations métier à durée non négligeable. Voici comment choisir et utiliser le bon outil.
Quel store choisir ?
| Contexte | Store recommandé | Raison |
|---|---|---|
| Serveur unique, dev ou prod simple | FlockStore |
Zéro dépendance, blocking natif, shared locks |
| Multi-serveurs, Redis disponible | RedisStore |
TTL, shared locks, distribué |
| PostgreSQL disponible, pas Redis | PostgreSqlStore |
Blocking natif, shared locks, advisory locks |
| Doctrine/DBAL sans Redis ni PostgreSQL | DoctrineDbalStore |
TTL, utilise la connexion DB existante |
| Haute disponibilité multi-nœuds | CombinedStore |
Quorum sur plusieurs Redis |
| Tests unitaires | InMemoryStore |
Isolation, single-process, pas de dépendance |
Patterns de production
Pour les imports, exports, traitements de queue : TTL court (30–60s) +
refresh() régulier.
$lock = $factory->createLock('import-csv', ttl: 30.0);
$lock->acquire(true);
try {
foreach ($lines as $i => $line) {
$this->process($line);
if ($i % 50 === 0) { $lock->refresh(); }
}
} finally {
$lock->release();
}
Pour les workers de queue — s'assurer qu'un message n'est traité qu'une fois même si plusieurs workers tournent.
$lock = $factory->createLock("message-{$message->getId()}", ttl: 60.0);
if (!$lock->acquire()) {
// Déjà en cours de traitement — ACK sans traiter
return;
}
try {
$this->handle($message);
} finally {
$lock->release();
}
Pour les caches de fichiers, rapports partagés : plusieurs lectures simultanées, une seule écriture.
// Lecture
$lock->acquireRead(true);
try { return $cache->get($key); }
finally { $lock->release(); }
// Écriture
$lock->acquire(true);
try { $cache->set($key, $value); }
finally { $lock->release(); }
Checklist avant de déployer
- Le store est cohérent entre tous les workers (même Redis, même chemin Flock…)
- TTL > durée maximale estimée du traitement
refresh()présent dans les boucles longuestry/finallyautour de toute section critiqueLockConflictedExceptionetLockAcquiringExceptiongérées- Tests avec
InMemoryStore— pas de dépendances externes
Quand ne pas utiliser Symfony Lock
| Cas | Alternative |
|---|---|
| Incrément atomique en DB | SELECT FOR UPDATE / transaction DB |
| Rate limiting | Symfony RateLimiter |
| Coordination de threads PHP-FPM intra-requête | PHP n'est pas multi-thread — pas besoin |
| File de messages avec exactly-once delivery | Messenger + idempotence clé DB unique |