Trilha 12 · Bancos de dados

Transações e ACID

Esta é a razão de existir dos bancos de dados. Guardar dados é fácil — um arquivo faz isso. O difícil é garantir que, com mil operações simultâneas e a energia podendo cair a qualquer instante, os dados nunca fiquem num estado que não deveria existir. Um saldo que sumiu no meio de uma transferência não é um bug que se corrige depois: é dinheiro perdido.

① Intuição

Duas escritas, um instante fatal entre elas

Transferir R$ 100 da conta A para a conta B são duas operações: subtrair de A, somar em B. Entre elas existe um instante em que o dinheiro não está em lugar nenhum. Se o servidor cair exatamente ali — e com milhões de transferências por dia, ele vai cair ali algum dia — o dinheiro simplesmente desaparece, e ninguém saberá que houve um problema.

Uma transação é a promessa de que esse instante não é observável nem sobrevivente. Ou as duas escritas acontecem, ou nenhuma acontece. Não existe meia transferência, nem para quem estiver olhando ao mesmo tempo, nem depois de um desastre.

Os dois inimigos são diferentes. Falhas (queda, crash, disco cheio) são resolvidas com o write-ahead log: escreva primeiro a intenção, depois execute, e na recuperação refaça ou desfaça. Concorrência (mil transações ao mesmo tempo) é resolvida com isolamento: travas ou MVCC. São mecanismos independentes, e é útil não confundi-los — o "A" e o "D" do ACID vêm do log; o "I" vem do controle de concorrência.
② Visualização interativa

Anomalias de concorrência e recuperação de falha

Na primeira aba, escolha uma anomalia e um nível de isolamento e acompanhe as duas transações passo a passo — a tabela no fim resume tudo. Na segunda, derrube a energia em cada ponto de uma transferência e compare o resultado com e sem write-ahead log.

O "I" de ACID — isolamento — não é ligado/desligado: é um dial. Cada nível proíbe mais anomalias e custa mais em concorrência. Escolha uma anomalia e veja em quais níveis ela acontece.

Estado inicial: contas(id=1, saldo=1000)
Transação 1
Transação 2
BEGIN
BEGIN
UPDATE contas SET saldo = 500 WHERE id = 1
ainda NÃO confirmado
SELECT saldo FROM contas WHERE id = 1
lê 500 — dado sujo
ROLLBACK
o saldo volta a 1000
-- decidiu com base no que leu
agiu sobre um valor que nunca existiu
Dirty read ACONTECE em READ UNCOMMITTED
T2 lê um valor que T1 escreveu mas ainda não confirmou — e T1 pode dar rollback, deixando T2 com um número que nunca existiu.
NívelLeitura sujaNão repetívelFantasmaUpdate perdido
READ UNCOMMITTEDpossívelpossívelpossívelpossível
READ COMMITTEDimpedidapossívelpossívelpossível
REPEATABLE READimpedidaimpedidaimpedidapossível
SERIALIZABLEimpedidaimpedidaimpedidaimpedida
Quanto mais alto o nível, menos anomalias — e menos paralelismo, porque o banco precisa segurar travas ou abortar e reexecutar transações em conflito. A maioria dos bancos usa READ COMMITTED como padrão.
③ Explicação técnica

O que ACID promete

-- Uma transferência bancária são DUAS escritas.
-- Entre elas, o mundo pode acabar: queda de energia, kill -9,
-- disco cheio, outra transação mexendo nas mesmas linhas.

BEGIN;
    UPDATE contas SET saldo = saldo - 100 WHERE id = 1;
    -- ⚡ e se cair AQUI?
    UPDATE contas SET saldo = saldo + 100 WHERE id = 2;
COMMIT;

-- ACID é a resposta a essa pergunta:
--
-- A (atomicidade)  tudo ou nada. Não existe metade de transação.
-- C (consistência) as restrições do esquema valem antes e depois.
-- I (isolamento)   transações concorrentes não veem o meio do
--                  trabalho uma da outra.
-- D (durabilidade) depois do COMMIT, está gravado — mesmo que a
--                  energia caia no milissegundo seguinte.

