O sintoma aparece sempre do mesmo jeito: a aplicação sobe em containers com autoscaling ou em funções serverless, o tráfego cresce um pouco acima do normal e o Postgres começa a devolver FATAL: remaining connection slots are reserved for non-replication superuser connections. O pool está configurado. O max do pool está baixo. E mesmo assim o banco recusa conexão.
O problema não é o tamanho do pool. É o fato de que existe um pool por processo, e a quantidade de processos deixou de ser uma constante.
O pool da aplicação assume um mundo que não existe mais
HikariCP, node-postgres, SQLAlchemy, database/sql: todos implementam o mesmo modelo. O processo abre N conexões TCP com o banco, mantém essas conexões vivas e distribui entre as threads ou entre as tasks do event loop. O ganho é evitar o handshake — em Postgres, cada conexão nova custa a criação de um processo backend no servidor, e isso não é barato.
Esse modelo funciona quando o número de processos da aplicação é conhecido e estável. Três instâncias com pool de 20 são 60 conexões, e a conta fecha. Quando o orquestrador escala de 3 para 30 réplicas em resposta a um pico, viram 600 conexões contra um banco cujo max_connections padrão é 100. O pool cumpriu exatamente o que foi configurado para fazer — o problema é que ninguém configurou o agregado.
Em serverless o quadro é pior, porque a unidade de execução não é a réplica: é a invocação. Cada ambiente de execução frio abre a própria conexão, e o pool interno perde a função — não há concorrência dentro de uma invocação para amortizar o custo. O pool vira um wrapper caro em volta de uma conexão só, e a soma de ambientes quentes vira um número que ninguém controla diretamente.
A conta que define o teto real
Aumentar max_connections é a primeira reação e quase sempre a errada. Cada conexão no Postgres é um processo do sistema operacional, com stack, catálogo em cache e área de trabalho própria. O custo por conexão ociosa é da ordem de alguns megabytes e varia bastante com o schema e com o padrão de consultas — mas o número que costuma estourar não é esse.
O que estoura é work_mem. Ele é alocado por operação de ordenação ou hash dentro de cada query, não por conexão. Uma consulta com três joins em hash e um sort pode consumir várias vezes o valor configurado. Multiplicar isso por 600 conexões ativas é a diferença entre um banco lento e um banco com OOM kill.
Há ainda o custo de escalonamento: com centenas de backends ativos, o Postgres passa a gastar tempo relevante em contenção de lightweight locks e em troca de contexto. O throughput cai antes de o banco ficar sem memória. É comum um banco entregar mais transações por segundo com 40 conexões ativas do que com 400.
SELECT state, application_name, count(*) FROM pg_stat_activity GROUP BY 1,2 ORDER BY 3 DESC;Se a maior fatia estiver em
idle — e não em idle in transaction ou active — o gargalo é de pooling, não de carga.Pooler externo: onde ele resolve e onde ele quebra o código
A correção estrutural é mover o pool para fora do processo. Um pooler externo — PgBouncer, pgcat, ou o pooler gerenciado do provedor — aceita centenas de conexões dos clientes e multiplexa sobre uma dezena de conexões reais com o banco. O ponto de decisão é o modo de pooling, e ele tem consequência direta no código da aplicação.
| Modo | Conexão real é devolvida | Multiplexação | O que deixa de funcionar |
|---|---|---|---|
| session | ao cliente desconectar | praticamente nenhuma | nada — mas também não resolve o problema |
| transaction | ao fim de cada transação | alta, é o modo útil | SET de sessão, LISTEN/NOTIFY, advisory locks de sessão, tabelas temporárias, cursores fora de transação |
| statement | ao fim de cada comando | máxima | transações com múltiplos comandos — inviável para a maioria das aplicações |
O modo transaction é o único que entrega o ganho procurado, e é onde a maior parte das migrações trava. Código que executa SET search_path logo após conectar e assume que aquilo vale para as queries seguintes vai falhar de forma intermitente: a próxima query pode cair em outra conexão real. O mesmo vale para isolamento multi-tenant baseado em SET app.tenant_id combinado com row level security — o padrão precisa migrar para SET LOCAL dentro da transação, ou o vazamento entre tenants é questão de tempo.
Advisory locks são o caso mais silencioso. pg_advisory_lock mantém o lock atrelado à sessão; em transaction pooling a sessão pertence ao pooler, não à aplicação. A variante pg_advisory_xact_lock resolve, porque o lock morre com a transação — mas isso muda a semântica de qualquer lock que precisava sobreviver a mais de uma transação.
Prepared statements e o atrito com o driver
O segundo bloqueio típico são prepared statements no protocolo estendido. O driver prepara uma vez, recebe um nome de statement e reutiliza. Em transaction pooling, a próxima execução pode cair numa conexão real que nunca viu aquele statement, e o resultado é o erro prepared statement "S_1" does not exist — intermitente, proporcional à taxa de reuso de conexão, e por isso quase impossível de reproduzir em staging com pouca carga.
Há dois caminhos. O primeiro é desligar o cache de prepared statements no driver — em JDBC, zerando prepareThreshold; em drivers Python e Node, desabilitando o modo estendido ou o cache nomeado. Custa CPU no banco, porque cada execução volta a passar por parse e plan, e o impacto real depende de quanto do workload são queries curtas e repetidas.
O segundo é usar uma versão de pooler que rastreia prepared statements por conexão de cliente e os replica na conexão real quando necessário. O PgBouncer suporta isso no modo transaction a partir da série 1.21, através do parâmetro max_prepared_statements — que vem desligado por padrão e precisa ser definido explicitamente. Poolers mais novos já nascem com o comportamento. Essa é a opção a escolher quando ela estiver disponível na versão em uso: preserva o ganho de plano em cache sem obrigar a aplicação a abrir mão do protocolo estendido.
Serverless muda a resposta, não o problema
Em ambiente serverless o pooler externo continua sendo a resposta certa, com uma ressalva: ele precisa estar do lado do banco, não do lado da função. Um pooler embutido no runtime da função não multiplexa nada — cada ambiente de execução tem o seu.
Quando a latência de rede entre a função e o pooler é alta, ou quando o modelo de execução não sustenta conexões TCP persistentes, a alternativa é um proxy de dados sobre HTTP: a função faz uma requisição, o proxy mantém as conexões reais com o Postgres. Perde-se transação multi-comando com estado do lado do cliente e ganha-se independência do ciclo de vida da conexão. Vale para workloads de leitura curta e escrita simples; não vale quando existem transações longas com lógica no meio.
Uma decisão que aparece pouco e economiza muito: separar o pool de leitura do de escrita. Réplicas de leitura com um pooler próprio absorvem a maior parte da concorrência e mantêm o primário com um número pequeno de conexões — que é exatamente onde a contenção dói.
Como dimensionar o pool real do pooler
Definido o modo, resta o número que importa: quantas conexões o pooler mantém abertas contra o banco. É o parâmetro default_pool_size no PgBouncer, e ele costuma ser configurado alto demais por reflexo — o que devolve o problema original com uma camada a mais no caminho.
A heurística clássica do ecossistema Postgres é dimensionar pelo paralelismo real do servidor, algo como o dobro do número de núcleos somado ao paralelismo do armazenamento, e não pelo número de clientes. Para workload OLTP em servidor de 8 núcleos, isso costuma cair entre 20 e 30 conexões ativas — número que soa baixo até a primeira medição mostrar que a latência média cai quando o valor diminui.
Duas armadilhas de configuração aparecem depois: o teto agregado precisa considerar todas as instâncias do pooler, porque dois poolers com pool de 25 são 50 conexões no banco; e a soma de default_pool_size por par usuário/base pode ultrapassar max_client_conn sem que nada avise até o pico. O parâmetro reserve_pool_size existe justamente para absorver rajadas curtas sem inflar o pool base.
Uma exceção precisa ficar documentada: aplicações que dependem de LISTEN/NOTIFY exigem uma conexão dedicada fora do pooler. É uma linha de configuração — e o custo de não fazê-la é descobrir em produção que as notificações passaram a chegar de forma intermitente, sem erro em log nenhum.
Deixe um comentário