1. OptimisticLockException comme issue normale
Lorsqu'un développeur attrape pour la première fois une OptimisticLockException, la réaction est généralement très humaine : « quelle ORM capricieuse, pourquoi plante-t-elle, on a simplement soustrait deux nombres ». En réalité, c'est à ce moment que Hibernate se comporte de la manière la plus honnête : il vous dit que vous avez essayé d'enregistrer un état qui est déjà obsolète. Ce n'est pas une “500 internal error selon l'humeur”, mais une issue normale du travail concurrent.
Le point où un tel conflit devient visible, nous l'avons déjà trouvé : c'est l'écriture en base de données lors du flush() / commit(). Maintenant, la question ne porte plus sur la mécanique du @Version, mais sur le comportement du service : que considérer comme résultat d'une telle opération et comment réagir sans casser le transaction flow.
Pour voir cela plus sereinement, il est utile de changer mentalement de cadre : le optimistic locking ne « rend pas la mise à jour réussie », il interdit l'écrasement silencieux. Autrement dit, le système ne promet pas que chaque tentative de réservation ou de changement de statut réussira forcément. Il promet que si quelqu'un a modifié la ligne après votre lecture, vous en serez informé, plutôt que d'obtenir un joli résultat mais incorrect.
Dans une application réelle, un conflit de version signifie le plus souvent l'une des deux choses suivantes. Soit deux utilisateurs (ou deux threads) touchent effectivement simultanément la même ressource (par exemple, le stock d'un article). Soit vous avez un « point chaud » et la concurrence est si fréquente qu'il faut concevoir le scénario différemment. Mais ici, il est important de retenir le principe principal : un conflit de version est un résultat distinct d'une opération métier, et non une « erreur d'infrastructure non traitée ».
2. Réaction face au conflit : résultat et retry
Lorsque vous savez déjà détecter un conflit de version, la question suivante devient étonnamment pratique : « OK, un conflit — et que doit faire le service ensuite ? ». À ce stade, il est important de ne pas tomber dans les extrêmes. La première extrémité est de transformer tout conflit en « on a planté parce qu'on a planté » et de renvoyer un générique 500. La deuxième est d'essayer de « forcer la réalité » avec des répétitions infinies, comme si la base de données devait céder si vous êtes assez persévérant.
Dans un code sain et pratique, il y a généralement deux stratégies. La première consiste à renvoyer explicitement un conflit (et souvent à donner un indice : relire les données et essayer à nouveau sur un état frais). La seconde consiste à effectuer une nouvelle tentative limitée, mais seulement si l'opération est courte et locale, et chaque tentative commence par une nouvelle lecture. L'idée du retry ici n'est pas de « vaincre la concurrence », mais d'éliminer les collisions aléatoires lorsque deux transactions se rencontrent littéralement sur une milliseconde.
Il est pratique de comparer les stratégies non pas philosophiquement, mais d'un point de vue ingénierie :
| Stratégie de réaction | Ce que reçoit le code appelant | Quand approprié | Ce qu'il est important de retenir |
|---|---|---|---|
| Conflit explicite (sans retry) | “CONFLICT / RELOAD_REQUIRED” (parfois + données actuelles) | Quand une nouvelle tentative est coûteuse ou peut changer le sens (opération longue, nombreux effets de bord, scénario UX important) | Le conflit est une issue normale, on ne doit pas le masquer sous une « erreur serveur » |
| Nouvelle tentative limitée | Soit “succès”, soit “conflit après N tentatives” | Quand l'opération est courte, locale et répétable sans danger (exemple typique : ajustement de compteurs de stock) | Chaque tentative doit commencer par une nouvelle lecture et une nouvelle transaction ; le nombre de tentatives est strictement limité |
Notez la nuance : retry ne signifie pas « appeler à nouveau flush() ». C'est une nouvelle tentative : relire l'état courant, réappliquer la logique métier, essayer à nouveau d'écrire. Si vous répétez seulement la dernière partie (par exemple, refaire un flush()), vous ne faites que répéter la même erreur avec le même état obsolète.
3. Conflit explicite dans le contrat du service
Une fois que le moment du conflit est compris, il faut un pattern stable au niveau du service. Le schéma de travail ici est le suivant : une tentative transactionnelle effectue un read–modify–write et ne cache rien, et le code externe transforme son issue en un résultat explicite ou en un retry limité.
Quand nous disons « renvoyer un conflit explicite », cela sonne comme un mantra architectural tant qu'on n'a pas essayé de le formaliser en code. En pratique, la tâche est simple : au niveau du service, nous avons besoin d'un résultat qui sache distinguer au moins deux issues — « succès » et « conflit ». Souvent, une troisième issue s'ajoute — « condition métier non satisfaite » (par exemple, stock insuffisant), car on ne peut pas la mélanger avec un conflit d'écriture concurrente.
Commençons par un modèle de résultat minimal et compréhensible. Dans le projet de laboratoire Commerce Persistence Lab, nous pouvons décrire le résultat d'une réservation de manière à ce qu'il soit lisible même pour ceux qui n'aiment pas les « exceptions comme contrôle de flux » :
package com.example.commerce.inventory.service;
public enum ReservationStatus {
// Réservation effectuée avec succès
RESERVED,
// Nous avons perdu la course : quelqu'un a modifié la ligne après notre lecture,
// il faut relire les données actuelles et, éventuellement, répéter l'action
CONFLICT_RELOAD_REQUIRED,
// Condition métier non satisfaite : stock réellement insuffisant
NOT_ENOUGH_STOCK
}
Si l'on souhaite un peu plus d'informations (par exemple, afficher le stock actuel après un conflit), il est pratique d'ajouter un petit record. Le record est intéressant car il est court et ne nécessite pas de boilerplate, et pour un projet pédagogique c'est un vrai cadeau du ciel (pardonnez le jeu de mots) :
package com.example.commerce.inventory.service;
public record ReservationResult(
// Résultat de l'opération (succès / conflit / erreur métier)
ReservationStatus status,
// Quantité actuelle disponible, si nous l'avons relue après le conflit.
// Peut être null si nous n'avons pas besoin de retourner cette valeur (par exemple en cas de succès).
Integer currentAvailableQty
) {
}
Ici, un point sémantique important : currentAvailableQty n'est pas toujours renseigné. En cas de réservation réussie, il peut être null, et en cas de conflit, il contient la valeur actuelle que le service a relue séparément.
Maintenant — la question pratique principale : où attraper le conflit ? L'option la plus sûre pour la compréhension est la suivante : une méthode qui effectue une seule tentative reste transactionnelle et, en cas de conflit, lève simplement une exception. Ensuite, l'enveloppe externe (non transactionnelle) décide quoi faire : renvoyer le conflit ou essayer à nouveau.
Cela ressemble à ceci : « une seule tentative » — dans un service séparé, pour que la transaction s'ouvre bien sur l'appel externe et ne soit pas perdue à cause d'un self-invocation. Notez : il n'y a pas de retry « intelligent » ici, nous effectuons simplement une tentative et forçons Hibernate à révéler le conflit immédiatement via flush().
package com.example.commerce.inventory.service;
import com.example.commerce.inventory.entity.InventoryItem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryReservationTxService {
@PersistenceContext
private EntityManager entityManager;
@Transactional
public void reserveOnce(Long itemId, int qty) {
// 1) Lecture de l'entité dans le contexte de persistance (état managed)
InventoryItem item = entityManager.find(InventoryItem.class, itemId);
// 2) Application de la logique métier à l'objet managed
item.reserve(qty);
// 3) Important : envoi forcé des modifications à la base de données,
// pour que le conflit optimiste se manifeste immédiatement (dans cette tentative),
// et non pas « à la fin » lors du commit.
entityManager.flush();
}
}
Et maintenant, le service externe qui transforme le conflit d'« exception ORM » en « issue compréhensible de l'opération » :
package com.example.commerce.inventory.service;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Service;
@Service
public class InventoryReservationService {
private final InventoryReservationTxService txService;
private final InventoryReadService readService;
public InventoryReservationService(
InventoryReservationTxService txService,
InventoryReadService readService
) {
this.txService = txService;
this.readService = readService;
}
public ReservationResult reserveOrReturnConflict(Long itemId, int qty) {
try {
// Une seule tentative transactionnelle "read-modify-write"
txService.reserveOnce(itemId, qty);
// Si on arrive ici, l'écriture s'est déroulée sans conflit de version
return new ReservationResult(ReservationStatus.RESERVED, null);
} catch (OptimisticLockingFailureException ex) {
// Nous avons perdu la course : quelqu'un a mis à jour la même ligne.
// Nous relisons la valeur actuelle pour renvoyer une indication
// « ce qui est actuellement dans la base » (par exemple pour l'UI/le client).
int current = readService.readAvailableQty(itemId);
return new ReservationResult(ReservationStatus.CONFLICT_RELOAD_REQUIRED, current);
}
}
}
Remarquez que nous attrapons OptimisticLockingFailureException — c'est une abstraction au niveau Spring, que l'on rencontre souvent dans les applications réelles. Selon l'endroit et le moment de l'erreur, vous pourriez voir aussi jakarta.persistence.OptimisticLockException, des exceptions Hibernate, ou des wrappers Spring. Du point de vue du service, ce qui importe est que c'est bien un conflit optimiste, et non une « erreur réseau » ou un « SQL incorrect ».
Il reste à montrer InventoryReadService, qui relit le stock actuel. Ici, il est utile de faire une lecture séparée dans une transaction readOnly, pour qu'elle soit économique et prévisible :
package com.example.commerce.inventory.service;
import com.example.commerce.inventory.entity.InventoryItem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryReadService {
@PersistenceContext
private EntityManager entityManager;
@Transactional(readOnly = true)
public int readAvailableQty(Long itemId) {
// Lecture séparée de l'état actuel depuis la base de données.
// La transaction readOnly n'est pas « pour la magie », mais pour la prévisibilité et les optimisations.
InventoryItem item = entityManager.find(InventoryItem.class, itemId);
return item.getAvailableQty();
}
}
Un tel style « conflit explicite comme résultat » a l'avantage de donner à votre application un contrat normal et contrôlable. Même si vous construisez ensuite une API REST, une UI, des intégrations, vous avez une issue compréhensible : l'opération n'a pas planté « parce qu'elle a planté », elle a perdu la course et demande de relire les données.
4. Retry limité : comment bien faire
Le retry semble dire « on va juste réessayer une fois de plus, et voilà ». Et c'est précisément pour cela que c'est l'une des parties de code les plus dangereuses : cela paraît simple, mais il est facile de répéter par erreur à l'intérieur de la même transaction, de répéter des effets de bord ou de créer une lutte sans fin contre la concurrence. Au final, vous pourriez obtenir soit des actions en double, soit une boucle infinie, soit des erreurs étranges qui « ne se reproduisent qu'en production, uniquement le vendredi ».
Commençons par la limite d’acceptabilité. Une nouvelle tentative limitée est généralement appropriée lorsque l'opération est courte, locale et répétable sans « monde extérieur ». La réservation de stock est un bon exemple pédagogique car elle se réduit le plus souvent à la modification d'une seule ligne (InventoryItem) et n'inclut pas de requêtes externes ou de « demande à l'utilisateur d'attendre ». Mais même ici, la répétition doit être très disciplinée : chaque tentative est un nouveau cycle read-modify-write et, de préférence, une transaction séparée, pour que la tentative soit indépendante et ne traîne pas un contexte de persistance corrompu.
L'idée clé du retry dans l'optimistic locking peut s'exprimer en une phrase : répéter est une nouvelle tentative, pas « forcer le même objet ». C'est pourquoi un retry correct se construit presque toujours de manière à ce qu'une fonction externe effectue plusieurs tentatives indépendantes, chacune appelant une méthode transactionnelle « one attempt ». À l'intérieur de la tentative, nous continuons à faire flush() pour que le conflit se manifeste immédiatement, et non à la fin (sinon il est difficile de savoir quelle tentative a réussi).
La forme la plus simple de « une chance supplémentaire » ressemble à ceci. Notez : nous répétons toute la tentative, et non flush() sur l'ancien objet. Et nous limitons à l'avance le nombre de tentatives : maximum 2 (la première + une supplémentaire).
package com.example.commerce.inventory.service;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Service;
@Service
public class InventoryReservationRetryService {
private final InventoryReservationTxService txService;
public InventoryReservationRetryService(InventoryReservationTxService txService) {
this.txService = txService;
}
public boolean reserveWithOneMoreAttempt(Long itemId, int qty) {
// Tentative n°1
if (tryReserve(itemId, qty)) {
return true;
}
// Tentative n°2 (une supplémentaire)
return tryReserve(itemId, qty);
}
private boolean tryReserve(Long itemId, int qty) {
try {
// Important : à l'intérieur — une seule tentative transactionnelle séparée
txService.reserveOnce(itemId, qty);
return true;
} catch (OptimisticLockingFailureException ex) {
// Conflit de version : nous signalons simplement "pas réussi"
// (la décision de la suite est au niveau de la politique)
return false;
}
}
}
Maintenant, à propos d'une nuance facile à manquer. Un retry signifie presque toujours : « le code peut être exécuté deux fois ». Même si la deuxième tentative échoue, vous avez quand même essayé d'exécuter l'opération deux fois. Par conséquent, à l'intérieur de reserveOnce(), il doit y avoir un minimum d'effets de bord non liés à l'écriture en base de données. S'il y avait « envoyer un email au client » ou « écrire dans un système tiers », vous pourriez accidentellement le faire deux fois — et c'est un tout autre niveau de complexité. Dans notre scénario pédagogique, nous maintenons délibérément la tentative aussi “DB-centric” que possible.
Une autre nuance importante : après un conflit, la version a changé, donc les données ont changé, et vous devez revérifier les conditions métier. Par exemple, si dans la première tentative l'article était encore en stock, et qu'à la deuxième tentative il a déjà été pris, alors un « retry pour le retry » pourrait se transformer en mystérieux stocks négatifs (si vous ne vérifiez pas du tout availableQty). C'est pourquoi la logique métier de reserve() doit être un point de vérification unique, et elle doit être appelée dans chaque tentative.
Que la méthode reserve() soit aussi directe que possible. Oui, elle est “mutable”, mais pour un laboratoire c'est normal : ce qui nous importe ici est l'optimistic locking, pas l'éternelle philosophie des domaines immuables.
package com.example.commerce.inventory.entity;
public class InventoryItem {
private int availableQty;
private int reservedQty;
public void reserve(int qty) {
// Validation d'entrée : une réservation négative/nulle est une erreur d'appel
if (qty <= 0) throw new IllegalArgumentException("qty must be positive");
// Vérification métier : impossible de réserver plus que ce qui est disponible
if (availableQty < qty) throw new IllegalStateException("not enough stock");
// Modification de l'état domaine (c'est ce qui sera ensuite sauvegardé en base de données)
availableQty -= qty;
reservedQty += qty;
}
}
Si les conflits sont rares, une tentative supplémentaire donne souvent un effet agréable : l'utilisateur ne voit pas le conflit « à cause d'une course aléatoire », et l'opération reste correcte. Mais si les conflits se produisent constamment, le retry devient coûteux : vous commencez à gaspiller beaucoup de transactions pour des tentatives infructueuses, et c'est déjà un signal que le scénario est devenu un « point chaud » et qu'il faut le repenser.
Pour que le retry ne devienne pas un réflexe « allez, encore une fois », il est utile de garder une règle de sélection simple :
| L'optimistic locking se sent bien ici | C'est déjà un signe d'alerte |
|---|---|
| Les conflits sont rares | La même ligne entre en conflit constamment |
| L'opération est courte et locale | L'opération est longue, coûteuse ou comporte plusieurs étapes |
| La répétition est peu coûteuse et commence par une nouvelle lecture | Chaque conflit entraîne un nouveau cycle coûteux read-modify-write |
| Pas d'effets de bord externes | Il y a des emails, des API externes, des actions d'arrière-plan longues ou une attente utilisateur |
Si vous vous retrouvez de plus en plus souvent dans la colonne de droite, le problème n'est plus que « le retry est peu pratique ». Vous payez le prix fort pour une course perdue encore et encore, et l'optimistic locking cesse d'être un défaut confortable pour ce point du système.
5. Schéma du flux : conflit + retry optionnel
Lorsque vous avez deux stratégies en main, il est utile de les voir comme un flux unique, et non comme « différents morceaux de code dans différents coins du projet ». Sinon, dans un mois, vous ouvrirez le service et verrez trois blocs catch, deux transactions et un triste commentaire “ne pas toucher, ça marche”. Fixons donc un schéma canonique où l'on voit ce que le système fait en cas de succès et en cas de conflit.
La logique modèle ici est la suivante : d'abord, on essaie d'exécuter l'opération. Si tout va bien, on renvoie un succès. Si conflit, soit on fait une tentative supplémentaire, soit on renvoie immédiatement le conflit (selon le contrat choisi). Et si le conflit se reproduit, on renvoie un conflit explicite et, si nécessaire, on relit l'état actuel.
Sous forme de schéma, cela peut être représenté ainsi :
flowchart TD
A["reserveWithPolicy(itemId, qty)"] --> B["Attempt #1: reserveOnce in tx + flush"]
B -->|succès| S["Retourner RESERVED"]
B -->|conflit optimiste| C{Retry autorisé ?}
C -->|non| R["Retourner CONFLICT_RELOAD_REQUIRED"]
C -->|oui| D["Attempt #2: reserveOnce in new tx + flush"]
D -->|succès| S
D -->|conflit optimiste| E["readAvailableQty in read-only tx"]
E --> F["Retourner CONFLICT + currentAvailableQty"]
Au niveau du code Java, cela se traduit généralement soit par deux méthodes publiques différentes (l'une « sans retry », l'autre « avec retry »), soit par une seule méthode avec un paramètre de politique. Pour un projet pédagogique, deux méthodes suffisent souvent pour que l'étudiant lise plus facilement : reserveOrReturnConflict et reserveWithOneMoreAttempt.
6. Erreurs typiques lors d'un conflit optimiste
Les erreurs dans l'optimistic locking ressemblent presque toujours aux erreurs lorsqu'on utilise un extincteur : tant qu'il n'y a pas de feu, tout semble simple, mais quand le feu se déclare, on découvre qu'on le tenait à l'envers et qu'on a lu les instructions en courant. Il est particulièrement important de comprendre à l'avance quelles solutions incorrectes semblent « logiques » mais rendent le comportement du système imprévisible. Voici les pièges les plus courants que l'on rencontre spécifiquement dans le traitement des conflits de version.
Erreur n°1 : transformer un conflit optimiste en « erreur interne du serveur » et renvoyer un 500.
Ainsi, vous perdez le principal sens du mécanisme @Version : il n'est pas fait pour planter joliment, mais pour vous donner la possibilité de construire un contrat normal. Si le conflit se transforme toujours en 500, les utilisateurs (et les développeurs) commencent à penser que le système est instable, alors qu'il dit honnêtement : « les données ont déjà changé ».
Erreur n°2 : faire un retry sur le même objet et dans la même transaction.
Intuitivement, on veut « ne pas perdre le travail » et répéter flush() ou « réécrire » sur un objet managed déjà modifié. Mais un conflit signifie que votre état est obsolète, et la transaction actuelle est souvent vouée au rollback. Un retry correct est une nouvelle lecture et une nouvelle tentative, et non la répétition de la dernière étape.
Erreur n°3 : oublier de limiter le nombre de tentatives.
Une boucle infinie « jusqu'à ce que ça marche » semble être une marque de confiance, mais en pratique elle se transforme en un code bruyant et difficile à gérer. Elle peut créer une avalanche de requêtes vers la base de données, aggraver la situation de concurrence et même conduire à l'effet « tout le monde répète, tout le monde empire, mais personne n'abandonne ». Dans le code pédagogique comme dans le code réel, il est raisonnable de garder un retry petit : une tentative supplémentaire est déjà bien.
Erreur n°4 : répéter une opération longue avec des effets de bord.
Le retry est sûr lorsque l'opération est essentiellement une courte mise à jour de base de données. Si la tentative contient des effets externes (logique qui envoie des courriels, écrit dans un autre système, effectue des imputations), la répétition peut entraîner des doublons. Même si vous « attrapez le conflit » et rollbackez ensuite la transaction, l'effet externe a pu déjà se produire, et la base de données n'y est pour rien.
Erreur n°5 : ne pas revérifier les conditions métier lors de la nouvelle tentative.
Après un conflit, les données ont changé — sinon il n'y aurait pas eu de conflit. Par conséquent, dans la nouvelle tentative, on ne peut pas simplement « appliquer la même modification ». Il faut revérifier les invariants : le stock est-il suffisant, la transition de statut est-elle autorisée, n'avons-nous pas violé une contrainte. Dans notre exemple, cela signifie que reserve() doit revérifier availableQty, et non « croire » les calculs de la première tentative.
GO TO FULL VERSION