-- Repare que o "C" é o único que depende de VOCÊ: o banco só pode
-- garantir as regras que você declarou (NOT NULL, CHECK, FK).
-- "O saldo nunca fica negativo" só é garantido se existir
-- um CHECK (saldo >= 0) — não basta o código da aplicação verificar.

Níveis de isolamento

-- Isolamento não é ligado/desligado: é um dial de 4 posições.
-- Cada nível proíbe mais anomalias e permite menos paralelismo.

-- Nível              | suja | não repetível | fantasma | update perdido
-- READ UNCOMMITTED   |  ✗   |      ✗        |    ✗     |      ✗
-- READ COMMITTED     |  ✓   |      ✗        |    ✗     |      ✗
-- REPEATABLE READ    |  ✓   |      ✓        |   ✗ (*)  |      ✗
-- SERIALIZABLE       |  ✓   |      ✓        |    ✓     |      ✓
--   ✓ = impedida    ✗ = possível
--   (*) pelo padrão SQL. Bancos MVCC como o PostgreSQL impedem.

BEGIN ISOLATION LEVEL SERIALIZABLE;
    SELECT SUM(saldo) FROM contas WHERE dono = 7;
    UPDATE contas SET saldo = saldo - 100 WHERE id = 1;
COMMIT;   -- pode falhar com erro de serialização!

-- ⚠ Em SERIALIZABLE, o COMMIT PODE FALHAR mesmo sem nada de errado
-- na sua lógica: o banco detectou um conflito e abortou uma das
-- transações. Sua aplicação PRECISA tratar isso e reexecutar.
-- Quem usa SERIALIZABLE sem laço de retentativa tem erros aleatórios
-- em produção sob carga.

-- O padrão da maioria dos bancos é READ COMMITTED — o que significa
-- que, por padrão, você está exposto a lost updates.
O nome dos níveis engana. "REPEATABLE READ" significa coisas diferentes em bancos diferentes: no PostgreSQL ele é implementado com snapshot e acaba impedindo fantasmas; no MySQL/InnoDB também, mas com semântica distinta para leituras travadas. Já o "SERIALIZABLE" do Oracle é, na prática, o snapshot isolation dos outros. Conclusão prática: não confie no nome — leia a documentação do banco que você usa e, na dúvida, escreva um teste com duas conexões abertas e confira o comportamento você mesmo.

A atualização perdida, e como evitá-la

-- A atualização perdida, e as três formas de evitá-la.

-- ✗ O padrão ler-modificar-gravar, feito na aplicação:
saldo = db.query("SELECT saldo FROM contas WHERE id=1")  # lê 1000
novo  = saldo + 100                                     # calcula 1100
db.execute("UPDATE contas SET saldo=? WHERE id=1", novo)  # grava
# Se duas requisições fizerem isso ao mesmo tempo, uma sobrescreve
# a outra e R$100 somem. É a race condition mais comum em web.

-- ✓ Opção 1: deixe o BANCO fazer a conta (atômico, e mais simples)
UPDATE contas SET saldo = saldo + 100 WHERE id = 1;

-- ✓ Opção 2: trava pessimista — segura a linha até o commit
BEGIN;
  SELECT saldo FROM contas WHERE id = 1 FOR UPDATE;
  -- qualquer outra transação que peça esta linha ESPERA aqui
  UPDATE contas SET saldo = 1100 WHERE id = 1;
COMMIT;

-- ✓ Opção 3: trava otimista — não trava, mas detecta o conflito
UPDATE contas SET saldo = 1100, versao = versao + 1
WHERE id = 1 AND versao = 42;
-- Se afetou 0 linhas, alguém mudou a linha antes: releia e refaça.
-- Melhor quando conflitos são raros: nenhuma espera no caminho feliz.

MVCC: isolamento sem travar leituras

-- Como o banco isola transações sem parar o mundo: MVCC.
-- (Multi-Version Concurrency Control)
--
-- Ideia central: UPDATE não sobrescreve. Ele cria uma VERSÃO NOVA
-- da linha, marcada com a transação que a criou. A versão antiga
-- continua lá enquanto alguém ainda puder precisar dela.
--
--   linha id=1:  [saldo=1000, criada por t10, morta por t42]
--                [saldo=900,  criada por t42, viva          ]
--
-- Cada transação enxerga um SNAPSHOT: o conjunto de versões que
-- estavam confirmadas quando ela começou.
--
-- A consequência mais importante:
--   LEITURA NUNCA BLOQUEIA ESCRITA. ESCRITA NUNCA BLOQUEIA LEITURA.
-- Um relatório de 10 minutos não segura ninguém — ele apenas vê o
-- banco como ele era quando começou.

