Destino Dev

Blog

Virtual Threads no Spring Boot: otimizar JDBC/Hikari em microservices

Por John Pires · 09 de março de 2026

Virtual Threads no Spring Boot: otimizar JDBC/Hikari em microservices

Contexto e motivacao

Por que revisar o modelo tradicional

Aplicações Java tradicionais em microservices usam threads de plataforma (OS threads) para atender requisições HTTP e executar JDBC. Cada requisição que faz I/O bloqueante ocupa uma thread do servlet container. Em picos de carga isso esgota o pool do contêiner (Tomcat, Jetty, Undertow) e leva a latência e escalonamento ineficiente.

Historicamente a resposta foi aumentar o pool de threads e o pool de conexões do HikariCP, ou migrar para stacks reativos. Ambas têm custo: aumentar threads consome memória e contexto de CPU; stacks reativos exigem reescrever camadas e lidar com adaptações de bibliotecas blocking-only, como JDBC.

O que as virtual threads mudam

Virtual threads (Project Loom) permitem criar milhões de threads leves que são desassociadas 1:1 de OS threads. Elas são ideais para I/O bloqueante porque, quando bloqueiam, o runtime desassocia a virtual thread do OS thread e reutiliza o OS thread para outro trabalho. Isso muda a equação: você pode ter muitas tarefas concorrentes sem multiplicar os threads do SO.

Mas virtual threads não são mágica: o banco de dados continua a limitar conexões ativas e operações bloqueantes fora do JDBC (e.g. banco, redes) ainda consomem recursos. É preciso ajustar Hikari, padrões de arquitetura e o shape das APIs.

Como funciona por baixo dos panos

Agregação de bloqueios e escalonamento

Quando uma virtual thread realiza uma operação de I/O bloqueante suportada pelo runtime, ela é suspensa (parked) e o scheduler mapeia outra virtual thread para o OS thread disponível. Esse mapeamento reduz conflitos com as estruturas de kernel e melhora a latência média de throughput sob carga.

Importante: a suspensão da virtual thread não encerra recursos do usuário. Se a operação bloqueante for no nível do driver JDBC que usa chamadas nativas e não cooperativas, o ganho pode ser parcial. Drivers JDBC modernos (postgres, mysql) têm melhor compatibilidade com Loom, mas versões antigas podem usar bloqueio nativo em lugares que impedem a plena cooperação.

Interação com HikariCP e o banco

HikariCP gerencia conexões reais ao banco. Essas conexões são o ponto de contenção principal: mesmo com milhões de virtual threads, apenas N conexões reais podem ser usadas em paralelo. Assim, Hikari age como um bulkhead natural: limitar o pool protege o banco. A fatalidade é quando desenvolvedores equivocadamente dimensionam o pool para refletir threads em vez de capacidade do banco.

Além disso, operações de aquisição de conexão (getConnection) podem bloquear quando o pool está esgotado. Com virtual threads isso é menos custoso para o sistema, mas pode levar a acúmulo de tasks aguardando conexão, o que precisa ser monitorado e controlado com timeouts e circuit breakers.

Implementacao passo a passo

Jeito errado (code review: anti-pattern)

Exemplo típico que vejo em PRs: controller REST que executa JDBC diretamente no thread do servlet. Funciona, mas escala mal. Código curto abaixo demonstra o erro: bloquear threads do contêiner sem offload.

Contexto: controller Spring MVC síncrono que delega para um repository JDBC e depende do pool padrão do Tomcat. Em carga alta, o servlet thread pool se esgota.

package com.exemplo.bad;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BadController {

    private final BadRepository repo;

    public BadController(BadRepository repo) { this.repo = repo; }

    @GetMapping("/sync")
    public String getSync() {
        // Bloqueia o thread do contêiner até o JDBC terminar
        return repo.queryHeavy();
    }
}

O que acontece: sob carga o número de threads do contêiner limita as requisições processadas. A solução não é aumentar indefinidamente o pool; é tirar trabalho bloqueante do thread do contêiner.

Jeito certo: offload com virtual threads e async servlet

Solução: usar o Servlet 3 async API ou DeferredResult/Callable do Spring MVC e um Executor baseado em virtual threads. Assim o thread do contêiner é liberado imediatamente e o trabalho de JDBC roda em uma virtual thread. Exemplo abaixo com DeferredResult.

package com.exemplo.good;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@RestController
public class GoodController {

    private final GoodRepository repo;
    private final ExecutorService vExecutor = Executors.newVirtualThreadPerTaskExecutor();

    public GoodController(GoodRepository repo) { this.repo = repo; }

    @GetMapping("/async")
    public DeferredResult<String> getAsync() {
        DeferredResult<String> dr = new DeferredResult<>();
        vExecutor.submit(() -> {
            try {
                String r = repo.queryHeavy();
                dr.setResult(r);
            } catch (Exception e) {
                dr.setErrorResult(e);
            }
        });
        return dr; // libera o thread do contêiner rapidamente
    }
}

O que acontece: o servlet thread retorna ao pool assim que a DeferredResult é registrada. A consulta JDBC roda em uma virtual thread, que quando bloqueada fica suspensa sem ocupar um OS thread ativo.

Configuração do Hikari: o ajuste crítico

Contexto: o pool de conexões deve refletir a capacidade do banco, não a contagem de virtual threads. A configuração recomendada é manter maximumPoolSize baixo e controlado, utilizar timeouts curtos e leak detection para identificar conexões não fechadas.

