Pular para o conteúdo

Trilha de Kotlin · parte 51 de 59

HikariCP e limites do banco

Aula da fase 7 da trilha de Kotlin: Como dimensionar o pool em relação ao número de threads e ao limite de conexões do Postgres?

avançadoPublicada em · 16 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Imagine um estacionamento com duas vagas. Cada carro que chega precisa de uma vaga, e quando o carro sai a vaga volta a ser livre. Se quatro carros chegam juntos, dois entram e os outros dois esperam do lado de fora, com um limite de paciência. Um serviço que fala com um banco de dados funciona quase do mesmo jeito: cada operação precisa de uma conexão aberta com o banco, e as conexões são poucas. O componente que guarda essas conexões e as empresta às threads se chama pool, e o pool padrão do Spring Boot é o HikariCP.

Esta aula responde a uma pergunta prática: quantas conexões o pool deve ter, e o que acontece quando o número está errado. A resposta curta é que mais conexões quase nunca resolvem um serviço lento. A resposta longa passa pelo que uma conexão custa, pelo que o banco aguenta e pelo que acontece quando alguém esquece de devolver uma conexão.

o mapa da aula

A aula começa pelo custo de uma conexão, porque sem ele o tamanho do pool parece um detalhe. Depois mostra o pool como fila de vagas, com o caminho feliz e o caminho em que a espera estoura. A terceira parte discute o dimensionamento com a fórmula da documentação do HikariCP e explica por que o número pequeno costuma vencer. Em seguida vem o vazamento de conexão, que é o erro mais caro desse tema, e a conta do limite do banco. Por fim, as boas práticas e o que ficou sem verificação.

Versões desta aula: Spring Boot 4.1.1, com Spring Framework 7.0.9, Kotlin 2.3.21 vindo do BOM do Spring Boot e HikariCP 7.0.2 gerenciado pelo mesmo BOM. A página de Kotlin do Spring Boot diz que o framework exige, no mínimo, Kotlin 2.2.x. O BOM do Spring Boot 4.1.1 gerencia a versão 2.3.21, a mesma usada na fase 5, e por isso esta aula também a usa. O banco usado nos experimentos é o PostgreSQL 17.11, em um container Docker com a imagem postgres:17. A compilação foi feita com Maven, porque o Gradle não está instalado nesta máquina. As dependências usadas estão listadas na seção de fontes.

1. o que custa uma conexão

Uma conexão com o banco é um canal de rede aberto, com handshake, autenticação e uma sessão do lado do servidor. No PostgreSQL, esse custo tem uma forma concreta. A documentação descreve o modelo do servidor assim: “PostgreSQL implements a “process per user” client/server model. In this model, every client process connects to exactly one backend process.” Ou seja, cada conexão aceita pelo servidor vira um processo do sistema operacional. Abrir uma conexão nova a cada requisição significa criar um processo novo a cada requisição, e fechar essa conexão significa destruí-lo.

Por isso o pool existe. Ele abre algumas conexões uma vez, as mantém vivas e as empresta às threads que pedem. A thread que termina de usar a conexão não a fecha de verdade: devolve a conexão ao pool com close(), e a próxima thread a recebe sem pagar o custo de abrir de novo. O pool de conexões é a mesma ideia que a nota de Go sobre database-sql e Connection Pooling descreve, com outro nome para os parâmetros.

Há um detalhe que confunde quem chega de outra linguagem. Em Go, sql.Open não abre conexão nenhuma, e a primeira conexão só aparece na primeira consulta. No Spring Boot, quando o HikariCP está no classpath, o pool já é criado na inicialização do contexto. A documentação do Spring diz: “If HikariCP is available, we always choose it.” A dependência vem dos starters spring-boot-starter-jdbc e spring-boot-starter-data-jpa, que trazem o HikariCP sem nenhuma configuração extra, como explica a nota Starters e configuração por ambiente.

2. o pool como fila de vagas

A figura abaixo mostra o que acontece quando quatro threads pedem conexão a um pool com duas vagas. O experimento usou maximumPoolSize = 2 e connectionTimeout = 300 ms, e cada thread que ganhou a conexão executou select pg_sleep(1), ou seja, segurou a vaga por um segundo.

