Transação é a primitiva que torna banco usável em sistemas com
concorrência. Sem ela, você teria que pensar em cada operação
como um leque de possibilidades intercaladas com as operações de
todos os outros usuários — cada combinação um caso a tratar. A
transação encapsula um conjunto de operações como se fossem
atômicas: ou todas acontecem, ou nenhuma; e o efeito visto pelo
mundo é como se executassem isoladamente. Esse contrato é o que
permite escrever débito de R$100 da conta A; crédito de
R$100 na conta B e dormir tranquila.
Mas o "como se executassem isoladamente" é negociável. O isolamento total — serializável — é caro: implica esperas, abortos, contenção. SGBDs oferecem níveis diferentes de isolamento, cada um com anomalias específicas que aceita em troca de performance. Saber qual nível seu banco usa por padrão, qual nível sua transação pediu, e que anomalias podem aparecer no nível escolhido — é parte do trabalho de quem desenha sistemas com banco.
Esse conceito explica ACID com profundidade, percorre os quatro
níveis de isolamento padrão SQL, descreve cada anomalia pelo nome,
e mergulha em MVCC — o mecanismo que permite ao Postgres oferecer
isolamento sem locks pesados. No final, padrões para usar
transações com inteligência: SELECT FOR UPDATE,
optimistic concurrency, retries em serializable.
ACID — quatro propriedades, quatro promessas
O acrônimo ACID foi cunhado por Andreas Reuter e Theo Härder em 1983, formalizando ideias que vinham sendo discutidas desde os anos 70. Cada letra é uma garantia distinta:
Atomicidade
Ou todas as operações da transação acontecem, ou nenhuma. Não há
meio-termo. Se a transação abortar (por falha do sistema, deadlock
ou ROLLBACK explícito), o estado volta para o que era
antes do BEGIN. Em Postgres, atomicidade é
implementada via WAL (Write-Ahead Log): cada mudança é registrada
no log antes de tocar páginas de dados; em crash, o WAL é replayed
até o último commit confirmado, e transações abertas são
revertidas.
Consistência
A transação leva o banco de um estado válido a outro estado válido.
"Válido" significa: respeita constraints (NOT NULL,
UNIQUE, FOREIGN KEY, CHECK),
respeita triggers que validam regras, respeita o estado dos dados
conforme a aplicação espera. É a única letra do ACID que depende
do desenvolvedor — o banco aplica suas constraints; a aplicação
precisa não introduzir violações lógicas. Dizer que "consistência
é trabalho do banco" é meia-verdade: o banco impõe constraints
declaradas, mas as regras de negócio que não viram constraint
ficam por sua conta.
Isolamento
Transações simultâneas não interferem entre si — ou interferem apenas conforme o nível de isolamento permite. Esta é a letra mais sutil de ACID e o foco principal deste conceito. Veja adiante.
Durabilidade
Transação confirmada (COMMIT retornou) não se
perde. Mesmo em queda de energia, restart do banco, falha de
hardware. Implementação típica: fsync do WAL antes
de retornar COMMIT. Postgres expõe controle fino —
synchronous_commit = off ganha throughput em troca
de perder até alguns ms de transações em crash, decisão para
sistemas com tolerância documentada.
Os fenômenos — anomalias com nome
Antes dos níveis de isolamento, é preciso conhecer as anomalias que isolamento previne. SQL standard nomeia quatro:
Dirty read
Transação A modifica linha; transação B lê o valor modificado antes de A confirmar; A aborta. B viu dado que nunca existiu oficialmente. É a anomalia mais grave — viola "consistência percebida" de forma gritante.
Non-repeatable read
Transação A lê uma linha; transação B atualiza essa linha e confirma; A lê de novo e vê valor diferente — dentro da mesma transação. Para lógicas que dependem de "tirar uma foto" do estado, é problema sério. Para dashboards que toleram leituras ligeiramente desatualizadas, é tolerável.
Phantom read
Transação A executa SELECT ... WHERE x > 100 e vê
10 linhas; B insere uma linha com x = 150 e confirma;
A repete a mesma query e vê 11 linhas. A "linha fantasma"
apareceu. É variante do non-repeatable, mas envolve o conjunto
mudando, não o valor de uma linha específica.
Lost update
Transação A lê saldo = 100, calcula +50;
B lê saldo = 100, calcula -30; A escreve
150; B escreve 70. A operação de A foi
"perdida" — o saldo final deveria ter aplicado os dois ajustes.
Não está no SQL standard original mas é o mais comum em
aplicações reais.
Write skew
Anomalia mais sutil, importante em isolamento snapshot. A e B leem o mesmo conjunto, ambas verificam que uma invariante "haverá pelo menos um médico de plantão" segue válida porque há dois; cada uma desliga um médico diferente baseado nessa leitura; ambas confirmam; ninguém de plantão. Anomalia famosa do paper "A Critique of ANSI SQL Isolation Levels" de Berenson et al. (1995).
Os quatro níveis padrão SQL
O SQL standard define quatro níveis de isolamento, cada um permitindo um subconjunto das anomalias acima. Importante: o standard especifica quais anomalias podem ocorrer, não como prevenir as outras. Cada banco implementa de forma diferente.
| Nível | Dirty read | Non-repeatable | Phantom |
|---|---|---|---|
| Read uncommitted | possível | possível | possível |
| Read committed | não | possível | possível |
| Repeatable read | não | não | possível* |
| Serializable | não | não | não |
* No standard. Postgres no nível REPEATABLE READ
previne phantoms também — implementação stricter que o standard
exige.
Read uncommitted — o nível inútil
Permite até dirty read. Postgres não implementa esse
nível distintamente — pedir READ UNCOMMITTED dá
READ COMMITTED. SQL Server permite, e quem usa
sabe que está pisando em ovos. Para a esmagadora maioria dos
sistemas, é nível a evitar.
Read committed — o default na maioria
Postgres usa por padrão. SQL Server também. Cada SELECT
vê um snapshot consistente do momento em que aquela query
começou — não da transação. Duas SELECT seguidas na
mesma transação podem ver dados diferentes (non-repeatable). É
o nível com melhor balance entre desempenho e sanidade para a
maioria dos sistemas web típicos.
Repeatable read — snapshot da transação
Cada transação vê um snapshot consistente do momento em que ela começou. Tudo lido durante a transação reflete esse momento, não mudanças commit-adas depois. MySQL InnoDB usa isso por padrão. Postgres oferece. Em Postgres, a implementação previne phantoms também.
Serializable — execução como se fosse sequencial
O nível mais forte. Garante que o resultado é equivalente a
alguma ordem sequencial das transações. Em Postgres, é
implementado via SSI (Serializable Snapshot Isolation, Cahill
et al., 2008): roda em snapshot, monitora dependências entre
transações, e aborta a transação que cria conflito de
serialização. Aplicação precisa estar preparada para retry em
SQLSTATE 40001.
MVCC — como Postgres faz tudo isso sem locks de leitura
Multiversion Concurrency Control é o mecanismo que dá ao Postgres sua principal característica: leitura nunca bloqueia escrita, escrita nunca bloqueia leitura. A ideia tem origem em paper de David Reed (MIT, 1978), mas Postgres é uma das implementações mais maduras.
O conceito central: cada linha não é sobrescrita; cada
UPDATE cria uma versão nova da linha, com timestamps
(xmin, xmax) que indicam em que
transação a versão se tornou visível e em que transação ela foi
removida. DELETE só marca xmax — a
linha continua fisicamente presente até VACUUM
limpar.
Quando uma transação faz SELECT, Postgres usa o
número da transação atual e a lista de transações em curso para
decidir qual versão de cada linha mostrar. É isso que permite
leitura sem locks: a leitora navega versões; a escritora cria
versões novas; ninguém espera a outra.
Implicações práticas:
-
Tabelas crescem em linhas mortas em sistemas
com muito
UPDATE. Autovacuum recicla espaço; sem ele, o disco enche e queries lentificam. -
Índices apontam para versões (ctid). Em
UPDATEde coluna não-indexada, Postgres tenta HOT update (Heap-Only Tuple) — atualiza só na heap, evita atualizar todos os índices. Otimização importante. - Long-running transactions são caras: enquanto uma transação está aberta, autovacuum não pode limpar versões que ela talvez precise ver. Transações deixadas abertas por horas viram pesadelo operacional ("bloat por transação idosa").
Conexão de aplicação que abre transação e esquece (BEGIN
sem COMMIT ou ROLLBACK) é uma das
causas mais comuns de problemas crônicos em Postgres. Bloqueia
autovacuum, gera bloat, e pode travar DDL inteiro.
idle_in_transaction_session_timeout deveria estar
configurado em todo Postgres de produção.
Lost update — onde isolamento padrão não basta
O caso "ler saldo, calcular, escrever" sob concorrência não é
coberto por READ COMMITTED nem por REPEATABLE
READ — ambas as transações veem o mesmo valor inicial,
ambas escrevem, a última vence. Há três soluções padrão.
SELECT FOR UPDATE — pessimistic locking
Você diz ao banco: vou modificar essa linha; trave-a contra
modificações até eu confirmar. Outras transações que tentem o
mesmo SELECT FOR UPDATE esperam.
BEGIN;
SELECT saldo FROM contas WHERE id = 42 FOR UPDATE; -- trava a linha
-- saldo = 100 e ninguém mais pode mudar
UPDATE contas SET saldo = saldo + 50 WHERE id = 42;
COMMIT; -- libera a trava
Funciona; é simples. Custo: serialização real onde houver
contenção. Para hot rows (a conta de uma empresa popular num
sistema de pagamentos), pode virar gargalo. Variantes:
FOR NO KEY UPDATE (mais permissivo),
FOR SHARE (compartilhado para leitura),
NOWAIT e SKIP LOCKED (não esperar) —
essas duas últimas são a base de filas de trabalho em
Postgres.
Optimistic concurrency — versionar e checar
Adicione coluna versao INT. Cada update inclui
WHERE versao = ? e incrementa. Se outra transação
atualizou no meio, sua atualização não afeta nenhuma linha — e
sua aplicação detecta e retry.
-- Aplicação leu (saldo=100, versao=7)
UPDATE contas
SET saldo = 150, versao = 8
WHERE id = 42 AND versao = 7;
-- Se rowcount = 0: outra transação venceu. Ler de novo, retry.
Custo: lógica de retry na aplicação. Vantagem: sem lock; ótimo onde concorrência é rara. EF Core, JPA/Hibernate e SQLAlchemy têm suporte nativo.
Serializable — deixe o banco resolver
Use isolamento serializable; o banco detecta o conflito e aborta
uma das transações. Aplicação precisa de retry handler para
SQLSTATE 40001. Mais simples no nível de código,
mas requer disciplina: cada transação precisa idealmente ser
idempotente sob retry.
Erros típicos sob concorrência
Confiar em READ COMMITTED para validações
Aplicação faz SELECT COUNT(*) FROM reservas WHERE sala_id
= ? AND periodo && ?; se zero, insere. Sob
concorrência, duas instâncias passam pela validação ao mesmo
tempo, ambas inserem. READ COMMITTED permite. A
solução é EXCLUDE constraint no banco
(EXCLUDE USING GIST (sala_id WITH =, periodo WITH &&)),
ou serializable, ou advisory lock — não SELECT
seguido de INSERT.
Transações longas
Aplicação abre transação no início de um request HTTP, fecha no fim. Se o request demora por causa de uma chamada externa lenta, a transação fica aberta minutos. Bloqueia limpeza, segura linhas contra outras transações, esgota conexões. Transações devem ser curtas; chamadas externas devem ficar fora de transação.
Não tratar deadlocks
Em qualquer banco com locks, deadlock acontece. Postgres detecta
e aborta uma das transações com SQLSTATE 40P01.
Aplicação que não retry vê erro intermitente em produção.
Wrapper de transação deveria sempre incluir retry com backoff
para erros de serialização e deadlock.
Transações nas três linguagens — incluindo retry
// Wrapper que faz retry em SQLState 40001 / 40P01
public async Task<T> InTransactionAsync<T>(
Func<NpgsqlTransaction, Task<T>> work,
IsolationLevel level = IsolationLevel.Serializable,
int maxRetries = 5)
{
for (int attempt = 0; ; attempt++) {
await using var conn = await dataSource.OpenConnectionAsync();
await using var tx = await conn.BeginTransactionAsync(level);
try {
var result = await work(tx);
await tx.CommitAsync();
return result;
}
catch (PostgresException e) when (
(e.SqlState == "40001" || e.SqlState == "40P01")
&& attempt < maxRetries) {
await tx.RollbackAsync();
await Task.Delay(Random.Shared.Next(10, 50 * (attempt + 1)));
}
}
}
Backoff aleatório evita re-colisão em pico de contenção. EF Core
tem EnableRetryOnFailure() que faz algo similar
internamente, mas ter o wrapper explícito ajuda em testes e
debug.
from sqlalchemy.exc import OperationalError
from sqlalchemy import text
import time, random
from functools import wraps
def in_transaction(level="SERIALIZABLE", max_retries=5):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
for attempt in range(max_retries + 1):
try:
with engine.begin() as conn:
conn.execute(text(f"SET TRANSACTION ISOLATION LEVEL {level}"))
return fn(conn, *args, **kwargs)
except OperationalError as e:
code = getattr(e.orig, "sqlstate", None)
if code in ("40001", "40P01") and attempt < max_retries:
time.sleep(random.uniform(0.01, 0.05 * (attempt + 1)))
continue
raise
return wrapper
return decorator
@in_transaction(level="SERIALIZABLE")
def transferir(conn, de, para, valor):
conn.execute(text("UPDATE contas SET saldo = saldo - :v WHERE id = :id"),
{"v": valor, "id": de})
conn.execute(text("UPDATE contas SET saldo = saldo + :v WHERE id = :id"),
{"v": valor, "id": para})
Decorator captura padrão de retry sem poluir lógica de domínio.
asyncpg tem comportamento similar — código equivalente usando
connection.transaction(isolation='serializable').
func InTx(ctx context.Context, pool *pgxpool.Pool, level pgx.TxIsoLevel,
fn func(pgx.Tx) error) error {
var lastErr error
for attempt := 0; attempt < 5; attempt++ {
tx, err := pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: level})
if err != nil {
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback(ctx)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) &&
(pgErr.Code == "40001" || pgErr.Code == "40P01") {
lastErr = err
jitter := time.Duration(rand.Intn(50*(attempt+1))) * time.Millisecond
time.Sleep(jitter)
continue
}
return err
}
return tx.Commit(ctx)
}
return fmt.Errorf("retry exhausted: %w", lastErr)
}
// Uso:
err := InTx(ctx, pool, pgx.Serializable, func(tx pgx.Tx) error {
_, e1 := tx.Exec(ctx, `UPDATE contas SET saldo = saldo - $1 WHERE id = $2`, v, de)
_, e2 := tx.Exec(ctx, `UPDATE contas SET saldo = saldo + $1 WHERE id = $2`, v, para)
return errors.Join(e1, e2)
})
Em Go, helpers de transação acabam recriados por projeto — não
há padrão único do ecossistema. pgx é o driver canônico; sqlc
gera código que recebe DBTX (interface comum entre
pool, conn e tx), o que torna esse padrão de wrapper reutilizável.
Padrões avançados — advisory locks & SKIP LOCKED
Advisory locks
Postgres oferece locks lógicos não associados a linhas:
pg_advisory_xact_lock(hash). Útil para coordenar
operações onde a "coisa" não é uma linha — ex: garantir que só
uma instância da aplicação roda determinado job. O lock dura a
transação (variantes existem para o duration "session").
Filas em Postgres com SKIP LOCKED
Padrão emergente: usar Postgres como fila de trabalho em vez de
RabbitMQ/SQS para volumes baixos a médios. Cada worker faz
SELECT ... FROM jobs WHERE status='pending' LIMIT 1 FOR
UPDATE SKIP LOCKED — pega um job sem disputar com outros
workers, marca como 'em andamento', confirma. SKIP LOCKED
faz o que outras transações já travaram ser ignorado em vez de
bloquear espera. Brandur Leach em "Choose Postgres queue
technology" defende esse padrão para muitos casos.
Como praticar
-
Reproduza cada anomalia em duas seções psql lado a
lado. Abra duas conexões; em uma, faça
BEGIN; UPDATEsem commit; na outra, observe o que acontece em cada nível de isolamento (mude comSET TRANSACTION ISOLATION LEVEL ...). Ver a anomalia com seus olhos cola o conceito. -
Implemente transferência entre contas com as três
estratégias:
SELECT FOR UPDATE, optimistic com versão, e serializable + retry. Compare comportamento sob carga (usepgbenchcom script custom). -
Configure
idle_in_transaction_session_timeoutem uma instância local. Force uma aplicação a deixar transação aberta. Veja o que acontece quando o timeout dispara — o comportamento deveria entrar no seu modelo mental de operação.
Referências para aprofundar
- livro Designing Data-Intensive Applications — Martin Kleppmann (2017).
- livro PostgreSQL 16 Internals — Egor Rogov (2024).
- livro Transactional Information Systems — Weikum & Vossen (2002).
- livro Database Internals — Alex Petrov (2019).
- paper A Critique of ANSI SQL Isolation Levels — Berenson et al. (1995).
- paper Serializable Snapshot Isolation in PostgreSQL — Cahill, Röhm, Fekete (2008).
- artigo PostgreSQL Anti-Patterns: Read-Modify-Write Cycles — Craig Ringer.
- artigo Choose Postgres queue technology — Brandur Leach (2024).
- artigo How does MVCC work in PostgreSQL? — Vlad Mihalcea.
- docs PostgreSQL Documentation — Concurrency Control.
- docs PostgreSQL Documentation — Routine Database Maintenance.
- vídeo PostgreSQL MVCC Internals — Bruce Momjian.