Les transactions et la concurrence avec Doctrine : LockMode et LockVersion
Comprendre la démarcation des transactions et les stratégies de verrouillage est crucial pour optimiser les performances et maintenir l'intégrité des données dans les applications utilisant Doctrine ORM.
Cet article porte sur deux concepts essentiels de Doctrine : LockMode
et LockVersion
.
Démarcation des transactions
La démarcation des transactions fait référence à la définition des limites de vos transactions. Une démarcation adéquate est cruciale, car les transactions coûteuses peuvent affecter les performances de l'application. Doctrine gère la démarcation des transactions de deux manières :
Approche Implicite
Doctrine ORM gère implicitement les transactions via l'EntityManager. Par exemple :
$user = new User();
$user->setName('George');
$entityManager->persist($user);
$entityManager->flush(); // Démarre et termine la transaction
Dans cet exemple, flush()
démarre et termine la transaction, regroupant toutes les opérations (INSERT/UPDATE/DELETE).
Approche Explicite
Pour un contrôle plus direct, vous pouvez utiliser Doctrine\DBAL\Connection
pour délimiter explicitement vos transactions :
// Commence la transaction
$entityManager->getConnection()->beginTransaction();
try {
// Votre logique métier ici
$entityManager->flush();
// Valide la transaction
$entityManager->getConnection()->commit();
} catch (Exception $e) {
// Annule la transaction
$entityManager->getConnection()->rollBack();
throw $e;
}
Cette méthode est nécessaire pour inclure des opérations DBAL personnalisées ou lors de l'utilisation de méthodes EntityManager nécessitant une transaction active.
Support du verrouillage
Doctrine ORM supporte le verrouillage pessimiste et optimiste, permettant un contrôle fin sur le verrouillage requis pour vos entités.
Verrouillage optimiste
Le verrouillage optimiste est idéal pour les transactions s'étendant sur plusieurs requêtes, où le contrôle de la concurrence devient partiellement la responsabilité de l'application. Doctrine le gère via un champ de version dans l'entité :
class User {
#[Version, Column(type: 'integer')]
private int $version;
// ...
}
Il est possible d'avoir un champ date à la place de version :
class User
{
#[Version, Column(type: 'datetime')]
private DateTimeImmutable $version;
// ...
}
Lors d'un conflit de version durant flush()
, une OptimisticLockException
est levée. Ce moment précis est critique, car c'est là que Doctrine tente de persister les modifications apportées aux entités dans la base de données. Voici comment cela fonctionne :
-
Lors d'une transaction, vous modifiez une entité qui est surveillée par Doctrine. Cette entité a un champ de version, marqué avec l'annotation
#[Version]
. Ce champ peut être un entier (un numéro de version simple) ou une date (timestamp). -
Lorsque vous appelez
flush()
, Doctrine prépare les requêtes SQL nécessaires pour mettre à jour la base de données en fonction des modifications que vous avez apportées aux entités. -
Avant d'exécuter ces requêtes, Doctrine vérifie la valeur actuelle du champ de version de l'entité dans la base de données et la compare à la valeur du champ de version de l'entité en mémoire (c'est-à-dire celle que vous avez modifiée).
4.1. Si les valeurs de version correspondent, cela signifie qu'aucune autre transaction n'a modifié l'entité entretemps, et Doctrine procède à la mise à jour de la base de données.
4.2. Si les valeurs de version ne correspondent pas (ce qui signifie que l'entité a été modifiée par une autre transaction depuis que vous avez chargé l'entité), Doctrine lève une OptimisticLockException
. Cette exception signale un conflit de concurrence : une autre transaction a modifié l'entité après que vous l'ayez chargée, mais avant que vous n'ayez pu enregistrer vos modifications.
- Lorsqu'une
OptimisticLockException
est levée, il vous incombe d'encadrer ce conflit. Les stratégies courantes consistent en l'information de l'utilisateur sur le conflit, le rechargement de l'entité depuis la base de données et la demande à l'utilisateur de re-appliquer ses modifications, ou l'abandon de ces modifications.
Le verrouillage optimiste est ainsi un moyen efficace de gérer la concurrence dans les applications avec lesquelles les conflits sont relativement rares et où il est acceptable de demander à l'utilisateur de résoudre manuellement les conflits lorsqu'ils surviennent.
Verrouillage pessimiste
Doctrine ORM supporte le verrouillage pessimiste au niveau de la base de données, utilisant des commandes SQL spécifiques pour obtenir des verrous au niveau des lignes. Il nécessite la désactivation du mode Auto-Commit et l'utilisation de transactions explicites.
Doctrine ORM prend en charge deux modes de verrouillage pessimiste :
- PESSIMISTIC_WRITE : Verrouille les lignes pour les opérations de lecture et d'écriture concurrentes.
- PESSIMISTIC_READ : Verrouille contre les requêtes concurrentes qui tentent de mettre à jour ou de verrouiller les lignes en mode écriture.
Vous pouvez appliquer le verrouillage pessimiste viafind()
,lock()
,refresh()
, ousetLockMode()
:
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManager;
use MyProject\Model\User;
// Supposons que $entityManager est votre instance de EntityManager
$userId = 123; // L'identifiant de l'entité à récupérer
try {
// Récupération de l'entité User avec un verrouillage pessimiste en écriture
$user = $entityManager->find(User::class, $userId, LockMode::PESSIMISTIC_WRITE);
if ($user === null) {
// Gérer le cas où l'entité n'est pas trouvée
}
// Effectuez les modifications sur l'entité ici
// ...
// Enregistrez les modifications
$entityManager->flush();
} catch (\Exception $e) {
// Gérer les exceptions, par exemple en cas de problème de verrouillage
// ou d'autres erreurs de base de données
}
Dans cet exemple, l'entité User
est récupérée avec un verrouillage pessimiste en utilisant LockMode::PESSIMISTIC_WRITE
. Ce mode de verrouillage signifie que l'entité est verrouillée pour empêcher d'autres transactions de la lire ou de la modifier jusqu'à ce que la transaction actuelle soit terminée.
Il est important de noter que le verrouillage pessimiste doit être utilisé dans le contexte d'une transaction. Si votre application n'a pas déjà démarré une transaction, vous devez en démarrer une avant d'effectuer un verrouillage pessimiste, en utilisant beginTransaction()
, et vous devez vous assurer de la terminer avec commit()
ou rollBack()
selon le cas.