Sua aplicação funciona liso com 20 usuários.

Com 200, o Postgres começa a recusar conexão.

Com 2000, não importa quão bons são seus índices — nada passa.

Isso não é problema de query. É aritmética que você pulou.


Toda conexão é um processo, não uma thread

Postgres não faz thread por conexão. Ele dá fork num processo do sistema operacional inteiro.

Cada um carrega sua própria memória: buffers de trabalho, espaço de sort, planos de query em cache, estado de TCP. Conservadoramente 5-10MB por conexão, muitas vezes mais em carga real.

100 conexões  × 8MB  ≈ 800MB
500 conexões  × 8MB  ≈ 4GB
2000 conexões × 8MB  ≈ 16GB

Isso antes de qualquer query rodar. É o pedágio só de existir.

Compara com uma thread no seu processo de aplicação — poucos KB de stack. A assimetria é a história toda: sua aplicação abre thread barato e entrega uma conexão pra cada uma, e o Postgres paga preço de processo por cada uma delas.


max_connections é um muro, não uma sugestão

Config padrão do Postgres: max_connections = 100.

Aumentar isso não "resolve" nada — só move onde vai quebrar:

  • mais RAM comprometida com conexão ociosa
  • os caches compartilhados do planner diluem entre mais backends
  • overhead de context-switch sobe conforme o SO malabarea mais processos
  • uma query com lock pesado agora concorre com 10x mais possíveis donos do lock

Passado algumas centenas de conexões reais, throughput cai conforme você adiciona mais, não sobe. Não é intuitivo se você está acostumado a pensar "mais capacidade = mais concorrência" — pra conexão, é o contrário depois de um certo ponto.


De onde as conexões realmente vêm

Ninguém decide abrir 2000 conexões de propósito. Isso se acumula:

Servidores da app Rails  × tamanho do pool   (config: database.yml pool:)
  × número de processos   (workers Puma, ou processos Unicorn/Passenger)
+ Workers Sidekiq          × concurrency
+ Rake tasks, consoles, migrations esquecidas abertas
+ Toda réplica do acima em cada deploy
─────────────────────────────────────────────
= bem mais que max_connections

Uma app Rails com pool: 25 rodando 8 workers Puma já quer 200 conexões só do web. Adiciona Sidekiq com concurrency: 20 em 5 processos: mais 100. Você bate em max_connections = 100 antes mesmo do staging subir.


O pool no database.yml é uma mentira por omissão

production:
  pool: 25

Isso não significa "25 conexões, compartilhadas de forma eficiente." Significa que cada processo tem seu próprio pool de até 25. Multiplica por cada processo que carrega o Rails.

# O que as pessoas acham que acontece:
App (todos os processos) ←→ [ pool compartilhado de 25 ] ←→ Postgres

# O que realmente acontece:
Puma worker 1 ←→ [ pool de 25 ] ←┐
Puma worker 2 ←→ [ pool de 25 ] ←┤
Puma worker 3 ←→ [ pool de 25 ] ←┼→ Postgres
Sidekiq proc 1 ←→ [ pool de 25 ] ←┤
Sidekiq proc 2 ←→ [ pool de 25 ] ←┘

Cada pool é por processo, em memória. Não existe compartilhamento entre processos sem algo na frente do Postgres fazendo a multiplexação.


PgBouncer: multiplexação, não mágica

PgBouncer fica entre sua aplicação e o Postgres e faz um trabalho só: mantém um número pequeno de conexões reais com o Postgres e distribui entre um número grande de conexões de cliente sob demanda.

2000 conexões de cliente (baratas, do lado da app)
        ↓
    PgBouncer
        ↓
50 conexões reais com o Postgres (caras, limitadas)

Sua aplicação continua achando que tem centenas de conexões disponíveis. O Postgres só enxerga as 50 que o PgBouncer mantém. Nenhum dos dois lados precisa saber da realidade do outro.

