Como Escalar PostgreSQL com Connection Pooling e PgBouncer

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema que ninguém te conta sobre PostgreSQL
Sua API funciona perfeitamente em desenvolvimento. Dez requisições simultâneas? Tranquilo. Cem? Sem problemas.
Aí chega produção. Mil requisições por segundo. O PostgreSQL começa a recusar conexões. Sua aplicação retorna FATAL: too many connections. E o pânico começa.
O PostgreSQL cria um processo por conexão. Cada processo consome entre 5MB e 10MB de RAM. Com 500 conexões simultâneas, são até 5GB de RAM só para gerenciar conexões, antes mesmo de executar uma query.
Esse é o gargalo silencioso. E o PgBouncer é a solução.
Como o PostgreSQL gerencia conexões
Antes de resolver o problema, entenda a raiz dele.
Quando sua aplicação abre uma conexão com o PostgreSQL, o servidor faz um fork() do processo principal. Esse processo filho vive enquanto a conexão existir. Ele aloca memória, mantém estado e consome CPU mesmo ocioso.
O padrão do PostgreSQL permite 100 conexões simultâneas. Parece muito, mas faça as contas.
10 instâncias da API × 10 conexões no pool de cada instância = 100 conexõesAcabou. Nenhuma conexão sobra para migrations, monitoramento ou um psql de emergência. E quando você escala para 20 instâncias? Desastre.
O custo real de cada conexão
Cada conexão PostgreSQL envolve:
- Handshake TCP
- Autenticação (possivelmente com SSL)
- Alocação de memória para o processo
- Inicialização de catálogos e caches internos
Esse ciclo leva entre 5ms e 50ms dependendo da rede e configuração SSL. Multiplicado por milhares de requisições, vira um gargalo brutal.
O que é Connection Pooling
Connection pooling é um intermediário entre sua aplicação e o banco. Ele mantém um conjunto de conexões abertas e as reutiliza.
Pense numa fila de táxis no aeroporto. Sem pooling, cada passageiro compra um carro novo. Com pooling, os táxis fazem várias corridas.
O resultado prático: 500 clientes podem compartilhar 50 conexões reais com o banco. A maioria das requisições espera menos de 1ms por uma conexão livre.
PgBouncer: o padrão da indústria
PgBouncer é um connection pooler leve, escrito em C, com footprint de memória mínimo. Ele roda como um processo separado entre sua aplicação e o PostgreSQL.
Empresas como GitLab, Supabase e Render usam PgBouncer em produção. É estável, testado em batalha e absurdamente eficiente.
Instalação
No Ubuntu/Debian:
sudo apt update
sudo apt install pgbouncer -yNo macOS com Homebrew:
brew install pgbouncerNo Amazon Linux / RHEL:
sudo yum install pgbouncer -yVerifique a instalação:
pgbouncer --versionOs três modos de pooling
PgBouncer oferece três modos. Escolher o errado quebra sua aplicação. Escolher o certo a transforma.
Session Pooling
A conexão é atribuída ao cliente até ele desconectar. É o modo mais seguro e menos eficiente.
Funciona com qualquer feature do PostgreSQL: prepared statements, variáveis de sessão, cursores, LISTEN/NOTIFY. Mas o ganho de pooling é mínimo.
Use quando: sua aplicação depende fortemente de estado de sessão.
Transaction Pooling
A conexão é atribuída ao cliente apenas durante uma transação. Quando o COMMIT ou ROLLBACK acontece, a conexão volta para o pool.
Este é o modo mais usado em produção. Oferece o melhor equilíbrio entre eficiência e compatibilidade.
Restrições: não funciona com prepared statements nomeados (a menos que use prepared_statement_cache), SET de variáveis de sessão, ou LISTEN/NOTIFY.
Statement Pooling
A conexão é devolvida ao pool após cada statement individual. Máxima eficiência, mínima compatibilidade.
Não suporta transações multi-statement. Na prática, quase ninguém usa.
Configuração completa para produção
O PgBouncer usa dois arquivos principais: pgbouncer.ini e userlist.txt.
Arquivo de configuração principal
Crie ou edite /etc/pgbouncer/pgbouncer.ini:
[databases]
; formato: nome_logico = host=... port=... dbname=...
myapp = host=127.0.0.1 port=5432 dbname=myapp_production
myapp_readonly = host=replica.internal port=5432 dbname=myapp_production
[pgbouncer]
; Endereço e porta que o PgBouncer escuta
listen_addr = 0.0.0.0
listen_port = 6432
; Autenticação
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
; Modo de pooling
pool_mode = transaction
; Limites de conexão
max_client_conn = 1000
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
; Timeouts
server_idle_timeout = 300
client_idle_timeout = 0
client_login_timeout = 15
query_timeout = 300
query_wait_timeout = 120
; Logging
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
stats_period = 60
; Admin
admin_users = pgbouncer_admin
stats_users = pgbouncer_stats
; TLS (produção)
client_tls_sslmode = prefer
client_tls_cert_file = /etc/pgbouncer/server.crt
client_tls_key_file = /etc/pgbouncer/server.keyArquivo de autenticação
Crie /etc/pgbouncer/userlist.txt:
"myapp_user" "SCRAM-SHA-256$4096:salt$StoredKey:ServerKey"
"pgbouncer_admin" "senha_admin_segura"
"pgbouncer_stats" "senha_stats_segura"Para gerar o hash SCRAM-SHA-256 a partir do PostgreSQL:
SELECT rolname, rolpassword FROM pg_authid WHERE rolname = 'myapp_user';Copie o valor de rolpassword e cole no userlist.txt. Alternativamente, use auth_query para autenticação dinâmica:
auth_type = scram-sha-256
auth_query = SELECT usename, passwd FROM pg_shadow WHERE usename=$1Iniciando o PgBouncer
# Iniciar como serviço
sudo systemctl start pgbouncer
sudo systemctl enable pgbouncer
# Ou manualmente (útil para debug)
pgbouncer -d /etc/pgbouncer/pgbouncer.ini
# Verificar se está rodando
sudo systemctl status pgbouncerTeste a conexão:
psql -h 127.0.0.1 -p 6432 -U myapp_user -d myappNote a porta 6432 em vez da 5432. Sua aplicação agora se conecta ao PgBouncer, não diretamente ao PostgreSQL.
Integrando com Node.js
Vamos configurar uma aplicação real com pg (node-postgres) e PgBouncer.
npm install pg dotenvCrie o arquivo de conexão:
// src/database.ts
import { Pool } from 'pg';
const pool = new Pool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 6432, // PgBouncer, não PostgreSQL
database: process.env.DB_NAME || 'myapp',
user: process.env.DB_USER || 'myapp_user',
password: process.env.DB_PASSWORD,
// Pool da aplicação (menor, pois PgBouncer faz o pooling real)
max: 5, // Antes era 20-50, agora 5 basta
min: 1,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
// IMPORTANTE para transaction pooling
allowExitOnIdle: true,
});
pool.on('error', (err) => {
console.error('Erro inesperado no pool:', err);
});
export async function query<T>(text: string, params?: unknown[]): Promise<T[]> {
const start = Date.now();
const result = await pool.query(text, params);
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`Query lenta (${duration}ms):`, text);
}
return result.rows as T[];
}
export async function transaction<T>(
fn: (client: import('pg').PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
export default pool;Cuidado com prepared statements
No modo transaction, prepared statements nomeados quebram. O node-postgres usa prepared statements por padrão. Desabilite assim:
// Opção 1: query sem prepared statement
const result = await pool.query({
text: 'SELECT * FROM users WHERE id = $1',
values: [userId],
name: undefined, // Sem nome = sem prepared statement
});
// Opção 2: configuração global mais segura
const pool = new Pool({
// ... outras configs
statement_timeout: 30000,
});
// Ao fazer queries, não passe 'name'
await pool.query('SELECT * FROM users WHERE id = $1', [userId]);Se precisar de prepared statements, considere a biblioteca pg-prepared:
npm install @pgkit/clientOu mude o PgBouncer para session mode apenas para o banco que precisa de prepared statements.
Integrando com Prisma
Prisma é popular e tem particularidades com PgBouncer.
npm install prisma @prisma/client
npx prisma initConfigure o schema.prisma:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}No .env:
# Conexão via PgBouncer (para queries da aplicação)
DATABASE_URL="postgresql://myapp_user:[email protected]:6432/myapp?pgbouncer=true&connection_limit=5"
# Conexão direta (para migrations e introspection)
DIRECT_URL="postgresql://myapp_user:[email protected]:5432/myapp"O parâmetro pgbouncer=true faz o Prisma desabilitar prepared statements automaticamente. O directUrl garante que migrations rodem direto no PostgreSQL, sem passar pelo pooler.
# Migrations usam DIRECT_URL automaticamente
npx prisma migrate dev --name init
# A aplicação usa DATABASE_URL (PgBouncer)
npx prisma generateMonitoramento do PgBouncer
PgBouncer expõe um banco virtual de administração. Conecte-se nele:
psql -h 127.0.0.1 -p 6432 -U pgbouncer_stats -d pgbouncerComandos essenciais:
-- Status geral dos pools
SHOW POOLS;
-- Conexões ativas
SHOW CLIENTS;
SHOW SERVERS;
-- Estatísticas de uso
SHOW STATS;
-- Verificar configuração ativa
SHOW CONFIG;
-- Métricas detalhadas por banco
SHOW STATS_TOTALS;Script de monitoramento
Crie um script para integrar com Prometheus, Datadog ou Grafana:
#!/bin/bash
# monitor_pgbouncer.sh
PGBOUNCER_HOST="127.0.0.1"
PGBOUNCER_PORT="6432"
PGBOUNCER_USER="pgbouncer_stats"
# Conexões ativas vs disponíveis
psql -h $PGBOUNCER_HOST -p $PGBOUNCER_PORT -U $PGBOUNCER_USER -d pgbouncer -t -c "
SELECT
database,
cl_active AS clients_active,
cl_waiting AS clients_waiting,
sv_active AS servers_active,
sv_idle AS servers_idle,
pool_mode
FROM SHOW POOLS
WHERE database NOT IN ('pgbouncer');
"
# Alerta se clientes estão esperando
WAITING=$(psql -h $PGBOUNCER_HOST -p $PGBOUNCER_PORT -U $PGBOUNCER_USER -d pgbouncer -t -c "
SELECT COALESCE(SUM(cl_waiting), 0) FROM SHOW POOLS;
" | xargs)
if [ "$WAITING" -gt "10" ]; then
echo "ALERTA: $WAITING clientes esperando por conexão!"
# Enviar alerta para Slack, PagerDuty, etc.
fiAdicione ao cron:
# Executar a cada minuto
* * * * * /opt/scripts/monitor_pgbouncer.sh >> /var/log/pgbouncer_monitor.log 2>&1Dimensionamento: quantas conexões configurar
A regra de ouro do PostgreSQL:
conexões ideais = (núcleos de CPU × 2) + número de discosUm servidor com 4 vCPUs e SSD:
(4 × 2) + 1 = 9 conexõesArredonde para 10-25 para ter margem. Parece pouco? É porque é eficiente. Conexões demais causam contention e degradam performance.
Configuração recomendada por cenário
# App pequena (1-5 instâncias, < 100 req/s)
default_pool_size = 10
max_client_conn = 200
reserve_pool_size = 3
# App média (5-20 instâncias, 100-1000 req/s)
default_pool_size = 25
max_client_conn = 1000
reserve_pool_size = 5
# App grande (20+ instâncias, 1000+ req/s)
default_pool_size = 50
max_client_conn = 5000
reserve_pool_size = 10No PostgreSQL, ajuste max_connections para acomodar:
-- postgresql.conf
-- Deve ser >= (default_pool_size + reserve_pool_size) × número de bancos
ALTER SYSTEM SET max_connections = 100;
SELECT pg_reload_conf();Deploy com Docker
Para ambientes containerizados, use a imagem oficial:
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp_production
POSTGRES_USER: myapp_user
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp_user -d myapp_production"]
interval: 5s
timeout: 3s
retries: 5
pgbouncer:
image: edoburu/pgbouncer:1.22.0
environment:
DATABASE_URL: "postgres://myapp_user:${DB_PASSWORD}@postgres:5432/myapp_production"
POOL_MODE: transaction
DEFAULT_POOL_SIZE: 25
MAX_CLIENT_CONN: 1000
MIN_POOL_SIZE: 5
RESERVE_POOL_SIZE: 5
AUTH_TYPE: scram-sha-256
ports:
- "6432:6432"
depends_on:
postgres:
condition: service_healthy
api:
build: .
environment:
DATABASE_URL: "postgresql://myapp_user:${DB_PASSWORD}@pgbouncer:6432/myapp_production?pgbouncer=true"
DIRECT_URL: "postgresql://myapp_user:${DB_PASSWORD}@postgres:5432/myapp_production"
depends_on:
- pgbouncer
ports:
- "3000:3000"
volumes:
pgdata:# Subir tudo
docker compose up -d
# Verificar logs do PgBouncer
docker compose logs pgbouncer -f
# Testar conexão via PgBouncer
docker compose exec api sh -c "psql \$DATABASE_URL -c 'SELECT 1'"Troubleshooting: problemas comuns
"prepared statement already exists"
Causa: transaction pooling com prepared statements. A conexão foi reutilizada e já tem um statement com aquele nome.
Solução: desabilite prepared statements ou use session pooling.
"server login has been failing"
Causa: credenciais no userlist.txt não batem com o PostgreSQL.
# Regenere o hash
psql -U postgres -c "SELECT usename, passwd FROM pg_shadow WHERE usename = 'myapp_user';""no more connections allowed"
Causa: max_client_conn atingido.
-- No console admin do PgBouncer
SHOW CLIENTS;
-- Identifique clientes ociosos e aumente max_client_connConexões ficam em "waiting"
Causa: default_pool_size muito baixo ou queries lentas monopolizando conexões.
-- Identifique queries lentas no PostgreSQL
SELECT pid, now() - pg_stat_activity.query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active' AND (now() - pg_stat_activity.query_start) > interval '5 seconds'
ORDER BY duration DESC;Minha Opinião Sincera
PgBouncer é indispensável em produção. Ponto. Se você roda PostgreSQL sem connection pooler, está vivendo perigosamente.
Dito isso, vamos falar das alternativas.
Pgpool-II tenta fazer tudo: pooling, replicação, load balancing, query caching. Na prática, faz tudo de forma medíocre. É complexo de configurar, consome mais recursos e tem edge cases bizarros. Evite a menos que precise especificamente de parallel query routing.
Supavisor (da Supabase) é promissor. Escrito em Elixir, suporta pooling nativo e é multi-tenant. Mas ainda é jovem. Para self-hosted, PgBouncer continua mais testado.
Connection pooling no nível da aplicação (como o pool do pg ou Prisma) é necessário, mas insuficiente. Ele resolve o problema de uma instância. Com múltiplas instâncias, você precisa de um pooler centralizado.
Serverless drivers como @neondatabase/serverless ou @vercel/postgres resolvem o pooling no edge. São excelentes para Vercel/Cloudflare, mas te prendem ao vendor. Para infraestrutura própria, PgBouncer dá controle total.
Minha stack recomendada: PgBouncer em transaction mode + pool pequeno na aplicação (3-5 conexões) + monitoramento ativo. Essa combinação aguenta milhares de requisições por segundo com um PostgreSQL modesto.
Uma última provocação: se você está escalando conexões além de 5000 clientes simultâneos, talvez o problema não seja o pooler. Talvez seja hora de repensar a arquitetura: caching agressivo, CQRS, read replicas, ou até um banco diferente para workloads específicos.
PgBouncer não é mágica. É engenharia. E engenharia boa começa entendendo o problema antes de aplicar a solução.
## Leia tambem
- [Prisma ORM vs Drizzle ORM](/backend/prisma-orm-vs-drizzle-orm-benchmark-real-2025)
- [Rate Limiting com Redis](/backend/rate-limiting-inteligente-redis-nodejs-apis-producao)
- [Arquitetura Hexagonal](/backend/arquitetura-hexagonal-typescript-guia-pratico)
Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


