Tecnologias/02 - Bancos de Dados/01 - Relacionais/01 - PostgreSQL6 min
Particionamento e Pooling de Conexões
Conceito
Duas técnicas sem relação conceitual entre si, juntas nesta nota porque são os dois gargalos que aparecem quando o Postgres cresce: tabela grande demais e conexões demais.
Parte 1 — Particionamento declarativo
Uma tabela particionada é uma tabela "pai" vazia com filhas que guardam faixas do dado:
CREATE TABLE eventos (
id bigserial, criado_em timestamptz NOT NULL, payload jsonb
) PARTITION BY RANGE (criado_em);
CREATE TABLE eventos_2026_09 PARTITION OF eventos
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE eventos_2026_10 PARTITION OF eventos
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
Três estratégias: RANGE (data, faixa numérica), LIST (tenant, região, status) e
HASH (distribuição uniforme, sem semântica).
O que particionar realmente entrega
| Ganho | Como |
|---|---|
| Partition pruning | o planejador descarta partições que não podem conter o resultado |
| Manutenção barata | DROP TABLE eventos_2025_01 apaga um mês em ms, sem DELETE, sem bloat |
VACUUM/ANALYZE por partição |
manutenção em pedaços digeríveis em vez de uma tabela de 2 TB |
| Índices menores | um B-tree por partição, cada um cabendo em cache |
| Carga em massa | ATTACH PARTITION de uma tabela já preenchida e indexada |
Apagar dado antigo com DELETE FROM eventos WHERE criado_em < ... gera milhões de
tuplas mortas, bloat e horas de VACUUM (4. MVCC, Tuplas e VACUUM). Com
partição, é um DROP TABLE instantâneo que devolve o espaço ao SO. Só isso já
justifica particionar tabela de série temporal.
A regra que faz o pruning funcionar
O pruning só acontece se a chave de partição estiver no WHERE:
-- ✅ lê 1 partição
SELECT * FROM eventos WHERE criado_em >= '2026-09-01' AND id = 42;
-- ❌ lê TODAS as partições (não há como saber onde o id 42 está)
SELECT * FROM eventos WHERE id = 42;
Isso tem uma consequência de modelagem que pega muita gente: a chave de partição precisa entrar em toda PK e em todo índice único. Não existe unicidade global numa tabela particionada — só por partição, com a chave incluída:
PRIMARY KEY (id, criado_em) -- a chave de partição É obrigatória na PK
Parte 2 — Pooling de conexões
Como cada conexão é um processo (2. Arquitetura de Processos e Memória), o Postgres não escala em número de conexões. Um pooler multiplexa muitas conexões de cliente sobre poucas conexões reais.
100 réplicas da app × pool de 20 = 2.000 conexões
↓ PgBouncer ↓
25 conexões reais no Postgres
Os três modos do PgBouncer — a parte que quebra aplicações
| Modo | Conexão real é devolvida ao pool… | Multiplexação |
|---|---|---|
session |
quando o cliente desconecta | baixa (quase inútil) |
transaction |
ao fim de cada transação | alta — é o modo que se usa |
statement |
ao fim de cada comando | máxima; proíbe transação multi-statement |
O modo transaction é o que entrega o ganho, e ele quebra tudo que depende de estado
de sessão, porque a próxima transação pode cair em outra conexão física:
| Recurso | Funciona em transaction mode? |
|---|---|
SET de sessão (SET work_mem) |
não — use SET LOCAL dentro da transação |
PREPARE / prepared statements do protocolo |
não por padrão (PgBouncer 1.21+ tem suporte com max_prepared_statements) |
| Tabelas temporárias | não |
Cursores WITH HOLD |
não |
LISTEN / NOTIFY |
não |
| Advisory locks de sessão | não — use os transacionais (pg_advisory_xact_lock) |
Drivers modernos (pgx, e o database/sql com lib/pq) usam prepared statements
por padrão no protocolo estendido. Em PgBouncer transaction mode sem
max_prepared_statements, isso gera prepared statement "lrupsc_1" already exists
em produção, de forma intermitente. As saídas: habilitar o suporte no PgBouncer,
ou desligar o cache no driver (pgx com QueryExecModeSimpleProtocol /
statement_cache_capacity=0). Ver 5. Go em Produção.
Quantas conexões, afinal?
A heurística clássica, ainda útil:
tamanho do pool ≈ (núcleos × 2) + spindles_efetivos
16 vCPU, SSD → ~30–40 conexões ativas
Mais conexões que isso não aumentam throughput — aumentam context switch, contenção de lock e latência. Ver Queue-Based Load Leveling: é melhor enfileirar no pooler do que sobrecarregar o banco.
Trade-offs
Particionamento adiciona complexidade permanente. Alguém precisa criar as partições
futuras (pg_partman, ou um cron), e o esquecimento derruba a escrita
(no partition of relation "eventos" found for row). Consultas sem a chave de partição
ficam mais lentas que na tabela única, porque tocam todas as partições. E o número de
partições tem custo de planejamento: milhares delas pesam no planner.
Particionar não é sharding. Todas as partições estão no mesmo servidor — você resolveu manutenção e tamanho de índice, não capacidade de escrita. Para isso é Sharding ou Federation, com Citus ou na aplicação.
Pooler é um salto de rede e um ponto de falha. Ele precisa de HA própria (dois PgBouncers + keepalived, ou o pooler do provedor). Em contrapartida, o alternativo — cada app abrindo 20 conexões diretas — é o modo mais comum de derrubar um Postgres.
O trade-off central: as duas técnicas trocam simplicidade por escala, cada uma num eixo. Nenhuma das duas é otimização prematura: particione antes de a tabela ficar impossível de manter, e coloque pooler antes do primeiro pico de tráfego.
Exemplo prático
Rotina de partição por mês com retenção automática:
CREATE EXTENSION pg_partman;
SELECT partman.create_parent(
p_parent_table => 'public.eventos',
p_control => 'criado_em',
p_type => 'native',
p_interval => '1 month',
p_premake => 3 -- mantém 3 meses futuros já criados
);
UPDATE partman.part_config
SET retention = '12 months',
retention_keep_table = false -- DROP de verdade após 12 meses
WHERE parent_table = 'public.eventos';
Configuração de PgBouncer para uma app Go:
[databases]
prod = host=10.0.0.10 port=5432 dbname=prod
[pgbouncer]
pool_mode = transaction
max_client_conn = 5000 # o que a app abre
default_pool_size = 25 # o que chega no Postgres
reserve_pool_size = 5
max_prepared_statements = 200 # ESSENCIAL para pgx/JDBC em transaction mode
server_idle_timeout = 60
query_wait_timeout = 10 # falhe rápido em vez de acumular fila infinita
E do lado da aplicação, o pool também precisa ser limitado — os dois se somam:
db.SetMaxOpenConns(20) // por réplica da app
db.SetConnMaxLifetime(30 * time.Minute) // sobrevive a failover do PgBouncer
Como isso falha em produção
| Sintoma | Causa |
|---|---|
no partition of relation found for row |
ninguém criou a partição do mês; escrita para |
| Query lenta depois de particionar | falta a chave de partição no WHERE → toca todas |
duplicate key inesperado |
unicidade é por partição; não há índice único global |
prepared statement already exists |
PgBouncer transaction mode sem max_prepared_statements |
SET work_mem sem efeito |
estado de sessão perdido no pooler; use SET LOCAL |
| Fila crescendo no PgBouncer | default_pool_size pequeno ou queries longas segurando conexão |
too many connections mesmo com pooler |
app conectando direto no banco, contornando o pooler |
Aprofundamento aplicado
Particionar não elimina a necessidade de selecionar
Em uma tabela particionada por mês, consultar um único cliente sem restringir a data pode visitar várias partições. Um índice local ajuda dentro de cada uma, mas o banco ainda precisa descobrir quais partes participam. A divisão física deve refletir tanto consultas quanto operações de retenção. Referência: partition pruning.
Duas filas entre HTTP e banco
O pool da aplicação limita suas conexões; um pool intermediário pode limitar as conexões reais ao servidor. Uma requisição pode esperar em ambos. Se cada camada recebe um timeout independente sem considerar o prazo restante, o usuário espera além do planejado.
Cenário: dez instâncias abrem 20 conexões cada e o intermediário disponibiliza 40 backends. Isso não cria capacidade para 200 consultas simultâneas; parte do trabalho espera. O limite deve seguir o throughput sustentável e a duração das consultas.
Exercício: liste os recursos de sessão usados pelo aplicativo, como temporárias, configurações e prepared statements, e confira o suporte do modo de pooling adotado. Resultado esperado: não depender de receber o mesmo backend entre transações.
Relacionado
- 2. Arquitetura de Processos e Memória
- 4. MVCC, Tuplas e VACUUM
- 7. Diagnóstico e Tuning
- Sharding
- Federation
- 5. Go em Produção
- Queue-Based Load Leveling
Parte de MOC PostgreSQL · Partitioning (docs) · pgbouncer.org