A configuração que importa é o modo do pool:

  • session: um cliente fica com uma conexão do servidor durante toda a sessão. Mais seguro, menos multiplexação. Quase não ajuda mais que não ter pooler nenhum.
  • transaction: uma conexão do servidor é entregue por transação, devolvida logo após o COMMIT. Esse é o que você quer — é onde a multiplexação realmente compensa.
  • statement: entrega por statement. Agressivo, quebra transações com múltiplos statements. Raramente vale a pena.

O modo transaction é o que torna a proporção 2000→50 acima realista. Também é o que quebra funcionalidades de nível de sessão: prepared statements, advisory locks, SET fora de uma transação, listen/notify. O cache de prepared statements do Rails é o que costuma morder — desative (prepared_statements: false no database.yml) ao rodar atrás de PgBouncer em modo transaction, ou você vai levar erros crípticos de "prepared statement already exists" sob carga.


Dimensionando o pool — a fórmula que realmente funciona

O instinto ingênuo é "pool maior = mais throughput." Passado o ponto em que o Postgres tem conexão suficiente pra manter todo core ocupado, mais conexão só significa mais contenção.

Uma fórmula de partida conhecida (wiki do PostgreSQL, emprestada de teoria geral de concorrência):

conexões = ((número_de_cores × 2) + spindles_efetivos)

Pra uma instância cloud moderna com storage em SSD (spindle_count efetivamente ≈ 1):

8 vCPUs  → ~17 conexões
16 vCPUs → ~33 conexões

Isso parece absurdamente baixo se você está acostumado com max_connections = 100+. É baixo — de propósito. Passado esse número, as queries começam a esperar por tempo de CPU em vez de pela conexão em si, e a "capacidade" extra é latência de fila disfarçada.

A jogada real: roda o PgBouncer com um pool real em torno dessa fórmula, e deixa a fila do próprio PgBouncer absorver o pico em vez do Postgres.


Os erros mais comuns

1. Aumentar max_connections quando as conexões acabam.

Trata o sintoma. Uso de RAM sobe, cache do planner dilui, e você vai bater no novo teto assim que o tráfego crescer de novo.

2. Assumir que o pool: do database.yml é um limite global.

É por processo. Multiplica pelo número de workers antes de confiar no número.

3. Rodar PgBouncer em modo session "pra ser seguro."

Modo session quase não multiplexa nada — você adicionou um hop com quase nenhum benefício.

4. Deixar o cache de prepared statements do Rails ligado atrás de PgBouncer em modo transaction.

Funciona liso no staging com baixa concorrência, depois joga erros inexplicáveis em produção sob carga. Desliga explicitamente ao usar pooling em modo transaction.

5. Esquecer o Sidekiq (e todo outro processo de background) no orçamento de conexões.

A camada web raramente é a história toda. Soma todo processo que abre um pool de conexão do Rails.

6. Dimensionar o pool no chute em vez de pelo número de cores.

"Vamos só deixar em 50" não é estratégia. Comece pela fórmula de cores, meça, ajuste.


Mapa mental rápido

Conexões morrendo sob carga?
   ↓
Conte a demanda real: (processos de app × pool) + (processos worker × concurrency)
   ↓
Compare com max_connections  →  geralmente a demanda ganha, e de longe
   ↓
Coloque PgBouncer na frente, modo transaction
   ↓
Desative o cache de prepared statements do Rails
   ↓
Dimensione o pool real do lado do Postgres pelo número de cores, não pela esperança
   ↓
Meça. Ajuste. Repita.

Conclusão

Esgotamento de conexão não é um problema de banco de dados. É um problema de aritmética que sua aplicação vem acumulando silenciosamente desde o primeiro worker Puma que você adicionou.

max_connections nunca foi feito pra escalar com seu tráfego — foi feito pra proteger o Postgres de ser cobrado por fazer o impossível.

PgBouncer não remove o limite. Ele muda quem tem permissão de ver o limite: sua aplicação passa a acreditar em abundância, e o Postgres mantém, por baixo, seu número pequeno e honesto de conexões reais.

Faça a conta antes que a produção faça por você.