🎯 OBJECTIF
Comprendre comment :
@Version) détecte les conflits sans bloquer la baseFOR UPDATE) bloque les accès concurrents dès la lecture🧠 MODÈLE MENTAL
Pense à trois dimensions orthogonales qui protègent des données différentes choses.
La transaction garantit l'atomicité : ton unité de travail réussit entièrement ou échoue entièrement. Mais une transaction ne sait rien de ce que font les autres transactions en parallèle.
L'isolation contrôle ce qu'une transaction peut lire de celles qui tournent en même temps. En READ COMMITTED, tu vois uniquement les données déjà commitées. En REPEATABLE READ, ta première lecture fige le snapshot. L'isolation protège la cohérence de lecture, pas la mise à jour.
Le lock protège la phase d'écriture : il garantit qu'entre le moment où tu lis une donnée et celui où tu la modifies, personne d'autre ne l'a changée. Le lock optimiste dit "je ne bloque rien à la lecture, mais au moment d'écrire je vérifie que la version n'a pas bougé". Le lock pessimiste dit "je verrouille la ligne dès la lecture, les autres attendent".
L'erreur classique : croire qu'ouvrir une transaction suffit à éviter les conflits. Deux caisses peuvent chacune lire "stock = 1", chacune décrémenter dans leur transaction respective, et toutes deux commiter correctement — résultat : stock à -1.
| Mécanisme | Ce qu'il protège | Quand il agit | Configuré par |
|---|---|---|---|
| Transaction | Atomicité (tout ou rien) | Début → commit/rollback | @Transactional |
| Isolation | Ce qu'une tx peut lire des autres | Pendant chaque lecture | Niveau isolation JDBC/DB |
| Lock optimiste | Conflits d'écriture concurrente | Au moment du flush | @Version sur l'entité |
| Lock pessimiste | Accès concurrent à une ligne | Dès la requête SELECT |
LockModeType sur la query |
🔥 Le piège classique : deux transactions qui commitent correctement mais laissent un état incohérent
T1 : SELECT stock FROM product WHERE id=42 → stock = 1 ✓ disponible
T2 : SELECT stock FROM product WHERE id=42 → stock = 1 ✓ disponible
T1 : UPDATE product SET stock = 0 WHERE id=42 → commit OK
T2 : UPDATE product SET stock = 0 WHERE id=42 → commit OK ⚠️ stock ne peut pas être négatif !
Les deux transactions ont commité sans erreur. Seul un lock pouvait bloquer ce scénario.
@Version@Entity
public class Product {
@Id
private Long id;
private int stock;
@Version // ✓ Hibernate gère cette colonne automatiquement
private Long version;
}javasequenceDiagram
participant T1 as Transaction 1
participant DB as Base de données
participant T2 as Transaction 2
T1->>DB: SELECT * FROM product WHERE id=42 (version=3)
T2->>DB: SELECT * FROM product WHERE id=42 (version=3)
T1->>DB: UPDATE product SET stock=0, version=4 WHERE id=42 AND version=3
DB-->>T1: 1 row updated — commit
T2->>DB: UPDATE product SET stock=0, version=4 WHERE id=42 AND version=3
DB-->>T2: 0 rows updated
T2-->>T2: OptimisticLockException — rollbackmermaid🔑 Conclusion clé
Hibernate ne lève pas l'exception lui-même : il vérifie le rowCount retourné par le driver JDBC après l'UPDATE. Si rowCount == 0, la version a changé entre-temps — OptimisticLockException est lancée lors du flush.
@Service
public class StockService {
@Retryable(
retryFor = OptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2, random = true) // ✓ jitter activé
)
@Transactional
public void decrementStock(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(ProductNotFoundException::new);
if (product.getStock() <= 0) throw new OutOfStockException();
product.setStock(product.getStock() - 1);
}
@Recover
public void recover(OptimisticLockingFailureException ex, Long productId) {
throw new ConcurrentModificationException("Impossible après 3 tentatives : " + productId);
}
}java⚡ Pourquoi le jitter est non-négociable
Sans jitter, si 10 threads échouent en même temps ils attendent tous exactement 50 ms et réessaient simultanément — même contention, même échec. Le jitter aléatoire désynchronise les retries.
FOR UPDATE@Transactional
public void reserveSeat(Long seatId) {
// ✓ Hibernate génère : SELECT ... FROM seat WHERE id=? FOR UPDATE
Seat seat = em.find(Seat.class, seatId, LockModeType.PESSIMISTIC_WRITE);
if (seat.isReserved()) throw new SeatAlreadyReservedException();
seat.setReserved(true);
}javaAvec Spring Data :
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Seat s WHERE s.id = :id")
Optional<Seat> findByIdForUpdate(@Param("id") Long id);javaPESSIMISTIC_WRITE |
PESSIMISTIC_READ |
|
|---|---|---|
| SQL généré | SELECT ... FOR UPDATE |
SELECT ... FOR SHARE |
| Bloque | Lectures ET écritures | Écritures seulement |
| Usage | Modification imminente garantie | Lecture cohérente sans bloquer les lecteurs |
🔥 Deadlock : le piège des verrous croisés
T1 acquiert le verrou sur Account(id=1)
T2 acquiert le verrou sur Account(id=2)
T1 tente d'acquérir Account(id=2) → attend T2
T2 tente d'acquérir Account(id=1) → attend T1
→ Deadlock : la base tue l'une des deux transactions
Règle absolue : toujours acquérir les verrous dans le même ordre (ex : par ID croissant).
| Niveau | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
READ UNCOMMITTED |
Possible | Possible | Possible |
READ COMMITTED (défaut PG) |
Impossible | Possible | Possible |
REPEATABLE READ |
Impossible | Impossible | Possible |
SERIALIZABLE |
Impossible | Impossible | Impossible |
🔑 Ce que l'isolation ne fait PAS
Même en REPEATABLE READ, deux transactions peuvent lire la même valeur, prendre des décisions divergentes basées sur cette valeur, et toutes deux commiter — produisant un état incohérent. L'isolation protège la cohérence de lecture, pas la cohérence décisionnelle. Pour ça, il faut un lock.
| Situation | Stratégie recommandée |
|---|---|
| Lecture pure, pas de modification | Aucun lock |
| Modification rare, peu de concurrence | Lock optimiste @Version |
| Forte concurrence, retry acceptable | Lock optimiste + @Retryable avec jitter |
| Opération non rejouable (paiement, siège) | Lock pessimiste FOR UPDATE |
| Multi-entités modifiées ensemble | Lock pessimiste sur toutes, ordre fixe |
⚡ TL;DR — chaque concept en une ligne
Lock optimiste (@Version)
✓ Ajoute une colonne version : Hibernate génère WHERE id=? AND version=? à chaque UPDATE — si une autre transaction a modifié entre-temps, OptimisticLockException est lancée.
⚠ Ne bloque rien : parfait en faible concurrence, mais provoque des cascades d'exceptions si de nombreuses transactions se marchent dessus.
Lock pessimiste (FOR UPDATE)
✓ Pose un verrou exclusif en base dès la lecture — les autres transactions qui tentent de lire la même ligne avec FOR UPDATE sont bloquées jusqu'au commit.
⚠ Risque de deadlock si deux transactions acquièrent des verrous dans des ordres inverses.
Isolation (READ COMMITTED / REPEATABLE READ)
✓ READ COMMITTED (défaut PostgreSQL) évite les dirty reads ; REPEATABLE READ fige le snapshot de lecture.
⚠ L'isolation ne protège pas contre les conflits d'écriture concurrente — elle protège ce que tu vois, pas ce que tu modifies.
Retry + Backoff + Jitter
✓ En cas d'OptimisticLockException, réessayer avec un délai exponentiel + jitter absorbe les pics de contention transitoire.
⚠ Sans jitter, tous les threads réessaient en même temps et recréent exactement la même contention.
🎓 À retenir
REPEATABLE READ ne remplace pas un lock — elle fige ce que tu lis, pas ce que tu décides d'écrire. Deux transactions peuvent chacune lire stock=1 en REPEATABLE READ et toutes deux commiter un décrement, laissant stock négatif.SELECT — il injecte AND version=? uniquement dans le UPDATE généré au moment du flush. La protection n'existe qu'à l'écriture.FOR SHARE. Garder les transactions avec FOR UPDATE aussi courtes que possible.@Version, LockModeType, PESSIMISTIC_WRITE, PESSIMISTIC_READ@Version, comportements garantis