Evitar exaustão de conexões JDBC ao usar Virtual Threads no Spring Boot
Por John Pires · 09 de março de 2026

Evitar exaustão de conexões JDBC ao usar Virtual Threads no Spring Boot
Este artigo faz code review: mostro implementações que levam à exaustão de conexões JDBC ao adotar virtual threads e depois apresento correções práticas para produção. Foco em padrões e práticas adotáveis em serviços Spring Boot que usam JDBC (JPA, JdbcTemplate) e pools como HikariCP.
1) Quando e por que usar
Virtual threads permitem criar muito mais threads lógicas com baixo custo de memória/stack; isso é útil para cargas IO-bound. Em aplicações web com muitas chamadas externas, virtual threads reduzem latência e evitam a necessidade de grandes pools de threads tradicionais.
No entanto, o ganho vem com um risco claro: recursos externos limitados — especialmente conexões de banco de dados. O banco tem um número finito de conexões concorrentes que suporta. Se sua aplicação criar centenas ou milhares de virtual threads que fazem chamadas JDBC bloqueantes ao mesmo tempo, você terá mais threads aguardando conexões do que conexões livres, levando a filas, timeouts e degradação geral.
1.1 Como virtual threads mudam a equação
Com thread-pool tradicional, você já tinha um limite implícito: threads máximas do pool. Virtual threads expõem o problema porque tornam barato criar milhares de concorrentes, removendo o limitador natural. O ponto crítico: virtual threads não aumentam conexões do banco automaticamente.
1.2 Quando não usar virtual threads com JDBC
Evite virtual threads sem controles quando: 1) o banco não suporta muitas conexões; 2) você tem queries longas ou transações amplas; 3) falta instrumentação/monitoramento do pool. Nestes cenários, usar um modelo com concorrência limitada ou migrar para R2DBC é mais seguro.
2) Comparação com alternativas
Temos alternativas para operar com virtual threads e JDBC sem explodir o pool. Cada alternativa tem trade-offs.
2.1 Alternativa A: limitar concorrência (bulkhead/semaphore)
Princípio: you bound the number of concurrent DB operations to the pool size (ou slightly below). É simples, previsível e funciona com JDBC sem mudar driver. Penalidade: pode criar backpressure (requests enfileirados), mas isso é preferível a timeouts aleatórios.
2.2 Alternativa B: migrar para R2DBC
R2DBC é non-blocking e desenhado para programação reativa; elimina a necessidade de bloquear threads por operações IO. Custo: mudança significativa na stack (repository, transações, operadores reativos) e ainda exige limites de concorrência no DB.
2.3 Alternativa C: reduzir paralelismo no cliente
Controlar a taxa de envio de requisições (rate limiting) ou colocar filas assíncronas/hopping. Útil em arquitetura de microservices com bursts vindos de mensagens; aplica backpressure antes de chegar ao DB.
3) Implementação progressiva
Aqui faço code review: primeiro um exemplo errado e por que falha; depois a correção com padrão bulkhead usando Semaphore e configuração de HikariCP.
3.1 Exemplo errado — controlador que cria virtual threads sem limites
O padrão abaixo cria tasks por requisição com virtual threads e executa query via JdbcTemplate/JPA. Problema: nada limita quantas requisições podem bloquear esperando uma conexão Hikari.
// Controlador ruim: cria virtual threads sem limitar concorrencia e faz JDBC diretamente.
@RestController
public class BadController {
private final ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
private final JdbcTemplate jdbcTemplate;
public BadController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@GetMapping("/bulk")
public String bulk() throws Exception {
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
futures.add(vt.submit(() -> jdbcTemplate.queryForObject("SELECT 1", Integer.class)));
}
for (Future<Integer> f : futures) f.get();
return "done";
}
}
O que acontece: centenas de virtual threads tentam adquirir conexões quase simultaneamente. HikariCP limita conexões (por exemplo, 50), então 950 threads ficarão bloqueadas aguardando. Resultado: latência alta, timeouts do pool e eventual OOM se outras camadas acumularem objetos.
3.2 Correção 1 — Bulkhead com Semaphore
Use um limiter local que não permite mais concorrentes que o pool máximo. Essa técnica é robusta: mesmo com virtual threads, apenas N threads farão JDBC ao mesmo tempo. As demais aguardam no semaphore (com timeout e tratamento adequado).
// DBConcurrencyLimiter: centraliza a lógica de limitação de concorrencia para operações JDBC
@Component
public class DBConcurrencyLimiter {
private final Semaphore semaphore;
private final Duration acquireTimeout;
public DBConcurrencyLimiter(@Value("${app.db.max-concurrent:40}") int maxConcurrent,
@Value("${app.db.acquire-timeout-ms:5000}") long timeoutMs) {
this.semaphore = new Semaphore(maxConcurrent);
this.acquireTimeout = Duration.ofMillis(timeoutMs);
}
public <T> T withPermit(Callable<T> callable) throws Exception {
boolean ok = semaphore.tryAcquire(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS);
if (!ok) {
throw new TimeoutException("Could not acquire DB permit in time");
}
try {
return callable.call();
} finally {
semaphore.release();
}
}
}
O que acontece: apenas 'maxConcurrent' chamadas ficam ativas no DB; as demais aguardam com timeout previsível. Combine esse limiter com limites do HikariCP para coerência.
3.3 Uso do limiter no controlador (versão correta)
Integrando com virtual threads: aceitamos muitas requisições, mas garantimos que apenas N acessos JDBC concorrentes ocorram.
// Controlador corrigido: usa virtual threads para concorrencia leve, mas limita acesso ao DB com DBConcurrencyLimiter
@RestController
public class GoodController {
private final ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
private final DBConcurrencyLimiter limiter;
private final JdbcTemplate jdbcTemplate;
public GoodController(DBConcurrencyLimiter limiter, JdbcTemplate jdbcTemplate) {
this.limiter = limiter;
this.jdbcTemplate = jdbcTemplate;
}
@GetMapping("/bulk-safe")
public String bulkSafe() throws Exception {
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
futures.add(vt.submit(() -> limiter.withPermit(() -> jdbcTemplate.queryForObject("SELECT 1", Integer.class))));
}
for (Future<Integer> f : futures) f.get();
return "done";
}
}
O que acontece: a aplicação continua escalável em termos de threads, mas o DB recebe apenas o número de conexões que pode suportar. Timeouts são explícitos e tratáveis.
4) Integração com ecossistema real
Ter o limiter é parte do trabalho. Em produção, combine-o com configuração correta do pool, monitoramento e práticas de observabilidade.
4.1 Configurando HikariCP
Principais parâmetros: maximumPoolSize, connectionTimeout, idleTimeout, maxLifetime. Ajuste máximo: se o banco só aceita 100 conexões por app, defina maximumPoolSize para um valor seguro (por exemplo, CPU-bound ratio ou 2x cores para conexões curtas). Nunca defina maximumPoolSize em milhares só porque você tem virtual threads.
// Exemplo de configuração de DataSource com Hikari em Spring Boot (application.yml / properties equivalente)
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 40
connection-timeout: 5000
idle-timeout: 600000
max-lifetime: 1800000
# Bean explicit (se quiser calcular dinamicamente)
@Configuration
public class DataSourceConfig {
@Bean
public HikariDataSource dataSource(Environment env) {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(env.getProperty("spring.datasource.url"));
cfg.setUsername(env.getProperty("spring.datasource.username"));
cfg.setPassword(env.getProperty("spring.datasource.password"));
cfg.setMaximumPoolSize(Integer.parseInt(env.getProperty("app.db.hikaricp.max", "40")));
return new HikariDataSource(cfg);
}
}
O que acontece: pool respeitável com timeouts configurados; combine com semaphore para evitar threads aguardando indefinidamente.
4.2 Métricas e alertas (Micrometer + Prometheus)
Monitore: activeConnections, idleConnections, threadsWaitingForConnection, poolUsage. Crie alertas para quando threads esperando por conexão excederem um limiar.
// Exemplo de exposição de métricas simples para o pool Hikari
@Bean
public MeterBinder hikariMetrics(HikariDataSource ds) {
return registry -> registry.gauge("hikari.connections.active", ds, HikariDataSource::getHikariPoolMXBean.getActiveConnections);
}
O que acontece: você visualiza em dashboards quantas conexões estão em uso e detecta bursts antes que causem falhas.
4.3 Mudar para R2DBC quando fizer sentido
R2DBC remove o bloqueio de threads em chamadas ao banco, mas não elimina a necessidade de gerenciar a pressão no DB. Use R2DBC para workloads altamente concorrentes com consultas curtas e quando a mudança de stack for viável.
Tip: não considere virtual threads uma bala de prata. Eles mudam o perfil de consumo de recursos, mas o backend (DB) continua o limitador real. Faça bulkheads e instrumentação antes de liberar virtual threads em produção.
5) Checklist de qualidade para produção
Resumo prático de pontos para revisar antes de rodar com virtual threads e JDBC em produção.
5.1 Checklist técnico
- Pool configurado: maximumPoolSize coerente com capacidade do banco e demanda da aplicação.
- Limiter/Bulkhead: limite local (semaphore) alinhado com o pool.
- Timeouts explícitos: connectionTimeout do pool e timeout de aquisição do semaphore.
- Transações enxutas: evite transações longas que seguram conexões.
- Fechar recursos: uso consistente de try-with-resources ou abstrações do Spring para evitar leaks.
5.2 Checklist operacional
- Monitorar métricas do pool (active/idle/waiting).
- Alertas quando threads aguardando > X segundos.
- Stress tests com virtual threads para validar limites reais.
- Planos de rollback: desabilitar virtual threads ou reduzir permissões dinamicamente.
Erros comuns e armadilhas:
Erros comuns
- Não alinhar maxConcurrent do semaphore com maximumPoolSize do Hikari — cria desalinhamento e filas ocultas.
- Esquecer timeouts no semaphore — threads podem bloquear indefinidamente.
- Manter transações abertas enquanto espera I/O externo; isso segura conexões por muito tempo.
- Achar que virtual threads reduzem necessidade de monitoramento — na verdade aumenta a importância de observabilidade.
Exemplo rápido de anti-pattern de transação longa:
// BAD: transacao larga que faz IO externo antes de liberar conexao
@Service
public class BadService {
@Transactional
public void processAndCallExternal() {
// abre conexao DB e inicia transacao
jdbcTemplate.update("UPDATE ...");
// chamada externa demorada enquanto transacao ainda aberta
externalHttpClient.callSlowService();
// commit somente depois
}
}
O que acontece: a conexão fica ocupada durante a chamada externa, reduzindo throughput global. Corrigir: mover chamadas externas para fora da transação ou usar patterns sagas/async.
Conclusão prática
Virtual threads trazem vantagens reais, mas não resolvem limitações do banco. Três regras práticas:
- Alinhe limites: maximumPoolSize do pool + limiter (semaphore) na aplicação.
- Configure timeouts previsíveis e monitore pool/espera de threads.
- Evite transações longas e feche conexões rápido — use try-with-resources e abstrações do Spring.
Implementar virtual threads sem essas salvaguardas é um anti-pattern que levará a erros de produção. A estratégia pragmática é: adote virtual threads para paralelismo leve, mas mantenha um bulkhead para JDBC e monitore tudo.
Checklist final para deploy:
- HikariCP configurado (maximumPoolSize coerente)
- DBConcurrencyLimiter com timeout
- Métricas e alertas configurados
- Tests de carga com cenários reais
Se mudar para R2DBC, faça isso com cuidado, mantendo limites de concorrência e testes. Em microservices, prefira limitar e observar — é melhor fila controlada do que falha catastrófica do banco.
Assuntos: Java, spring-boot, jdbc, virtual-threads, hikaricp, r2dbc
Leia também
Idempotência em APIs Spring Boot: chaves, Redis e exemplo passo a passo
Implementação prática de idempotência em APIs Spring Boot usando chaves e Redis. Tutorial passo a passo com configuração, interceptor, controller e dicas para…
Instrumentando Spring Boot com OpenTelemetry: traces, métricas e logs passo a passo
Guia prático para instrumentar Spring Boot com OpenTelemetry: do ambiente à instrumentação manual e via javaagent, traces, métricas e correlação de logs com…
JPA Embeddables: criar e usar Value Objects (Address, Money) passo a passo
Aprenda passo a passo a criar e usar JPA Embeddables para value objects (Address, Money). Exemplos Java, boas práticas, armadilhas comuns e código funcional…
Virtual Threads no Spring Boot: migrar controllers bloqueantes, JPA e tuning do HikariCP
Guia prático para migrar controllers bloqueantes e JPA para virtual threads no Spring Boot, com exemplos reais, setup do HikariCP, armadilhas e padrões para…
Virtual Threads no Spring Boot: otimizar JDBC/Hikari em microservices
Guia prático para usar virtual threads em Spring Boot e otimizar JDBC/Hikari em microservices: corrija anti-patterns, configure Hikari corretamente e aplique…
Virtual Threads no Spring Boot: crie uma API concorrente passo a passo
Aprenda a usar virtual threads com Spring Boot para criar APIs concorrentes sem reescrever tudo. Veja code-review: o jeito errado, o certo, configuração…