Connection pooling: por que seu banco de dados fica sem conexão disponível

Entenda por que abrir uma conexão nova com o banco a cada requisição é caro, como um pool de conexões resolve isso reaproveitando conexões já abertas, o erro clássico do connection leak e como dimensionar o tamanho certo do pool.


Já falei aqui sobre índice, sobre migration e sobre condição de corrida, todos assuntos que assumem que a conexão com o banco já está ali, disponível. Mas abrir essa conexão em si tem um custo que passa batido até o dia em que o sistema recebe mais tráfego e começa a aparecer um erro estranho: "too many connections", ou uma requisição simplesmente travada esperando algo que nunca chega.

O custo de abrir uma conexão

Toda conexão com um banco de dados relacional (Postgres, MySQL e companhia) envolve uma negociação: handshake de rede, autenticação, alocação de memória do lado do banco pra aquela sessão específica. Isso não é instantâneo, geralmente leva alguns milissegundos, o que parece pouco até você fazer isso de novo, do zero, em toda requisição que chega.

// ruim: abre e fecha uma conexão nova em cada requisição
app.get("/usuarios/:id", async (req, res) => {
  const conexao = await criarConexao();
  const usuario = await conexao.query("SELECT * FROM usuarios WHERE id = $1", [req.params.id]);
  await conexao.close();
  res.json(usuario);
});

Com poucas requisições por segundo, isso nem se nota. Com tráfego real, cada requisição paga esse custo de handshake de novo, e o banco de dados também tem um limite rígido de quantas conexões simultâneas ele aceita (no Postgres, o padrão costuma ser 100). Passado esse limite, qualquer tentativa de conexão nova passa a falhar, mesmo que o banco em si esteja com capacidade de sobra pra processar mais consulta.

O que um pool de conexão resolve

Um pool mantém um conjunto de conexões já abertas e prontas, reaproveitando elas entre requisições em vez de abrir e fechar uma a cada vez:

import { Pool } from "pg";
 
const pool = new Pool({
  host: "localhost",
  database: "meusistema",
  max: 20, // número máximo de conexões no pool
});
 
app.get("/usuarios/:id", async (req, res) => {
  const usuario = await pool.query("SELECT * FROM usuarios WHERE id = $1", [req.params.id]);
  res.json(usuario);
});

Quando a requisição chega, o pool entrega uma conexão já existente, que acabou de ser liberada por outra requisição que já terminou. Quando a query acaba, essa conexão volta pro pool, disponível pra próxima requisição que precisar, sem fechar e reabrir do zero. O custo de handshake só acontece quando o pool precisa criar uma conexão nova, não a cada requisição individual.

Connection leak: o erro mais comum

O problema mais frequente com pool não é de configuração, é de disciplina: esquecer de devolver a conexão pro pool depois de usar, geralmente porque uma conexão foi pega manualmente (fora do pool.query direto) pra rodar várias operações numa transação, e algum caminho de erro não libera ela de volta:

// perigoso: se "operacaoQuePodeFalhar" lançar erro, a conexão nunca é liberada
app.post("/pedidos", async (req, res) => {
  const cliente = await pool.connect();
  await cliente.query("BEGIN");
  await operacaoQuePodeFalhar(cliente);
  await cliente.query("COMMIT");
  cliente.release();
  res.json({ ok: true });
});

Se operacaoQuePodeFalhar lançar uma exceção, o código salta direto pro catch (ou simplesmente quebra, se não tiver nenhum), e a linha cliente.release() nunca roda. Aquela conexão específica fica permanentemente marcada como "em uso" do ponto de vista do pool, mesmo sem ninguém mais usando ela de fato. Repetindo esse erro algumas centenas de vezes, o pool inteiro fica saturado de conexões presas, e toda requisição nova começa a travar esperando uma conexão livre que nunca aparece, mesmo com o banco de dados saudável e disponível por trás.

A correção é garantir que a liberação aconteça sempre, com try/finally:

app.post("/pedidos", async (req, res) => {
  const cliente = await pool.connect();
  try {
    await cliente.query("BEGIN");
    await operacaoQuePodeFalhar(cliente);
    await cliente.query("COMMIT");
    res.json({ ok: true });
  } catch (erro) {
    await cliente.query("ROLLBACK");
    throw erro;
  } finally {
    cliente.release();
  }
});

O bloco finally roda sempre, dando erro ou não, garantindo que a conexão sempre volta pro pool, não importa o caminho que o código tomou pra chegar ali.

Dimensionando o tamanho do pool

Um erro comum na direção oposta é achar que um pool maior sempre resolve mais rápido. Não é bem assim: o banco de dados também tem um limite real de quanta consulta simultânea consegue processar de forma eficiente, geralmente limitado pelo número de núcleos de CPU disponíveis nele. Um pool grande demais só faz as conexões ficarem competindo por recurso real do banco, sem ganho de performance proporcional, e ainda consumindo memória à toa tanto do lado da aplicação quanto do banco.

O tamanho certo depende do tráfego real e do tempo médio que cada consulta leva, mas a regra prática é: comece com um valor moderado (algo como 10 a 20 conexões por instância da aplicação), monitore quantas conexões realmente ficam ociosas ou saturadas em produção, e ajuste a partir de dado real, não de um número arbitrário copiado de outro projeto.

Fechando

Um pool de conexão não é configuração opcional só pra projeto grande, é a diferença entre pagar o custo de abrir conexão em toda requisição individual ou reaproveitar o que já existe. O cuidado real está em garantir, com try/finally, que toda conexão retirada do pool sempre volta pra ele, porque um pool bem dimensionado não protege contra um código que simplesmente esquece de devolver o que pegou.

Direto na sua
caixa de entrada.

Um aviso por e-mail sempre que eu publicar um post novo. Sem spam, sem newsletter chata, só isso.