trilha

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
O ganho principal não é performance de consulta — é manutenção

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)
O bug clássico Go + PgBouncer

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


Parte de MOC PostgreSQL · Partitioning (docs) · pgbouncer.org

Buscar

Busca por título, seção e texto das notas