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.
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.