Database Performance

Connection Pooling

Abrir uma conexão com o banco de dados é caro. Um pool mantém um pequeno conjunto de conexões abertas e as reutiliza, transformando um custo por requisição em uma configuração única e protegendo o banco de dados de tempestades de conexões.

intermediate14 min readUpdated 16 de set. de 2026
db.ts
ts
// db.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});

export async function query(text: string, params?: unknown[]) {
  const client = await pool.connect();
  try {
    return await client.query(text, params);
  } finally {
    client.release();
  }
}
Ideia central
Reutilizar conexões abertas
Custo típico economizado
TCP + TLS + auth + fork
Regra de dimensionamento
cores × 2 + spindles
Máximo padrão
10 (node-postgres)
Solução serverless
PgBouncer / pooler proxy
Métrica chave
Tempo de espera do pool

Por que importa

O que um pool realmente faz

Menor latência por query

Reutilizar uma conexão quente pula o handshake TCP, a negociação TLS, a autenticação e, no Postgres, o fork de um processo de backend para cada query.

Um teto rígido de conexões

Um pool se recusa a abrir mais do que o máximo de conexões, então um pico de tráfego gera uma fila breve em vez de esgotar o max_connections do banco de dados e causar falhas para todos.

Conexões com auto-recuperação

Limites de inatividade e de tempo de vida máximo aposentam sockets obsoletos, para que um reinício do banco de dados ou failover não deixe a aplicação segurando conexões mortas.

O panorama completo

Os três custos que um pool elimina

Um pool amortiza o handshake, limita a contagem de conexões e fornece a cada requisição uma conexão quente e pronta.

Reuso

Amortizar

Uma única conexão atende a muitas queries e muitas requisições ao longo de sua vida, portanto, a configuração cara acontece uma vez em vez de a cada requisição.

Limite

Proteger

O máximo do pool corresponde ao que o banco de dados consegue sustentar, mantendo o número de processos de backend sob controle.

Fila

Suavizar

Quando todas as conexões estão ocupadas, os solicitantes aguardam em uma fila ordenada em vez de abrir novos sockets, o que converte a sobrecarga em latência.

HTML5 de uma olhada

O pool em um relance

Aquisição

Um solicitante pede ao pool uma conexão e recebe uma inativa ou aguarda na fila.

Reuso quente

A conexão já está autenticada e pronta, então a query é executada imediatamente.

Liberação

O solicitante devolve a conexão ao pool para que a próxima requisição possa utilizá-la.

Idle timeout

Conexões não utilizadas são fechadas após o idleTimeoutMillis para liberar recursos do servidor.

Validação

Um health check confirma se o socket está vivo antes de ele ser entregue.

Saturação

O tempo de espera e a contagem de ativas versus inativas revelam se o pool está pequeno ou grande demais.

Modelo de dados

Configuração do pool

As poucas configurações que decidem como um pool se comporta sob carga. Os nomes seguem o node-postgres, mas cada driver possui equivalentes.

Configuração do poolOpções de pool de conexão
  • maxnumberMáximo de conexões que o pool abrirá. Deve corresponder ao orçamento do banco de dados dividido pelo número de instâncias da aplicação.
  • minnumberConexões mantidas abertas mesmo quando inativas, para que as primeiras requisições após um período de silêncio não paguem o custo do handshake.
  • idleTimeoutMillisnumberQuanto tempo uma conexão não utilizada pode ficar aberta antes de ser fechada. Libera recursos do servidor sem causar instabilidade.
  • connectionTimeoutMillisnumberQuanto tempo um solicitante espera por uma conexão antes de falhar. Falhe rápido em vez de travar a requisição.
  • maxUsesnumberAs conexões são aposentadas após este número de checkouts, o que ajuda a rebalancear e aplicar mudanças de configuração.

As poucas configurações que decidem como um pool se comporta sob carga. Os nomes seguem o node-postgres, mas cada driver possui equivalentes.

Fluxo

A jornada de uma query através do pool

Toda query percorre o mesmo caminho, quer obtenha uma conexão inativa instantaneamente ou aguarde por um pool ocupado.

  1. 1

    A aplicação solicita uma conexão

    Um handler chama pool.connect() ou executa uma query, que retira uma conexão do pool.

  2. 2

    O pool retorna uma inativa ou abre uma nova

    Se houver uma conexão livre, ela é entregue imediatamente. Caso contrário, o pool abre uma nova até o limite máximo ou coloca o solicitante na fila.

  3. 3

    Executar a query

    A query é executada sobre um socket estabelecido. Sem handshake, sem autenticação, sem novo processo de backend.

  4. 4

    Liberar a conexão de volta

    O solicitante devolve a conexão ao pool, geralmente em um bloco finally para que um erro não cause vazamento (leak).

  5. 5

    Conexões inativas são removidas

    Conexões não utilizadas além do idleTimeoutMillis, ou mais velhas que o tempo de vida máximo, são fechadas e substituídas sob demanda.

