Connection pooling no Postgres: por que o pool dentro da aplicação para de funcionar quando o deploy vira serverless

Escrito por

em

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.

Antes de mexer em qualquer parâmetro, meça de onde vêm as conexões:
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.

Comentários

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *