Le principe de verrouillage optimiste (optimistic locking) avec Doctrine ORM

Lorsque l’on travaille sur une application manipulant une base de données, il est possible de se retrouver dans un cas où une donnée va être modifiée par deux requêtes concurrentes. Cela peut-être problématique, car cela peut entraîner des incohérences dans les données d’un système.

Prenons l’exemple d’un système bancaire:

  • La première requête va récupérer le solde du compte du client (disons 100€), puis va effectuer un débit de 20€, il reste donc 80€ sur le compte du client.
  • La seconde requête va récupérer le solde du client (80€), puis va effectuer un débit de 50€, il restera donc 30€

Imaginons maintenant que les deux requêtes sont exécutées simultanément:

  • La requête 1 consulte le solde du compte: 100€
  • La requête 2 consulte le solde du compte avant que la requête 1 ne fasse l’opération de modification: 100€
  • La requête 1 débite le compte de 50€: il reste 50€
  • La requête 2 effectue le débit de 50€ sur le solde connu dans son contexte d’exécution (100€), il reste 50€

L’exemple précédent présente les problèmes d’incohérence de données que des requêtes simultanées peuvent introduire dans un système.

Pour corriger cela, la solution la plus simple et qui vient naturellement à l’esprit, est de vérouiller la donnée à manipuler le temps nécessaire à la réalisation des différentes opérations (consultation et débit) nécessaires au traitement. C’est ce que l’on appelle un verrouillage pessimiste (ou Pessimistic Locking en anglais), du fait que la donnée sera systématiquement verrouillée même s’il n’y a pas de requêtes concurrentes.

Cela pourrait être fait avec le pseudo-code ci-dessous:

function débiterCompte(string $numeroCompte)
{
    if ($this->isCompteVerrouiller($numeroCompte)) {
        throw new RuntimeException('Compte vérouiller');
    }

    $this->verrouillerCompte($numeroCompte);

    // ... on fait les traitements nécessaires ...

    $this->déverrouillerCompte($numeroCompte);
}

Si l’on travaille en PHP avec Doctrine ORM ce dernier met à disposition plusieurs méthodes permettant de mettre en place cette stratégie de verrouillage:

  • EntityManager::find($className, $id, LockMode::PESSIMISTIC_WRITE|LockMode::PESSIMISTIC_READ)
  • EntityManager::lock($entity, LockMode::PESSIMISTIC_WRITE|LockMode::PESSIMISTIC_READ)
  • EntityManager::refresh($entity, LockMode::PESSIMISTIC_WRITE|LockMode::PESSIMISTIC_READ
  • Query::setLockMode(LockMode::PESSIMISTIC_WRITE|LockMode::PESSIMISTIC_READ)

Cette approche est très simple. Elle est également rapide à mettre en place. Mais elle présente l’inconvénient de verrouiller l’accès à une donnée durant tout le temps du process de modification, et cela de manière potentiellement inutile. Si dans des traitements simple et rapide cela n’est pas un problème, sur des traitements nécessitant des calculs importants, verrouiller la donnée peut générer de potentiels incidents.

Pour éviter ces problèmes, il est possible de mettre en place un verrouillage optimiste (on parle alors d’optimistic locking en anglais) des données. Il n’est alors plus question de verrouiller une donnée de manière systématique. Le principe de fonctionnement est le suivant:

  • La requête va récupérer les informations dont elle a besoin en base de données. Les informations contiennent un numéro de version de la donnée (cela peut correspondre à un numéro, une date…),
  • La requête modifie les données nécessaires au traitement effectué,
  • Au moment de valider les modifications et de les enregistrer définitivement, une vérification du numéro de version va être effectuée afin de s’assurer qu’aucune donnée n’a été modifiée par une autre requête durant le traitement:
    • Si la version ne correspond pas à l’état de la base de données, une erreur est renvoyée,
    • Sinon, les modifications sont validées et persistées en base de données.

Ici encore, si vous utilisez un ORM, ce dernier offre les outils nécessaires pour vous simplifier le travail et vous éviter d’avoir à gérer cela manuellement. Avec Doctrine ORM, cela se fait simplement en ajoutant la métadonnée Version à la propriété correspondante de votre entité.

class CompteBancaire
{
    // ...

    #[Version, Column(type: 'integer')]
    private int $version;
}