O guia completo

Connection Pooling: Tudo que voce precisa saber

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:

  • idleTimeoutMillis fecha 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_statements em 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 max significa 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 connectionTimeoutMillis para 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 finally e trate o evento error do 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 Client por requisição em vez de usar um pool.
  • Definir max para centenas e chamar isso de tuning.
  • Esquecer de liberar um client e esvaziar lentamente o pool.
  • Executar BEGIN e COMMIT através de pool.query() em conexões diferentes.
  • Manter uma transação aberta durante uma chamada HTTP ou interação do usuário.
  • Deixar connectionTimeoutMillis em zero, fazendo com que as requisições esperem para sempre.
  • Ignorar o evento error do 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.

Na pratica

Configurar, adquirir, transacionar, pool

As quatro partes de um pool em produção: configurações, checkout seguro, transações e um pooler no lado do servidor.

db.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  min: 2,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
  maxUses: 7_500,
  allowExitOnIdle: false,
});

pool.on("error", (err) => {
  console.error("idle client error", err.message);
});

Conexões com pool vs uma conexão por requisição

O pool transforma uma configuração cara por requisição em um custo único e dá ao banco de dados uma contagem de conexões previsível.

Preferir
import { pool } from "./db.js";

app.get("/users/:id", async (req, res) => {
  const { rows } = await pool.query(
    "SELECT * FROM users WHERE id = $1",
    [req.params.id],
  );
  res.json(rows[0]);
});
Evitar
import { Client } from "pg";

app.get("/users/:id", async (req, res) => {
  const client = new Client({ connectionString: process.env.DATABASE_URL });
  await client.connect();

  const { rows } = await client.query(
    "SELECT * FROM users WHERE id = $1",
    [req.params.id],
  );

  await client.end();
  res.json(rows[0]);
});

PgBouncer modo transaction vs modo session

O modo transaction multiplexa a maior quantidade de clientes e é o padrão correto para aplicações web, ao custo de funcionalidades com escopo de sessão.

Transaction
[pgbouncer]
pool_mode = transaction
default_pool_size = 20
max_client_conn = 1000

; A server connection is held only for the
; duration of a transaction, so thousands of
; clients share a small set of backends.
Session
[pgbouncer]
pool_mode = session
default_pool_size = 20
max_client_conn = 200

; Each client holds a server connection for
; its whole session. Safe for SET, LISTEN and
; advisory locks, but it multiplexes far less.

Trade-offs

Pool ou pooler?

Um pool no processo é obrigatório. Um pooler no lado do servidor é o que salva você quando as instâncias se multiplicam ou as funções se tornam serverless.

Strengths

  • Latência previsível

    Reutilizar conexões quentes remove o handshake e a autenticação do caminho crítico, então a latência p99 para de depender de quão ocupado o banco de dados está.

  • Proteção contra picos

    Um pool limitado converte uma tempestade de conexões em uma fila curta, que é uma condição recuperável em vez de uma falha generalizada no banco de dados.

  • Controle centralizado

    Um pooler como o PgBouncer coloca o orçamento de conexões em um único lugar, permitindo adicionar instâncias da aplicação sem refazer os cálculos para cada serviço.

Trade-offs

  • Dimensionamento incorreto causa filas

    Um pool pequeno demais faz cada requisição esperar, e um grande demais anula o propósito ao esgotar o banco de dados de qualquer maneira. O número precisa ser medido.

  • Transações retêm a conexão

    No momento em que uma requisição abre uma transação, ela detém a conexão até o commit. Transações lentas reduzem silenciosamente o pool para todos.

  • Serverless quebra o modelo

    Milhares de funções de vida curta constroem seus próprios pools, o que se multiplica em milhares de conexões. Serverless precisa de um pooler na frente.

Perguntas frequentes

Perguntas frequentes

Keep learning

Related topics from the roadmap.

$ comecar a aprender

Pronto para aprender Connection Pooling?

Nosso tutorial interativo te guia por Connection Pooling passo a passo — com quizzes e codigo real que voce pode executar no navegador.