← tutti gli articoli

Databases

Prevenire Dati Inconsistenti con le ACID Transactions

Ogni proprietà ACID ha un default, e tre di questi promettono meno di quanto dica la parola. Il quarto default è onesto, ed è proprio lì che sta il pericolo.

Card del titolo con la scritta Preventing Inconsistent Data with ACID Transaction, categoria Database

Avvolgere il lavoro in una transaction dà la sensazione di stipulare un'assicurazione. Scrivi BEGIN, scrivi COMMIT, e le quattro proprietà ACID dovrebbero pensare al resto.

Lo fanno, ma solo fino a dove arrivano le impostazioni che hai ereditato. Atomicity, Consistency, Isolation e Durability arrivano ognuna con un default, e tre di quei default consegnano silenziosamente meno di quanto la parola suggerisca. Ecco dove si ferma ciascuno.

Atomicity si ferma al primo errore che decide di ignorare

Atomicity è tutto o niente, e la sorpresa sta in cosa conta come errore che merita un abort.

Su SQL Server, XACT_ABORT è OFF di default in T-SQL. Con quello spento, un errore a run-time dentro una transaction esplicita può annullare solo lo statement fallito e lasciare che l'esecuzione prosegua. La transaction resta aperta. Se la riga successiva è COMMIT, committi metà del lavoro e il database segnala successo.

BEGIN TRANSACTION;

UPDATE Accounts SET Balance = Balance - 100 WHERE Id = 1;
UPDATE Accounts SET Balance = Balance + 100 WHERE Id = 999;  -- viola una constraint

COMMIT;   -- con XACT_ABORT OFF, questo può comunque committare l'addebito

Una riga risolve, e sta in cima a qualsiasi cosa faccia scritture su più statement:

SET XACT_ABORT ON;

Adesso qualsiasi errore a run-time termina l'intera transaction e fa rollback. In .NET conta meno se lasci che EF Core sia padrone dell'unità di lavoro, perché una singola chiamata a SaveChangesAsync è già avvolta in una transaction che annulla tutto insieme. La trappola lì è spezzare un'operazione di business in due chiamate a SaveChangesAsync: sono due transactions, e lo spazio fra le due è una finestra in cui metà del lavoro è durevole e metà non è mai successa.

Consistency garantisce solo quello che hai scritto

Questa è la proprietà che più spesso si dà per automatica. Non è automatica per niente.

Consistency garantisce che una transaction porti il database da uno stato valido a un altro, dove valido significa le constraints che hai dichiarato. Primary keys, foreign keys, unique indexes, CHECK, NOT NULL. La lista è tutta qui. Il database non ha mai sentito parlare delle tue regole di business.

Quindi una regola come "il totale di un ordine non è mai negativo" che vive in una guard clause in C# non è una garanzia del database. Regge esattamente finché ogni scrittura passa da quella classe, cosa che smette di essere vera la prima volta che qualcuno lancia uno script di migration, un'importazione massiva, o un secondo servizio sulla stessa tabella.

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

Oppure dichiarata in EF Core, così viaggia con la migration invece di vivere in un runbook:

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

Atomicity e Isolation sono comportamento che ti dà l'engine. Consistency è una proprietà del tuo schema, e uno schema vuoto non promette niente.

Isolation parte a un livello che perde ancora scritture

SQL Server gira a READ COMMITTED salvo indicazione contraria. Questo ti impedisce di leggere dati non committati, e la gente ragionevolmente sente "committed" come "sicuro".

Rilascia gli shared lock man mano che ogni statement finisce, quindi non dice nulla sullo spazio fra la tua lettura e la tua scrittura:

Session A: SELECT Balance FROM Accounts WHERE Id = 1;   -- legge 100
Session B: SELECT Balance FROM Accounts WHERE Id = 1;   -- legge 100
Session A: UPDATE Accounts SET Balance = 50 WHERE Id = 1;
Session B: UPDATE Accounts SET Balance = 50 WHERE Id = 1;   -- la scrittura di A è sparita

Entrambe le transactions erano valide da sole. Insieme hanno perso un update. Stare dentro una transaction ed essere al sicuro da scritture concorrenti sono garanzie separate, e il default ti dà solo la prima.

La correzione abituale non è un livello di isolation più stretto, che costa concorrenza ovunque per risolvere un problema in un punto solo. È una colonna di versione, così la seconda scrittura fallisce a voce alta invece di vincere in silenzio:

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

EF Core aggiunge poi WHERE RowVersion = @original all'update, e solleva DbUpdateConcurrencyException quando nessuna riga corrisponde.

Durability ha il default onesto, ed è quello il problema

Durability è l'eccezione. Il suo default è corretto: SQL Server usa il write-ahead logging, il log record arriva sullo storage durevole prima che il commit venga confermato, e un crash viene riprodotto in fase di recovery. COMMIT significa quello che pensi che significhi.

Ed è esattamente per questo che nessuno lo controlla. Dal SQL Server 2014 lo si può barattare:

ALTER DATABASE AppDb SET DELAYED_DURABILITY = FORCED;

Adesso COMMIT ritorna appena il log record è in memoria e il flush avviene a lotti. Ti compri throughput vero sui carichi pesanti in scrittura, e lo paghi con una finestra in cui transactions che all'applicazione erano state date per committate spariscono in un blackout.

Arriva DISABLED, quindi nessuno lo accende per sbaglio. Ma è una soluzione plausibile per un collo di bottiglia sul log, applicata a livello di database, invisibile dall'applicazione. Ogni sviluppatore a monte di quel cambiamento continua a credere che COMMIT significhi quello che significava prima.

La versione breve

Le transactions valgono la pena, e i default sono in gran parte ragionevoli. Semplicemente non sono le garanzie che le quattro parole lasciano intendere.

Accendi XACT_ABORT per le scritture su più statement. Metti le tue invarianti nello schema, perché è l'unico posto in cui consistency viene garantita. Dai per scontato che il livello di isolation di default perderà una scrittura concorrente e aggiungi una colonna di versione dove conta. E tratta durability come un'impostazione che qualcuno può cambiare, non come una legge del database.