Fila de espera do pool. Thread 1 → Há conexão livre?; Thread 2 → Há conexão livre?; Thread 3 → Há conexão livre?; Thread 4 → Há conexão livre?…
figura 1 · Fila de espera do pool

Leia a figura da esquerda para a direita. As quatro threads chegam ao losango “Há conexão livre?”. Quando há vaga, a conexão é emprestada e a consulta vai ao PostgreSQL. Quando não há, a thread entra na fila de espera. Ela fica ali até o tempo do connectionTimeout acabar, ou até alguma conexão voltar. Se uma conexão volta a tempo, a thread pega a que voltou. Se o prazo termina, a thread recebe uma SQLTransientConnectionException, e esse é o caminho em vermelho, com o ícone de bloqueio.

O resultado real do experimento foi este:

thread 1: concluiu
thread 2: concluiu
thread 3: SQLTransientConnectionException: espera - Connection is not available, request timed out after 309ms (total=2, active=2, idle=0, waiting=0)
thread 4: SQLTransientConnectionException: espera - Connection is not available, request timed out after 307ms (total=2, active=2, idle=0, waiting=1)

As threads 1 e 2 ganharam as duas vagas. As threads 3 e 4 esperaram e estouraram o prazo de 300 ms. A mensagem do HikariCP traz três contadores úteis: total, active e idle mostram quantas conexões o pool tem, quantas estão em uso e quantas estão livres, e waiting mostra quantas threads esperam naquele instante. O tempo exato (309 ms e 307 ms nesta execução) e o valor de waiting mudam de rodada para rodada, porque dependem da ordem em que as threads chegam. Por isso a aula não os trata como resultado fixo. O que não muda é o formato da mensagem e a conta: duas vagas ocupadas por um segundo cada deixam as outras duas sem chance de entrar dentro de 300 ms.

Essa mensagem é uma forma comum de descobrir que o pool está pequeno, ou que alguma operação segura a conexão por tempo demais. Ela aparece nos logs como erro, e a tentação é aumentar o maximumPoolSize. Antes de aumentar, vale perguntar por que a conexão está presa tanto tempo.

3. dimensionar: a fórmula e por que o número pequeno ganha

A página “About Pool Sizing”, na wiki do HikariCP, propõe uma fórmula inicial e atribui essa fórmula ao projeto do PostgreSQL: “connections = ((core_count * 2) + effective_spindle_count)”. O core_count são os núcleos físicos da máquina do banco, sem contar threads de hyperthreading. O effective_spindle_count é o número de discos que o banco efetivamente usa para ler dados, e a página diz que ele é zero quando o conjunto de dados ativo está totalmente em cache (“Effective spindle count is zero if the active data set is fully cached”). O exemplo da página é um servidor de 4 núcleos com um disco: ((4 * 2) + 1), arredondado para 10.

A fórmula parece arbitrária, mas o argumento por trás dela é direto. Um banco com poucos núcleos executa poucas consultas ao mesmo tempo com eficiência. Se cem consultas chegam juntas, elas disputam CPU, memória e disco, e o tempo de cada uma cresce. Ter mais conexões do que o banco consegue atender em paralelo só move a fila do pool para dentro do banco, onde ela custa mais. A página resume a ideia com uma frase que vale a pena guardar: “the question is not how big but rather how small!”. E acrescenta que um pool de 10.000 conexões para 10.000 usuários seria “shear insanity”, ou seja, uma loucura.

Há uma ressalva importante. A fórmula é um ponto de partida para medir, não uma garantia. Esta aula não mediu throughput de nenhum serviço real, então não há número de desempenho aqui. O que o experimento mostra é o mecanismo: com pool pequeno, a espera aparece e tem causa visível. Dimensionar bem é medir a espera, o tempo de consulta e o número de conexões ativas no momento do pico, e ajustar a partir disso.

Uma consequência prática: o pool deve ser dimensionado para o banco, e não para o número de usuários. Se o serviço tem 200 threads de servidor web e um pool de 10 conexões, 190 threads esperam sempre que o tráfego sobe. Essa espera é o comportamento que o pool quer: ela protege o banco. O erro seria remover o limite para que ninguém espere.

