MÓDULO 03 · CONCEITO 03 DE 8

Transações & ACID

Níveis de isolamento, anomalias e MVCC. O contrato que torna possível raciocinar sobre concorrência em banco.

Tempo de leitura ~24 min Pré-requisito SQL básico + concorrência Próximo Planos de execução

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 uncommittedpossívelpossívelpossível
Read committednãopossívelpossível
Repeatable readnãonãopossível*
Serializablenãonãonã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 UNCOMMITTEDREAD 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:

armadilha em produção

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

C# — Npgsql + 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.

Python — SQLAlchemy + retry decorator
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').

Go — pgx + helper
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

  1. Reproduza cada anomalia em duas seções psql lado a lado. Abra duas conexões; em uma, faça BEGIN; UPDATE sem commit; na outra, observe o que acontece em cada nível de isolamento (mude com SET TRANSACTION ISOLATION LEVEL ...). Ver a anomalia com seus olhos cola o conceito.
  2. 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 (use pgbench com script custom).
  3. Configure idle_in_transaction_session_timeout em 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

  1. livro Designing Data-Intensive Applications — Martin Kleppmann (2017). Cap. 7 (Transactions) é o melhor tratamento moderno do tema. Anomalias, isolamento, serializability — explicado com clareza incomum.
  2. livro PostgreSQL 16 Internals — Egor Rogov (2024). Quatro capítulos sobre MVCC, snapshots, locks, e níveis de isolamento — com diagramas de versões e timelines.
  3. livro Transactional Information Systems — Weikum & Vossen (2002). A bíblia teórica. Concurrency control e recovery em profundidade. Denso, mas é a fonte para quem quer entender por que cada algoritmo existe.
  4. livro Database Internals — Alex Petrov (2019). Cap. 5 (Transaction Processing) e 9 (Distributed Transactions) cobrem ACID, MVCC e 2PC com pragmatismo.
  5. paper A Critique of ANSI SQL Isolation Levels — Berenson et al. (1995). microsoft.com/en-us/research — paper que mostrou que o standard SQL sub-especifica os níveis. Introduz snapshot isolation e write skew formalmente.
  6. paper Serializable Snapshot Isolation in PostgreSQL — Cahill, Röhm, Fekete (2008). drkp.net/papers — base teórica do SSI implementado no Postgres 9.1. Explica como detectar conflitos sem locks pesados.
  7. artigo PostgreSQL Anti-Patterns: Read-Modify-Write Cycles — Craig Ringer. Anti-padrão clássico explicado com soluções para cada caso. Material formativo.
  8. artigo Choose Postgres queue technology — Brandur Leach (2024). brandur.org — defesa do padrão SKIP LOCKED + outbox. Mostra que muitas filas separadas são desnecessárias se você sabe usar Postgres.
  9. artigo How does MVCC work in PostgreSQL? — Vlad Mihalcea. vladmihalcea.com — explicação visual de xmin/xmax, versões e visibilidade. Excelente para quem está formando intuição.
  10. docs PostgreSQL Documentation — Concurrency Control. postgresql.org/docs/current/mvcc.html — capítulo 13. Cobertura oficial completa: isolamento, locks explícitos, advisory locks.
  11. docs PostgreSQL Documentation — Routine Database Maintenance. postgresql.org/docs/current/routine-vacuuming.html — vacuum, autovacuum, transaction ID wraparound. Essencial para operação.
  12. vídeo PostgreSQL MVCC Internals — Bruce Momjian. YouTube. Apresentação clássica do Momjian, com slides claros mostrando versões de tuplas e snapshots.