trilha

Fundamentos de Programação/03 - Sistemas Reais/Semana 09 - Banco de Dados e SQL6 min

Transações e ACID

Perguntas-guia
  • 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
O default é READ COMMITTED, e ele permite lost update

Postgres, 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:

  1. 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
  2. lock explícito: SELECT ... FOR UPDATE antes de calcular
  3. 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ê 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.

O bug nº 1 de transação em Go: usar db em vez de tx
tx, _ := 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 COMMITTED compra 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 Executor custa 5 linhas e elimina estruturalmente o bug de usar db em vez de tx.
  • 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


Parte de Semana 09 - Banco de Dados e SQL · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas