Postgres Profundo: MVCC, Isolation Levels e Locks
- ⬜🔄 Modelos de Consistência: strong, eventual, causal, read-your-writes(Sistemas Distribuídos)
Recomendamos completar os pré-requisitos antes de seguir, mas nada te impede de continuar.
Postgres é o banco favorito do mundo moderno — e você provavelmente usa sem saber o que acontece por baixo. Este módulo é sobre o que todo engenheiro sério precisa entender: como o MVCC permite leituras e escritas concorrentes sem locks recíprocos, a diferença real entre os 4 isolation levels, quando usar SELECT FOR UPDATE vs advisory locks, e por que sua tabela de 2GB virou 15GB (bloat).
Saber isso separa dev que copia código do StackOverflow do engenheiro que diagnostica deadlock em produção às 3 da manhã.
MVCC: o coração do Postgres
MVCC (Multi-Version Concurrency Control) é a técnica que permite: readers não bloqueiam writers, writers não bloqueiam readers. Cada linha pode existir em múltiplas versões simultaneamente, cada versão visível apenas pras transações cuja "visão" do mundo inclui essa versão.
Toda tupla no Postgres tem campos de controle: ┌────────────────────────────────────────────────────────────┐ │ ctid │ xmin │ xmax │ ... dados da linha ... │ │ (ptr) │ (criou) │ (deletou│ │ │ │ │ /null) │ │ └────────────────────────────────────────────────────────────┘ UPDATE user SET balance = 150 WHERE id = 42; → marca tupla antiga com xmax = txid (me) → INSERE nova tupla com xmin = txid (me), xmax = null → resultado: DUAS tuplas no mesmo id, com xmin/xmax diferentes Outra tx (txid menor) lendo: → vê a tupla antiga (a nova foi criada por txid maior que o snapshot dela) → resultado: não vê a mudança até commit + refresh do seu snapshot
Visibility rule simplificada: uma tupla é visível pra uma tx se:
- xmin foi committed antes do snapshot da tx (e não é ela mesma)
- xmax é null, ou aborted, ou ainda não committed
Os 4 isolation levels (e as anomalias que previnem)
SQL standard define 4 níveis + 3 anomalias. Postgres na prática oferece 3(Read Uncommitted é tratado como Read Committed).
| Isolation Level | Dirty read | Non-repeatable read | Phantom read | Serialization anomaly |
|---|---|---|---|---|
| Read Uncommitted | (não aplica no PG) | Sim | Sim | Sim |
| Read Committed (DEFAULT) | Não | Sim | Sim | Sim |
| Repeatable Read | Não | Não | Não (PG) | Sim |
| Serializable | Não | Não | Não | Não |
| Anomalia | Descrição | Exemplo |
|---|---|---|
| Dirty read | Tx1 lê um valor que Tx2 escreveu mas ainda não fez commit | Ler saldo 100 que Tx2 está prestes a rollback |
| Non-repeatable read | Tx1 lê a mesma linha duas vezes e vê valores diferentes (Tx2 commitou entre as leituras) | SELECT balance FROM a WHERE id=1 → 100, depois 150 |
| Phantom read | Tx1 executa o mesmo query com WHERE duas vezes e recebe linhas diferentes (Tx2 inseriu novas) | SELECT COUNT(*) FROM a WHERE balance>0 → 5, depois 6 |
| Serialization anomaly | Dois txs concurrent, cada um passa sozinho, mas juntos violam invariante | Constraint "total >= 0": 2 débitos concorrentes que individualmente passam mas juntos estouram |
40001 — você deve retriar.Setar isolation level:
-- Por transação: BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT balance FROM accounts WHERE id = 42; UPDATE accounts SET balance = balance - 50 WHERE id = 42; COMMIT; -- Setar default da sessão: SET default_transaction_isolation = 'repeatable read'; -- Setar como default do banco (não recomendado — caso a caso): ALTER DATABASE mydb SET default_transaction_isolation = 'read committed';
Exemplo real: lost update com Read Committed
Clássico bug em CRUD: dois usuários editando o mesmo registro concorrentemente. Read Committed não previne.
-- Cenário: 2 usuários editando perfil do user 42 (balance inicial: 100) -- Tx1 e Tx2 ambas em Read Committed (default) -- Tx1: BEGIN; SELECT balance FROM accounts WHERE id = 42; -- lê 100 -- ... app calcula novo valor: 100 + 50 = 150 -- Tx2: BEGIN; SELECT balance FROM accounts WHERE id = 42; -- lê 100 -- ... app calcula novo valor: 100 + 30 = 130 -- Tx1: UPDATE accounts SET balance = 150 WHERE id = 42; COMMIT; -- OK -- Tx2: UPDATE accounts SET balance = 130 WHERE id = 42; -- sobrescreve! COMMIT; -- Tx1 foi PERDIDA. Balance: 130 (deveria ser 180).
3 soluções:
-- Solução 1: SELECT FOR UPDATE (pessimistic lock) BEGIN; SELECT balance FROM accounts WHERE id = 42 FOR UPDATE; -- bloqueia a linha UPDATE accounts SET balance = balance + 50 WHERE id = 42; COMMIT; -- Tx2 trava no SELECT FOR UPDATE até Tx1 commitar; depois lê 150 e segue. -- Solução 2: UPDATE atômico (não precisa de lock se a lógica cabe em SQL) UPDATE accounts SET balance = balance + 50 WHERE id = 42; -- Postgres executa atomicamente. Sem race, sem lock manual. -- Solução 3: Optimistic lock (version column) -- schema: ALTER TABLE accounts ADD COLUMN version INT DEFAULT 0; BEGIN; SELECT balance, version FROM accounts WHERE id = 42; -- lê (100, 3) UPDATE accounts SET balance = 150, version = version + 1 WHERE id = 42 AND version = 3; -- CAS -- Se rowcount = 0, alguém mudou antes → erro pro cliente / retry. COMMIT;
balance = balance + :amount), essa é a solução mais simples e performante. Só recorra a FOR UPDATE ou optimistic quando a lógica envolve decisões na aplicação.Tipos de lock no Postgres
Postgres tem múltiplos níveis de lock — linha (tuple) e tabela.
Locks de linha:
| Lock | SQL | O que bloqueia |
|---|---|---|
| FOR UPDATE | SELECT ... FOR UPDATE | Outras FOR UPDATE, FOR SHARE, UPDATE, DELETE nessa linha |
| FOR NO KEY UPDATE | SELECT ... FOR NO KEY UPDATE | Mais leve — permite outros FOR KEY SHARE concorrentes (usado em FKs) |
| FOR SHARE | SELECT ... FOR SHARE | Outras UPDATE, DELETE, FOR UPDATE. Permite outras FOR SHARE. |
| FOR KEY SHARE | SELECT ... FOR KEY SHARE | Apenas UPDATE da key. Mais leve. Usado internamente por FKs. |
Locks de tabela:
| Lock | Quando acontece | Bloqueia o quê |
|---|---|---|
| ACCESS SHARE | SELECT simples | Só ACCESS EXCLUSIVE (ex: DROP TABLE) |
| ROW SHARE | SELECT FOR UPDATE/SHARE | EXCLUSIVE, ACCESS EXCLUSIVE |
| ROW EXCLUSIVE | UPDATE, DELETE, INSERT | SHARE e acima |
| SHARE | CREATE INDEX | ROW EXCLUSIVE e acima |
| EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY | SHARE, ROW EXCLUSIVE e acima |
| ACCESS EXCLUSIVE | DROP TABLE, TRUNCATE, ALTER TABLE (maioria), VACUUM FULL | TUDO, inclusive SELECT |
ALTER TABLE ADD COLUMN NOT NULL DEFAULT 'x' em tabelas grandes adquire ACCESS EXCLUSIVE e reescreve tudo. Quebra produção em tabelas de milhões. Solução:- ADD COLUMN sem default (instantâneo no PG 11+)
- UPDATE em batches pra preencher o default
- ALTER COLUMN SET NOT NULL (sem reescrita no PG 12+)
Advisory locks: locks application-level
Advisory locks não protegem linhas — protegem conceitos. Você passa um id (int64 ou par de int32), Postgres garante que só uma sessão detém aquele lock.
-- Exclusivo, session-level (dura até sessão terminar ou unlock explícito) SELECT pg_advisory_lock(12345); -- ... faz algo crítico ... SELECT pg_advisory_unlock(12345); -- Exclusivo, transaction-level (libera no COMMIT/ROLLBACK — preferido) BEGIN; SELECT pg_advisory_xact_lock(12345); -- ... faz algo crítico ... COMMIT; -- lock liberado automaticamente -- Non-blocking (try) SELECT pg_try_advisory_xact_lock(12345); -- retorna true se pegou, false se já tá preso — pra padrões "rodar 1 de N"
Casos de uso clássicos:
- Leader election leve: quem pegar o lock 42 é o "leader" naquele momento (cron singleton, job runner).
- Rate limit global: gate pra operações idempotentes custosas.
- Migration coordination: apenas uma réplica executa a migration.
- Evitar thundering herd: só uma tx faz o trabalho caro, outras esperam.
VACUUM e bloat: o lixo que ninguém limpa
MVCC cobra preço: cada UPDATE/DELETE deixa tuplas mortas. Sem recuperação, disco enche e queries degradam.
Tabela com 1000 rows, todos recebem UPDATE 10x sem VACUUM: Disco: ┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐ ... 10000 tuplas │live ││dead ││dead ││dead ││live │ (1000 vivas + 9000 mortas) └─────┘└─────┘└─────┘└─────┘└─────┘ SELECT * FROM t WHERE ... → varre 10000 tuplas, filtra dead ones, retorna live → 10x mais lento que tabela limpa (scan fica caro, cache miss, etc.) VACUUM (normal): → Marca tuplas mortas como reutilizáveis pra novos INSERTs → NÃO devolve espaço ao SO (arquivo não encolhe) → Não segura ACCESS EXCLUSIVE — concorrência OK VACUUM FULL: → Reescreve a tabela inteira, elimina espaço morto → Segura ACCESS EXCLUSIVE — tabela inacessível até terminar → DON'T em produção em tabelas grandes. Use pg_repack.
Autovacuum: daemon que roda VACUUM automaticamente. Por default, ativado. Mas configurado conservadoramente demais pra muitas workloads.
-- Ver estatísticas de bloat (approximação)
SELECT schemaname, relname,
n_live_tup, n_dead_tup,
round(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
-- Tunar autovacuum pra tabela hot específica:
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.02, -- 2% de dead → roda (default 20%)
autovacuum_vacuum_cost_limit = 2000 -- mais IO permitido (default 200)
);
-- Forçar VACUUM imediato (sem ACCESS EXCLUSIVE):
VACUUM (VERBOSE, ANALYZE) orders;BEGIN aberto no psql. Monitor pg_stat_activity em state=idle in transaction.Dicas práticas de troubleshooting
-- 1. Ver queries rodando agora
SELECT pid, usename, application_name, state, wait_event_type, wait_event,
now() - query_start AS duration, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;
-- 2. Ver locks e quem espera quem
SELECT blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_query,
blocking_activity.query AS blocking_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity
ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity
ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
-- 3. Matar query (com cuidado):
SELECT pg_cancel_backend(pid); -- pede educadamente
SELECT pg_terminate_backend(pid); -- força — aborta tx
-- 4. Ver tamanho real das tabelas + bloat:
SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS total,
pg_size_pretty(pg_relation_size(relid)) AS "table only"
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC LIMIT 20;Decisões reais
📋 Preciso de contador global (hits por produto) com alta concorrência
UPDATE é atômico por natureza no PG. SELECT seguido de UPDATE na app exige FOR UPDATE ou optimistic lock. Em volumes altos, contadores viram hot row e ainda assim gargalam — nesse caso, considere sharding do contador (10 rows, agrega no SELECT).
Alt: Redis INCR — Muito mais rápido pra contador puro. Sincroniza com PG em batch.
Alt: Table-level counter ser shardado — counter_id % 10 → 10 rows. Reduz contenção.
📋 Leader election leve pra worker que só deve ter 1 ativo
Se você já tem Postgres, é 1 linha de código e 0 dependência nova. Em cada iteração do worker, tenta pegar o lock — se falhar, ele não é o leader, pula. Se master do PG cair, o replacement assume, lock se perde e outro worker pega. Perfeito pra cron singleton.
Alt: Redis lease (TTL) — Se já tem Redis. Cuidado com split-brain em failover.
Alt: etcd lease — Se já tem K8s/etcd. Mais robusto que advisory lock.
📋 Transação de e-commerce: preciso subtrair estoque sem vender negativo
UPDATE products SET stock = stock - $1 WHERE id = $2 AND stock >= $1. Se rowcount = 0, não havia estoque — retorna erro. Atomicidade nativa, zero lock explícito, performance excelente. Nem FOR UPDATE nem optimistic é necessário.
Alt: SELECT FOR UPDATE + UPDATE — Funciona mas mais lento e mais código.
📋 Preciso Serializable Snapshot Isolation pra invariantes complexas
SSI no PG é a melhor implementação comercial de serializable — performance quase RR, mas previne anomalias. Custo: txs podem abortar com 40001 — sua app precisa retriar. Ótimo pra regras complexas (ex: 'soma de todas as fatias ≤ 100').
Alt: Repeatable Read + locks explícitos — Mais controle, mais código, mais bugs.
Perguntas típicas (Q&A)
Por que minha query lenta ficou rápida depois de VACUUM?
Tabela tava cheia de tuplas mortas — cada SELECT varria muito lixo. VACUUM marcou tuplas mortas como reutilizáveis + atualizou visibility map. Bônus: ANALYZE (roda junto) atualizou estatísticas do planner, que pode ter escolhido plano melhor.
O que é "deadlock"?
Duas txs esperando cada uma o lock da outra. PG detecta (via grafo de wait) em 1s por default e aborta uma das duas com erro 40P01. App deve retriar. Pra prevenir: sempre adquirir locks na mesma ordem (ex: ordenar por id).
Transação ROLLBACK "perde" o txid?
Sim — PG consome txids pra toda tx (mesmo abortada). Txid é uint32, wrap-around em ~4B. Autovacuum faz "freeze" pra evitar apocalipse de wraparound. Se autovacuum não dá conta, cluster entra em read-only. Monitor datfrozenxid.
FOR UPDATE funciona em SELECT com JOIN?
Sim, mas por default trava linhas de todas as tabelas joinadas. Você pode restringir com FOR UPDATE OF tablename pra travar só uma.
Qual isolation level usar por default?
Read Committed (default) é suficiente pra 95% dos casos se você escrever SQL atômico (UPDATE com expressões, SELECT FOR UPDATE onde precisa). Suba pra Repeatable Read só quando precisa de snapshot consistente da tx inteira, e Serializable quando tem invariante multi-row complexo.
- MVCC: cada tupla tem xmin/xmax, múltiplas versões coexistem. Readers não bloqueiam writers.
- Read Committed é o default; Repeatable Read ou Serializable quando invariantes importam.
- UPDATE atômico sempre que possível. FOR UPDATE só quando lógica na app decide.
- Advisory locks pra leader election, gate de jobs, dedup — application-level, não protege linhas.
- Bloat mata performance. Monitor pg_stat_user_tables, tune autovacuum em tabelas hot.
- Long-running txs bloqueiam VACUUM — monitor idle-in-transaction religiosamente.
- Serializable no PG = SSI, melhor implementação comercial. Aceite retry com 40001.
Próximo (e último) módulo dessa trilha: quando você tem muito tráfego — rate limiting distribuído.
Quiz rápido
4 perguntas · Acerte tudo e ganhe o badge 🎯 Gabarito