Designing Data-Intensive Applications
Chapitre 7 / 11 · 19 min de lecture

Les transactions

ACID, niveaux d'isolation faibles (read committed, snapshot, write skew) et sérialisabilité (verrouillage 2PL, SSI).

Dans la dure réalité des systèmes de données, beaucoup de choses tournent mal : le matériel ou le logiciel peut tomber en panne au milieu d'une écriture, l'application peut planter à mi-parcours d'une série d'opérations, le réseau peut couper la liaison, plusieurs clients peuvent écrire simultanément et s'écraser mutuellement, ou bien un client peut lire des données qui n'ont de sens qu'à moitié. Pour qu'un système soit fiable, il doit absorber ces fautes sans s'effondrer — mais coder soi-même cette tolérance aux pannes est un travail considérable, qui exige de penser à tout ce qui peut mal se passer.

Depuis des décennies, le mécanisme de prédilection pour simplifier ce problème porte un nom : la transaction. Une transaction regroupe plusieurs lectures et écritures en une unité logique. Conceptuellement, tout s'exécute comme une seule opération : soit l'ensemble réussit (validation, commit), soit l'ensemble échoue (abandon, abort / rollback) et peut être réessayé sans risque. Les transactions ne sont pas une loi de la nature ; elles ont été inventées dans un but précis — simplifier le modèle de programmation en laissant la base de données prendre en charge certaines fautes et conditions de concurrence à la place de l'application. Reste une question : en avez-vous besoin ? Pour y répondre, il faut comprendre exactement quelles garanties de sûreté elles offrent, et à quel coût.

ACID, décortiqué

Les garanties de sûreté d'une transaction sont souvent résumées par l'acronyme ACID : Atomicité, Cohérence, Isolation, Durabilité (Atomicity, Consistency, Isolation, Durability), forgé en 1983. Mais en pratique, l'implémentation ACID d'une base diffère de celle d'une autre — il y a notamment énormément d'ambiguïté autour de l'isolation. ACID est devenu surtout un terme marketing : quand un système se dit « conforme ACID », on ne sait pas trop ce qu'on peut réellement en attendre.

Atomicité

Dans le contexte d'ACID, l'atomicité ne parle pas de concurrence (cela, c'est le I). Elle décrit ce qui arrive si un client veut effectuer plusieurs écritures mais qu'une faute survient après seulement quelques-unes : crash de processus, coupure réseau, disque plein, contrainte violée. Si les écritures forment une transaction atomique qui ne peut aboutir, la transaction est abandonnée et la base doit défaire toutes les écritures déjà faites. Sans cette garantie, après une erreur en cours de route, il devient difficile de savoir quels changements ont pris effet — réessayer risquerait de dupliquer les données. Kleppmann le dit nettement : la capacité à avorter (abortability) est la propriété qui définit vraiment l'atomicité ACID. « Abortabilité » aurait sans doute été un meilleur mot qu'« atomicité ».

Cohérence

Le mot « cohérence » (consistency) est terriblement surchargé : cohérence des réplicas (chapitre 5), linéarisabilité dans le théorème CAP (chapitre 9), et ici, dans ACID, une notion propre à l'application d'une base dans un « bon état ». L'idée est qu'il existe des invariants — par exemple, dans un système comptable, débits et crédits doivent toujours s'équilibrer — qui doivent rester vrais. Mais c'est à l'application de définir ses transactions de façon à préserver ces invariants ; la base ne peut pas vous empêcher d'écrire des données qui les violent.

Note

Atomicité, isolation et durabilité sont des propriétés de la base de données, tandis que la cohérence (au sens ACID) est une propriété de l'application. Comme l'a remarqué Joe Hellerstein, le C a été « glissé dans l'acronyme pour qu'il sonne bien ». La lettre C n'a donc pas vraiment sa place dans ACID.

Isolation

L'isolation signifie que des transactions s'exécutant en parallèle sont isolées les unes des autres : elles ne se marchent pas sur les pieds. L'exemple canonique est celui de deux clients qui incrémentent un compteur partagé : chacun lit la valeur (42), ajoute 1 et réécrit. Le compteur devrait passer à 44, mais à cause de cette condition de concurrence (race condition) il ne va qu'à 43 : une mise à jour est perdue. Les manuels formalisent l'isolation comme la sérialisabilité (serializability) — chaque transaction peut faire comme si elle était seule, le résultat final étant identique à une exécution sérielle (l'une après l'autre). En pratique cependant, l'isolation sérialisable est rarement utilisée car elle coûte cher en performance : Oracle 11g, par exemple, ne l'implémente même pas (son niveau dit « serializable » est en fait l'isolation par instantané, plus faible).

