🧠FFVAcademy
🐘

Postgres Profundo: MVCC, Isolation Levels e Locks

17 min de leitura·+85 XP
Pré-requisitos (0/1)0%

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:

  1. xmin foi committed antes do snapshot da tx (e não é ela mesma)
  2. xmax é null, ou aborted, ou ainda não committed
💡
Consequência prática: SELECT nunca bloqueia outro SELECT ou um UPDATE/DELETE, e vice-versa. Só operações na mesma tupla entre si (ex: 2 UPDATEs) disputam lock. É por isso que Postgres aguenta carga altíssima de leitura concorrente.

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 LevelDirty readNon-repeatable readPhantom readSerialization anomaly
Read Uncommitted(não aplica no PG)SimSimSim
Read Committed (DEFAULT)NãoSimSimSim
Repeatable ReadNãoNãoNão (PG)Sim
SerializableNãoNãoNãoNão
AnomaliaDescriçãoExemplo
Dirty readTx1 lê um valor que Tx2 escreveu mas ainda não fez commitLer saldo 100 que Tx2 está prestes a rollback
Non-repeatable readTx1 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 readTx1 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 anomalyDois txs concurrent, cada um passa sozinho, mas juntos violam invarianteConstraint "total >= 0": 2 débitos concorrentes que individualmente passam mas juntos estouram
⚠️
Postgres Repeatable Read > SQL standard: o PG também previne phantom reads no nível RR (graças ao MVCC — usa snapshot inteiro da tx). Serializable no PG é "Serializable Snapshot Isolation (SSI)", detecta conflitos via predicado e aborta uma das txs com erro 40001 — você deve retriar.

Setar isolation level:

sql
-- 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.

sql
-- 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:

sql
-- 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;
💡
Regra: se você pode expressar a lógica inteira em um único UPDATE atômico (ex: 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:

LockSQLO que bloqueia
FOR UPDATESELECT ... FOR UPDATEOutras FOR UPDATE, FOR SHARE, UPDATE, DELETE nessa linha
FOR NO KEY UPDATESELECT ... FOR NO KEY UPDATEMais leve — permite outros FOR KEY SHARE concorrentes (usado em FKs)
FOR SHARESELECT ... FOR SHAREOutras UPDATE, DELETE, FOR UPDATE. Permite outras FOR SHARE.
FOR KEY SHARESELECT ... FOR KEY SHAREApenas UPDATE da key. Mais leve. Usado internamente por FKs.

Locks de tabela:

LockQuando aconteceBloqueia o quê
ACCESS SHARESELECT simplesSó ACCESS EXCLUSIVE (ex: DROP TABLE)
ROW SHARESELECT FOR UPDATE/SHAREEXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVEUPDATE, DELETE, INSERTSHARE e acima
SHARECREATE INDEXROW EXCLUSIVE e acima
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLYSHARE, ROW EXCLUSIVE e acima
ACCESS EXCLUSIVEDROP TABLE, TRUNCATE, ALTER TABLE (maioria), VACUUM FULLTUDO, inclusive SELECT
🚨
Gotcha clássico de migration: 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:
  1. ADD COLUMN sem default (instantâneo no PG 11+)
  2. UPDATE em batches pra preencher o default
  3. ALTER COLUMN SET NOT NULL (sem reescrita no PG 12+)
O projeto pg-osc ou pgroll automatizam migrations zero-downtime.

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.

sql
-- 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.
⚠️
Advisory locks não sobrevivem a failover. Se Postgres master cai e um replica vira master, locks se perdem. Pra leader election em cluster, use etcd/Consul/ZooKeeper.

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.

sql
-- 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;
🚨
Long-running transactions são inimigas do VACUUM. Enquanto uma transação antiga tá aberta, o PG não pode recuperar tuplas mortas mais novas que ela (pra manter snapshot consistente). Resultado: bloat explodindo em tabelas hot porque alguém esqueceu BEGIN aberto no psql. Monitor pg_stat_activity em state=idle in transaction.

Dicas práticas de troubleshooting

sql
-- 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 (counter += 1), nunca SELECT + UPDATE

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 INCRMuito mais rápido pra contador puro. Sincroniza com PG em batch.

Alt: Table-level counter ser shardadocounter_id % 10 → 10 rows. Reduz contenção.

📋 Leader election leve pra worker que só deve ter 1 ativo

pg_try_advisory_xact_lock em um id fixo

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 leaseSe já tem K8s/etcd. Mais robusto que advisory lock.

📋 Transação de e-commerce: preciso subtrair estoque sem vender negativo

UPDATE com WHERE stock >= qty, checando rowcount

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 + UPDATEFunciona mas mais lento e mais código.

📋 Preciso Serializable Snapshot Isolation pra invariantes complexas

BEGIN ISOLATION LEVEL SERIALIZABLE + retry loop

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ícitosMais 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.

Take-aways:
  • 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

Continue lendo