Shopping cart

Subtotal $0.00

View cartCheckout

Building better devs

TnewsTnews
Banco de Dados

Postgres para Startups: 9 Erros que Explodem em Produção

Email : 1

Seu banco PostgreSQL está acumulando lixo agora mesmo

Enquanto você lê isso, o Postgres da sua startup está acumulando dead tuples, segurando transações que já deveriam ter morrido e, se ninguém mexer nisso, vai travar sozinho numa terça-feira às 3 da manhã. Eu já vi acontecer.

O PostgreSQL é a escolha óbvia de banco de dados para startups. É open source, confiável, tem um ecossistema absurdo e escala bem se você souber o que está fazendo. O problema é esse “se”. A maioria dos times de startup trata o Postgres como uma caixa mágica: coloca os dados dentro, puxa os dados de fora, e reza pra funcionar. Funciona. Até o dia que não funciona mais.

Esse guia é baseado em lições reais de produção, incluindo um artigo excelente do time do Hatchet que viralizou no Hacker News, e em dores que eu mesmo já senti debugando queries às 2 da manhã.

Bora pros 9 erros.

1. Ignorar o autovacuum (e pagar o preço depois)

Esse é, disparado, o erro mais comum e o mais perigoso. Se você não sabe o que é autovacuum, aqui vai a versão curta: o PostgreSQL não deleta dados imediatamente quando você roda um DELETE ou UPDATE. Ele marca a linha como “morta” (dead tuple) e continua a vida. O autovacuum é o processo que limpa essas linhas mortas periodicamente.

O problema? As configurações padrão do autovacuum são conservadoras demais para ambientes de produção com muita escrita. Numa tabela com 1 milhão de linhas, o Postgres vai esperar acumular mais de 200 mil dead tuples antes de disparar o vacuum. Isso é insano para uma tabela com alta taxa de escrita.


-- Verifique dead tuples nas suas tabelas agora
SELECT schemaname, relname, n_dead_tup, n_live_tup,
       round(n_dead_tup::numeric / greatest(n_live_tup, 1) * 100, 2) as dead_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

Se dead_pct estiver acima de 10%, você tem um problema. Se estiver acima de 30%, você tem uma emergência.

Como ajustar: para tabelas com alto volume de escrita, configure o autovacuum por tabela:


ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor = 0.01,
  autovacuum_vacuum_cost_delay = 2,
  autovacuum_analyze_scale_factor = 0.005
);

Isso faz o vacuum rodar com apenas 1% de dead tuples em vez dos 20% padrão.

2. Não entender o Transaction ID Wraparound

Se o erro #1 é o mais comum, esse é o mais destrutivo. O PostgreSQL usa um contador de 32 bits para Transaction IDs (XIDs). Cada INSERT, UPDATE e DELETE consome um XID. Quando esse contador se aproxima do limite (2 bilhões de transações), o Postgres precisa “congelar” os XIDs antigos via VACUUM FREEZE.

Se isso não acontece a tempo, o Postgres entra em modo de proteção e para de aceitar escritas. Seu banco vira read-only. Em produção. Com clientes usando.

Parece improvável? Não é. Em junho de 2026, um artigo no Medium descreveu um incidente real onde o wraparound aconteceu num ambiente com carga estável, sem nenhum pico de tráfego. Simplesmente passou tempo suficiente sem o freezing completar.


-- Monitore a idade dos XIDs
SELECT datname, age(datfrozenxid) as xid_age,
       round(age(datfrozenxid)::numeric / 2000000000 * 100, 2) as pct_to_wraparound
FROM pg_database
WHERE datname NOT IN ('template0', 'template1')
ORDER BY xid_age DESC;

Faixa de pct_to_wraparound Status
—— ——–
0 a 25% Saudável
25 a 50% Atenção, investigar autovacuum
50 a 75% Alerta, rodar VACUUM FREEZE manual
Acima de 75% Emergência, risco de shutdown

Se você não monitora isso, está voando no escuro.

3. Criar índices sem CONCURRENTLY em tabelas grandes

Vou contar uma história rápida. Um dev junior precisava adicionar um índice numa tabela de 50 milhões de linhas. Rodou um CREATE INDEX normal. O que aconteceu? O Postgres travou todas as escritas naquela tabela durante 12 minutos. O sistema de pagamentos parou. Clientes não conseguiam comprar. Pânico geral.

A solução é simples, mas muita gente não conhece:


-- ERRADO: bloqueia escritas
CREATE INDEX idx_orders_user ON orders(user_id);

-- CERTO: não bloqueia escritas
CREATE INDEX CONCURRENTLY idx_orders_user ON orders(user_id);

O CONCURRENTLY demora mais, mas não trava a tabela. Em produção, não existe outro jeito.

Atenção: se o CREATE INDEX CONCURRENTLY falhar no meio (e isso acontece), o índice fica em estado “invalid”. Verifique depois:


SELECT indexrelid::regclass, indisvalid
FROM pg_index
WHERE NOT indisvalid;