Durabilité

La durabilité est la promesse qu'une fois une transaction validée, les données écrites ne seront pas oubliées, même en cas de panne matérielle ou de crash. Sur un nœud unique, cela signifie l'écriture sur stockage non volatil, généralement avec un journal d'écriture anticipée (write-ahead log) pour la reprise. Sur une base répliquée, cela peut signifier la copie sur un certain nombre de nœuds. Mais la durabilité parfaite n'existe pas : aucune technique ne donne de garantie absolue, seulement des moyens de réduire le risque (disque, réplication, sauvegardes) — à combiner, et à prendre avec un grain de sel.

Les systèmes qui ne satisfont pas ACID sont parfois qualifiés de BASE (Basically Available, Soft state, Eventual consistency). C'est encore plus vague qu'ACID ; sa seule définition sensée est « non ACID », c'est-à-dire à peu près tout ce que l'on veut.

Opérations mono-objet et multi-objets

Atomicité et isolation supposent souvent que l'on veut modifier plusieurs objets (lignes, documents) à la fois. Les transactions multi-objets sont nécessaires quand plusieurs morceaux de données doivent rester synchronisés : intégrité des clés étrangères, données dénormalisées (comme un compteur de messages non lus stocké à part), ou index secondaires à mettre à jour avec la valeur. En relationnel, le regroupement se fait classiquement via la connexion TCP : tout ce qui est entre BEGIN TRANSACTION et COMMIT appartient à la même transaction. Beaucoup de bases non relationnelles n'offrent pas ce regroupement, même avec une API multi-clés.

L'atomicité et l'isolation s'appliquent aussi à un seul objet : si vous écrivez un document JSON de 20 ko et que le réseau coupe à 10 ko, la base ne doit pas stocker un fragment illisible ni laisser un autre client voir une valeur à moitié mise à jour. Les moteurs de stockage offrent presque universellement ces garanties au niveau de l'objet. Certaines bases proposent en plus des opérations atomiques (un incrément, un compare-and-set) qui évitent un cycle lecture-modification-écriture. Mais ce ne sont pas des transactions au sens habituel : les baptiser « transactions légères » ou « ACID », comme on le voit pour le marketing, est trompeur.

Niveaux d'isolation faibles

L'isolation sérialisable a un coût ; beaucoup de bases refusent de le payer et emploient des niveaux d'isolation plus faibles, qui protègent contre certaines conditions de concurrence mais pas toutes. Ces niveaux sont bien plus difficiles à comprendre et conduisent à des bugs subtils — qui ont causé de réelles pertes d'argent et des corruptions de données. Le réflexe « utilisez une base ACID pour des données financières ! » manque la cible, car beaucoup de bases relationnelles dites ACID utilisent une isolation faible par défaut. Mieux vaut comprendre les conditions de concurrence que de se fier aveuglément à un outil.

Read committed

Le niveau de base est read committed (lecture validée). Il offre deux garanties :

  • Pas de lectures sales (dirty reads) : on ne lit que des données qui ont été validées. Les écritures d'une transaction ne deviennent visibles qu'à sa validation, et toutes en même temps. Cela évite de voir un état partiellement mis à jour, ou des données qui seront ensuite annulées (rollback).
  • Pas d'écritures sales (dirty writes) : on n'écrase que des données validées. La deuxième écriture est retardée jusqu'à ce que la première transaction ait validé ou abandonné. Sans cela, des écritures de transactions concurrentes peuvent se mélanger — par exemple, sur un site de vente de voiture, la vente est attribuée à Bob mais la facture envoyée à Alice.

En pratique, les écritures sales sont évitées par des verrous au niveau de la ligne : pour modifier un objet, une transaction acquiert un verrou qu'elle garde jusqu'à la fin. Les lectures sales, en revanche, ne sont pas gérées par des verrous de lecture (qui bloqueraient les lecteurs derrière une longue écriture) : la base mémorise à la fois l'ancienne valeur validée et la nouvelle valeur non validée, et sert l'ancienne aux lecteurs tant que l'écriture n'est pas validée.

