Ir para o conteúdo
Backend

Como Escalar PostgreSQL com Connection Pooling e PgBouncer

Marcos Soares
Atualizado em 
11 minutos de leitura
Ilustracao 3D de funil de vidro translucido consolidando conexoes luminosas representando connection pooling PostgreSQL
Ouça este artigo
0:00Como 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.

Text
10 instâncias da API × 10 conexões no pool de cada instância = 100 conexões

Acabou. 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:

Bash
sudo apt update
sudo apt install pgbouncer -y

No macOS com Homebrew:

Bash
brew install pgbouncer

No Amazon Linux / RHEL:

Bash
sudo yum install pgbouncer -y

Verifique a instalação:

Bash
pgbouncer --version

Os 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:

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.key

Arquivo de autenticação

Crie /etc/pgbouncer/userlist.txt:

Text
"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:

SQL
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:

INI
auth_type = scram-sha-256
auth_query = SELECT usename, passwd FROM pg_shadow WHERE usename=$1

Iniciando o PgBouncer

Bash
# 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 pgbouncer

Teste a conexão:

Bash
psql -h 127.0.0.1 -p 6432 -U myapp_user -d myapp

Note 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.

Bash
npm install pg dotenv

Crie o arquivo de conexão:

TYPESCRIPT
// 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:

TYPESCRIPT
// 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:

Bash
npm install @pgkit/client

Ou 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.

Bash
npm install prisma @prisma/client
npx prisma init

Configure o schema.prisma:

Prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}
 
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["driverAdapters"]
}

No .env:

Bash
# 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.

Bash
# Migrations usam DIRECT_URL automaticamente
npx prisma migrate dev --name init
 
# A aplicação usa DATABASE_URL (PgBouncer)
npx prisma generate

Monitoramento do PgBouncer

PgBouncer expõe um banco virtual de administração. Conecte-se nele:

Bash
psql -h 127.0.0.1 -p 6432 -U pgbouncer_stats -d pgbouncer

Comandos essenciais:

SQL
-- 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:

Bash
#!/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.
fi

Adicione ao cron:

Bash
# Executar a cada minuto
* * * * * /opt/scripts/monitor_pgbouncer.sh >> /var/log/pgbouncer_monitor.log 2>&1

Dimensionamento: quantas conexões configurar

A regra de ouro do PostgreSQL:

Text
conexões ideais = (núcleos de CPU × 2) + número de discos

Um servidor com 4 vCPUs e SSD:

Text
(4 × 2) + 1 = 9 conexões

Arredonde para 10-25 para ter margem. Parece pouco? É porque é eficiente. Conexões demais causam contention e degradam performance.

Configuração recomendada por cenário

INI
# 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 = 10

No PostgreSQL, ajuste max_connections para acomodar:

SQL
-- 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:

YAML
# 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:
Bash
# 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.

Bash
# 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.

SQL
-- No console admin do PgBouncer
SHOW CLIENTS;
-- Identifique clientes ociosos e aumente max_client_conn

Conexões ficam em "waiting"

Causa: default_pool_size muito baixo ou queries lentas monopolizando conexões.

SQL
-- 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.

Text
 
## 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)
Marcos Soares

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.