Contactez-nous

Verrouillage optimiste et pessimiste avec JPA (`@Version`)

Comprenez et appliquez les stratégies de verrouillage optimiste (avec @Version) et pessimiste pour gérer la concurrence d'accès aux données dans vos applications JPA.

Le problème de la concurrence : Les mises à jour perdues

Dans un système multi-utilisateurs où plusieurs transactions peuvent tenter de lire et de modifier les mêmes données simultanément, un problème classique est celui de la "mise à jour perdue" (lost update). Imaginons le scénario suivant :

  1. Transaction A lit l'enregistrement X (par exemple, un solde de compte).
  2. Transaction B lit le même enregistrement X.
  3. Transaction A modifie X (par exemple, débite le compte) et valide (commit).
  4. Transaction B modifie X (par exemple, crédite le compte en se basant sur la valeur lue à l'étape 2, qui est maintenant obsolète) et valide (commit).

Le résultat est que la modification effectuée par la Transaction A est perdue, écrasée par la Transaction B. Pour éviter ce type de problème et garantir l'intégrité des données, les systèmes de gestion de bases de données et les frameworks ORM comme JPA proposent des stratégies de verrouillage (locking).

Deux approches principales existent : le verrouillage optimiste et le verrouillage pessimiste.

Verrouillage Optimiste : Vérifier avant d'écrire (`@Version`)

Philosophie : Le verrouillage optimiste part du principe que les conflits de mise à jour sont relativement rares. Au lieu de verrouiller les données lors de la lecture (ce qui pourrait bloquer d'autres transactions), il permet à plusieurs transactions de lire les données librement. Le contrôle s'effectue uniquement au moment de la mise à jour (commit).

Mécanisme avec JPA et `@Version` : La stratégie la plus courante pour implémenter le verrouillage optimiste avec JPA est d'utiliser un attribut de version dans l'entité. Cet attribut est généralement un champ numérique (int, long, Integer, Long) ou un timestamp (java.sql.Timestamp, bien que moins recommandé).

  1. Ajoutez un champ à votre entité et annotez-le avec javax.persistence.Version (ou jakarta.persistence.Version).
    @Entity
    public class Product {
        @Id
        private Long id;
        private String name;
        private BigDecimal price;
    
        @Version // Champ pour le verrouillage optimiste
        private Long version; 
        // Ou : private int version;
        // Ou moins courant: private java.sql.Timestamp version;
    
        // Getters, Setters...
    }
    
  2. Fonctionnement :
    • Lorsque JPA charge une entité, il lit également la valeur du champ @Version.
    • Lorsque vous modifiez l'entité et que la transaction est validée (commit), JPA génère une requête UPDATE qui inclut une condition WHERE vérifiant que la version actuelle dans la base de données correspond toujours à la version lue initialement.
    • En même temps, JPA incrémente automatiquement la valeur du champ @Version dans la requête UPDATE.
    • Si une autre transaction a modifié l'enregistrement entre-temps (et donc incrémenté la version dans la base), la clause WHERE de la requête UPDATE de votre transaction ne trouvera pas de ligne correspondante (car la version ne correspond plus).
    • Dans ce cas, le fournisseur JPA (Hibernate) détecte qu'aucune ligne n'a été mise à jour et lève une exception spécifique, typiquement OptimisticLockException (ou une de ses sous-classes comme StaleObjectStateException chez Hibernate).

Exemple SQL généré (conceptuel) :

-- Lire le produit avec ID=1 (supposons version=5)
SELECT id, name, price, version FROM product WHERE id = 1;

-- Modifier le prix en mémoire...

-- Tenter de valider la transaction
UPDATE product 
SET price = 120.00, version = 6 
WHERE id = 1 AND version = 5; -- Vérifie la version lue initialement !

Gestion de l'exception : Votre application doit attraper l'OptimisticLockException et décider comment la gérer : informer l'utilisateur du conflit, lui proposer de rafraîchir les données et de réessayer, ou appliquer une autre logique métier.

Avantages :

  • Haute concurrence : Ne bloque pas les lecteurs. Les verrous ne sont acquis que brièvement lors de la mise à jour.
  • Bonne scalabilité : Moins de contention sur les ressources de la base de données.

Inconvénients :

  • Le conflit n'est détecté qu'au moment du commit. L'application doit gérer l'exception et potentiellement faire rejouer l'opération par l'utilisateur.
  • Ne convient pas si les conflits sont très fréquents (car cela entraînerait beaucoup d'exceptions et de tentatives).

Verrouillage Pessimiste : Bloquer dès la lecture

Philosophie : Le verrouillage pessimiste part du principe que les conflits sont probables. Pour les éviter, il verrouille les données dès qu'elles sont lues par une transaction qui a l'intention de les modifier. Ce verrou empêche les autres transactions d'acquérir un verrou incompatible (par exemple, un autre verrou exclusif) sur les mêmes données jusqu'à ce que la première transaction soit terminée (commit ou rollback).

Mécanisme avec JPA : JPA permet de demander un verrouillage pessimiste lors de la lecture d'une entité. Cela se fait généralement via :

  • L'EntityManager.find() ou EntityManager.refresh() en passant un LockModeType.
  • L'EntityManager.lock() sur une entité déjà gérée.
  • Des hints de requête JPA ou des annotations @Lock sur les méthodes de Repository Spring Data JPA.

Les principaux LockModeType pessimistes sont :

  • PESSIMISTIC_READ (Verrou partagé) : Permet à d'autres transactions de lire les données (avec un verrou partagé également) mais les empêche d'acquérir un verrou exclusif (PESSIMISTIC_WRITE) pour les modifier. Souvent traduit par SELECT ... FOR SHARE ou équivalent selon la base de données.
  • PESSIMISTIC_WRITE (Verrou exclusif) : Empêche les autres transactions de lire (avec un verrou partagé ou exclusif) et d'écrire sur les données verrouillées. C'est le verrou le plus restrictif. Souvent traduit par SELECT ... FOR UPDATE ou équivalent.
  • PESSIMISTIC_FORCE_INCREMENT : Comme PESSIMISTIC_WRITE, mais incrémente également la version de l'entité (si elle a un champ @Version), même si l'entité n'est pas modifiée. Utile pour forcer une exception de verrouillage optimiste dans d'autres transactions qui liraient l'ancienne version.

Exemple avec Spring Data JPA Repository :

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import javax.persistence.LockModeType;
import java.util.Optional;

public interface ProductRepository extends JpaRepository {

    // Acquiert un verrou exclusif lors de la lecture
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional findLockedById(Long id);

    // Utilisation dans un service (doit être transactionnel)
    // @Service
    // public class ProductUpdateService {
    //     @Autowired ProductRepository repo;
    //     @Transactional
    //     public void updatePrice(Long productId, BigDecimal newPrice) {
    //         Product product = repo.findLockedById(productId)
    //                              .orElseThrow(() -> new RuntimeException("Product not found"));
    //         // A ce stade, la ligne produit est verrouillée dans la BDD
    //         product.setPrice(newPrice);
    //         // Le commit libérera le verrou
    //     }
    // }
}

Fonctionnement : La transaction qui appelle findLockedById acquiert un verrou sur la ligne correspondante dans la base de données. Si une autre transaction tente d'acquérir un verrou incompatible (par exemple, un autre PESSIMISTIC_WRITE) sur la même ligne, elle sera bloquée jusqu'à ce que la première transaction libère le verrou (commit ou rollback). JPA permet aussi de spécifier des timeouts pour l'acquisition des verrous.

Avantages :

  • Garantit qu'une mise à jour ne peut pas échouer à cause d'un conflit concurrent une fois le verrou acquis. L'intégrité est assurée au niveau de la base de données.
  • Plus simple à gérer côté applicatif car il n'y a pas d'OptimisticLockException à gérer (la transaction attend simplement ou échoue avec un timeout si le verrou ne peut être obtenu).

Inconvénients :

  • Réduit la concurrence : Bloque les autres transactions qui tentent d'accéder aux données verrouillées, potentiellement pour de longues durées.
  • Risque de Deadlocks : Si plusieurs transactions acquièrent des verrous dans des ordres différents, elles peuvent se bloquer mutuellement (deadlock).
  • Moins bonne scalabilité : La contention sur les verrous peut devenir un goulot d'étranglement sous forte charge.
  • Dépendance à la base de données : Le comportement exact des verrous peut varier légèrement selon le SGBD.

Optimiste vs Pessimiste : Lequel choisir ?

  • Choisissez le verrouillage optimiste (@Version) lorsque :
    • Les conflits de mise à jour concurrents sont attendus comme étant rares.
    • La haute concurrence et la scalabilité sont des priorités.
    • Vous êtes prêt à gérer les OptimisticLockException dans votre code applicatif (par exemple, en demandant à l'utilisateur de réessayer).
    • Le coût d'un rollback et d'une nouvelle tentative est acceptable.
  • Choisissez le verrouillage pessimiste lorsque :
    • Les conflits de mise à jour concurrents sont fréquents ou très probables.
    • Il est impératif d'éviter les mises à jour perdues et l'intégrité des données doit être garantie par la base de données dès la lecture.
    • Le coût d'un blocage temporaire d'autres transactions est acceptable.
    • La durée des transactions est généralement courte pour minimiser les temps de blocage.
    • Vous voulez une gestion d'erreur plus simple côté applicatif (pas d'exception optimiste à gérer spécifiquement).

Le verrouillage optimiste est souvent le choix par défaut dans de nombreuses applications web en raison de sa meilleure scalabilité. Le verrouillage pessimiste est réservé aux scénarios où la contention est élevée et où l'intégrité immédiate lors de la lecture/mise à jour est critique.