4. vazamento: a conexão que não volta

O erro mais caro desse tema é esquecer de devolver a conexão. O código abaixo mostra o caminho que o Kotlin torna seguro com use, que fecha o recurso ao sair do bloco, e o caminho que sai da regra quando alguém pega a conexão e não a fecha.

Conexão que não volta ao pool. Thread pega conexão → close() devolve (close()); Thread pega conexão → Sem close() (sem close)…
figura 2 · Conexão que não volta ao pool

A figura mostra os dois destinos. Com close(), a conexão volta à fila de vagas e a próxima thread a recebe. Sem close(), a conexão fica presa. O HikariCP não a fecha sozinho. Ele tem um recurso de detecção de vazamento, controlado por leakDetectionThreshold, que registra um aviso quando uma conexão fica fora do pool por mais tempo que esse valor. O aviso não libera a vaga, por isso a conexão presa continua reduzindo o pool até que alguém reinicie o serviço ou o código seja corrigido.

Com o pool de duas vagas do experimento, a regra fica clara. Um vazamento tira uma vaga, outro tira a segunda, e a próxima thread espera e estoura o connectionTimeout mesmo sem nenhuma consulta lenta. O sintoma aparece longe da causa, e quem olha só a mensagem de timeout procura o problema no lugar errado.

O experimento tentou o detector com leakDetectionThreshold = 1000, e o HikariCP desligou o recurso com este aviso:

WARN com.zaxxer.hikari.HikariConfig -- HikariPool-1 - leakDetectionThreshold is less than 2000ms or more than maxLifetime, disabling it.
limite=1000 conexao esquecida ainda aberta: true getLeakDetectionThreshold=0

A segunda linha mostra o valor que o HikariCP guardou depois do aviso: 0, ou seja, a detecção ficou desligada, e a conexão esquecida continuou aberta. O README do projeto confirma o limite: “Lowest acceptable value for enabling leak detection is 2000 (2 seconds).” Ou seja, na versão 7.0.2 um valor menor não falha o serviço, apenas desliga a detecção. Por isso o teste foi repetido com 2000 ms. Com o valor correto, o aviso apareceu:

WARN com.zaxxer.hikari.pool.ProxyLeakTask -- Connection leak detection triggered for org.postgresql.jdbc.PgConnection@... on thread main, stack trace follows
java.lang.Exception: Apparent connection leak detected
conexao esquecida ainda aberta: true

O trecho @... esconde o endereço da conexão, que muda a cada execução. O que a aula registra é que o aviso veio com a pilha de chamadas de quem pegou a conexão, e que a conexão continuou aberta depois do aviso. Essa pilha é o dado que aponta o código culpado, então vale configurar a detecção em homologação com um valor pequeno e mantê-la desligada em produção só quando o custo do log for um problema. A própria documentação do HikariCP diz que o valor 0 desliga a detecção: “A value of 0 means leak detection is disabled.” O padrão do Hikari é esse 0.

Pergunta de entrevistaQuantas conexões um pool precisa ter?

Não existe número mágico. Pools pequenos costumam ser melhores que grandes, porque muitas conexões disputam CPU e disco no banco. O total de conexões de todas as instâncias da aplicação precisa caber no limite do banco. Começo com um valor pequeno, meço a espera por conexão sob carga e só aumento se a espera for real. Aumentar o pool sem medir costuma piorar o problema.

5. o limite do banco e a conta de conexões

O pool tem um limite dentro da aplicação, e o banco tem o seu. No PostgreSQL, o limite está em max_connections. A documentação diz que o valor padrão é “typically 100 connections”, podendo ser menor se o kernel não suportar, e que três conexões ficam reservadas para superusuários por padrão. O container usado no experimento mostrou o valor real:

 max_connections
-----------------
 100

A conta que importa é a soma. Cada instância do serviço tem o seu próprio pool, e todas as instâncias falam com o mesmo banco. Se cada uma das 12 instâncias usa maximumPoolSize = 10, o banco recebe até 120 conexões, acima das 100 do padrão, e o excedente recebe erro de conexão recusada. Esse cálculo não depende de nenhuma medição: é aritmética do lado da implantação. O mesmo raciocínio vale para outros bancos e para outros serviços que compartilham o banco. A pergunta a fazer antes de subir uma nova réplica é quantas conexões o banco ainda tem livres.

Um detalhe do modelo de processo ajuda a entender por que o limite pesa. Como cada conexão vira um processo no servidor, o custo de memória e de troca de contexto cresce com o número de conexões, mesmo as que estão ociosas. Por isso o pool pequeno também protege o banco de si mesmo.

6. erros comuns e boas práticas

O primeiro erro é aumentar o pool para curar timeout. Se a espera aparece, o pool pode estar pequeno, mas quase sempre há uma consulta lenta ou uma conexão presa segurando a vaga. Aumentar o número só move o gargalo para o banco, que agora recebe mais consultas ao mesmo tempo. A conduta certa é olhar active e waiting no momento do problema, e depois medir a duração das consultas.

O segundo erro é deixar o connectionTimeout no padrão sem pensar. O valor padrão é 30 segundos, e uma thread de servidor web que espera 30 segundos por uma conexão ocupa recursos enquanto o cliente do outro lado já desistiu. O timeout curto faz a falha aparecer cedo e permite que a aplicação responda com erro controlado.

O terceiro erro é ignorar o maxLifetime. O padrão do HikariCP é 30 minutos, e a documentação recomenda que ele seja “several seconds shorter than any database or infrastructure imposed connection time limit”. Se um balanceador ou o próprio banco derruba conexões antigas, o pool pode entregar uma conexão já morta. Um maxLifetime menor que o limite da infraestrutura faz o pool trocar as conexões antes que isso aconteça.

O quarto erro é tratar o close() como detalhe. Com use, o Kotlin fecha o recurso mesmo quando uma exceção acontece no meio do bloco. Escrever o fechamento à mão em um finally também funciona, mas é mais fácil esquecer. A regra prática é pegar a conexão no escopo mais curto possível e deixar o use cuidar do resto.

A boa prática, em resumo, é começar pela fórmula, medir a espera antes de mudar o número, manter a soma das conexões de todas as instâncias abaixo do max_connections com folga, fechar sempre a conexão no mesmo bloco em que ela foi aberta e configurar a detecção de vazamento em homologação com um valor de pelo menos 2000 ms.

7. exercícios

  1. Altere o experimento para maximumPoolSize = 3 e connectionTimeout = 300, mantendo quatro threads. Diga quantas threads concluem e explique a diferença em relação ao resultado desta aula.
  2. Troque select pg_sleep(1) por select pg_sleep(0.1) e rode de novo. Explique por que quase não há timeout, e diga qual parte da fórmula da seção 3 você mudou ao fazer isso.
  3. Troque leakDetectionThreshold para 1000 e confirme, no log, a mensagem que desliga o detector. Depois use 2000 e confirme que o aviso aparece quando a conexão fica presa por mais de dois segundos.
  4. Calcule quantas instâncias de um serviço com maximumPoolSize = 10 cabem em um PostgreSQL com max_connections = 100 e três conexões reservadas, e diga o que acontece com a instância que sobra.

8. glossário

Pool de conexões: conjunto de conexões abertas com o banco, mantidas vivas e emprestadas às threads que precisam delas.

HikariCP: biblioteca de pool de conexões JDBC, usada por padrão pelo Spring Boot quando está no classpath.

maximumPoolSize: número máximo de conexões que o pool mantém, em uso e ociosas.

connectionTimeout: tempo máximo que uma thread espera por uma conexão livre antes de receber uma exceção.

leakDetectionThreshold: tempo, em milissegundos, depois do qual o HikariCP registra um aviso de conexão fora do pool. O valor 0 desliga a detecção.

max_connections: limite de conexões simultâneas que o servidor PostgreSQL aceita.

Vazamento de conexão: conexão retirada do pool e nunca devolvida, o que reduz as vagas disponíveis até o serviço travar.

fontes

Consultadas em 20/10/2026:

Consultadas em 20/10/2026, com as citações conferidas nessa data. Os textos citados foram lidos com WebFetch nas URLs acima.

