Fundamentos de Programação/03 - Sistemas Reais/Semana 09 - Banco de Dados e SQL6 min
Transações e ACID
- Atomicidade, consistência, isolamento, durabilidade: o que cada letra promete?
- Read uncommitted, read committed, repeatable read, serializable: o que cada nível permite vazar?
- O que são dirty read, phantom read e lost update?
- Como um deadlock se forma, e o que o banco faz quando detecta um?
Conceito
Uma transação agrupa operações em uma unidade que acontece inteira ou não acontece. ACID são quatro promessas distintas, e confundi-las é comum:
| Letra | Promete | Quem garante |
|---|---|---|
| Atomicidade | tudo ou nada | o banco (log de undo) |
| Consistência | invariantes do schema continuam valendo | você, com constraints |
| Isolamento | transações concorrentes não se atropelam | o banco (nível configurável) |
| Durabilidade | commitado sobrevive a queda | o banco (log de redo, fsync) |
O C é o mais mal-entendido: o banco não sabe as suas regras de negócio. Ele garante que
CHECK, FOREIGN KEY, UNIQUE e NOT NULL continuam valendo — nada mais. "Saldo nunca
negativo" só é garantido se você declarar (4. Modelagem e Normalização).
Os níveis de isolamento, e o que cada um permite vazar:
| Nível | Dirty read | Non-repeatable read | Phantom |
|---|---|---|---|
| Read uncommitted | sim | sim | sim |
| Read committed | não | sim | sim |
| Repeatable read | não | não | sim (em teoria) |
| Serializable | não | não | não |
E as três anomalias, em uma linha cada:
- dirty read — você lê algo que outra transação ainda não commitou (e pode desfazer)
- non-repeatable read — você lê a mesma linha duas vezes e obtém valores diferentes
- phantom read — você roda a mesma consulta duas vezes e aparecem linhas novas
- lost update — dois leem, os dois calculam, os dois escrevem: o segundo apaga o primeiro
READ COMMITTED, e ele permite lost updatePostgres, Oracle e SQL Server usam READ COMMITTED por padrão. Nesse nível, o clássico
"ler saldo, calcular, gravar saldo" perde atualizações sob concorrência — sem erro.
As três defesas, em ordem de preferência:
- atomizar no SQL:
UPDATE conta SET saldo = saldo - 100 WHERE id = 1 AND saldo >= 100— a leitura e a escrita são a mesma operação - lock explícito:
SELECT ... FOR UPDATEantes de calcular - isolamento maior (
REPEATABLE READ/SERIALIZABLE) e retry no erro de serialização
A opção 1 resolve a maioria dos casos e não custa nada.
Deadlock: duas transações travam recursos em ordem oposta e cada uma espera a outra.
O banco detecta e mata uma (a "vítima"), que recebe erro e deve repetir. A prevenção é
travar sempre na mesma ordem — se todo mundo trava por id crescente, o ciclo não se forma.
Na prática
Rollback verificado:
sqlite> SELECT count(*) FROM cliente; -- antes: 4
sqlite> BEGIN;
sqlite> INSERT INTO cliente VALUES (99,'fantasma','XX');
sqlite> SELECT count(*) FROM cliente; -- dentro da transacao: 5
sqlite> ROLLBACK;
sqlite> SELECT count(*) FROM cliente; -- apos rollback: 4
Dentro da transação você vê sua própria escrita; após o rollback ela nunca existiu. É esse comportamento que torna o rollback a melhor forma de isolar teste de integração (2. Teste de Integração).
Em Go
O idioma de transação em Go — e ele tem uma sutileza que salva:
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback() // <- seguro DEPOIS do commit: devolve ErrTxDone, ignorado
if _, err := tx.ExecContext(ctx, "UPDATE ..."); err != nil {
return err // o defer faz o rollback
}
return tx.Commit()
defer tx.Rollback() antes do commit é correto e idiomático: se o commit já ocorreu, o
rollback devolve sql.ErrTxDone, que você ignora. Isso garante rollback em todo caminho
de erro, incluindo panic — sem precisar repetir o rollback em cada if err != nil.
db em vez de txtx, _ := db.BeginTx(ctx, nil)
tx.ExecContext(ctx, "UPDATE conta SET ...") // dentro da transação
db.ExecContext(ctx, "INSERT INTO log ...") // FORA! outra conexão, sem transação
tx.Commit()
A segunda linha usa outra conexão do pool. Ela não é atômica com a transação, não vê as escritas dela, e não é desfeita no rollback.
É silencioso e passa em teste (com um registro, tudo parece funcionar). A defesa
estrutural: as funções de repositório aceitam uma interface que *sql.DB e *sql.Tx
ambos satisfazem, e a decisão de transação fica em quem chama:
type Executor interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
Interface no consumidor, satisfeita implicitamente pelos dois tipos (3. Dependency Inversion Principle). É Go fazendo o que faz melhor.
Nível de isolamento e retry:
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
Com SERIALIZABLE, o banco pode abortar com erro de serialização — e retry é obrigatório,
não opcional. Em Postgres o código é 40001.
Contexto e transação: se o ctx for cancelado, database/sql faz rollback
automaticamente. Consequência: um ctx com timeout curto pode abortar uma transação longa no
meio, e o log vai mostrar "context canceled" sem mais explicação.
Respostas às perguntas-guia
1. Atomicidade, consistência, isolamento, durabilidade: o que cada letra promete?
Tudo-ou-nada; invariantes declarados preservados; concorrentes não se atropelam; commitado sobrevive a queda. O C depende de você declarar as constraints.
2. Read uncommitted, read committed, repeatable read, serializable: o que cada nível permite vazar?
Ver a tabela acima. O ponto prático: o default é READ COMMITTED, que permite
non-repeatable read, phantom e lost update.
Em Go: sql.LevelDefault usa o default do banco. Se você precisa de mais, peça
explicitamente e implemente retry.
3. O que são dirty read, phantom read e lost update?
Ler não commitado; consulta repetida traz linhas novas; duas escritas baseadas na mesma leitura e uma se perde.
Em Go, o lost update é o que você vai encontrar de verdade, e a correção é fazer o cálculo
no UPDATE (SET saldo = saldo - $1 WHERE saldo >= $1) e checar
RowsAffected() == 0 para saber que a condição falhou.
4. Como um deadlock se forma, e o que o banco faz quando detecta um?
Duas transações travam em ordem oposta e esperam-se mutualmente. O banco detecta o ciclo e mata uma, que recebe erro e deve repetir. Prevenção: ordem de travamento consistente.
Em Go: trate como erro retryável, com backoff. Em Postgres, 40P01 (deadlock) e
40001 (serialização) são os dois códigos que merecem retry automático.
Trade-offs
Do conceito:
- Isolamento maior compra corretude e cobra concorrência e abortos por serialização.
READ COMMITTEDcompra throughput e transfere a você o cuidado com lost update.- Transação longa compra atomicidade ampla e cobra locks retidos, bloqueio de outros e risco de deadlock.
- Durabilidade (
fsync) compra sobrevivência e cobra latência de escrita.
Em Go:
defer tx.Rollback()custa uma linha e cobre todos os caminhos de erro.- A interface
Executorcusta 5 linhas e elimina estruturalmente o bug de usardbem vez detx. - Contexto com timeout compra proteção contra query pendurada e cobra abortar transação longa legítima.
Comandos e consultas essenciais
BEGIN; -- ou START TRANSACTION
UPDATE conta SET saldo = saldo - 100 WHERE id = 1 AND saldo >= 100;
-- checar linhas afetadas: 0 significa que a condição falhou
UPDATE conta SET saldo = saldo + 100 WHERE id = 2;
COMMIT;
-- lock explícito antes de calcular fora do SQL
SELECT saldo FROM conta WHERE id = 1 FOR UPDATE;
-- não bloqueia: falha na hora se estiver travado
SELECT ... FOR UPDATE NOWAIT;
SELECT ... FOR UPDATE SKIP LOCKED; -- padrão de fila de trabalho
-- isolamento por transação
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- savepoint: rollback parcial
SAVEPOINT antes_do_item;
ROLLBACK TO SAVEPOINT antes_do_item;
-- quem está travando quem (Postgres)
SELECT pid, wait_event_type, wait_event, state, query FROM pg_stat_activity
WHERE wait_event_type = 'Lock';
FOR UPDATE SKIP LOCKED merece nota: é como se implementa fila de trabalho sobre banco
relacional sem broker — cada worker pega linhas que ninguém travou.
Relacionado
- 4. Modelagem e Normalização — o "C" de ACID depende das constraints declaradas
- 7. Problema N+1 — N queries dentro de uma transação retêm locks N vezes mais tempo
- 2. Teste de Integração — semana 7, rollback como isolamento de teste
- 5. Índices —
UPDATEsem índice trava mais linhas do que você imagina
Parte de Semana 09 - Banco de Dados e SQL · 00 - MOC Fundamentos de Programação