← tous les articles

Databases

Éviter les Données Incohérentes avec les ACID Transactions

Chaque propriété ACID a un défaut, et trois d'entre eux promettent moins que ce que le mot laisse entendre. Le quatrième défaut est honnête, et c'est justement là que se cache le danger.

Carte de titre affichant Preventing Inconsistent Data with ACID Transaction, catégorie Database

Emballer son travail dans une transaction donne l'impression de souscrire une assurance. Vous écrivez BEGIN, vous écrivez COMMIT, et les quatre propriétés ACID sont censées s'occuper du reste.

Elles s'en occupent, mais seulement jusqu'où vont les réglages dont vous avez hérité. Atomicity, Consistency, Isolation et Durability arrivent chacune avec un défaut, et trois de ces défauts livrent discrètement moins que ce que le mot suggère. Voici où chacun s'arrête.

Atomicity s'arrête à la première erreur qu'elle décide d'ignorer

Atomicity, c'est tout ou rien, et la surprise porte sur ce qui compte comme une erreur méritant un abandon.

Sur SQL Server, XACT_ABORT est à OFF par défaut en T-SQL. Avec ça désactivé, une erreur d'exécution à l'intérieur d'une transaction explicite peut n'annuler que le statement qui a échoué et laisser l'exécution continuer. La transaction reste ouverte. Si la ligne suivante est COMMIT, vous committez la moitié du travail et le database annonce un succès.

BEGIN TRANSACTION;

UPDATE Accounts SET Balance = Balance - 100 WHERE Id = 1;
UPDATE Accounts SET Balance = Balance + 100 WHERE Id = 999;  -- viole une constraint

COMMIT;   -- avec XACT_ABORT OFF, ceci peut quand même committer le débit

Une ligne règle ça, et elle a sa place en tête de tout ce qui écrit sur plusieurs statements :

SET XACT_ABORT ON;

Maintenant, toute erreur d'exécution met fin à la transaction entière et fait un rollback. En .NET ça compte moins si vous laissez EF Core posséder l'unité de travail, parce qu'un seul appel à SaveChangesAsync est déjà enveloppé dans une transaction qui annule tout d'un bloc. Le piège là, c'est de couper une opération métier en deux appels à SaveChangesAsync : ce sont deux transactions, et l'espace entre les deux est une fenêtre où la moitié du travail est durable et l'autre moitié n'a jamais eu lieu.

Consistency ne garantit que ce que vous avez écrit

C'est la propriété que l'on suppose le plus souvent automatique. Elle ne l'est pas le moins du monde.

Consistency garantit qu'une transaction fait passer le database d'un état valide à un autre, où valide signifie les constraints que vous avez déclarées. Primary keys, foreign keys, unique indexes, CHECK, NOT NULL. La liste est là. Le database n'a jamais entendu parler de vos règles métier.

Donc une règle comme « le total d'une commande n'est jamais négatif » qui vit dans une guard clause en C# n'est pas une garantie du database. Elle tient exactement tant que chaque écriture passe par cette classe, ce qui cesse d'être vrai la première fois que quelqu'un lance un script de migration, un import massif, ou un deuxième service sur la même table.

ALTER TABLE Orders
ADD CONSTRAINT CK_Orders_Total_Positive CHECK (Total > 0);

Ou déclarée dans EF Core, pour qu'elle voyage avec la migration au lieu de vivre dans un runbook :

builder.ToTable(t => t.HasCheckConstraint("CK_Orders_Total_Positive", "Total > 0"));

Atomicity et Isolation sont des comportements que l'engine vous donne. Consistency est une propriété de votre schema, et un schema vide ne promet rien.

Isolation démarre à un niveau qui perd encore des écritures

SQL Server tourne en READ COMMITTED sauf indication contraire. Ça vous empêche de lire des données non commitées, et on entend raisonnablement « committed » comme « sûr ».

Il relâche ses shared locks au fur et à mesure que chaque statement se termine, donc il ne dit rien sur l'espace entre votre lecture et votre écriture :

Session A: SELECT Balance FROM Accounts WHERE Id = 1;   -- lit 100
Session B: SELECT Balance FROM Accounts WHERE Id = 1;   -- lit 100
Session A: UPDATE Accounts SET Balance = 50 WHERE Id = 1;
Session B: UPDATE Accounts SET Balance = 50 WHERE Id = 1;   -- l'écriture de A a disparu

Les deux transactions étaient valides séparément. Ensemble, elles ont perdu un update. Être dans une transaction et être à l'abri des écritures concurrentes sont deux garanties distinctes, et le défaut ne vous donne que la première.

La correction habituelle n'est pas un niveau d'isolation plus strict, qui coûte de la concurrence partout pour régler un problème à un seul endroit. C'est une colonne de version, pour que la seconde écriture échoue bruyamment au lieu de gagner en silence :

[Timestamp]
public byte[] RowVersion { get; set; } = [];

EF Core ajoute alors WHERE RowVersion = @original à l'update, et lève DbUpdateConcurrencyException quand aucune ligne ne correspond.

Durability a le défaut honnête, et c'est bien le problème

Durability est l'exception. Son défaut est correct : SQL Server utilise le write-ahead logging, le log record atteint le stockage durable avant que le commit ne soit confirmé, et un crash est rejoué au moment de la récupération. COMMIT veut dire ce que vous croyez qu'il veut dire.

C'est exactement pour ça que personne ne va vérifier. Depuis SQL Server 2014, ça se négocie :

ALTER DATABASE AppDb SET DELAYED_DURABILITY = FORCED;

Maintenant COMMIT retourne dès que le log record est en mémoire et le flush se fait par lots. Vous achetez du vrai throughput sur les charges lourdes en écriture, et vous le payez avec une fenêtre où des transactions que l'application croyait commitées disparaissent dans une coupure de courant.

Ça arrive DISABLED, donc personne ne l'active par accident. Mais c'est un correctif plausible pour un goulot d'étranglement sur le log, appliqué au niveau du database, invisible depuis l'application. Tous les développeurs en amont de ce changement continuent de croire que COMMIT veut dire ce qu'il voulait dire avant.

La version courte

Les transactions valent le coup, et les défauts sont pour l'essentiel raisonnables. Ils ne sont simplement pas les garanties que les quatre mots laissent supposer.

Activez XACT_ABORT pour les écritures sur plusieurs statements. Mettez vos invariants dans le schema, parce que c'est le seul endroit où consistency est garantie. Partez du principe que le niveau d'isolation par défaut perdra une écriture concurrente et ajoutez une colonne de version là où ça compte. Et traitez durability comme un réglage que quelqu'un peut changer, pas comme une loi du database.