Verificações locais, feitas em 20/10/2026:

  • Os valores padrão do HikariCP foram lidos da própria biblioteca com new HikariConfig(): maximumPoolSize=10, connectionTimeout=30000, idleTimeout=600000, maxLifetime=1800000, minimumIdle=-1 (igual ao máximo), leakDetectionThreshold=0.
  • O experimento de espera e o de vazamento foram executados contra um PostgreSQL 17.11 em container, com HikariCP 7.0.2. O código completo está abaixo.
  • A consulta SHOW max_connections foi executada no mesmo container.

Dependências usadas na compilação, resolvidas pelo BOM spring-boot-dependencies 4.1.1: spring-boot-starter-data-jpa, spring-boot-starter-data-mongodb, spring-boot-liquibase, postgresql 42.7.13, kotlin-stdlib e kotlin-reflect 2.3.21. Os jars resolvidos incluem HikariCP 7.0.2, Hibernate 7.4.5.Final, Spring Data JPA 4.1.1, Liquibase 5.0.3, Spring Data MongoDB 5.1.1 e o driver Java do MongoDB 5.8.1.

Código do experimento (arquivo aula61/Main.kt):

package aula61

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import java.util.concurrent.Executors

private fun configurar(nome: String) = HikariConfig().apply {
    jdbcUrl = "jdbc:postgresql://localhost:5436/pedidos"
    username = "aula"
    password = "aula"
    poolName = nome
}

// Cenario 1: pool de 2 conexoes, 4 threads pedem conexao ao mesmo tempo.
// Cada conexao fica presa 1 s e o timeout de espera e 300 ms.
fun cenarioEspera() {
    val cfg = configurar("espera").apply {
        maximumPoolSize = 2
        connectionTimeout = 300
    }
    HikariDataSource(cfg).use { ds ->
        val executor = Executors.newFixedThreadPool(4)
        val futuros = (1..4).map { i ->
            executor.submit<String> {
                try {
                    ds.connection.use { c ->
                        c.createStatement().use { st -> st.execute("select pg_sleep(1)") }
                    }
                    "thread $i: concluiu"
                } catch (e: Exception) {
                    "thread $i: ${e::class.simpleName}: ${e.message}"
                }
            }
        }
        futuros.forEach { println(it.get()) }
        executor.shutdown()
    }
}

// Cenario 2: uma conexao pega do pool e esquecida sem close().
// Com leakDetectionThreshold = 2000 ms, o Hikari registra um aviso de vazamento.
fun cenarioVazamento() {
    val cfg = configurar("vazamento").apply {
        maximumPoolSize = 2
        leakDetectionThreshold = 2000
    }
    HikariDataSource(cfg).use { ds ->
        val esquecida = ds.connection
        Thread.sleep(3000)
        println("conexao esquecida ainda aberta: ${!esquecida.isClosed}")
    }
}

fun main() {
    println("== cenario espera ==")
    cenarioEspera()
    println("== cenario vazamento ==")
    cenarioVazamento()
}

Programa de verificação do limite de vazamento (arquivo Leak.java, rodado com java -cp <classpath> Leak.java 1000 e depois com 2000). Ele repete a configuração de vazamento do experimento, sem Spring:

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
public class Leak {
  public static void main(String[] a) throws Exception {
    HikariConfig c = new HikariConfig();
    c.setJdbcUrl("jdbc:postgresql://localhost:5436/pedidos");
    c.setUsername("aula"); c.setPassword("aula");
    c.setMaximumPoolSize(2);
    c.setLeakDetectionThreshold(Long.parseLong(a[0]));
    try (HikariDataSource ds = new HikariDataSource(c)) {
      var esquecida = ds.getConnection();
      Thread.sleep(3000);
      System.out.println("limite=" + a[0] + " conexao esquecida ainda aberta: " + !esquecida.isClosed() + " getLeakDetectionThreshold=" + ds.getLeakDetectionThreshold());
    }
  }
}

Não verificado nesta aula: o comportamento do HikariCP sob carga real, o efeito do maxLifetime em conexões derrubadas pela infraestrutura, a API do MXBean usada no exercício 3, e qualquer configuração de pool feita com Gradle.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?