Isolation par instantané et MVCC

Read committed ne suffit pas toujours. Imaginez qu'Alice possède 1 000 $ répartis sur deux comptes de 500 $, et qu'un virement de 100 $ passe de l'un à l'autre. Si elle consulte ses soldes au mauvais moment, elle peut voir le compte 1 avant réception (500 $) et le compte 2 après envoi (400 $) : il lui semble que 100 $ se sont volatilisés. Cette anomalie s'appelle lecture non reproductible (non-repeatable read) ou biais de lecture (read skew). Elle est tolérée sous read committed, et transitoire — mais inacceptable pour une sauvegarde ou une requête analytique qui balaye une grande partie de la base et observerait des points temporels différents.

La solution la plus répandue est l'isolation par instantané (snapshot isolation), aussi appelée contrôle de concurrence multiversion (MVCC, multiversion concurrency control). Chaque transaction lit depuis un instantané cohérent : toutes les données validées à un instant donné, figées au démarrage de la transaction. Le principe de performance clé est : les lecteurs ne bloquent jamais les écrivains, et les écrivains ne bloquent jamais les lecteurs.

Astuce

L'isolation par instantané est une bénédiction pour les longues requêtes en lecture seule (sauvegardes, analytique) : elles tournent sur un instantané figé pendant que les écritures se poursuivent normalement, sans contention de verrous entre les deux.

Pour l'implémenter, la base garde plusieurs versions validées de chaque objet. Dans PostgreSQL, chaque ligne porte un champ created by (l'ID de la transaction qui l'a insérée) et un champ deleted by. Une mise à jour devient en interne une suppression suivie d'une création. Des règles de visibilité décident ce qu'une transaction voit : on ignore les écritures des transactions encore en cours au démarrage, celles des transactions abandonnées, et celles dont l'ID est postérieur.

Visibilité d'un objet pour une transaction lectrice :
  visible SI
    la transaction qui a créé l'objet avait déjà validé
      au démarrage de la lectrice
    ET l'objet n'est pas marqué supprimé,
       OU la transaction qui a demandé la suppression
          n'avait pas encore validé à ce démarrage.

À retenir

L'isolation par instantané souffre d'une confusion de noms. Le standard SQL, antérieur à son invention, ne la connaît pas et définit plutôt repeatable read. Oracle l'appelle « serializable », PostgreSQL et MySQL l'appellent « repeatable read », et IBM DB2 utilise « repeatable read » pour désigner la sérialisabilité. Résultat : personne ne sait vraiment ce que « repeatable read » veut dire.

Empêcher les mises à jour perdues

Les sections précédentes concernaient surtout ce qu'une lecture peut voir. Reste le conflit écriture-écriture le plus connu : la mise à jour perdue (lost update). Elle survient quand une application lit une valeur, la modifie et la réécrit (cycle lecture-modification-écriture), et que deux transactions le font en parallèle : la seconde écriture écrase la première sans l'intégrer. Cas typiques : incrémenter un compteur ou un solde, ajouter un élément à une liste JSON, ou deux utilisateurs éditant une page wiki. Plusieurs remèdes existent.

SolutionMécanismeQuand l'utiliser
Opération atomiqueUPDATE counters SET value = value + 1 … exécuté par la base, sans cycle applicatif.Le meilleur choix si la logique s'exprime ainsi (compteurs, structures Redis, modif locale d'un document MongoDB).
Verrou expliciteL'application verrouille les lignes (FOR UPDATE) avant le cycle.Quand la logique métier dépasse une requête (ex. valider un coup de jeu). Facile à oublier.
Détection automatiqueLa base laisse les cycles tourner en parallèle et abandonne la transaction fautive.Sûr et peu sujet à l'erreur : pas besoin de pensée applicative particulière.
Compare-and-setL'écriture n'a lieu que si la valeur n'a pas changé depuis la lecture.Bases sans transactions ; attention à ce que le WHERE ne lise pas un vieil instantané.
-- Verrou explicite : empêcher deux joueurs de déplacer
-- la même pièce simultanément. FOR UPDATE verrouille
-- toutes les lignes renvoyées par le SELECT.
BEGIN TRANSACTION;