package com.exemplo.config;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DataSourceConfig {

    @Bean
    public HikariDataSource dataSource() {
        HikariConfig cfg = new HikariConfig();
        cfg.setJdbcUrl("jdbc:postgresql://db:5432/app");
        cfg.setUsername("app");
        cfg.setPassword("secret");
        cfg.setMaximumPoolSize(20); // limite prático ao banco
        cfg.setConnectionTimeout(10_000); // fail fast
        cfg.setMaxLifetime(30 * 60_000); // 30 minutos
        cfg.setLeakDetectionThreshold(10_000); // detectar leaks
        return new HikariDataSource(cfg);
    }
}

O que acontece: com virtual threads o número de tasks concorrentes pode ser muito maior que 20, mas apenas 20 conexões serão ativas no banco ao mesmo tempo. Requisições extras aguardam conexão com timeout configurado e podem acionar fallback/circuit-breaker.

Patterns e boas praticas

Bulkhead via pool de conexões e semáforos

Use o pool de conexões como bulkhead primário. Além disso, é sensato aplicar um semáforo lógico ao nível da aplicação para limitar a taxa de submissões de queries pesadas. Bibliotecas como resilience4j facilitam implementar bulkhead/circuit-breaker com políticas claras.

Exemplo de pattern: para endpoints que disparam queries complexas, envolva a chamada em um Bulkhead e defina fallback rápido. Isso evita que filas de espera criem latências enormes ou OOM por acúmulo de tarefas.

Transações e propagação em virtual threads

Transactions em Spring (PlatformTransactionManager) normalmente usam ThreadLocal para guardar contexto. Virtual threads suportam ThreadLocal, mas tenha cuidado com código que usa InheritableThreadLocal ou executores que reemplacem threads: o contexto pode não propagar como esperado entre virtual threads e platform threads.

Boas práticas:

  • Evite manipular diretamente ThreadLocal fora do framework de transação.
  • Prefira usar declarative @Transactional na camada de serviço (que será executada na virtual thread se offloaded corretamente).
  • Se usar @Async, garanta que o executor propague contexto de transação quando necessário (Spring proporciona TransactionAwareExecutor).

Armadilhas comuns e como evitar

Armadilha 1: aumentar pool de conexões como remédio

Critério errado: "temos 1000 threads, então precisamos de 1000 conexões". Aumentar pool sem considerar limites do banco causa degradação do próprio banco, longas latências e timeouts. O dimensionamento correto considera:

  • Quantidade de CPU e IOPS do banco
  • Tipos de queries (curtas vs longas)
  • Padrões de pico e tempo de resposta aceitável

Solução: medir, simular carga e aplicar limites conservadores no Hikari. Use índices, otimização de queries, caches e fallback em vez de aumentar indiscriminadamente o pool.

Armadilha 2: esquecer de fechar conexões

Mesmo com virtual threads, conexões não fechadas saturam o pool. Sempre usar try-with-resources ou JdbcTemplate que fecha recursos automaticamente. Leak detection no Hikari ajuda a encontrar problemas em produção.

dica: ative leakDetectionThreshold e monitore logs para SQLException indicando conexão ocupada por muito tempo.

Armadilha 3: confiar em drivers antigos

Drivers JDBC que usam chamadas nativas bloqueantes podem não ser completamente cooperativos com o scheduler de virtual threads. Teste com a versão mais recente do driver e confira issues conhecidos para seu driver particular. Em alguns casos, upgrades do driver ou do próprio JDK são necessários.

Checklist prático antes de adotar virtual threads em produção

Teste e métricas

Não adote por fé. Execute testes de carga com o cenário real: use dados representativos, workloads de leitura/escrita e monitore:

  • tempo de aquisição de conexão (Hikari)
  • fila de requisições no app
  • latência do banco
  • consumo de CPU e memory footprint

Garanta que o comportamento degrade graciosamente: timeouts curtos, circuit-breakers e limites em fila (bulkheads).

Gestão de executor lifecycle

Executors baseados em virtual threads (Executors.newVirtualThreadPerTaskExecutor()) devem ser fechados no shutdown. No Spring, declare como @Bean e implemente DisposableBean ou use ConfigurableBeanFactory para garantir shutdown.

Conclusao

Virtual threads são uma ferramenta potente para reduzir o custo de I/O bloqueante sem reescrever a aplicação para um modelo reativo. Mas não são uma bala de prata: o ponto de contenção real permanece o banco de dados. A estratégia correta combina:

  1. Offload do trabalho bloqueante para virtual threads usando DeferredResult/Callable ou executors adequados;
  2. Tuning do Hikari para refletir a capacidade do banco (limite do pool, timeouts, leak detection);
  3. Patterns de resiliência (bulkhead, circuit-breaker, fallback);
  4. Medição e testes de carga antes de colocar em produção.

Se você revisar um PR hoje e encontrar controllers que executam JDBC direto no thread do contêiner, comente para extrair o trabalho e aplicar um virtual-thread executor ou migrar o endpoint para um fluxo assíncrono. Verifique também a configuração do Hikari: é o controlador do tráfego para o seu banco, trate-o como tal.

Resumo final: virtual threads permitem alta concorrência sem multiplicar OS threads — mas requerem disciplina no gerenciamento de conexões, atenção aos drivers JDBC e adoção de padrões de resiliência. Faça code review com foco nesses pontos e você terá microservices mais escaláveis sem reescrever a lógica de negócio.

Referências práticas:

  • HikariCP docs para tuning
  • Spring docs sobre DeferredResult e Callable
  • Project Loom/JEPs e configuração do JDK (Java 21+ recomendado)

Assuntos: Java, spring-boot, virtual-threads, jdbc, hikari, microservices

Leia também