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.
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.
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.
contas(id=1, saldo=1000)| Nível | Leitura suja | Não repetível | Fantasma | Update perdido |
|---|---|---|---|---|
| READ UNCOMMITTED | possível | possível | possível | possível |
| READ COMMITTED | impedida | possível | possível | possível |
| REPEATABLE READ | impedida | impedida | impedida | possível |
| SERIALIZABLE | impedida | impedida | impedida | impedida |
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.
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.
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.
Teste sua intuição
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.