Índices inválidos não são usados pelo query planner mas continuam ocupando espaço. Drope e recrie.

4. Transações longas que matam o vacuum

Esse é sutil e traiçoeiro. O autovacuum não consegue limpar dead tuples que foram criados depois do início de qualquer transação aberta. Então se você tem uma transação que ficou aberta por 2 horas (aquele BEGIN sem COMMIT que alguém esqueceu, ou uma query de relatório gigante), o vacuum fica paralisado.


-- Encontre transações zumbis
SELECT pid, now() - xact_start as duration, state, query
FROM pg_stat_activity
WHERE state != 'idle'
  AND xact_start < now() - interval '5 minutes'
ORDER BY duration DESC;

Eu já vi tabelas acumularem 40 GB de bloat porque uma conexão de um microserviço abria transação, fazia uma chamada HTTP externa, e às vezes essa chamada travava. A transação ficava aberta por horas.

Regra de ouro: nunca faça chamadas externas (HTTP, gRPC, fila) dentro de uma transação de banco. Busque os dados, feche a transação, depois faça a chamada externa.

Configure um timeout para matar transações zumbis automaticamente:


ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
SELECT pg_reload_conf();

5. Ignorar connection pooling

O PostgreSQL cria um processo do sistema operacional para cada conexão. Com 200 conexões simultâneas, são 200 processos. Cada um consome memória (tipicamente 5 a 10 MB). Além do custo de memória, muitas conexões causam contenção de locks e degradam performance.

A maioria das startups começa sem pooler, e quando o tráfego cresce, simplesmente aumenta o max_connections. Isso é como resolver um vazamento de água abrindo mais torneiras.

Cenário Sem pooler Com PgBouncer
——— ———– ————–
Conexões da aplicação 200 200
Conexões reais no Postgres 200 20
Memória por conexão 5 a 10 MB 2 KB no pooler
Overhead total ~1.5 GB ~150 MB

Setup mínimo do PgBouncer:


[databases]
myapp = host=localhost dbname=myapp

[pgbouncer]
listen_port = 6432
max_client_conn = 1000
default_pool_size = 20
pool_mode = transaction

O pool_mode = transaction é o mais eficiente: a conexão com o Postgres é devolvida ao pool no fim de cada transação, não no fim da sessão.

Se você usa Go, o pgxpool já faz pooling no nível da aplicação. Se usa Node.js, o pg com pool nativo resolve. Mas em cenários com múltiplas instâncias da aplicação, um pooler externo como PgBouncer é quase obrigatório.

6. Sequential scans que ninguém percebe

O Postgres tem basicamente dois modos de leitura: index scan (rápido, O(log n)) e sequential scan (lê a tabela inteira). Para tabelas pequenas (menos de 20 mil linhas), sequential scan é tão rápido que não importa. Mas numa tabela de 10 milhões de linhas, é a diferença entre 2ms e 15 segundos.

O problema é que sequential scans silenciosos são comuns. A query funciona, retorna resultados, ninguém reclama. Até que a tabela cresce e a query que levava 50ms agora leva 8 segundos.


-- Use EXPLAIN ANALYZE para investigar
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM orders WHERE user_id = 12345 AND status = 'pending';

Cole a saída no site explain.dalibo.com para uma visualização interativa. Procure por “Seq Scan” em tabelas grandes.

Índice composto é o seu amigo:


-- Se você filtra por user_id e ordena por created_at
CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders(user_id, created_at DESC);

A ordem das colunas no índice importa. Colunas de igualdade (WHERE user_id = X) vêm primeiro, colunas de ordenação (ORDER BY created_at) vêm por último.

7. Não fazer batching de escritas

Inserir linha por linha é um dos pecados capitais de performance no Postgres. Cada INSERT individual tem overhead de rede, parsing, planejamento e WAL logging. Fazer 1.000 inserts individuais é absurdamente mais lento que um único insert com 1.000 linhas.


# ERRADO: 1000 roundtrips de rede
for user in users:
    cursor.execute("INSERT INTO users (name, email) VALUES (%s, %s)", (user.name, user.email))

# CERTO: 1 roundtrip
from psycopg2.extras import execute_values
execute_values(cursor,
    "INSERT INTO users (name, email) VALUES %s",
    [(u.name, u.email) for u in users]
)

A diferença real? Em benchmarks do Hatchet, batching trouxe 10x mais throughput. Não 10%. 10x.

Método 10k linhas Throughput
——– ———– ————
INSERT individual ~8 segundos ~1.250 linhas/s
Batch INSERT ~0.8 segundo ~12.500 linhas/s
COPY ~0.3 segundo ~33.000 linhas/s

Se você precisa de performance máxima de ingestão, o comando COPY é imbatível. Mas execute_values ou equivalente já resolve 90% dos casos.

8. Migrations que travam o banco

Esse erro é primo do #3, mas vai além de índices. Qualquer ALTER TABLE que modifica o schema de uma tabela grande pode causar problemas. Alguns exemplos clássicos:


-- PERIGOSO: reescreve a tabela inteira
ALTER TABLE orders ALTER COLUMN amount TYPE numeric(12,4);

-- PERIGOSO: lock exclusivo enquanto seta default em linhas existentes
ALTER TABLE orders ADD COLUMN processed_at timestamptz DEFAULT now();

A partir do PostgreSQL 11, adicionar uma coluna com default constante é instantâneo (o Postgres guarda o default no catálogo sem reescrever linhas). Mas se o default for uma função como now(), a história muda.

Para migrações seguras em tabelas grandes:

  1. Adicione a coluna sem default: ALTER TABLE orders ADD COLUMN processed_at timestamptz;
  2. Backfill em batches fora de uma transação:


-- Backfill em batches de 10.000
UPDATE orders SET processed_at = created_at
WHERE id IN (
  SELECT id FROM orders WHERE processed_at IS NULL LIMIT 10000
);
-- Repita até zerar

  1. Depois, se necessário, adicione o default para novas linhas:


ALTER TABLE orders ALTER COLUMN processed_at SET DEFAULT now();

Ferramentas como pg-osc (online schema change) e pgroll automatizam esse processo. Se você faz deploys frequentes com migrations, vale muito investir nisso.

9. Usar Postgres como fila de mensagens (sem saber o que está fazendo)

“Não preciso de RabbitMQ, meu Postgres resolve.” Eu já ouvi isso dezenas de vezes. E a verdade é: o Postgres pode funcionar como fila, sim. Mas a maioria das implementações que eu vejo são bombas-relógio.

O padrão mais comum é perigoso:


-- Padrão ingênuo: race condition + lock contention
SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at LIMIT 1;
UPDATE jobs SET status = 'processing' WHERE id = ?;

Se dois workers executam isso ao mesmo tempo, os dois pegam o mesmo job. A solução correta usa FOR UPDATE SKIP LOCKED:


-- Padrão correto: cada worker pega um job diferente
BEGIN;
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

-- Processa o job...

UPDATE jobs SET status = 'done' WHERE id = ?;
COMMIT;

O FOR UPDATE SKIP LOCKED reserva a linha para a transação atual e faz outros workers pularem ela em vez de esperar. É elegante e eficiente.

Mas mesmo assim, usar Postgres como fila tem armadilhas:

  • Dead tuples acumulam rápido (cada job é inserido, atualizado para “processing”, atualizado para “done”)
  • O autovacuum precisa estar bem configurado (veja erro #1)
  • Para alto volume (milhares de jobs por segundo), considere particionamento por data e drop de partições antigas em vez de DELETE

Bibliotecas como o pgmq (extensão do Postgres) e o Graphile Worker já implementam esses padrões corretamente. Antes de reinventar a roda, veja se uma delas resolve seu caso.

Como monitorar tudo isso

Não adianta conhecer os erros se você não monitora. Aqui vai um checklist mínimo de queries que todo DBA de startup deveria rodar diariamente (ou melhor, ter em um dashboard):


-- 1. Dead tuples por tabela
SELECT relname, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables WHERE n_dead_tup > 1000 ORDER BY n_dead_tup DESC LIMIT 10;

-- 2. Idade do XID (risco de wraparound)
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY age DESC;

-- 3. Transações longas
SELECT pid, now() - xact_start as duration, query
FROM pg_stat_activity WHERE state = 'active' AND xact_start < now() - interval '1 minute';

-- 4. Tabelas sem vacuum recente
SELECT relname, last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables WHERE last_autovacuum < now() - interval '7 days' OR last_autovacuum IS NULL;

-- 5. Cache hit ratio (deve ser > 99%)
SELECT sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) as cache_hit_ratio
FROM pg_statio_user_tables;

Se o cache hit ratio estiver abaixo de 99%, você provavelmente precisa aumentar o shared_buffers. A regra geral é configurar entre 25% e 40% da memória total do servidor.

E o que vem depois?

O Postgres continua evoluindo. O PostgreSQL 17, lançado no fim de 2024, trouxe melhorias significativas no vacuum e no particionamento. O PostgreSQL 18, previsto para o fim de 2025, promete avanços em replicação lógica e compressão. A cada versão, vários desses problemas ficam menos dolorosos.

Mas a realidade é que nenhuma versão nova vai te salvar de transações zumbis, falta de índice ou autovacuum mal configurado. Essas são decisões de engenharia que dependem de quem opera o banco.

Se eu pudesse dar um conselho só: reserve uma hora por mês para rodar as queries de monitoramento, olhar os dead tuples, checar a idade do XID e verificar se o autovacuum está saudável. Uma hora por mês pode te poupar de um incidente de 12 horas num final de semana.

A fonte de inspiração desse artigo é o excelente guia do time do Hatchet, que viralizou no Hacker News. Vale a leitura do original para mais detalhes técnicos.

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Posts