SELECT * FROM figures
  WHERE name = 'robot' AND game_id = 222
  FOR UPDATE;

-- Vérifier que le coup est valide, puis :
UPDATE figures SET position = 'c4' WHERE id = 1234;

COMMIT;

La détection automatique est précieuse car elle se combine efficacement avec l'isolation par instantané : le repeatable read de PostgreSQL, le serializable d'Oracle et le snapshot de SQL Server détectent les mises à jour perdues et abandonnent les transactions coupables. Le repeatable read de MySQL/InnoDB, lui, ne les détecte pas. Enfin, dans les bases répliquées en multi-leader ou sans leader, verrous et compare-and-set ne s'appliquent plus (il n'y a pas de copie unique à jour) : on laisse cohabiter des versions conflictuelles à fusionner, idéalement via des opérations commutatives (comme les types de données de Riak 2.0). Le last write wins, défaut malheureux de beaucoup de bases, est lui sujet aux pertes.

Biais d'écriture et fantômes

Voici une condition de concurrence plus subtile. Une application de gestion des gardes médicales exige qu'au moins un médecin reste de garde. Alice et Bob, tous deux de garde et souffrants, cliquent pour se mettre en congé au même instant. Sous isolation par instantané, chaque transaction lit « 2 médecins de garde », juge sûr de se retirer, et écrit. Les deux valident : plus aucun médecin de garde. L'invariant est violé.

Cette anomalie s'appelle biais d'écriture (write skew). Ce n'est ni une écriture sale ni une mise à jour perdue, car les deux transactions modifient des objets différents (les enregistrements d'Alice et de Bob). C'est une généralisation de la mise à jour perdue : deux transactions lisent les mêmes objets, puis en mettent à jour certains. On la retrouve un peu partout une fois qu'on l'a en tête :

ExempleVérificationRisque
Médecins de garde« au moins 2 de garde »Plus personne de garde.
Réservation de salle« aucune réservation chevauchante »Double réservation.
Jeu en ligne« case libre » / règle du jeuDeux pièces sur la même case.
Nom d'utilisateur« nom non pris »Deux comptes homonymes.
Double dépense« solde positif »Solde négatif sans que personne ne le voie.
-- Réservation de salle : NON sûr sous isolation par instantané.
BEGIN TRANSACTION;

-- Y a-t-il une réservation chevauchant midi - 13 h ?
SELECT COUNT(*) FROM bookings
  WHERE room_id = 123 AND
    end_time > '2015-01-01 12:00' AND
    start_time < '2015-01-01 13:00';

-- ... si la requête renvoie zéro :
INSERT INTO bookings (room_id, start_time, end_time, user_id)
  VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);

COMMIT;

Tous ces cas suivent le même schéma : (1) un SELECT vérifie qu'une condition est satisfaite ; (2) l'application décide selon le résultat ; (3) elle écrit (INSERT/UPDATE/DELETE) et valide ; (4) si l'on rejouait le SELECT, le résultat aurait changé. Cet effet, où une écriture dans une transaction modifie le résultat d'une requête de recherche d'une autre transaction, s'appelle un fantôme (phantom).

Les options pour empêcher le biais d'écriture sont limitées : les opérations atomiques mono-objet n'aident pas (plusieurs objets), la détection automatique de mises à jour perdues non plus, et les contraintes de la base portent rarement sur plusieurs objets. Dans le cas des médecins, on peut verrouiller explicitement les lignes lues (SELECT … FOR UPDATE). Mais quand la vérification porte sur l'absence de lignes (salle, nom, solde), FOR UPDATE n'a rien à verrouiller.

Une parade de dernier recours est la matérialisation des conflits (materializing conflicts) : on crée artificiellement des lignes-verrous (par exemple toutes les combinaisons salle × créneau) que les transactions verrouillent. Mais c'est difficile et sujet à erreur, et cela fait fuiter le contrôle de concurrence dans le modèle de données. Une isolation sérialisable est presque toujours préférable.

La sérialisabilité : le niveau fort

