Skip to main content

Command Palette

Search for a command to run...

Verrouillage Optimiste et Pessimiste en Django : Guide Complet

Updated
11 min read
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 :

  1. Utilisateur A lit : stock = 1

  2. Utilisateur B lit : stock = 1

  3. Utilisateur A décrémente : stock = 0

  4. 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 produit

  • Aucune autre transaction ne peut modifier cette ligne jusqu'à la fin de la transaction

  • Le décorateur @transaction.atomic garantit 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 :

  1. Lire les données avec leur numéro de version actuel

  2. Effectuer les modifications en mémoire

  3. Au moment de la sauvegarde, vérifier que le numéro de version n'a pas changé

  4. Si changé : conflit détecté

  5. 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èrePessimisteOptimiste
Fréquence des conflitsÉlevéeFaible
Durée des transactionsCourtePeut être longue
Performance lectureImpact négatifAucun impact
Performance écritureGarantiePeut nécessiter des retries
ScalabilitéLimitéeExcellente
ComplexitéSimpleMoyenne
Risque de deadlockOuiNon

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 !