Verrouillage Optimiste et Pessimiste en Django : Guide Complet

Dans le développement d'applications web modernes, la gestion de la concurrence est un défi crucial, particulièrement lorsque plusieurs utilisateurs ou processus tentent de modifier simultanément les mêmes données. Django, en tant que framework web robuste, offre plusieurs mécanismes pour gérer ces situations de concurrence. Parmi eux, le verrouillage optimiste (optimistic locking) et le verrouillage pessimiste (pessimistic locking) sont deux stratégies fondamentales mais radicalement différentes.
Cet article explore en profondeur ces deux approches, leurs cas d'usage, et comment les implémenter efficacement dans vos projets Django.
Le Problème : La Concurrence de Données
Imaginons un scénario classique d'e-commerce. Deux utilisateurs consultent le même produit dont il ne reste qu'un seul exemplaire en stock. Ils cliquent tous les deux sur "Acheter" au même moment. Sans mécanisme de gestion de concurrence approprié, voici ce qui pourrait se passer :
Utilisateur A lit : stock = 1
Utilisateur B lit : stock = 1
Utilisateur A décrémente : stock = 0
Utilisateur B décrémente : stock = 0 (devrait être -1, erreur !)
Ce problème de "race condition" peut entraîner des incohérences de données, des surventes, ou même des pertes financières. C'est là qu'interviennent les stratégies de verrouillage.
Verrouillage Pessimiste (Pessimistic Locking)
Concept
Le verrouillage pessimiste part du principe que les conflits sont probables. Il verrouille donc les données dès qu'elles sont lues, empêchant toute autre transaction de les modifier jusqu'à ce que le verrou soit libéré.
C'est comme si vous mettiez un panneau "En cours de modification - Ne pas toucher" sur vos données.
Comment ça fonctionne en Django ?
Django implémente le verrouillage pessimiste via la méthode select_for_update() qui utilise la clause SQL SELECT ... FOR UPDATE.
Exemple Basique
from django.db import transaction
from myapp.models import Product
@transaction.atomic
def purchase_product(product_id, quantity):
# Verrouille la ligne en base de données
product = Product.objects.select_for_update().get(id=product_id)
if product.stock >= quantity:
product.stock -= quantity
product.save()
return True
else:
return False
Dans cet exemple :
select_for_update()verrouille la ligne du produitAucune autre transaction ne peut modifier cette ligne jusqu'à la fin de la transaction
Le décorateur
@transaction.atomicgarantit que le verrou est maintenu pendant toute la durée de la transaction
Exemple Avancé : E-commerce avec Gestion de Panier
from django.db import transaction
from django.db.models import F
from myapp.models import Product, Order, OrderItem
@transaction.atomic
def process_order(user, cart_items):
"""
Traite une commande en verrouillant tous les produits concernés
pour éviter les surventes.
"""
order = Order.objects.create(user=user, status='pending')
# Récupérer tous les IDs de produits
product_ids = [item['product_id'] for item in cart_items]
# Verrouiller TOUS les produits en une seule requête
# nowait=True : échoue immédiatement si un verrou existe déjà
products = Product.objects.select_for_update(nowait=True).filter(
id__in=product_ids
)
# Créer un dictionnaire pour un accès rapide
products_dict = {p.id: p for p in products}
# Vérifier la disponibilité de tous les produits
for item in cart_items:
product = products_dict.get(item['product_id'])
if not product or product.stock < item['quantity']:
raise ValueError(f"Stock insuffisant pour {product.name}")
# Si tout est OK, créer les items de commande et décrémenter le stock
for item in cart_items:
product = products_dict[item['product_id']]
OrderItem.objects.create(
order=order,
product=product,
quantity=item['quantity'],
price=product.price
)
product.stock -= item['quantity']
product.save()
order.status = 'confirmed'
order.save()
return order
Options de select_for_update()
Django offre plusieurs options pour affiner le comportement du verrouillage :
# nowait : échoue immédiatement si la ligne est déjà verrouillée
Product.objects.select_for_update(nowait=True).get(id=1)
# skip_locked : ignore les lignes déjà verrouillées
available_products = Product.objects.select_for_update(skip_locked=True).filter(
stock__gt=0
)
# of : verrouille seulement certaines tables liées
Order.objects.select_for_update(of=('self',)).select_related('customer').get(id=1)
Exemple avec Timeout Personnalisé
from django.db import transaction, OperationalError
import time
@transaction.atomic
def safe_update_with_timeout(product_id, timeout=5):
"""
Tente de verrouiller un produit avec un timeout personnalisé.
"""
start_time = time.time()
while time.time() - start_time < timeout:
try:
product = Product.objects.select_for_update(nowait=True).get(
id=product_id
)
# Effectuer la modification
product.stock -= 1
product.save()
return True
except OperationalError:
# Le verrou n'est pas disponible, attendre un peu
time.sleep(0.1)
return False # Timeout dépassé
Avantages du Verrouillage Pessimiste
Simplicité conceptuelle : facile à comprendre et à implémenter
Cohérence garantie : impossible d'avoir des modifications concurrentes
Idéal pour les opérations critiques : transactions bancaires, gestion de stock
Inconvénients
Performance : peut créer des goulots d'étranglement
Deadlocks potentiels : deux transactions s'attendent mutuellement
Scalabilité limitée : problématique avec beaucoup d'utilisateurs concurrents
Verrouillage Optimiste (Optimistic Locking)
Concept
Le verrouillage optimiste part du principe que les conflits sont rares. Au lieu de verrouiller les données, il vérifie au moment de la sauvegarde si elles ont été modifiées entre-temps.
C'est comme si vous disiez : "Je vais travailler sur ces données, et au moment de sauvegarder, je vérifierai si quelqu'un d'autre les a modifiées."
Comment ça fonctionne ?
L'approche la plus courante consiste à utiliser un champ de version :
Lire les données avec leur numéro de version actuel
Effectuer les modifications en mémoire
Au moment de la sauvegarde, vérifier que le numéro de version n'a pas changé
Si changé : conflit détecté
Si inchangé : sauvegarder et incrémenter la version
Implémentation Manuelle avec un Champ de Version
from django.db import models
from django.core.exceptions import ValidationError
class Product(models.Model):
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock = models.IntegerField(default=0)
version = models.IntegerField(default=0)
def save(self, *args, **kwargs):
"""
Sauvegarde avec vérification de version pour le locking optimiste.
"""
if self.pk is not None: # C'est une mise à jour
# Incrémenter la version
new_version = self.version + 1
# Mettre à jour seulement si la version n'a pas changé
updated = Product.objects.filter(
pk=self.pk,
version=self.version
).update(
name=self.name,
price=self.price,
stock=self.stock,
version=new_version
)
if updated == 0:
raise ValidationError(
"Ce produit a été modifié par un autre utilisateur. "
"Veuillez recharger et réessayer."
)
# Mettre à jour la version locale
self.version = new_version
else:
# Nouvelle instance
super().save(*args, **kwargs)
class Meta:
db_table = 'products'
Utilisation du Verrouillage Optimiste
from django.core.exceptions import ValidationError
from myapp.models import Product
def update_product_price(product_id, new_price, max_retries=3):
"""
Met à jour le prix d'un produit avec retry automatique.
"""
for attempt in range(max_retries):
try:
# Lire le produit avec sa version actuelle
product = Product.objects.get(id=product_id)
original_version = product.version
# Modifier le produit
product.price = new_price
# Sauvegarder (vérifie automatiquement la version)
product.save()
return True
except ValidationError:
# Conflit détecté, réessayer
if attempt == max_retries - 1:
raise
continue
return False
Exemple Avancé : Système de Réservation
from django.db import models, transaction
from django.utils import timezone
from datetime import timedelta
class Event(models.Model):
name = models.CharField(max_length=200)
total_seats = models.IntegerField()
available_seats = models.IntegerField()
version = models.IntegerField(default=0)
def reserve_seats(self, quantity):
"""
Réserve des sièges avec verrouillage optimiste.
"""
if self.available_seats < quantity:
raise ValueError("Sièges insuffisants disponibles")
# Simuler une latence (traitement métier)
# Dans un cas réel : validation de paiement, etc.
# Décrémenter les sièges
self.available_seats -= quantity
self.save() # La méthode save() vérifie la version
return True
class Reservation(models.Model):
event = models.ForeignKey(Event, on_delete=models.CASCADE)
user = models.ForeignKey('auth.User', on_delete=models.CASCADE)
seats_reserved = models.IntegerField()
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
status = models.CharField(
max_length=20,
choices=[
('pending', 'En attente'),
('confirmed', 'Confirmée'),
('cancelled', 'Annulée'),
('expired', 'Expirée'),
],
default='pending'
)
def create_reservation(user, event_id, seats_count, max_retries=5):
"""
Crée une réservation avec gestion des conflits.
"""
for attempt in range(max_retries):
try:
with transaction.atomic():
# Récupérer l'événement
event = Event.objects.get(id=event_id)
# Tenter de réserver
event.reserve_seats(seats_count)
# Créer la réservation
reservation = Reservation.objects.create(
event=event,
user=user,
seats_reserved=seats_count,
expires_at=timezone.now() + timedelta(minutes=15),
status='pending'
)
return reservation
except ValidationError as e:
# Conflit de version, réessayer
if attempt == max_retries - 1:
raise Exception(
"Impossible de réserver après plusieurs tentatives. "
"L'événement est peut-être complet."
)
continue
except ValueError as e:
# Plus de sièges disponibles
raise e
return None
Utilisation avec Django-Concurrency
Pour simplifier l'implémentation, vous pouvez utiliser la bibliothèque django-concurrency :
pip install django-concurrency
from django.db import models
from concurrency.fields import IntegerVersionField
class Product(models.Model):
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock = models.IntegerField(default=0)
version = IntegerVersionField() # Gère automatiquement le versioning
class Meta:
db_table = 'products'
# Utilisation
from concurrency.exceptions import RecordModifiedError
try:
product = Product.objects.get(id=1)
product.stock -= 1
product.save()
except RecordModifiedError:
# Quelqu'un d'autre a modifié le produit
print("Conflit détecté ! Veuillez réessayer.")
Exemple avec Timestamp au lieu de Version
from django.db import models
from django.utils import timezone
class Document(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
last_modified = models.DateTimeField(auto_now=True)
def update_content(self, new_content, expected_timestamp):
"""
Met à jour le contenu seulement si le timestamp correspond.
"""
updated = Document.objects.filter(
pk=self.pk,
last_modified=expected_timestamp
).update(
content=new_content,
last_modified=timezone.now()
)
if updated == 0:
raise ValidationError(
"Le document a été modifié. Actualisez et réessayez."
)
# Recharger l'instance
self.refresh_from_db()
return True
# Utilisation
document = Document.objects.get(id=1)
original_timestamp = document.last_modified
# L'utilisateur modifie le document (peut prendre du temps)
# ...
try:
document.update_content(
"Nouveau contenu",
expected_timestamp=original_timestamp
)
except ValidationError as e:
print("Conflit détecté:", e)
Avantages du Verrouillage Optimiste
Meilleures performances : pas de verrouillage de base de données
Scalabilité : supporte beaucoup d'utilisateurs concurrents
Pas de deadlocks : pas de verrous à gérer
Idéal pour les lectures fréquentes : les lectures ne sont jamais bloquées
Inconvénients
Complexité de gestion des conflits : nécessite une logique de retry
Expérience utilisateur : l'utilisateur peut perdre son travail en cas de conflit
Plus de code : implémentation plus complexe
Comparaison et Choix de la Stratégie
| Critère | Pessimiste | Optimiste |
| Fréquence des conflits | Élevée | Faible |
| Durée des transactions | Courte | Peut être longue |
| Performance lecture | Impact négatif | Aucun impact |
| Performance écriture | Garantie | Peut nécessiter des retries |
| Scalabilité | Limitée | Excellente |
| Complexité | Simple | Moyenne |
| Risque de deadlock | Oui | Non |
Quand Utiliser le Verrouillage Pessimiste ?
Situations idéales :
Transactions financières (virements, paiements)
Gestion de stock critique (derniers articles en stock)
Systèmes de réservation à forte demande (billets de concert)
Opérations courtes et critiques
Forte probabilité de conflits
Exemple de cas d'usage :
# Virement bancaire - DOIT être pessimiste
@transaction.atomic
def transfer_money(from_account_id, to_account_id, amount):
# Verrouiller les deux comptes
accounts = Account.objects.select_for_update().filter(
id__in=[from_account_id, to_account_id]
).order_by('id') # Ordre fixe pour éviter les deadlocks
from_account = accounts.get(id=from_account_id)
to_account = accounts.get(id=to_account_id)
if from_account.balance < amount:
raise ValueError("Solde insuffisant")
from_account.balance -= amount
to_account.balance += amount
from_account.save()
to_account.save()
Quand Utiliser le Verrouillage Optimiste ?
Situations idéales :
Édition collaborative de documents
Mise à jour de profils utilisateurs
Systèmes de gestion de contenu (CMS)
Opérations longues avec faible probabilité de conflit
Applications à fort trafic avec beaucoup de lectures
Exemple de cas d'usage :
# Édition de profil utilisateur - peut être optimiste
class UserProfile(models.Model):
user = models.OneToOneField('auth.User', on_delete=models.CASCADE)
bio = models.TextField()
avatar = models.ImageField()
version = IntegerVersionField()
def update_profile(user_id, bio, avatar):
try:
profile = UserProfile.objects.get(user_id=user_id)
profile.bio = bio
if avatar:
profile.avatar = avatar
profile.save()
return True
except RecordModifiedError:
# Conflit rare, l'utilisateur peut réessayer
return False
Approche Hybride
Dans certains cas, vous pouvez combiner les deux approches :
from django.db import transaction
from concurrency.fields import IntegerVersionField
from concurrency.exceptions import RecordModifiedError
class Inventory(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE)
quantity = models.IntegerField()
version = IntegerVersionField() # Optimiste pour les MAJ non-critiques
class Meta:
unique_together = ['product', 'warehouse']
@transaction.atomic
def transfer_inventory(product_id, from_warehouse_id, to_warehouse_id, quantity):
"""
Transfert d'inventaire : pessimiste pour la lecture, optimiste pour l'écriture.
"""
# Verrouillage pessimiste pour garantir la cohérence
inventories = Inventory.objects.select_for_update().filter(
product_id=product_id,
warehouse_id__in=[from_warehouse_id, to_warehouse_id]
)
from_inv = inventories.get(warehouse_id=from_warehouse_id)
to_inv = inventories.get(warehouse_id=to_warehouse_id)
# Vérifications métier
if from_inv.quantity < quantity:
raise ValueError("Quantité insuffisante")
# Modifications
from_inv.quantity -= quantity
to_inv.quantity += quantity
# Sauvegarde avec check de version (optimiste)
try:
from_inv.save()
to_inv.save()
except RecordModifiedError:
raise Exception("Conflit détecté lors du transfert")
Bonnes Pratiques
1. Toujours Utiliser des Transactions
from django.db import transaction
# ✅ BON
@transaction.atomic
def update_with_lock():
obj = MyModel.objects.select_for_update().get(id=1)
obj.value += 1
obj.save()
# ❌ MAUVAIS - le verrou sera libéré immédiatement
def bad_update():
obj = MyModel.objects.select_for_update().get(id=1)
# Le verrou est déjà libéré ici !
obj.value += 1
obj.save()
2. Gérer les Exceptions
from django.db import OperationalError
from concurrency.exceptions import RecordModifiedError
@transaction.atomic
def safe_operation(obj_id):
try:
obj = MyModel.objects.select_for_update(nowait=True).get(id=obj_id)
# ... opérations
except OperationalError:
# L'objet est déjà verrouillé
raise Exception("Ressource occupée, réessayez plus tard")
except RecordModifiedError:
# Conflit de version (optimiste)
raise Exception("Données modifiées, veuillez actualiser")
3. Éviter les Deadlocks
# ✅ BON - Toujours verrouiller dans le même ordre
@transaction.atomic
def transfer(from_id, to_id, amount):
# Trier les IDs pour garantir un ordre cohérent
ids = sorted([from_id, to_id])
accounts = Account.objects.select_for_update().filter(
id__in=ids
).order_by('id')
# ...
# ❌ MAUVAIS - Ordre variable peut causer des deadlocks
@transaction.atomic
def bad_transfer(from_id, to_id, amount):
from_account = Account.objects.select_for_update().get(id=from_id)
to_account = Account.objects.select_for_update().get(id=to_id)
# ...
4. Implémenter des Retries Intelligents
import time
from functools import wraps
def retry_on_conflict(max_attempts=3, delay=0.1, backoff=2):
"""
Décorateur pour réessayer automatiquement en cas de conflit.
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
attempt = 0
current_delay = delay
while attempt < max_attempts:
try:
return func(*args, **kwargs)
except (RecordModifiedError, ValidationError) as e:
attempt += 1
if attempt >= max_attempts:
raise
time.sleep(current_delay)
current_delay *= backoff
return wrapper
return decorator
# Utilisation
@retry_on_conflict(max_attempts=5, delay=0.2)
def update_product(product_id, new_stock):
product = Product.objects.get(id=product_id)
product.stock = new_stock
product.save()
5. Monitorer les Performances
import logging
from time import time
from contextlib import contextmanager
logger = logging.getLogger(__name__)
@contextmanager
def log_lock_time(operation_name):
"""
Contexte pour mesurer le temps passé avec un verrou.
"""
start = time()
try:
yield
finally:
duration = time() - start
logger.info(f"{operation_name} - Lock duration: {duration:.3f}s")
if duration > 1.0: # Alerter si > 1 seconde
logger.warning(f"Long lock detected: {operation_name}")
# Utilisation
@transaction.atomic
def monitored_operation(obj_id):
with log_lock_time(f"Update object {obj_id}"):
obj = MyModel.objects.select_for_update().get(id=obj_id)
# ... opérations
obj.save()
Conclusion
Le choix entre verrouillage optimiste et pessimiste dépend fortement du contexte de votre application :
Choisissez le verrouillage pessimiste quand :
La cohérence des données est critique
Les conflits sont fréquents
Les transactions sont courtes
Vous gérez des opérations financières ou du stock
Choisissez le verrouillage optimiste quand :
La performance et la scalabilité sont prioritaires
Les conflits sont rares
Les opérations peuvent être longues
Vous avez beaucoup de lectures et peu d'écritures
Dans tous les cas :
Testez votre implémentation sous charge
Mesurez les performances réelles
Gérez correctement les erreurs
Documentez votre choix pour l'équipe
N'oubliez pas que Django offre tous les outils nécessaires pour implémenter ces deux stratégies efficacement. À vous de choisir celle qui correspond le mieux à vos besoins !
Ressources Complémentaires
Cet article vous a été utile ? N'hésitez pas à partager vos expériences avec le locking en Django dans les commentaires !