-- O preço: lixo. Versões antigas se acumulam e precisam ser
-- recolhidas — é o VACUUM do PostgreSQL, ou o undo log do MySQL.
-- Uma transação esquecida aberta por horas impede a limpeza de
-- TUDO que mudou desde que ela começou: a tabela incha, os índices
-- incham e o desempenho cai. Daí a regra de ouro:
-- transações devem ser CURTAS. Nunca abra uma transação e vá
-- chamar uma API externa no meio dela.
④ Projeto para programar

Provocando as anomalias

Mini projeto: abra dois terminais com psql (ou dois clientes MySQL) no mesmo banco e reproduza cada anomalia à mão. Digite BEGIN nos dois, execute os comandos alternando entre as janelas e observe o que cada um enxerga. Repita trocando o nível de isolamento. Ver a leitura não repetível acontecer na sua frente ensina mais que qualquer tabela.

Projeto principal: escreva o teste da atualização perdida. Um script que dispara 100 threads, cada uma somando 1 ao mesmo saldo pelo padrão ler-modificar-gravar. O resultado final deveria ser 100 e quase nunca será. Depois conserte de três formas — UPDATE saldo = saldo + 1, SELECT ... FOR UPDATE e coluna de versão — e meça o tempo total de cada uma. Você vai ver o custo do pessimismo: a trava serializa tudo, enquanto a versão otimista só paga quando há conflito de verdade.

Desafio extra: implemente um mini write-ahead log em Python. Um "banco" que é um dicionário persistido em arquivo, com begin, set, commit e uma rotina de recuperar() que roda ao abrir. Grave as intenções num arquivo de log antes de tocar nos dados. Depois teste de verdade: mate o processo com kill -9 em pontos diferentes e confirme que a recuperação sempre deixa o estado íntegro. Preste atenção especial ao os.fsync() — sem ele, o dado está no buffer do sistema operacional, e não no disco.

⑤ Exercícios rápidos

Teste sua intuição

O que significa ACID?
O que acontece se a energia cair no meio de uma transação, num banco com write-ahead log?
Qual é a diferença entre uma leitura suja e uma leitura não repetível?
Qual é a principal vantagem do MVCC?
⑥ Aplicações no mundo real

Onde você encontra isso

🎫

Venda de ingressos e estoque

Duas pessoas clicam em "comprar" no último ingresso no mesmo instante. Sem controle de concorrência, as duas leem "1 disponível" e as duas compram — a casa vendeu um lugar que não existe. A solução é uma trava na linha do estoque, ou um UPDATE ingressos SET restantes = restantes - 1 WHERE id = ? AND restantes > 0 que devolve 0 linhas afetadas quando acabou. É o mesmo problema do lost update, com consequência física.

🧾

Sagas: transações que atravessam serviços

Numa arquitetura de microsserviços, pedido, pagamento e estoque estão em bancos diferentes — e não existe COMMIT que abranja os três. O padrão usado é a saga: uma sequência de transações locais, cada uma com uma operação de compensação (estornar o pagamento, devolver o estoque). Não é atomicidade de verdade, é atomicidade reconstruída na mão — e é bem mais difícil de acertar. Um bom argumento para manter dados que mudam juntos no mesmo banco.

🕰️

Point-in-time recovery

Como o WAL registra toda alteração em ordem, ele permite restaurar o banco ao estado exato de um instante qualquer: aplique o backup de ontem e depois reproduza o log até 14h32 de hoje, um segundo antes do DELETE sem WHERE. É também assim que réplicas se mantêm em dia — elas simplesmente consomem o log do primário — e como funciona o CDC (change data capture) que alimenta pipelines de dados em tempo real.

← Anterior: Índices e B-trees Próxima: Escala e NoSQL →