Les niveaux faibles sont difficiles à comprendre, implémentés de façon incohérente d'une base à l'autre, et il n'existe pas de bons outils pour détecter les conditions de concurrence (les tests sont non déterministes). Depuis les années 1970, la réponse des chercheurs est simple : utilisez l'isolation sérialisable. Elle garantit que le résultat est le même que si les transactions s'étaient exécutées une à une, et donc prévient toutes les conditions de concurrence possibles. Si elle est si supérieure, pourquoi tout le monde ne l'emploie-t-il pas ? À cause du coût de ses implémentations. Trois techniques existent.

Exécution réellement sérielle

La façon la plus simple d'éviter tout problème de concurrence est de la supprimer : exécuter une seule transaction à la fois, sur un seul thread. L'isolation résultante est sérialisable par définition. Longtemps jugée irréaliste, l'idée est devenue viable vers 2007 grâce à deux changements : la RAM est devenue assez bon marché pour garder le jeu de données actif en mémoire, et l'on a compris que les transactions OLTP sont courtes et touchent peu de données. C'est l'approche de VoltDB/H-Store, Redis et Datomic.

Comme l'attente du réseau ruinerait le débit en mono-thread, ces systèmes interdisent les transactions interactives multi-instructions : l'application soumet tout le code de la transaction à l'avance, sous forme de procédure stockée (stored procedure) qui s'exécute sans attendre d'E/S. Les procédures stockées ont mauvaise réputation (langages propriétaires archaïques, difficiles à débogger et déployer), mais les implémentations modernes utilisent des langages généralistes (Java, Clojure, Lua). Limite majeure : le débit est plafonné à un seul cœur CPU. Pour passer à l'échelle, on peut partitionner les données afin que chaque partition ait son propre thread — à condition que chaque transaction reste dans une seule partition ; les transactions inter-partitions sont radicalement plus lentes (VoltDB rapporte ~1 000 écritures inter-partitions par seconde).

Verrouillage à deux phases (2PL)

Pendant une trentaine d'années, le verrouillage à deux phases (two-phase locking, 2PL) fut la seule option viable. Il renforce nettement le verrouillage : plusieurs transactions peuvent lire un objet tant que personne ne l'écrit, mais dès que l'une veut écrire, un accès exclusif est requis. Surtout, un lecteur bloque un écrivain et inversement — l'exact opposé du mantra de l'isolation par instantané. C'est ce qui lui donne la sérialisabilité.

Attention

Le verrouillage à deux phases (2PL) n'a rien à voir avec la validation à deux phases (two-phase commit, 2PC), malgré la ressemblance des noms. 2PC concerne les transactions distribuées et sera vu au chapitre 9.

Chaque objet porte un verrou en mode partagé (lecture, plusieurs simultanés) ou exclusif (écriture, un seul). Un verrou partagé peut être promu en exclusif. Le nom « deux phases » vient de ce que les verrous sont acquis pendant l'exécution (phase 1) puis tous relâchés à la fin (phase 2). Pour empêcher les fantômes, il faut un verrou de prédicat (predicate lock) : il porte non sur un objet précis mais sur tous les objets satisfaisant une condition de recherche — y compris ceux qui n'existent pas encore. Comme les verrous de prédicat sont coûteux à vérifier, la plupart des bases implémentent à la place un verrou de plage d'index (index-range lock, ou next-key locking), approximation plus grossière mais bien moins coûteuse : on verrouille une entrée ou une plage d'index couvrant la condition.

Le gros défaut du 2PL est la performance. La cause profonde n'est pas tant le surcoût des verrous que la réduction de concurrence : par conception, dès que deux transactions risquent un conflit, l'une attend l'autre. Sans limite de durée de transaction, l'attente peut être longue ; il suffit d'une transaction lente ou gourmande en verrous pour figer tout le système. Les latences sont instables et très mauvaises aux hauts centiles (latence de queue, tail latency). De plus, les interblocages (deadlocks) sont fréquents : la base en détecte un, abandonne une transaction, qui doit tout refaire.

Sérialisabilité optimiste par instantané (SSI)

Le tableau est sombre : d'un côté des implémentations sérialisables lentes (2PL) ou peu extensibles (exécution sérielle), de l'autre des niveaux faibles performants mais sujets aux anomalies. La sérialisabilité optimiste par instantané (serializable snapshot isolation, SSI) est très prometteuse : elle fournit la sérialisabilité complète avec une faible pénalité par rapport à l'isolation par instantané. Décrite en 2008, elle est utilisée par PostgreSQL (niveau sérialisable depuis la 9.1) et, sous forme distribuée, par FoundationDB.

