githublinkedinemail
← /articles

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.

5 minpostgres

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 UPDATE está dentro de um loop
  • você esquece o COMMIT (transação aberta por engano)

Diferença entre dev senior e junior:

  • junior usa FOR UPDATE em 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_locks em 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.

Lock contention: o assassino silencioso do Postgres
Lock contention: o assassino silencioso do Postgres