Esse ganho tem um custo específico: o modo de transação quebra silenciosamente tudo o que pressupõe uma conexão Postgres estável de uma solicitação para outra. Sessão SET, LISTEN/NOTIFY, bloqueios de aviso, cursores que sobrevivem à transação, chamados de instruções preparadas. Este artigo detalha o mecanismo, lista esses limites com seus sintomas exatos e depois mostra como um backend em produção (o nosso, verificado diretamente em seu repositório) o configura sem ser preso por ele. Para o método de medição por trás de quaisquer números de desempenho citados aqui, consulte nossa metodologia de benchmark .
O essencial
- Modo transação: a conexão do servidor é liberada ao final de cada transação, e não quando o cliente se desconecta. Este é o modo mais eficiente para compartilhar conexões curtas do tipo API REST.
- Incompatível por construção com: sessão SET/RESET, LISTEN/NOTIFY, bloqueios de aconselhamento de sessão, cursores WITH HOLD, tabelas temporárias reutilizadas de uma solicitação para outra.
- A armadilha mais comum na prática: instruções preparadas nomeadas, que vários drivers (sqlx, asyncpg, o driver JDBC pgjdbc) ativam por padrão, podem ser reproduzidas em uma conexão de servidor diferente e disparar um erro como
prepared statement does not existsob carga. - Desde a versão 1.21, o PgBouncer pode seguir instruções preparadas por protocolo em modo de transação (cache LRU via conexão do servidor). Isso não impede a desativação do cache do lado do cliente se seu aplicativo alterar
search_pathem cada solicitação. - Verificado no código Aurabase: os pools de locatários são executados com
statement_cache_capacity(0)e PgBouncer empool_mode=transaction, enquanto PostgREST permanece voluntariamente em conexão direta para recarregar seu esquema via LISTEN/NOTIFY.
Os 3 modos de pool do PgBouncer
O PgBouncer oferece três modos, que diferem apenas quando a conexão do servidor Postgres retorna ao pool comum. A documentação oficial os nomeia session, transaction e statement (pgbouncer.org/features.html, seção “Modos de pooling”, acessado em 24 de agosto de 2026).
| Moda | Conexão do servidor perdida | Compatibilidade de sessão |
|---|---|---|
| sessão (padrão) | Na desconexão do cliente | Total: SET, LISTEN, cursores, tudo funciona como ao vivo |
| transação | No final de cada transação (COMMIT/ROLLBACK) | Parcial: apenas o que permanece local para a transação |
| declaração | Após cada solicitação individual | Mínimo: transações explícitas de múltiplas consultas proibidas |
O modo de sessão é o mais permissivo, mas o menos eficaz em escalabilidade: uma conexão Postgres permanece reservada para um cliente enquanto permanecer conectado, mesmo que não faça nada entre duas solicitações. O modo de instrução é reservado para casos muito específicos (proxy somente leitura, verificações de integridade) e até quebra transações explícitas clássicas. O modo de transação é o compromisso que domina na prática para uma API REST: cada solicitação HTTP geralmente corresponde a uma única transação curta do Postgres.
Como funciona o modo de transação, conexão por conexão
No modo de transação, o PgBouncer apenas conecta uma conexão de servidor a um cliente quando este abre uma transação e a retorna ao pool após COMMIT ou ROLLBACK. Entre duas transações, o mesmo cliente pode ser transferido para uma conexão de servidor completamente diferente.
Concretamente, com um default_pool_size de 20, o PgBouncer pode absorver várias centenas de clientes simultâneos que, a qualquer momento, têm apenas algumas transações em andamento. É esta relação que justifica o modo de transação para uma API REST com alto tráfego, mas transações curtas: o recurso raro (uma conexão Postgres, cara em memória do lado do servidor) só é ocupado pelo tempo estritamente necessário.
Este último comentário resume a essência: o modo de transação funciona porque quebra deliberadamente o link entre "minha sessão de aplicativo" e "minha conexão Postgres". Tudo baseado neste link quebra. A próxima seção lista exatamente o quê.
O que quebra no modo de pooling de transações
A documentação oficial do PgBouncer lista explicitamente os recursos do PostgreSQL que perdem seu significado assim que uma conexão de servidor pode ser reciclada entre duas solicitações do mesmo cliente.
| Funcionalidade afetada | Por que isso quebra | Sintoma típico |
|---|---|---|
| DEFINIR / DEFINIR SESSÃO | A configuração se aplica a uma conexão que pode ser reciclada imediatamente após | Um parâmetro parece ser esquecido aleatoriamente entre duas solicitações |
| OUÇA / NOTIFIQUE | Assume uma conexão persistente para receber notificações | O cliente nunca é notificado ou apenas intermitentemente |
| Bloqueios de aconselhamento de sessão | O bloqueio é mantido pela conexão do servidor, não pelo cliente lógico | Um bloqueio é liberado antes da conclusão esperada ou nunca é liberado |
| COM controles deslizantes HOLD | Deve sobreviver além da transação que o abriu | Erro "cursor não existe" na próxima iteração |
| Tabelas temporárias | Relacionado à sessão do Postgres, não à transação | A tabela "desaparece" na próxima consulta |
| Declarações preparadas nomeadas | Preparado em uma conexão de servidor específica, reproduzido em outro | "declaração preparada... não existe" sob carga |
A maioria destas limitações não se manifesta no desenvolvimento local, onde uma única ligação normalmente serve todo o tráfego. Eles aparecem sob carga real, quando vários clientes realmente compartilham o pool e uma conexão de servidor realmente muda de mãos entre duas solicitações do mesmo cliente lógico. Um teste de fumaça quase nunca os revela.
Declarações preparadas: o limite mais incompreendido
A maioria dos drivers Postgres modernos prepara solicitações nomeadas no lado do protocolo por padrão, sem que o código do aplicativo solicite explicitamente. Isto é precisamente o que torna esta armadilha difícil de prever.
Uma instrução preparada por protocolo é nomeada e armazenada em cache em uma conexão de servidor específica, no momento de Parse. No modo transação, esta conexão pode ser reatribuída a outro cliente entre duas solicitações do mesmo cliente lógico. Se o driver reproduzir o mesmo nome de instrução em uma conexão onde nunca foi preparado, o Postgres responderá com um erro explícito, normalmente prepared statement "sqlx_s_N" does not exist para um cliente sqlx. O comportamento é intermitente: depende do desempenho das conexões sob carga, e não de um bug determinístico reproduzível em cada chamada.
A correção do lado do cliente é a mesma, independentemente do idioma: desabilite o cache de instruções preparadas nomeadas ou force consultas não nomeadas para qualquer pool que cruze um pooler no modo de transação. No Rust com sqlx, passa por statement_cache_capacity(0) nas opções de conexão.
Desde a versão 1.21, o PgBouncer alivia parte do problema do lado do servidor: ele pode seguir instruções preparadas por protocolo em modo de transação e prepará-las dinamicamente na conexão atribuída, com um cache LRU por conexão cujo tamanho é ajustado via max_prepared_statements. Isso reduz o número de falhas, mas não o isenta de desabilitar o cache do cliente em um pool multilocatário onde o search_path muda de uma solicitação para outra: um plano em cache congela o identificador interno (OID) da tabela resolvida no momento de Parse, e reproduzi-lo sob outro esquema pode retornar dados do locatário errado em vez de um simples erro.
Como o Aurabase configura o PgBouncer em modo de transação
O repositório Aurabase implanta PgBouncer como pool_mode=transaction na frente do plano de dados compartilhado (deploy/helm/aurabase/templates/infra/pgbouncer.yaml) e um Pooler CNPG configurado de forma idêntica na frente de cada instância Postgres dedicada de um locatário (deploy/cnpg/tenant-pooler.yaml). Ambos os caminhos aplicam a mesma disciplina descrita acima.
O código-fonte documenta um motivo de segurança específico para esta escolha, não apenas um motivo de estabilidade. Os pools Postgres compartilhados entre locatários posicionam um search_path diferente por projeto em conexões reutilizadas. Uma instrução preparada em cache congela o OID da tabela resolvida no momento de Parse; reproduzi-lo para outro locatário na mesma conexão executaria a consulta no esquema do primeiro locatário, um desvio de isolamento, não apenas um erro de aplicativo. statement_cache_capacity(0) é, portanto, aplicado sem exceção, inclusive em instâncias dedicadas que também passam por um Pooler CNPG em modo de transação.
Medida de higiene da segunda sessão: quando cada conexão retorna ao pool, um gancho executa DISCARD ALL (redefinindo configurações, desalocando instruções preparadas no lado do servidor, liberando bloqueios de aconselhamento, limpando cursores e tabelas temporárias). Sem esse gancho, um resíduo de sessão colocado por uma solicitação anterior poderia vazar na próxima solicitação de um locatário diferente, reutilizando a mesma conexão reciclada.
Exceção aceita: instâncias PostgREST dedicadas permanecem em conexão direta com o primário, sem passar pelo pooler. O recarregamento do esquema PostgREST depende de LISTEN/NOTIFY, que assume uma conexão persistente, exatamente a funcionalidade que o modo de transação quebra (detalhe já documentado em nosso artigo sobre Compatibilidade do PostgREST no Aurabase). As configurações de RLS por solicitação são passadas para SET LOCAL dentro de uma transação explícita, a única maneira de permanecer compatível com um pool que pode alterar as conexões do servidor em qualquer COMMIT (consulte nosso artigo sobreisolamento RLS multilocatário).
Habilite o modo de transação sem interromper seu aplicativo
Uma pequena lista de verificação, aplicável a qualquer back-end que esteja mudando de uma conexão direta do Postgres para um PgBouncer no modo de transação.
- Audite o código do aplicativo. Procure
SET,LISTEN/NOTIFY, bloqueios de aconselhamento de sessão, cursoresWITH HOLDe tabelas temporárias reutilizadas entre consultas. - Substitua SETs de sessão por SETs LOCAL em uma transação explícita. Esta é a única configuração que sobrevive adequadamente à reciclagem de conexão, porque ela é limpa em COMMIT/ROLLBACK em vez de vazar na próxima conexão.
- Desative o cache de instruções preparadas do lado do driver se o seu conjunto atravessar o pooler e o esquema ou a função mudar de uma solicitação para outra. O custo do desempenho é real, mas mensurável, e muito inferior ao risco de fuga entre inquilinos.
- Isole as conexões que realmente precisam do modo de sessão (migrações, scripts administrativos, qualquer coisa que dependa de LISTEN/NOTIFY) para uma conexão direta sem pooler, em vez de renunciar ao modo de transação para todos os outros tráfegos.
- Tamanho
default_pool_sizeemax_client_connem relação aomax_connectionsreal do Postgres, não por uma figura arbitrária copiada de outro projeto. - Teste sob carga real, não apenas teste de fumaça. Erros de instrução preparada e vazamentos de configurações de sessão quase nunca aparecem em uma única conexão local.
- Monitore
SHOW POOLSeSHOW STATSno console de administração do PgBouncer uma vez em produção, para detectar a saturação do pool antes que ela se torne visível no lado do cliente.
Você deve sempre escolher o modo de transação em vez de sessão?
Não, mas é a escolha padrão correta para a grande maioria das APIs REST. O modo de sessão permanece preferível para um aplicativo legado fortemente dependente de funcionalidades de sessão que você não pode refatorar rapidamente ou para tráfego baixo onde o ganho de pooling não compensa o esforço de migração.
O PgBouncer também não é a única implementação deste modelo de pooling: Supavisor (Supabase) e PgCat são duas alternativas recentes, com diferentes compensações na distribuição de carga e clustering. Veja nossa comparação detalhada, PgBouncer vs Supavisor vs PgCat, para escolher entre os três dependendo de sua topologia.
Perguntas frequentes
As perguntas que surgem com mais frequência quando o modo de transação é ativado na produção.