Transactions — flush, savepoints, outbox pattern
TL;DR — Une transaction =
BEGIN ... COMMIT/ROLLBACK. Doctrine ouvre implicitement une transaction auflush()si aucune n'est active. Pour grouper plusieurs flushes, encapsuler viawrapInTransaction(). Les "nested transactions" reposent sur des SAVEPOINTs. L'outbox pattern (DB + Messenger) garantit la cohérence "send mail si commit ok".
Mental model — ASCII diagram + analogy
Analogie : Une transaction = trajet d'avion. Tu embarques (BEGIN), tu peux faire demi-tour avant décollage (SAVEPOINT), et au commit le vol décolle vraiment.
beginTransaction() commit()
│ │
▼ ▼
┌────────────────────────────────────────────────────────┐
│ BEGIN │
│ │
│ ┌──── SAVEPOINT sp1 (nested begin) │
│ │ ...writes... │
│ │ RELEASE SAVEPOINT sp1 ← commit "nested" │
│ │ ROLLBACK TO sp1 ← rollback "nested" │
│ └──── │
│ │
│ flush() → INSERT/UPDATE/DELETE │
│ │
│ COMMIT / ROLLBACK │
└────────────────────────────────────────────────────────┘Le EntityManager maintient un transactionNestingLevel ; au niveau 0, BEGIN ; au niveau >0, SAVEPOINT (si setNestTransactionsWithSavepoints(true) est activé, sinon Doctrine lève ConnectionException au second beginTransaction()).
Comment un staff engineer raisonne sur une transaction
Trois questions, dans cet ordre, avant d'écrire une ligne de code :
- Quel est l'invariant ? Une transaction protège un invariant métier (« débit = crédit », « stock ≥ 0 »), pas un bloc de code. Si tu ne peux pas nommer l'invariant en une phrase, tu n'as probablement pas besoin d'une transaction explicite —
flush()suffit. - Quelle est la durée de détention des verrous ? Une transaction = des lignes verrouillées. Plus elle vit longtemps, plus la contention monte. Règle : zéro I/O réseau (HTTP, mail, S3) à l'intérieur. Tout appel externe se fait soit avant (idempotent, caché), soit après (outbox).
- Que se passe-t-il au commit partiel ? Le danger n'est pas le rollback (propre), c'est le « DB committé, side-effect jamais exécuté » ou « side-effect exécuté, DB rollback ». L'outbox élimine le premier ; l'idempotence élimine le second.
Le piège mental classique : croire que wrapInTransaction() rend ton code « sûr ». Il rend l'écriture DB atomique. Il ne rend pas atomiques tes appels réseau, tes dispatch() synchrones, ni tes mutations d'état en mémoire. La frontière transactionnelle est plus étroite que la frontière de ton use-case.
ACID, concrètement, côté Doctrine
| Propriété | Ce que la DB garantit | Ce que TU dois garantir |
|---|---|---|
| Atomicity | ROLLBACK annule tous les writes SQL | Que tes side-effects (mail, message) soient dans la transaction (outbox) ou idempotents |
| Consistency | Contraintes FK/UNIQUE/CHECK respectées au commit | Tes invariants métier (validation Doctrine ≠ contrainte DB) |
| Isolation | Niveau choisi (READ COMMITTED…) | Choisir le bon niveau + locks ; l'isolation par défaut autorise des races |
| Durability | Écrit sur disque au commit (fsync WAL) | synchronous_commit=on en prod ; pas de =off « pour la perf » sur de la finance |
Code minimal — realistic snippet
<?php
namespace App\Service;
use App\Entity\Order;
use App\Entity\Payment;
use Doctrine\DBAL\Exception\RetryableException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
final readonly class CheckoutService
{
public function __construct(
private EntityManagerInterface $em,
private MessageBusInterface $bus,
) {}
public function checkout(Order $order, Payment $payment): void
{
// Méthode recommandée : pattern fonctionnel
$this->em->wrapInTransaction(function () use ($order, $payment): void {
$this->em->persist($order);
$this->em->persist($payment);
$this->em->flush();
// Outbox : message persisté dans la même transaction (transport doctrine://)
$this->bus->dispatch(new \App\Message\OrderPlaced($order->getId()));
});
}
}<?php
// Gestion manuelle avec retry sur deadlock
public function transferWithRetry(int $fromId, int $toId, int $cents): void
{
$attempts = 0;
do {
$this->em->beginTransaction();
try {
$from = $this->em->find(Account::class, $fromId, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE);
$to = $this->em->find(Account::class, $toId, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE);
$from->debit($cents);
$to->credit($cents);
$this->em->flush();
$this->em->commit();
return;
} catch (RetryableException) {
$this->em->rollback();
if (++$attempts >= 3) {
throw new \RuntimeException('Transfer failed after retries');
}
usleep(100_000 * $attempts); // backoff
} catch (\Throwable $e) {
$this->em->rollback();
throw $e;
}
} while (true);
}# config/packages/messenger.yaml — outbox via transport Doctrine
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%' # doctrine://default
options:
use_notify: true
check_delayed_interval: 60000
routing:
App\Message\OrderPlaced: asyncAvec un transport doctrine://, les messages sont insérés dans une table de la même DB. Tant qu'on dispatch dans la transaction métier, soit tout commit (DB + message), soit tout rollback.
Patterns courants — 3–6 patterns
- Transactional outbox : DB business + table messages dans la même transaction → worker async consomme la table. Évite "DB commit ok, mail jamais envoyé".
- Pessimistic locking :
find($id, LockMode::PESSIMISTIC_WRITE)→SELECT ... FOR UPDATE. Anti-race condition sur compteurs / stocks. - Optimistic locking :
#[ORM\Version] private int $version;→ Doctrine ajouteWHERE version = ?au UPDATE.OptimisticLockExceptionsi conflit. - Saga / process manager : multi-services, transactions distribuées simulées via compensations (pas de 2PC en pratique).
wrapInTransaction()vs manual : préférer la closure, elle gère commit/rollback même sur exception non capturée.- Read-only transactions :
SET TRANSACTION READ ONLY(PG/MySQL 8+) sur queries lourdes → meilleure perf I/O.
Versions — Symfony 5.4 / 6.4 / 7.x
| Topic | 5.4 / ORM 2.x | 6.4+ / ORM 2.15+ / 3.x |
|---|---|---|
transactional() | OK | Renommé wrapInTransaction() (l'ancien deprecated puis retiré ORM 3) |
LockMode constantes | \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE | Idem |
flush($entity) argument unique | Deprecated dans 2.x | Retiré en 3.x |
| Messenger doctrine transport | OK, single table | OK, supporte auto_setup, PERSIST_FAIL strategy |
getConnection()->transactional() (DBAL) | OK | OK, DBAL 4 a typé strict |
- DBAL 4 :
Connection::commit()retournevoid(avantbool). - ORM 3 : signatures plus strictes,
LockModeenum-like (mais reste constantes int).
Pitfalls — 5–8 concrete traps
- Catch silencieux dans une transaction : si tu catches une exception sans rollback, Doctrine garde une transaction "marquée pour rollback only" → tout
flush()ultérieur explose. - Long-running transaction : tenir une transaction pendant un appel HTTP externe = lock DB pendant des secondes. Couper en deux : checkout → commit → call API.
- Pas de
commit()aprèsbeginTransaction()manuel : oublié dans une branche conditionnelle → transaction abandonnée, connexion bloquée jusqu'au timeout. - Nested transactions et MySQL : MySQL ne supporte pas le SAVEPOINT sur certaines opérations DDL. Sur Postgres c'est solide.
em->clear()mid-transaction : invalide les entités tracking. Toute écriture suivante peut générer des SQL inattendus.- Messenger dispatch hors transaction : si tu dispatch avant
flush(), le worker peut consommer le message avant que la DB ait commit → 404 / état incohérent. Toujours dispatch aprèsflush()dans la même transaction. - Auto-commit DDL :
ALTER TABLEcôté MySQL auto-commit la transaction en cours → tes inserts précédents sont commit malgré toi. - Transactions par worker Messenger :
doctrine_clear_em_middlewareclear l'EM entre messages, mais si tu manipules des entités d'un message précédent → detached. - Deadlocks ignorés : ne pas catcher
RetryableException= bug intermittent en prod sous charge.
Testing — phpunit / KernelTestCase
<?php
namespace App\Tests\Service;
use App\Entity\Account;
use App\Service\TransferService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class TransferServiceTest extends KernelTestCase
{
public function testRollbackOnDebitFailure(): void
{
self::bootKernel();
$em = static::getContainer()->get(EntityManagerInterface::class);
$svc = static::getContainer()->get(TransferService::class);
$from = new Account(initial: 100);
$to = new Account(initial: 0);
$em->persist($from); $em->persist($to); $em->flush();
try {
$svc->transfer($from->getId(), $to->getId(), 500); // overdraft
self::fail('Expected exception');
} catch (\DomainException) {}
$em->refresh($from);
$em->refresh($to);
self::assertSame(100, $from->getBalance()); // pas de partial state
self::assertSame(0, $to->getBalance());
}
}Test outbox : démarrer le worker en in-memory transport, assert que le message n'est délivré que si la transaction commit.
🎬 Cas d'usage concrets
Scénario 1 — Virement bancaire interne transactional
Une néobanque française permet à ses 200 000 clients d'effectuer des virements internes entre leurs propres comptes (courant → épargne) avec une exigence absolue de cohérence : le débit et le crédit doivent être atomiques. L'opération est encapsulée dans une transaction PostgreSQL en isolation REPEATABLE READ, débit puis crédit, avec verrouillage pessimiste SELECT ... FOR UPDATE sur les deux comptes ordonnés par identifiant pour éviter les deadlocks. Le commit déclenche l'écriture d'un événement VirementExecute dans l'outbox table, jamais directement sur Messenger, ce qui garantit que la notification client ne part qu'après la persistance comptable. En cas d'erreur réseau côté SMS (Twilio), l'utilisateur voit son virement effectué et reçoit la notification ultérieurement via retry exponentiel. L'équipe instrumente chaque transaction avec OpenTelemetry pour mesurer la latence p99 (35 ms) et le taux de rollback (0,03 %).
Scénario 2 — Signature électronique multi-parties d'un contrat juridique
Une plateforme de signature électronique pour cabinets d'avocats (SaaS B2B) doit garantir que la signature d'un contrat par trois parties (par exemple cédant, cessionnaire, garant lors d'une cession de parts) est atomique : soit les trois signatures sont enregistrées et le contrat passe en SIGNE, soit aucune ne l'est. Le handler de la commande FinaliserSignature ouvre une transaction Doctrine, charge le contrat avec LockMode::PESSIMISTIC_WRITE, vérifie l'invariant "toutes les signatures collectées", appelle un service externe d'horodatage qualifié eIDAS pour obtenir un timestamp signé, puis persiste l'événement ContratSigne dans l'outbox et flush. Le timestamp eIDAS étant un appel HTTP, il est entouré d'un cache idempotent par identifiant de signature pour pouvoir rejouer la commande en cas de crash entre l'horodatage et le commit. La transaction est courte (moins de 200 ms) et les opérations longues (génération PDF final, envoi mail aux parties) sont déléguées à des handlers asynchrones.
Scénario 3 — Commande e-commerce panier + réservation stock
Une marketplace e-commerce de bricolage transforme 18 000 commandes par jour. Lors du placeOrder, l'opération atomique englobe la création de la commande, des lignes de commande, et la décrémentation du stock disponible pour chaque référence. Le risque principal est l'oversell : deux acheteurs qui valident la même unité simultanément. La solution combine isolation READ COMMITTED (default PostgreSQL), update conditionnel UPDATE stock SET quantite = quantite - :q WHERE produit_id = :p AND quantite >= :q et vérification du nombre de lignes affectées : 0 ligne signifie rupture, on rollback et on renvoie une erreur métier RuptureStockException. La transaction inclut également l'insertion d'un événement CommandePassee dans l'outbox, traité plus tard pour facturation et préparation logistique. Pour les pics du Black Friday (1 100 commandes/min), l'équipe a éliminé les verrous pessimistes au profit du pattern check-and-set ci-dessus, ce qui a divisé la latence p95 par 4.
🛠️ Exemple end-to-end
Use case : virement interne entre deux comptes du même client, transactional avec verrouillage pessimiste ordonné et outbox pour notification post-commit.
<?php
// src/Application/Virement/Command/EffectuerVirementHandler.php
declare(strict_types=1);
namespace App\Application\Virement\Command;
use App\Domain\Compte\Exception\SoldeInsuffisantException;
use App\Domain\Compte\Repository\CompteRepository;
use App\Infrastructure\Outbox\OutboxPublisher;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Uid\Uuid;
#[AsMessageHandler]
final readonly class EffectuerVirementHandler
{
public function __construct(
private EntityManagerInterface $em,
private CompteRepository $comptes,
private OutboxPublisher $outbox,
) {}
public function __invoke(EffectuerVirement $cmd): void
{
$this->em->wrapInTransaction(function () use ($cmd): void {
// Ordre déterministe des verrous : évite le deadlock A→B / B→A
// (deux virements croisés simultanés qui se bloquent mutuellement).
// On verrouille TOUJOURS dans le même ordre (ici par UUID croissant).
$ids = [(string) $cmd->compteSource, (string) $cmd->compteDestination];
sort($ids); // ordre total stable, indépendant du sens du virement
// Un seul find verrouillant par compte (SELECT ... FOR UPDATE).
// On garde une map id → entité pour récupérer source/destination ensuite.
$verrous = [];
foreach ($ids as $id) {
$verrous[$id] = $this->comptes->find($id, LockMode::PESSIMISTIC_WRITE);
}
$source = $verrous[(string) $cmd->compteSource];
$destination = $verrous[(string) $cmd->compteDestination];
if (!$source->aSoldeSuffisant($cmd->montant)) {
throw new SoldeInsuffisantException();
}
$source->debiter($cmd->montant, $cmd->reference);
$destination->crediter($cmd->montant, $cmd->reference);
// Outbox : événement écrit dans la même transaction
$this->outbox->publish('virement.executed', [
'eventId' => Uuid::v7()->toRfc4122(),
'source' => (string) $source->getId(),
'destination' => (string) $destination->getId(),
'montant' => $cmd->montant->toCents(),
'reference' => $cmd->reference,
'occurredAt' => (new \DateTimeImmutable())->format(\DATE_RFC3339_EXTENDED),
]);
});
}
}
// src/Infrastructure/Outbox/OutboxPublisher.php
namespace App\Infrastructure\Outbox;
use Doctrine\DBAL\Connection;
use Symfony\Component\Uid\Uuid;
final readonly class OutboxPublisher
{
public function __construct(private Connection $db) {}
public function publish(string $topic, array $payload): void
{
$this->db->insert('outbox_message', [
'id' => Uuid::v7()->toRfc4122(),
'topic' => $topic,
'payload' => json_encode($payload, JSON_THROW_ON_ERROR),
'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s.u'),
'published_at' => null,
]);
}
}
// src/Infrastructure/Outbox/OutboxRelayCommand.php
namespace App\Infrastructure\Outbox;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand(name: 'app:outbox:relay')]
final class OutboxRelayCommand extends Command
{
public function __construct(private Connection $db, private MessageBusInterface $bus)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
// CLÉ : le SELECT ... FOR UPDATE SKIP LOCKED et l'UPDATE doivent vivre
// dans LA MÊME transaction. Sinon le verrou tombe dès le SELECT et deux
// relais concurrents publient le même message en double (FOR UPDATE
// hors transaction = no-op en autocommit).
// SKIP LOCKED permet d'avoir N relais en parallèle sans qu'ils se bloquent.
$this->db->transactional(function (Connection $conn): void {
$rows = $conn->fetchAllAssociative(
'SELECT * FROM outbox_message
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 500
FOR UPDATE SKIP LOCKED'
);
foreach ($rows as $row) {
// Le dispatch reste at-least-once : le consommateur doit être
// idempotent (dédup sur eventId). On marque published_at AVANT
// le commit ; si le dispatch échoue, on rollback et on rejoue.
$this->bus->dispatch(new OutboxEnvelope(
$row['topic'],
json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR),
));
$conn->update(
'outbox_message',
['published_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s.u')],
['id' => $row['id']],
);
}
});
return Command::SUCCESS;
}
}Subtilité at-least-once. Le relais garantit au moins une livraison, jamais exactement une. Entre le
dispatch()(transport async réel) et leCOMMITdupublished_at, un crash rejoue le message → le consommateur doit être idempotent (cléeventIden base ou cache Redis avec TTL). Vouloir « exactly-once » au niveau transport est un mirage : on le simule par idempotence côté consommateur. C'est le théorème fondamental de la messagerie distribuée, pas une faiblesse de Doctrine.
Quand utiliser / éviter
Utiliser explicitement :
- Multi-aggregate update (cohérence forte).
- Outbox / sagas.
- Compteurs critiques (stocks, soldes).
Éviter / repenser :
- Long-running batches : transaction par chunk, pas globale.
- Inter-service distribué : préférer eventual consistency + sagas.
- "Tout dans une transaction" géant : verrous longs, indisponibilité.
Isolation levels — savoir choisir
$conn = $em->getConnection();
$conn->setTransactionIsolation(\Doctrine\DBAL\TransactionIsolationLevel::SERIALIZABLE);| Niveau | Phénomènes possibles | Usage type |
|---|---|---|
READ_UNCOMMITTED | dirty read, non-repeatable, phantom | quasi-jamais |
READ_COMMITTED (PG default) | non-repeatable, phantom | OLTP standard |
REPEATABLE_READ (MySQL default) | phantom (sauf MySQL gap locks) | batchs cohérents |
SERIALIZABLE | aucun, mais conflits → retry | finance critique |
En Postgres, SERIALIZABLE utilise SSI (Serializable Snapshot Isolation) — performances correctes mais préparer des retries sur SQLSTATE 40001.
Pattern : transaction script vs domain transaction
// ❌ Transaction script - tout est dans le service applicatif
public function placeOrder(int $userId, array $items): void {
$this->em->wrapInTransaction(function() use ($userId, $items) {
$user = $this->em->find(User::class, $userId);
$order = new Order($user);
foreach ($items as $i) {
$product = $this->em->find(Product::class, $i['id']);
if ($product->getStock() < $i['qty']) throw new \LogicException();
$product->decreaseStock($i['qty']);
$order->addItem(new OrderItem($product, $i['qty']));
}
$this->em->persist($order);
});
}
// ✅ Domain transaction - logique dans l'aggregate
public function placeOrder(PlaceOrderCommand $cmd): void {
$this->em->wrapInTransaction(function() use ($cmd) {
$cart = $this->cartRepo->ofUser($cmd->userId); // aggregate root
$order = $cart->checkout(); // règles à l'intérieur
$this->orderRepo->save($order);
$this->bus->dispatch(new OrderPlaced($order->id()));
});
}Le second isole la logique métier de l'infra ; les tests unitaires du Cart ne touchent pas la DB.
Pessimistic vs optimistic — l'arbre de décision
Le choix n'est pas idéologique, il dépend du taux de contention sur la ligne.
Contention sur la ressource ?
│
├── Faible (la collision est rare : profil utilisateur, panier perso)
│ → OPTIMISTIC (#[ORM\Version]). Pas de verrou tenu, on retry l'exception
│ rare. Scale en lecture, zéro lock contention.
│
├── Élevée + write court (compteur stock chaud, solde)
│ → CHECK-AND-SET atomique : UPDATE ... WHERE quantite >= :q.
│ Aucune entité chargée, aucun lock applicatif, la DB arbitre.
│ (cf. Scénario 3 Black Friday : ÷4 sur la p95)
│
└── Élevée + invariant multi-lignes (virement, réservation de sièges)
→ PESSIMISTIC (SELECT ... FOR UPDATE), verrous ordonnés.
On accepte la sérialisation locale pour garantir l'invariant.| Critère | Optimistic (@Version) | Pessimistic (FOR UPDATE) | Check-and-set (WHERE … >= …) |
|---|---|---|---|
| Verrou tenu | Aucun | Toute la transaction | Aucun (atomique en 1 UPDATE) |
| Échec | OptimisticLockException → retry | Attente (ou deadlock) | 0 ligne affectée → erreur métier |
| Coût sous forte contention | Retries en cascade (livelock possible) | File d'attente, throughput limité | Le moins cher, mais 1 seule ligne |
| Bon pour | Édition humaine, faible collision | Invariant multi-aggregats | Compteurs, stock chaud |
| Anti-pattern | Boucle de retry infinie sans backoff | Tenir le lock pendant un appel HTTP | Logique métier complexe dans le SQL |
OptimisticLockException côté code — toujours prévoir le retry borné, sinon c'est un 500 intermittent :
use Doctrine\ORM\OptimisticLockException;
for ($attempt = 1; ; $attempt++) {
try {
$this->em->wrapInTransaction(function () use ($cmd): void {
$produit = $this->em->find(Produit::class, $cmd->id);
$produit->reserver($cmd->qty); // bumpe @Version au flush
});
return;
} catch (OptimisticLockException) {
$this->em->clear(); // l'EM est dans un état incertain : on repart propre
if ($attempt >= 3) {
throw new ConflitConcurrentException($cmd->id);
}
usleep(random_int(10_000, 50_000) * $attempt); // backoff + jitter
}
}Le jitter (random_int) est non négociable : sans lui, N requêtes en conflit retentent toutes au même instant et se re-collisionnent (thundering herd).
Production — observabilité, perf, sécurité
Observabilité : ce qu'un staff engineer instrumente
Une transaction est invisible dans la plupart des dashboards par défaut. À tracer explicitement :
- Durée de transaction (du
BEGINauCOMMIT), pas juste la latence HTTP. Une p99 de transaction qui dérive = contention naissante. - Taux de rollback par cause (
deadlockvsserialization_failurevs métier). Un pic de40001/40P01annonce un incident de concurrence avant qu'il ne devienne visible côté users. - Lag de l'outbox :
now() - min(created_at) WHERE published_at IS NULL. Si ça monte, le relais est en retard → notifications en retard. - Locks en attente :
pg_stat_activity(wait_event_type = 'Lock') côté Postgres ;SHOW ENGINE INNODB STATUScôté MySQL.
// Tracing manuel autour d'une transaction (OpenTelemetry / Symfony Stopwatch)
$span = $tracer->spanBuilder('db.transaction.virement')->startSpan();
try {
$this->em->wrapInTransaction($work);
$span->setAttribute('db.transaction.outcome', 'commit');
} catch (RetryableException $e) {
$span->setAttribute('db.transaction.outcome', 'deadlock');
throw $e;
} finally {
$span->end();
}Requête Postgres à mettre en alerte (transactions « idle in transaction » — le tueur silencieux des pools de connexions) :
SELECT pid, now() - xact_start AS duration, state, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - xact_start > interval '5 seconds'
ORDER BY duration DESC;Configure idle_in_transaction_session_timeout = '15s' en prod : une transaction laissée ouverte (oubli de commit/rollback dans une branche, cf. Pitfall 3) bloque le VACUUM et fait gonfler le bloat. C'est l'une des causes #1 de dégradation Postgres en prod.
Perf : le coût caché des transactions
- Connection pool : une transaction monopolise une connexion. Pool de 20, 20 transactions lentes → la 21e attend. Les transactions longues sont une attaque DoS sur ton propre pool.
- WAL / redo log : chaque commit
fsync. Batcher 10 000 inserts en 1 transaction est plus rapide que 10 000 commits — mais 1 transaction de 10 000 lignes tient des verrous longtemps. Compromis : commit par chunks de ~500–1000 (flush()+clear()+commit()par lot). READ ONLY:SET TRANSACTION READ ONLYpermet à Postgres de sauter une partie du tracking MVCC et d'éviter d'allouer un XID — gain réel sur les gros reporting.
// Batch import : transaction par chunk, pas une transaction géante
foreach (array_chunk($lignes, 500) as $chunk) {
$this->em->wrapInTransaction(function () use ($chunk): void {
foreach ($chunk as $ligne) {
$this->em->persist($this->mapper->toEntity($ligne));
}
$this->em->flush();
});
$this->em->clear(); // libère l'identity map → évite la fuite mémoire O(n)
}Sécurité
- Pas de logique d'autorisation à l'intérieur de la transaction si elle dépend d'un appel externe (OPA, LDAP) : tu tiendrais un lock pendant un appel réseau. Autorise avant d'ouvrir la transaction.
- Injection via
payloadoutbox :json_encode/json_decodeavecJSON_THROW_ON_ERROR(déjà fait ci-dessus) ; ne jamais relayer un payload non validé vers un handler qui exécute du SQL dynamique. - TOCTOU sur les soldes : « vérifier le solde puis débiter » sans verrou = race exploitable (double-spend). Le verrou pessimiste ou le check-and-set atomique ferment la fenêtre.
🏋️ Exercices
Chaque exercice s'écrit avec une vraie DB (Postgres en conteneur). Pour ceux marqués « concurrence », ouvre deux connexions psql côté à côté pour observer les verrous en temps réel.
Exercice 1 — Le débit atomique (implémentation)
Objectif : implémenter transfer(int $fromId, int $toId, int $cents) atomique, refus si solde insuffisant, sans aucune race possible.
Écris un test qui lance 100 virements concurrents et vérifie qu'aucun compte ne passe sous zéro et que la somme totale est conservée.
Indice / Solution
wrapInTransaction + find(..., LockMode::PESSIMISTIC_WRITE) sur les deux comptes, dans un ordre déterministe (tri des IDs). L'invariant à asserter en test : SUM(balance) constant avant/après, et MIN(balance) >= 0. Sans le verrou ordonné, ton test de concurrence finira par deadlock — c'est le but, observe le SQLSTATE 40P01.
Exercice 2 — Optimistic + retry borné (production-grade)
Objectif : remplacer le verrou pessimiste de l'exercice 1 par de l'optimistic locking (#[ORM\Version]) sur un compteur de stock à forte contention, avec retry borné + backoff jitter.
Mesure le throughput (virements/s) sous 50 threads pessimiste vs optimistic vs check-and-set. Trace lequel gagne et pourquoi.
Indice / Solution
Ajoute #[ORM\Version] private int $version à Produit. Boucle for avec catch (OptimisticLockException), em->clear(), usleep(random_int(...)). Tu observeras : sous faible contention, optimistic gagne (pas de lock) ; sous forte contention, le check-and-set (UPDATE ... WHERE stock >= :q) écrase tout le monde car il n'y a ni retry ni lock applicatif. C'est exactement le ÷4 du Scénario 3.
Exercice 3 — Transactional outbox de bout en bout (production-grade)
Objectif : table outbox_message, écriture dans la transaction métier, relais app:outbox:relay avec FOR UPDATE SKIP LOCKED, consommateur idempotent.
Prouve par un test que tuer le process entre le dispatch et le published_at provoque exactement un rejeu (at-least-once) et que le consommateur dédup correctement.
Indice / Solution
Le relais doit envelopper SELECT+UPDATE dans db->transactional() (sinon SKIP LOCKED est un no-op). Côté consommateur : table processed_events(event_id PK), INSERT ... ON CONFLICT DO NOTHING ; si 0 ligne insérée → déjà traité, on ignore. Le test de crash : register_shutdown_function ou un Throwable injecté après le dispatch, puis relance le relais et compte les effets.
Exercice 4 — Casse puis répare : le deadlock (break-then-fix)
Objectif : écrire délibérément deux handlers qui se verrouillent en sens inverse (A→B et B→A), reproduire le deadlock de façon fiable, puis le corriger.
Indice / Solution
Deux transactions : T1 lock compte 1 puis 2, T2 lock compte 2 puis 1, avec un sleep entre les deux locks pour forcer la fenêtre. Postgres détecte le cycle et tue une transaction (40P01). Fix : ordre de verrouillage total (tri des IDs) — le cycle devient impossible. Bonus : ajoute le retry sur RetryableException et montre que même un deadlock résiduel est absorbé.
Exercice 5 — Casse puis répare : la transaction « idle in transaction » (break-then-fix)
Objectif : reproduire le tueur de pool — une branche conditionnelle qui beginTransaction() sans jamais commit()/rollback() — puis le diagnostiquer via pg_stat_activity et le réparer.
Indice / Solution
Code fautif : beginTransaction(), puis if ($x) { return; } avant le commit. La connexion reste « idle in transaction ». Diagnostic : la requête pg_stat_activity de la section Observabilité. Fix : wrapInTransaction() (gère commit/rollback automatiquement même sur return/exception), + idle_in_transaction_session_timeout comme filet de sécurité. Leçon : la closure n'est pas du sucre syntaxique, c'est une garantie de cleanup.
Exercice 6 — Isolation et anomalies (break-then-fix)
Objectif : reproduire une write-skew anomaly sous READ COMMITTED, montrer qu'elle disparaît sous SERIALIZABLE, et gérer le 40001 qui en découle.
Indice / Solution
Classique du « on-call doctor » : deux médecins de garde, chacun voit l'autre de garde et se retire ; sous READ COMMITTED les deux UPDATE passent → zéro médecin de garde (invariant violé). Sous SERIALIZABLE (Postgres SSI), l'une des transactions échoue avec 40001 → retry. Le fix n'est pas juste « monter l'isolation » : c'est isolation + retry borné sur la serialization failure. Sans le retry, tu as juste déplacé le bug en 500.
🎤 En entretien
Q : « Tu dispatches un message Messenger dans un handler transactionnel. Quel est le risque, et comment tu le règles ? » R : Race entre le worker et le commit DB — le worker peut consommer le message avant que la transaction soit visible (SELECT renvoie 404 / état pré-commit). Solution : transport doctrine:// + dispatch dans la transaction (le message est inséré dans la même DB, committé atomiquement), ou un vrai outbox table + relais. Jamais un dispatch vers un transport AMQP synchrone à l'intérieur de la transaction : il part même si la transaction rollback.
Q : « Exactly-once delivery, c'est possible ? » R : Non, pas au niveau transport dans un système distribué (le couple « envoyer le message » + « marquer envoyé » ne peut pas être atomique entre deux systèmes). On vise at-least-once + idempotence côté consommateur (dédup sur un eventId), ce qui est observationnellement equivalent à exactly-once. Quiconque promet exactly-once vend soit de l'idempotence déguisée, soit un bug.
Q : « Pessimistic ou optimistic locking ? » R : Fonction du taux de contention, pas de préférence. Faible collision (édition humaine) → optimistic (@Version), zéro verrou tenu, on retry l'exception rare. Forte contention multi-lignes (virement) → pessimistic ordonné. Compteur chaud (stock) → ni l'un ni l'autre : check-and-set atomique UPDATE ... WHERE stock >= :q, la DB arbitre sans verrou applicatif. Le critère décisif : combien de temps je tiens un verrou, et que se passe-t-il sous 50 requêtes concurrentes.
Q : « Ta transaction tient un appel HTTP (paiement externe) au milieu. Pourquoi c'est un incident en puissance ? » R : Le verrou (et la connexion du pool) est tenu pendant toute la latence réseau — des centaines de ms, voire un timeout de plusieurs secondes. Sous charge, le pool de connexions s'épuise et c'est une panne en cascade. Fix : découper — réserver (transaction courte), appeler l'API hors transaction avec idempotency-key, puis confirmer (seconde transaction courte). Pattern : PENDING → call → CONFIRMED/CANCELLED. La transaction protège l'invariant, pas le workflow.
Liens
- Doctrine ORM Transactions — https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/transactions-and-concurrency.html
- Symfony Messenger Doctrine transport — https://symfony.com/doc/current/messenger.html#doctrine-transport
- Microservices.io — Transactional Outbox Pattern.
- "Postgres SAVEPOINT vs MySQL nested transactions" — articles 2ndQuadrant.
- Vlad Mihalcea blog — concurrency patterns.
- Postgres SSI — https://www.postgresql.org/docs/current/transaction-iso.html
- Chris Richardson — "Microservices Patterns" (chapitres saga + outbox).