ApprochePhilosophieComportement en cas de risque
2PLPessimisteOn bloque jusqu'à ce que la situation soit sûre.
Exécution sériellePessimiste à l'extrêmeVerrou exclusif sur toute la base, mais transactions très courtes.
SSIOptimisteOn continue, puis on vérifie à la validation et on abandonne si l'isolation a été violée.

Le contrôle de concurrence optimiste laisse les transactions avancer dans l'espoir que tout ira bien, et ne vérifie qu'au moment de valider. Il performe mal en forte contention (beaucoup d'abandons et de réessais), mais mieux que le pessimisme s'il y a de la capacité disponible et une contention modérée. SSI se construit sur l'isolation par instantané (toutes les lectures viennent d'un instantané cohérent) et y ajoute la détection des conflits de sérialisation.

Le cœur du problème : une transaction agit sur une prémisse (un fait vrai au départ, ex. « il y a deux médecins de garde ») qui peut devenir fausse avant la validation. La base doit détecter ces décisions fondées sur une prémisse périmée, dans deux cas :

1. Lectures MVCC périmées : la transaction a ignoré, via les règles
   de visibilité, une écriture non encore validée. À la validation,
   si cette écriture a depuis été validée, la prémisse est fausse
   -> abandon. (On attend la validation : une transaction en
    lecture seule n'a pas besoin d'être abandonnée.)

2. Écritures affectant des lectures antérieures : une écriture
   survient après la lecture. La base enregistre, via les entrées
   d'index, quelles transactions ont lu une plage. Une écriture
   agit comme un fil-piège (tripwire) qui notifie ces lectrices
   que leur lecture est peut-être périmée -> abandon à la validation.

Par rapport au 2PL, le grand avantage de SSI est qu'une transaction n'a jamais à se bloquer en attendant un verrou : comme sous l'isolation par instantané, lecteurs et écrivains ne se bloquent pas. Les latences de requête sont bien plus prévisibles, et les lectures seules tournent sur instantané sans aucun verrou — idéal pour les charges à dominante lecture. Par rapport à l'exécution sérielle, SSI n'est pas plafonné à un seul cœur.

Le compromis central de SSI est la granularité du suivi des lectures et écritures : un suivi fin abandonne moins de transactions inutilement mais coûte cher en comptabilité ; un suivi grossier est plus rapide mais multiplie les abandons. Le taux d'abandon gouverne la performance globale, ce qui impose des transactions en lecture-écriture assez courtes — mais SSI est probablement moins sensible aux transactions lentes que le 2PL ou l'exécution sérielle.

À retenir

  • Une transaction regroupe lectures et écritures en une unité tout-ou-rien : elle réussit (commit) ou s'annule (abort) et peut être réessayée, ce qui simplifie énormément la gestion des fautes et de la concurrence.
  • ACID : l'atomicité, c'est surtout la capacité à avorter ; l'isolation, l'illusion d'être seul ; la durabilité, la persistance des données validées. La cohérence est une propriété de l'application, pas de la base — et « ACID » est devenu un terme marketing flou.
  • Les niveaux faibles vont par puissance croissante : read committed (ni lectures ni écritures sales), puis l'isolation par instantané / MVCC (chaque transaction lit un instantané cohérent, lecteurs et écrivains ne se bloquent jamais).
  • Trois anomalies à connaître : la mise à jour perdue (remèdes : opération atomique, verrou FOR UPDATE, compare-and-set, détection auto), et surtout le biais d'écriture avec ses fantômes (médecins de garde, salle, double dépense) — que seule la sérialisabilité prévient totalement.
  • La sérialisabilité s'implémente de trois façons : exécution réellement sérielle (mono-thread, procédures stockées, VoltDB/Redis), verrouillage à deux phases (2PL) (pessimiste, sûr mais lent, deadlocks et latence de queue), et SSI (optimiste, détection a posteriori des conflits, PostgreSQL).
  • Le choix relève du compromis : peu de données et faible débit d'écriture favorisent l'exécution sérielle ; une charge à dominante lecture avec contention modérée favorise SSI ; le 2PL reste robuste mais paie cher en débit et en latence.