Lock contention: o assassino silencioso do Postgres
Migração de 10ms que congela produção por 40 minutos. ALTER TABLE pegando ACCESS EXCLUSIVE. pg_locks + pg_stat_activity. Locks são invisíveis até não serem mais.
Lock contention: o assassino silencioso do Postgres
A app está lenta.
CPU baixa. IO baixo. Pool com conexão sobrando.
E mesmo assim, requests empilhando.
Bem-vindo ao mundo dos locks.
Por que lock é diferente de tudo o resto
Performance ruim por query lenta você vê no APM.
Performance ruim por N+1 você vê no log.
Lock contention você não vê em lugar nenhum até alguém abrir o pg_stat_activity e descobrir que metade da app está esperando o outro lado terminar.
Lock não consome CPU. Não consome IO. Só consome tempo.
E tempo, em produção, é dinheiro e paciência do usuário.
Os lock modes do Postgres
Postgres tem 8 níveis de lock em tabela. Decora os extremos, o meio você consulta.
ACCESS SHARE ← SELECT
ROW SHARE ← SELECT FOR UPDATE
ROW EXCLUSIVE ← INSERT, UPDATE, DELETE
SHARE UPDATE EXCLUSIVE ← VACUUM, CREATE INDEX CONCURRENTLY
SHARE ← CREATE INDEX
SHARE ROW EXCLUSIVE ← raro
EXCLUSIVE ← REFRESH MATERIALIZED VIEW CONCURRENTLY
ACCESS EXCLUSIVE ← ALTER TABLE, DROP TABLE, TRUNCATE, REINDEX
A regra simples:
ACCESS SHAREé o mais leve. Convive com quase todo mundo.ACCESS EXCLUSIVEé o mais pesado. Bloqueia literalmente tudo, atéSELECT.
E adivinha o que sua migration faz quando você roda um ALTER TABLE inocente?
A migration que congelou a produção
Caso real. Migration aparentemente trivial:
class AddStatusToOrders < ActiveRecord::Migration[7.0]
def change
add_column :orders, :status, :string, default: 'pending', null: false
end
end
Em dev: 10ms.
Em prod, com 80M de linhas: 42 minutos com a tabela travada.
E enquanto a coluna estava sendo preenchida com o default, nenhum SELECT em orders rodou. Checkout, listagem, painel admin, tudo parado.
O motivo? ALTER TABLE ... ADD COLUMN ... DEFAULT 'pending' em versão antiga de Postgres faz rewrite da tabela inteira segurando ACCESS EXCLUSIVE.
O jeito certo, em Postgres moderno:
ALTER TABLE orders ADD COLUMN status text;
ALTER TABLE orders ALTER COLUMN status SET DEFAULT 'pending';
-- backfill em lotes
UPDATE orders SET status = 'pending' WHERE status IS NULL AND id BETWEEN 1 AND 10000;
-- ...
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
Cada passo segura lock por milissegundos. Não 42 minutos.
Row-level locks: o outro inimigo
SELECT ... FOR UPDATE trava a linha até o fim da transação.
BEGIN;
SELECT * FROM accounts WHERE id = 42 FOR UPDATE;
-- enquanto isso ninguém atualiza essa conta
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;
Útil para evitar race condition em saldo, estoque, contadores.
Perigoso quando:
- a transação é longa
- o
FOR UPDATEestá dentro de um loop - você esquece o
COMMIT(transação aberta por engano)
Diferença entre dev senior e junior:
- junior usa
FOR UPDATEem tudo "por garantia" - senior usa só quando há disputa real, e mede o tempo da transação
A query que salva sua sexta-feira
Decora essa. Sério.
SELECT
blocked.pid AS blocked_pid,
blocked.usename AS blocked_user,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.usename AS blocking_user,
blocking.query AS blocking_query,
blocking.state AS blocking_state,
now() - blocked.query_start AS blocked_duration
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE NOT blocked.pid = blocking.pid;
Em uma linha: quem está esperando, e quem está segurando.
Se aparece resultado, alguém está bloqueando alguém.
Se o blocking_state for idle in transaction, você achou o culpado: alguém abriu transação e esqueceu de fechar.
pg_locks na unha
SELECT
l.pid,
l.locktype,
l.mode,
l.granted,
a.query,
a.state,
now() - a.xact_start AS tx_age
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE NOT l.granted
ORDER BY tx_age DESC;
Mostra locks não concedidos — ou seja, processos esperando.
Se a fila tem 30 entradas todas esperando o mesmo pid, você tem o assassino.
Mata sem dó:
SELECT pg_cancel_backend(12345); -- pede educadamente
SELECT pg_terminate_backend(12345); -- arranca o cabo
Os timeouts que você devia setar ontem
Postgres deixa você esperar lock pra sempre. Por padrão. Sim, pra sempre.
Configure isso:
SET lock_timeout = '3s';
SET statement_timeout = '30s';
SET idle_in_transaction_session_timeout = '10s';
O que cada um faz:
lock_timeout
└── desiste de esperar por lock após N ms
statement_timeout
└── mata query que passa de N ms
idle_in_transaction_session_timeout
└── mata transação aberta sem atividade
No Rails:
# config/database.yml
production:
variables:
lock_timeout: 3000
statement_timeout: 30000
idle_in_transaction_session_timeout: 10000
Em migration, ainda mais agressivo:
class AddIndexConcurrently < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def change
execute "SET lock_timeout = '2s'"
add_index :orders, :user_id, algorithm: :concurrently
end
end
Migration que não pega lock em 2s falha rápido, em vez de derrubar a aplicação.
Hierarquia mental do diagnóstico
App lenta sem CPU/IO alto?
↓
Checa pg_stat_activity
↓
Tem queries em state = 'active' há mais de X segundos?
├── sim → query lenta clássica → EXPLAIN ANALYZE
└── não
↓
Tem locks não concedidos em pg_locks?
├── sim → lock contention → acha o blocker
└── não → procura em outro lugar (rede, app, GC)
Tem 'idle in transaction' velhos?
└── sim → transação aberta → mata e investiga código
90% dos "Postgres tá lento e ninguém entende" cai num desses ramos.
Os pecados clássicos
1. ALTER TABLE sem lock_timeout.
Você acha que vai durar 1s. Não dura. A tabela trava. Tudo trava.
2. Transação Rails segurando lock enquanto chama API externa.
Order.transaction do
order.lock!
PaymentGateway.charge(order) # 8s de HTTP
order.update!(paid: true)
end
Durante 8s, ninguém toca nessa order. Pior: a connection do pool fica refém.
3. Migration que combina ADD COLUMN NOT NULL DEFAULT em tabela grande.
Já vimos. Não faz.
4. CREATE INDEX em vez de CREATE INDEX CONCURRENTLY.
O primeiro trava escrita na tabela. O segundo não.
5. Worker que faz SELECT FOR UPDATE e processa devagar.
A linha fica refém pelo tempo inteiro do job.
O fluxo correto para mudança de schema em produção
1. Avalia o impacto (tamanho da tabela, tráfego)
↓
2. Quebra em passos pequenos (ADD, BACKFILL em lote, NOT NULL)
↓
3. Sempre SET lock_timeout antes de DDL
↓
4. CREATE INDEX CONCURRENTLY, sempre
↓
5. Roda em horário de menor tráfego
↓
6. Tem plano de rollback
↓
7. Monitora pg_locks DURANTE a migration
Não é paranoia. É o mínimo.
Conclusão
Lock contention é o tipo de problema que:
- não aparece no APM
- não aparece em métricas de CPU/memória
- só aparece quando alguém abre o
pg_stat_activity
A diferença entre dev senior e junior aqui é brutal:
- junior debuga olhando log de aplicação
- senior abre
pg_locksem 30 segundos
Aprende os modos de lock. Configura os timeouts. Trata DDL como cirurgia.
Locks são invisíveis até não serem.
E quando deixam de ser, geralmente é em produção, sexta à noite.
