Skip to content

Verrouillage optimiste ​

Le verrouillage optimiste prévient le problème de mise à jour perdue — quand deux écrivains concurrents lisent tous les deux le même enregistrement, font des modifications indépendantes, et le second écrivain écrase silencieusement les changements du premier.

Utilisez le verrouillage optimiste quand :

  • Les conflits sont rares (la plupart des mises à jour réussissent)
  • Vous avez besoin de lectures non bloquantes (pas de SELECT FOR UPDATE)
  • L'enregistrement a un champ version ou updated_at pour suivre son état

Le problème de mise à jour perdue ​

Sans verrouillage :

temps | Écrivain A              | Écrivain B
------|------------------------|-------------------
  1   | GET /articles/1        | GET /articles/1
      | ← version: 1           | ← version: 1
  2   | [édite le titre]       | [édite le corps]
  3   | PATCH /articles/1      |
      | title = "Titre de A"   |
      | ← version: 1, 200 OK   |
  4   |                        | PATCH /articles/1
      |                        | body = "Corps de B"
      |                        | ← version: 1, 200 OK  ← Le titre de A est PERDU

L'écrivain B écrase le changement de titre de l'écrivain A parce qu'aucun des deux n'a vérifié la modification concurrente.

Schéma ​

Ajoutez une colonne version qui s'incrémente à chaque mise à jour :

sql
CREATE TABLE articles (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT NOT NULL,
    body       TEXT NOT NULL,
    version    INTEGER NOT NULL DEFAULT 1,
    updated_at TEXT NOT NULL
);

Implémentation du Repository ​

php
/**
 * @throws ConflictException si un autre écrivain a mis à jour l'enregistrement en premier
 * @throws \RuntimeException si l'article n'existe pas
 */
public function update(int $id, string $title, string $body, int $expectedVersion): Article
{
    $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');

    // WHERE version = $expectedVersion est la vérification du verrou optimiste.
    // Si un autre écrivain a déjà incrémenté la version, cet UPDATE correspond à 0 lignes.
    $affected = $this->executor->execute(
        'UPDATE articles SET title = ?, body = ?, version = version + 1, updated_at = ? WHERE id = ? AND version = ?',
        [$title, $body, $now, $id, $expectedVersion],
    );

    if ($affected === 0) {
        // 0 lignes mises à jour : soit non trouvé SOIT conflit de version — les distinguer
        $current = $this->findById($id);
        if ($current === null) {
            throw new \RuntimeException("Article {$id} n'existe pas.");
        }
        throw new ConflictException($id, $expectedVersion);
    }

    return new Article(id: $id, title: $title, body: $body, version: $expectedVersion + 1, updatedAt: $now);
}

Pourquoi version = version + 1 en SQL (pas en PHP) ​

php
// ❌ Condition de course : deux écrivains lisent tous les deux version=1, calculent tous les deux version=2
$newVersion = $article->version + 1;
$this->executor->execute('UPDATE ... SET version = ? ...', [$newVersion, $id, $expectedVersion]);

// ✅ Atomique : la base de données incrémente — la version est toujours correcte
$this->executor->execute('UPDATE ... SET version = version + 1 ...', [$id, $expectedVersion]);

La vérification WHERE version = $expectedVersion est le garde ; version = version + 1 garantit que la nouvelle valeur est exactement une de plus que ce qui a passé le garde.

Intégration dans le Controller ​

Le client doit lire la version courante et la renvoyer à chaque mise à jour :

php
private function update(ServerRequestInterface $request): ResponseInterface
{
    $id   = (int) Router::param($request, 'id');
    $body = json_decode((string) $request->getBody(), true);

    if (!is_array($body) || !is_int($body['version'] ?? null)) {
        return $this->problems->create($request, 'invalid-body', 'version (int) is required.', 400);
    }

    try {
        $article = $this->repo->update($id, $body['title'], $body['body'], $body['version']);
        return $this->json->create($this->serialize($article));
    } catch (ConflictException $e) {
        $current = $this->repo->findById($id);
        return $this->problems->create(
            $request,
            'conflict',
            'Optimistic lock conflict.',
            409,
            $e->getMessage(),
            $current !== null ? ['current_version' => $current->version] : [],
        );
    } catch (\RuntimeException) {
        return $this->problems->create($request, 'not-found', 'Article not found.', 404);
    }
}

Flux client ​

POST /articles            → 201 { id: 1, version: 1, ... }
GET /articles/1           → 200 { id: 1, version: 1, ... }

PATCH /articles/1         → 200 { id: 1, version: 2, ... }
  { title: "...", version: 1 }

PATCH /articles/1         → 409 { type: "conflict", current_version: 2 }
  { title: "...", version: 1 }   (version périmée — conflit !)

PATCH /articles/1         → 200 { id: 1, version: 3, ... }
  { title: "...", version: 2 }   (re-fetch ou utiliser current_version du 409)

Inclure current_version dans la réponse 409 permet au client de réessayer sans GET supplémentaire.

Payload de réponse ​

Toujours inclure version dans chaque réponse pour que les clients aient toujours la dernière valeur :

php
/** @return array<string, mixed> */
private function serialize(Article $article): array
{
    return [
        'id'         => $article->id,
        'title'      => $article->title,
        'body'       => $article->body,
        'version'    => $article->version,  // ← le client en a besoin pour renvoyer
        'updated_at' => $article->updatedAt,
    ];
}

Verrouillage optimiste vs pessimiste ​

OptimistePessimiste
MécanismeWHERE version = ? + vérification 0 lignesSELECT ... FOR UPDATE
Blocage en lectureAucunBloque les autres lecteurs
Taux de conflitFaible (la plupart des mises à jour réussissent)Haute contention OK
Coût de réessaiLe client réessaie sur 409Attend la libération du verrou
Support SQLite✅❌ (non supporté)
Idéal pourConflits rares, réessais pilotés par UXHaute contention, opérations devant réussir

Liste de vérification pour la revue de code ​

  • [ ] UPDATE inclut AND version = ? dans la clause WHERE
  • [ ] La valeur de retour de execute() (lignes affectées) est vérifiée — 0 signifie conflit ou non trouvé
  • [ ] Le cas 0 lignes distingue "non trouvé" de "conflit de version" (findById supplémentaire sur le chemin de conflit)
  • [ ] version = version + 1 est calculé en SQL, pas dans le code PHP applicatif
  • [ ] Chaque payload de réponse inclut version pour que le client ait toujours la dernière valeur
  • [ ] La réponse 409 inclut current_version pour le réessai client sans GET supplémentaire
  • [ ] version dans le corps de la requête est validé comme int, pas string (vérification is_int())
  • [ ] Les tests couvrent : mise à jour réussie, mises à jour successives, conflit concurrent, réessai après conflit, 404, version manquante

Publié sous licence MIT.