← todos os artigos

Databases

Prevenindo Dados Inconsistentes com ACID Transactions

Cada propriedade do ACID tem um default, e três deles prometem menos do que a palavra sugere. O quarto default é honesto, e é justamente aí que mora o perigo.

Card de título com os dizeres Preventing Inconsistent Data with ACID Transaction, categoria Database

Envolver o trabalho em uma transaction dá a sensação de contratar um seguro. Você escreve BEGIN, escreve COMMIT, e as quatro propriedades do ACID deveriam cuidar do resto.

Elas cuidam, mas só até onde vão as configurações que você herdou. Atomicity, Consistency, Isolation e Durability chegam cada uma com um default, e três desses defaults entregam silenciosamente menos do que a palavra sugere. Aqui está onde cada um deles para.

Atomicity para no primeiro erro que ela decide ignorar

Atomicity é tudo ou nada, e a surpresa está em o que conta como um erro que merece abortar.

No SQL Server, XACT_ABORT vem OFF por padrão em T-SQL. Com ele desligado, um erro em tempo de execução dentro de uma transaction explícita pode desfazer apenas o statement que falhou e deixar a execução continuar. A transaction segue aberta. Se a próxima linha for COMMIT, você commita metade do trabalho e o database informa sucesso.

BEGIN TRANSACTION;

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

COMMIT;   -- com XACT_ABORT OFF, isso ainda pode commitar o débito

Uma linha resolve, e ela pertence ao topo de qualquer coisa que faça escritas em vários statements:

SET XACT_ABORT ON;

Agora qualquer erro em tempo de execução encerra a transaction inteira e faz rollback. No .NET isso importa menos se você deixar o EF Core ser dono da unidade de trabalho, porque uma única chamada de SaveChangesAsync já vem embrulhada em uma transaction que desfaz tudo junto. A armadilha ali é dividir uma operação de negócio em duas chamadas de SaveChangesAsync: são duas transactions, e o intervalo entre elas é uma janela onde metade do trabalho é durável e metade nunca aconteceu.

Consistency só garante aquilo que você escreveu

Essa é a propriedade que as pessoas mais assumem ser automática. Ela não é nem um pouco automática.

Consistency garante que uma transaction leva o database de um estado válido para outro, onde válido significa as constraints que você declarou. Primary keys, foreign keys, unique indexes, CHECK, NOT NULL. A lista é essa. O database nunca ouviu falar das suas regras de negócio.

Então uma regra como "o total de um pedido nunca é negativo" vivendo em um guard clause em C# não é uma garantia do database. Ela se sustenta exatamente enquanto toda escrita passar por aquela classe, o que deixa de ser verdade na primeira vez que alguém roda um script de migration, uma importação em massa, ou um segundo serviço contra a mesma tabela.

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

Ou declarada no EF Core, para viajar junto com a migration em vez de morar em um runbook:

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

Atomicity e Isolation são comportamento que você ganha do engine. Consistency é uma propriedade do seu schema, e um schema vazio não promete nada.

Isolation vem em um nível que ainda perde escritas

O SQL Server roda em READ COMMITTED a menos que você diga o contrário. Isso impede você de ler dado não commitado, e as pessoas razoavelmente entendem "committed" como "seguro".

Ele solta os shared locks conforme cada statement termina, então não diz nada sobre o intervalo entre a sua leitura e a sua escrita:

Session A: SELECT Balance FROM Accounts WHERE Id = 1;   -- lê 100
Session B: SELECT Balance FROM Accounts WHERE Id = 1;   -- lê 100
Session A: UPDATE Accounts SET Balance = 50 WHERE Id = 1;
Session B: UPDATE Accounts SET Balance = 50 WHERE Id = 1;   -- a escrita de A sumiu

As duas transactions eram válidas sozinhas. Juntas, perderam um update. Estar dentro de uma transaction e estar seguro contra escritas concorrentes são garantias separadas, e o default só te dá a primeira.

A correção de sempre não é um nível de isolation mais rígido, que custa concorrência no sistema inteiro para resolver um problema em um lugar só. É uma coluna de versão, para a segunda escrita falhar alto em vez de vencer em silêncio:

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

O EF Core então adiciona WHERE RowVersion = @original ao update, e lança DbUpdateConcurrencyException quando nenhuma linha bate.

Durability tem o default honesto, e é aí que mora o problema

Durability é a exceção. O default dela está correto: o SQL Server usa write-ahead logging, o log record chega ao armazenamento durável antes de o commit ser confirmado, e um crash é reproduzido na recuperação. COMMIT significa o que você acha que significa.

E é exatamente por isso que ninguém confere. Desde o SQL Server 2014 dá para negociar isso:

ALTER DATABASE AppDb SET DELAYED_DURABILITY = FORCED;

Agora o COMMIT retorna assim que o log record está em memória e o flush acontece em lotes. Você compra throughput de verdade em cargas pesadas de escrita, e paga com uma janela onde transactions que a aplicação foi informada que tinham commitado desaparecem numa queda de energia.

Vem DISABLED, então ninguém liga por acidente. Mas é uma correção plausível para um gargalo de log, aplicada no nível do database, invisível a partir da aplicação. Todo desenvolvedor acima dessa mudança segue acreditando que COMMIT significa o que significava antes.

A versão curta

Transactions valem a pena, e os defaults são em boa parte razoáveis. Eles só não são as garantias que as quatro palavras sugerem.

Ligue o XACT_ABORT para escritas em vários statements. Coloque suas invariantes no schema, porque é o único lugar onde consistency é garantida. Assuma que o nível de isolation padrão vai perder uma escrita concorrente e adicione uma coluna de versão onde isso importa. E trate durability como uma configuração que alguém pode mudar, e não como uma lei do database.