O que é um connection pool?
Um connection pool é um cache de conexões abertas com o banco de dados que a aplicação “empresta” e devolve. Em vez de abrir uma nova conexão para cada query, uma requisição retira uma conexão do pool, executa suas instruções e a devolve. A próxima requisição reutiliza o mesmo socket, que já está autenticado e pronto para uso.
Isso é importante porque uma conexão com o banco de dados não é barata. Em um banco de dados de teste local, pode parecer instantâneo, mas em produção, cada nova conexão tem um custo fixo de configuração antes de realizar qualquer trabalho útil. Um pool paga esse custo apenas uma vez por conexão, em vez de uma vez por requisição, e é por isso que ele é a primeira peça de infraestrutura que quase todo backend adiciona.
O pool também funciona como um mecanismo de segurança. Como ele possui um limite máximo rígido, evita que a aplicação abra acidentalmente dez mil conexões durante um pico de tráfego. As chamadas que chegam quando o pool está cheio aguardam em uma fila — o que torna a resposta lenta, mas sobrevivível — em vez de sobrecarregar o banco de dados, o que seria fatal.
Um pool em poucas linhas
No Node.js, o driver pg já vem com um pool pronto para uso. Criar um requer apenas uma chamada ao construtor, e cada query que você executa através dele solicita e devolve uma conexão automaticamente.
import { Pool } from "pg";
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
const { rows } = await pool.query("SELECT id, email FROM users WHERE id = $1", [id]);
Essa é a ideia central. O pool abre conexões de forma preguiçosa (lazy) conforme a demanda surge, mantém as conexões abertas entre as queries e as fecha quando ficam ociosas por muito tempo. A aplicação nunca interage com um socket; ela interage com um método query.
O mesmo padrão existe em todos os ecossistemas. Java tem o HikariCP, Python tem o pool do SQLAlchemy e o pool do asyncpg, Go tem database/sql com SetMaxOpenConns, e cada ORM encapsula um desses. Os nomes mudam, mas as configurações são basicamente as mesmas: um máximo, um mínimo, um idle timeout e um checkout timeout.
Por que as conexões são caras
O custo é composto por uma série de etapas, cada uma das quais exige que a rede ou o servidor do banco de dados realize um trabalho real.
- TCP handshake — uma viagem de ida e volta (round trip) para estabelecer o socket. Em um banco de dados remoto com um RTT de 30 ms, isso sozinho consome 30 ms.
- TLS negotiation — se a conexão for criptografada, ocorrem mais algumas viagens de ida e volta para concordar com as chaves. É comum gastar mais 50 a 100 ms.
- Autenticação — o cliente prova sua identidade, geralmente com um hash de senha que o servidor deve computar. A autenticação SCRAM, deliberadamente, realiza um trabalho pesado.
- Processo de backend — este é um custo específico do Postgres. O Postgres cria um novo processo do sistema operacional para cada conexão, cada um com sua própria memória. O MySQL utiliza uma thread, que é mais leve, mas ainda não é gratuita.
- Configuração da sessão — search paths, fuso horário, nome da aplicação e outras configurações devem ser aplicadas.
Somando tudo, uma nova conexão pode custar dezenas de milissegundos antes mesmo da primeira query. Se uma página executa dez queries, abrir uma conexão por query dominaria o tempo de resposta da requisição. Pior ainda, o modelo de processo por conexão significa que conexões ociosas ainda consomem memória, então algumas centenas delas podem realmente prejudicar o servidor.
Um pool transforma tudo isso em um custo único. A conexão é criada uma vez, utilizada para milhares de queries e fechada quando o pool decide que ela está velha demais ou ociosa por muito tempo.
O ciclo de vida de uma conexão em pool
Cada checkout segue as mesmas quatro etapas, e compreendê-las explica quase todo comportamento de pool que você precisará depurar.
Acquire (Aquisição). O chamador solicita uma conexão ao pool. Se houver uma ociosa, ela é entregue imediatamente. Se todas estiverem ocupadas, mas o pool estiver abaixo de max, uma nova é aberta. Se o pool estiver em max, o chamador aguarda em uma fila FIFO até que algo seja liberado. Essa espera é o sinal de que o pool está saturado.
Use (Uso). O chamador executa uma ou mais queries na conexão. É aqui que a conexão realmente cumpre sua função. É também onde ocorrem os erros: uma transação deixada aberta, um client que nunca foi liberado, ou uma query sem timeout.
Release (Liberação). O chamador devolve a conexão ao pool. Isso deve acontecer obrigatoriamente em um bloco finally, pois uma exceção entre a aquisição e a liberação causa o vazamento (leak) permanente da conexão. Uma conexão vazada permanece invisível até que o pool se esgote e cada requisição comece a dar timeout.
Reap (Coleta). Conexões que ficam ociosas por mais tempo que idleTimeoutMillis, ou que excedem um tempo de vida máximo, são fechadas. Isso evita que o pool acumule recursos que o banco de dados poderia usar em outro lugar, e é assim que conexões obsoletas de antes de um restart são aposentadas.
const client = await pool.connect();
try {
return await client.query("SELECT now()");
} finally {
client.release();
}
Muitos drivers permitem que você pule o checkout explícito para queries simples — pool.query() faz a aquisição e a liberação para você — o que elimina a fonte mais comum de leaks. Use a forma explícita apenas quando precisar de vários statements na mesma conexão.
Checkout, query, release, com segurança
O padrão de três linhas acima é a estrutura de toda interação segura com o pool, mas o código de produção exige um pouco mais de cuidado nos detalhes.
Envolva todo o checkout em um helper para que nenhum chamador esqueça o release. O helper gerencia o try/finally e o tratamento de erros, e o restante da base de código apenas aguarda a execução de uma função.
export async function withClient<T>(
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
return await fn(client);
} finally {
client.release();
}
}
await withClient((client) =>
client.query("UPDATE jobs SET status = 'done' WHERE id = $1", [id]),
);
Considere o que acontece quando a própria conexão é interrompida. Uma query pode falhar porque o SQL está incorreto, o que é um problema do chamador, ou porque o socket caiu, o que é um problema do pool. O segundo caso merece uma tentativa de reexecução (retry), mas apenas se a operação for segura para ser repetida. Um SELECT é; um INSERT sem uma chave de idempotência não é.
async function queryWithRetry(text: string, params: unknown[], retries = 2) {
for (let attempt = 0; ; attempt++) {
try {
return await pool.query(text, params);
} catch (err) {
const isConnectionError = (err as { code?: string }).code === "ECONNRESET";
if (!isConnectionError || attempt >= retries) throw err;
}
}
}
Por fim, defina um timeout de statement na conexão para que uma única query descontrolada não ocupe um slot do pool para sempre. No Postgres, statement_timeout cancela a query no lado do servidor; sem isso, o cliente espera tanto quanto o banco de dados.
Dimensionando o pool
A tentação é definir max como um valor alto para que nada precise esperar. Isso é exatamente o oposto do que você deseja. Um banco de dados possui um número limitado de núcleos de CPU e uma quantidade limitada de memória, e não consegue executar mais queries em paralelo do que a sua capacidade permite. Conexões extras não aumentam o throughput; elas adicionam troca de contexto (context switching), contenção de locks e pressão na memória.
A regra prática clássica vem da wiki do PostgreSQL:
connections = (cores × 2) + effective_spindle_count
Para um banco de dados de quatro núcleos com armazenamento SSD, isso resulta em aproximadamente oito a dez conexões. Parece alarmantemente baixo, e está correto: uma query bem indexada é concluída em um ou dois milissegundos, portanto, algumas poucas conexões podem atender a milhares de requisições por segundo. A fórmula é um ponto de partida, não uma lei — meça e ajuste.
A segunda parte do cálculo é a que as pessoas esquecem. Cada instância da aplicação executa seu próprio pool, portanto, o orçamento do banco de dados deve ser dividido pelo número de instâncias:
pool max per instance = database budget / number of instances
Dez instâncias com um pool de vinte solicitam duzentas conexões ao banco de dados, o que quase certamente ultrapassa max_connections. Ou você reduz o pool por instância, ou coloca um pooler na frente.
Por fim, verifique os próprios limites do banco de dados. O Postgres define max_connections como 100 por padrão, e as conexões reservadas para superusuários e replicação reduzem o que está realmente disponível. Se você solicitar mais do que isso, receberá erros, e não uma degradação suave do sistema.
O problema do fan-out de pool por instância
Esta é a falha de conexão mais comum em implantações modernas, e ela acontece de forma gradual.
Um serviço inicia em uma única instância com um pool de vinte conexões. Funciona. O tráfego cresce, então o serviço escala para cinco instâncias — e agora o banco de dados vê cem conexões, exatamente no limite. Escale para dez e teremos duzentas, bem acima do limite. Nada mudou na aplicação; o que mudou foi o fan-out.
Esse padrão se repete no Kubernetes, em serverless e em qualquer lugar onde os processos se multiplicam. Um pool limita as conexões por processo, não por sistema. A solução é utilizar um pool pequeno por instância, dimensionado de acordo com a frota, ou um pooler no lado do servidor que apresente um único pool pequeno ao banco de dados, independentemente de quantos clientes se conectem.
10 instances × pool max 20 = 200 database connections
↓
database max_connections = 100
↓
"too many clients already"
Um exemplo prático de dimensionamento
Números tornam as compensações concretas. Suponha que o banco de dados seja uma instância gerenciada de quatro núcleos com armazenamento SSD e o max_connections padrão de 100, e que o serviço rode em oito instâncias de aplicação.
A regra geral fornece um orçamento de cerca de dez conexões que o banco de dados consegue usar genuinamente em paralelo. Distribuído por oito instâncias, isso resulta em um pool de uma ou duas conexões por instância — muito menor do que as dez que as pessoas costumam configurar, e frequentemente o correto para uma carga de trabalho rápida e bem indexada. Se isso parecer muito limitado, a solução não é aumentar o pool; é colocar um pooler na frente para que os oito pools pequenos compartilhem um conjunto controlado de backends.
database budget ≈ 10 connections
app instances = 8
pool max per instance = 10 / 8 ≈ 1
too small to be useful → add PgBouncer
PgBouncer default_pool_size = 10
app pool max (per instance) = 5 # clients may wait; backends stay bounded
A percepção fundamental é que o pool da aplicação e o orçamento do banco de dados são números diferentes. O pool da aplicação controla quantas requisições cada instância pode executar simultaneamente; o pooler controla quantas conexões de servidor realmente existem. Configurar o pool da aplicação um pouco maior do que a cota por instância permite que uma instância tenha picos de processamento enquanto o pooler mantém o banco de dados seguro.
Sempre deixe uma margem de manobra. Bancos de dados gerenciados reservam algumas conexões para administração e replicação, e um failover precisa de mais conexões brevemente. Mirar em 70 a 80 por cento de max_connections deixa espaço para migrações, monitoramento e um deploy problemático.
Mantendo as conexões saudáveis
Conexões de longa duração podem se tornar obsoletas. A reinicialização de um banco de dados, um failover, uma partição de rede ou um timeout de inatividade de firewall podem derrubar o socket sem que a aplicação perceba. A próxima query nessa conexão falhará e, sem o devido tratamento, a falha será confusa.
Três configurações gerenciam isso:
idleTimeoutMillisfecha conexões que ficaram sem uso por um tempo. Valores baixos mantêm o servidor organizado, mas correm o risco de reabrir conexões durante períodos de inatividade; trinta segundos é um equilíbrio comum.- Um tempo de vida máximo (maximum lifetime) aposenta conexões após uma idade fixa, independentemente do uso. Isso distribui as reconexões em vez de permitir que todas as conexões morram ao mesmo tempo durante um failover.
- Validação executa uma verificação leve, como
SELECT 1, antes de entregar uma conexão, para que um socket morto seja descartado em vez de ser passado para quem fez a requisição.
Mesmo com as três configurações, trate os erros de conexão explicitamente. Um cliente inativo que apresente erro deve ser removido do pool, e o driver geralmente emite um evento error exatamente para isso:
pool.on("error", (err) => {
console.error("unexpected idle client error", err);
});
Ignorar esse evento transforma uma instabilidade recuperável em uma exceção não tratada que pode derrubar o processo.
Transações precisam de uma única conexão
Uma transação está vinculada a uma única conexão. Cada BEGIN, instrução e COMMIT deve ser executado no mesmo cliente, pois o estado da transação reside naquele processo de backend. É aqui que o pooling e as transações interagem, e onde códigos ingênuos falham.
O modo de falha é sutil: se você executar BEGIN através de pool.query(), o pool pode entregar uma conexão diferente para a próxima instrução, e o COMMIT irá falhar ou não confirmará nada. A regra é simples — reserve um cliente para toda a transação e libere-o apenas após o commit ou rollback.
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("UPDATE accounts SET balance_cents = balance_cents - $1 WHERE id = $2", [100, from]);
await client.query("UPDATE accounts SET balance_cents = balance_cents + $1 WHERE id = $2", [100, to]);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
Como uma transação retém uma conexão durante toda a sua duração, transações longas reduzem o pool efetivo para todos os demais. Mantenha-as curtas, evite chamadas de rede dentro delas e defina um statement_timeout para que uma query descontrolada não prenda uma conexão indefinidamente.
Prepared statements e o pool
Prepared statements são um recurso de performance: o banco de dados analisa e planeja uma query apenas uma vez e, depois, reutiliza esse plano. É aqui que o pooling se torna sutil, pois um prepared statement reside em uma conexão de backend específica.
Com um pool in-process, isso geralmente funciona bem. Drivers como o pg preparam um statement em qualquer conexão que esteja disponível no momento, e o plano é reutilizado apenas quando essa mesma conexão processa a query novamente. Nada deixa de funcionar, mas o benefício é irregular e o driver precisa gerenciar um cache crescente de statements nomeados.
O problema começa quando você combina named prepared statements com um pooler em modo de transação (transaction-mode). O pooler pode enviar sua query para um backend diferente daquele que preparou o statement; consequentemente, o servidor não reconhece o nome e retorna um erro. Esta é a surpresa mais comum ao utilizar o PgBouncer.
As soluções são simples:
- Desabilite os prepared statements no lado do servidor no driver ao usar transaction pooling, permitindo que ele envie unnamed statements. Muitos drivers possuem uma flag especificamente para isso.
- Ou utilize session pooling, que mantém um cliente em um único backend, tornando os prepared statements seguros novamente.
- Ou configure
max_prepared_statementsem versões modernas do PgBouncer, que faz o proxy do protocolo de prepare corretamente.
O princípio geral é que qualquer coisa armazenada na sessão do servidor é frágil sob transaction pooling. Prepared statements, variáveis SET, tabelas temporárias e advisory locks pertencem a uma conexão, e um pooler tem a liberdade de atribuir a você uma conexão diferente na próxima vez.
Serverless e exaustão de conexões
Funções serverless representam o pior cenário para o connection pooling. Cada invocação pode ser executada em um container novo e efêmero, sem memória compartilhada, impossibilitando o reuso de um pool criado por outra invocação. Sob carga, centenas de funções simultâneas abrem cada uma a sua conexão, e o banco de dados sofre uma “tempestade de conexões” que não consegue suportar.
A solução é um pooler no lado do servidor entre as funções e o banco de dados. O PgBouncer, ou equivalentes gerenciados como o RDS Proxy ou o pooler nativo de um provedor, mantém um conjunto pequeno de conexões reais e multiplexa as diversas conexões efêmeras dos clientes nelas.
1000 concurrent functions ──► PgBouncer ──► 20 Postgres connections
(clients) (pooler) (backends)
Um pool dentro de uma função ainda é útil, mas deve ser minúsculo — frequentemente apenas uma única conexão — e configurado para fechar rapidamente, pois um container que mantém conexões abertas enquanto está ocioso desperdiça a capacidade do banco de dados. O pooler é o que faz os números funcionarem.
PgBouncer e transaction pooling
O PgBouncer é o pooler externo padrão para Postgres. Ele utiliza o protocolo de rede do Postgres, portanto, as aplicações se conectam a ele exatamente como fariam com o banco de dados. Ele possui três modos de pooling, e a escolha entre eles traz consequências reais.
Session pooling atribui uma conexão do servidor durante toda a sessão do cliente. É o modo mais compatível — SET, LISTEN, advisory locks e prepared statements funcionam normalmente — mas é o que menos multiplexa, pois o cliente mantém sua conexão mesmo quando está ocioso.
Transaction pooling atribui uma conexão do servidor apenas durante a duração de uma transação e a retorna ao pool no commit. Mil clientes podem compartilhar vinte backends, e é por isso que esta é a escolha padrão para aplicações web. A contrapartida é que o estado da sessão não persiste entre transações: um SET pode cair em um backend diferente na próxima vez, advisory locks mantidos entre statements param de funcionar e prepared statements no lado do servidor podem entrar em conflito.
Statement pooling retorna a conexão após cada statement. É o que mais multiplexa e o mais restritivo; transações com múltiplos statements não são permitidas. Raramente é o que você deseja.
[databases]
shop = host=127.0.0.1 port=5432 dbname=shop
[pgbouncer]
pool_mode = transaction
default_pool_size = 20
max_client_conn = 1000
server_idle_timeout = 60
Se você utilizar o modo de transação, audite seu ORM e suas queries. Use SET LOCAL dentro de uma transação em vez de SET, evite manter advisory locks entre statements e configure o driver para desativar prepared statements no lado do servidor ou utilize os não nomeados.
Monitorando a saúde do pool
Um pool possui quatro métricas que valem a pena observar e, juntas, elas indicam se o dimensionamento está correto.
- Tempo de espera por uma conexão. O sinal mais direto de saturação. Se quem chama a API espera regularmente, o pool está pequeno demais para a carga ou algo está retendo as conexões por tempo excessivo.
- Conexões ativas versus ociosas. Um pool que está sempre no limite máximo com todas as conexões ativas está subdimensionado. Um pool que fica a maior parte do tempo ocioso está superdimensionado e desperdiçando a memória do banco de dados.
- Total versus máximo. O quão próximo o pool está do seu teto. Estar consistentemente perto de
maxsignifica que o próximo pico de tráfego causará filas. - Erros e timeouts. Falhas de conexão, falhas de validação e expirações de
connectionTimeoutMillis. Um aumento nessas contagens aponta para problemas na rede, no banco de dados ou conexões obsoletas (stale connections).
Do lado do banco de dados, pg_stat_activity mostra cada conexão e seu estado, que é a maneira mais rápida de verificar se conexões ociosas estão se acumulando. Combine as duas visões: as métricas da aplicação dizem como o pool está se comportando, e as métricas do banco de dados dizem o que ele está causando no servidor.
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY state
ORDER BY count(*) DESC;
Um pool de aplicação saudável apresenta um pequeno número de conexões active e algumas idle. Um acúmulo crescente de linhas idle in transaction é o padrão perigoso: essas conexões foram retiradas do pool, mas não estão fazendo nada, geralmente porque uma transação foi aberta e nunca commitada. Elas ocupam slots do pool e, no Postgres, podem bloquear o vacuum. Trate o aumento dessa contagem como um bug, não como um problema de ajuste de performance.
Pools de ORM e o pooler
Os ORMs não eliminam a necessidade de pooling; eles apenas a ocultam. Prisma, TypeORM, Drizzle e Knex mantêm seu próprio pool e expõem uma opção de connectionLimit ou pool. Isso é conveniente, mas significa que a mesma aritmética de dimensionamento se aplica e o mesmo problema de fan-out persiste.
O erro é executar um pool de ORM e um pooler sem coordená-los. O pool do ORM decide quantas conexões uma instância deseja; o pooler decide quantas o banco de dados permite. Se o pool do ORM for grande e o default_pool_size do pooler for pequeno, o ORM manterá conexões que o pooler não consegue atender, e as requisições ficarão na fila do pooler em vez de no ORM. Configure ambos deliberadamente, e prefira um pool de ORM modesto atrás de um pooler do que um pool grande sem ele.
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
Um pool não é um cache
Vale a pena destacar a diferença, pois os dois são frequentemente confundidos. Um cache armazena resultados para que você possa evitar a execução de uma query. Um pool armazena conexões para que você possa executar queries de forma mais eficiente. Adicionar um pool não reduz o número de queries; ele apenas torna o início de cada uma mais barato.
Se uma página executa vinte queries, um pool torna essas vinte queries rápidas, mas não as faz desaparecer. A próxima camada de otimização é fazer o cache dos resultados dispendiosos, que é o que o guia de Caching aborda. Os dois combinam bem: um pool mantém as queries restantes eficientes, enquanto um cache remove aquelas que você pode evitar completamente.
Essa distinção também explica uma decepção comum. Equipes adicionam um pool, veem a latência cair e depois se perguntam por que o throughput permanece inalterado sob carga pesada. O pool removeu o overhead de conexão, mas o banco de dados ainda está fazendo todo o trabalho. Apenas um cache, um índice melhor ou menos queries mudam isso.
Testando com um pool
Os testes devem exercitar o mesmo caminho de pooling da produção, porque os bugs que realmente importam — como um client vazado ou uma transação na conexão errada — só aparecem através do pool.
Use um único pool compartilhado para a suíte de testes e feche-o apenas uma vez ao final. Abrir um pool por arquivo de teste é lento e pode atingir o limite de conexões do banco de dados quando os testes são executados em paralelo.
import { afterAll } from "vitest";
import { pool } from "../src/db.js";
afterAll(async () => {
await pool.end();
});
test("findUser returns null for a missing id", async () => {
const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", ["nope"]);
expect(rows).toHaveLength(0);
});
Direcione os testes para um banco de dados descartável ou para uma transação que sofre rollback, e faça asserções no comportamento do pool onde for relevante: um teste que verifique pool.totalCount e pool.idleCount antes e depois de uma operação detectará um client vazado que uma asserção normal ignoraria. No Postgres, pg_stat_activity pode confirmar se a contagem de conexões retornou ao nível base.
Melhores práticas
- Sempre use um pool; nunca abra uma conexão por requisição.
- Dimensione o pool com base na capacidade do banco de dados e, em seguida, divida pelo número de instâncias.
- Configure um
connectionTimeoutMillispara que as chamadas falhem rapidamente em vez de ficarem travadas. - Configure um idle timeout e um maximum lifetime para que conexões obsoletas sejam descartadas.
- Libere as conexões em um bloco
finallye trate o eventoerrordo pool. - Mantenha uma única conexão para toda a transação e mantenha as transações curtas.
- Coloque um pooler na frente do Postgres assim que as instâncias multiplicarem ou as funções forem serverless.
- Use transaction pooling para aplicações web e audite funcionalidades com escopo de sessão.
- Monitore o tempo de espera, a contagem de conexões ativas versus inativas e os timeouts, não apenas a latência das queries.
- Coordene o tamanho do pool do ORM com o tamanho do pool do pooler.
Erros comuns
- Criar um
Clientpor requisição em vez de usar um pool. - Definir
maxpara centenas e chamar isso de tuning. - Esquecer de liberar um client e esvaziar lentamente o pool.
- Executar
BEGINeCOMMITatravés depool.query()em conexões diferentes. - Manter uma transação aberta durante uma chamada HTTP ou interação do usuário.
- Deixar
connectionTimeoutMillisem zero, fazendo com que as requisições esperem para sempre. - Ignorar o evento
errordo pool e sofrer um crash devido a um client idle morto. - Executar um pool in-process em funções serverless sem um pooler na frente.
- Assumir que o modo de transação do PgBouncer suporta prepared statements e estado de sessão.
- Monitorar a latência da query enquanto o tempo de espera do pool sobe sem ser notado.
Próximos passos
O pooling é inseparável do banco de dados que ele atende, por isso o guia de PostgreSQL aborda max_connections, PgBouncer e o modelo de processo por conexão em profundidade. Antes de ajustar o pool, verifique se a query realmente precisa ser executada — o guia de Caching mostra como remover a carga na origem. Se você utiliza workers, o guia de Batch Processing explica como evitar que uma frota deles esgote o banco de dados, e vale a pena revisitar Node.js para entender como o event loop e o I/O assíncrono interagem com um pool.