WEBKT

Spring Boot 3 开启虚拟线程后 HikariCP 瞬间被挤爆?聊聊优雅调优的几个关键姿势

2 0 0 0

在 Spring Boot 3.2+ 中,引入了一个令人兴奋的特性:一键开启 JDK 21 的虚拟线程(Virtual Threads)

只需要在 application.yml 中简单地配置一行:

spring:
  threads:
    virtual:
      enabled: true

原本受限于 Tomcat 默认 200 个平台线程(Platform Threads)的并发瓶颈瞬间被打破。虚拟线程极其轻量,你可以轻而易举地同时运行成千上万个并发任务。

然而,许多开发者在狂喜之后,立刻在压测甚至生产环境中遭遇了当头一棒。控制台开始疯狂抛出类似以下的异常:

java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.

为什么并发能力提升了,数据库连接池反而崩溃了?在虚拟线程时代,我们该如何优雅地对 HikariCP 进行调优?本文将从底层原理出发,聊聊切实可行的调优方案。


1. 幻觉破灭:虚拟线程不等于数据库连接无限

在传统的多线程模型中,Servlet 容器的线程数数据库连接池的大小之间存在一种天然的“物理隔离保护”。

默认情况下,Tomcat 最多只有 200 个工作线程。这意味着,即便数据库连接池(HikariCP)的最大连接数(maximum-pool-size)只设为 10,最极端的情况下也只有 200 个线程在争抢这 10 个连接。排队是可控的。

但开启虚拟线程后,这个“保护罩”消失了。

虚拟线程的创建成本极低。如果突然涌入 10000 个并发请求,Spring Boot 会瞬间创建 10000 个虚拟线程。如果这些请求都需要访问数据库,它们会同时向 HikariCP 申请连接。

HikariCP 本质上是一个物理连接的缓存池,它无法像虚拟线程那样被无限凭空创造。 数据库服务(如 MySQL、PostgreSQL)能承载的物理连接数是极其有限的(通常在几百到几千之间,取决于内存和 CPU)。

如果盲目把 HikariCP 的 maximum-pool-size 设为几千,不仅无法提升吞吐量,反而会因为数据库端频繁的上下文切换、锁争抢以及内存暴涨,导致整个数据库实例直接挂掉。


2. 警惕“线程钉死”(Thread Pinning)

在虚拟线程与数据库交互的场景中,有一个致命的性能杀手——Thread Pinning

虚拟线程是运行在载体线程(Carrier Thread,即底层的 ForkJoinPool 平台线程)之上的。当虚拟线程遇到阻塞操作(如网络 I/O)时,它会主动让出(yield)载体线程。

但是,如果虚拟线程在执行同步块(synchronized同步方法时遇到了阻塞操作,它就无法让出载体线程。这种状态被称为“线程钉死(Pinning)”。

不幸的是,许多老旧的 JDBC 驱动(包括旧版本的 MySQL Connector/J)内部大量使用了 synchronized 关键字。当 1000 个虚拟线程因为获取不到 HikariCP 连接而进入阻塞,且这个阻塞发生在 synchronized 内部时,底层的载体线程会被迅速耗尽(默认载体线程数等于 CPU 核心数)。

如何排查与解决?

  1. 开启 JVM 参数检测:
    在启动参数中加入以下配置,当发生线程钉死时,控制台会打印出详细的堆栈信息:
    -Djdk.tracePinnedThreads=full
    
  2. 升级驱动程序:
    务必将数据库驱动升级到最新版本。例如,MySQL Connector/J 8.0.33+ 已经重构了大量代码,将很多 synchronized 替换为了 ReentrantLock,从而对虚拟线程更加友好。

3. HikariCP 优雅调优的三大实战策略

面对虚拟线程带来的高并发冲击,我们需要从“控制争抢”和“优化配置”两个维度入手。

策略一:使用信号量(Semaphore)在应用层做流量拦截

既然我们不能无限扩大物理数据库连接池,那就必须在虚拟线程进入 HikariCP 之前,进行一层“软限流”。

不要让成千上万个虚拟线程直接去冲撞 HikariCP。我们可以使用 java.util.concurrent.Semaphore 或 Spring Boot 自带的限流机制,将并发访问数据库的虚拟线程数量限制在合理范围内。

@Service
public class UserService {

    private final UserRepository userRepository;
    // 限制同时访问数据库的虚拟线程数为 50
    private final Semaphore dbSemaphore = new Semaphore(50);

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User findUserById(Long id) {
        try {
            // 在这里排队,而不是在 HikariCP 内部硬排队
            dbSemaphore.acquire();
            return userRepository.findById(id).orElse(null);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Query interrupted", e);
        } finally {
            dbSemaphore.release();
        }
    }
}

为什么这样做更好?
在 JVM 层面通过 Semaphore 挂起虚拟线程的开销,远远小于在 HikariCP 内部因为连接获取超时而不断抛出异常、重试的开销。这保证了系统的优雅降级和弹性。

策略二:科学调整 HikariCP 参数

在虚拟线程环境下,传统的 HikariCP 配置需要做针对性微调:

spring:
  datasource:
    hikari:
      # 1. 适当调大最大连接数,但绝不能过大。通常设为 (CPU 核心数 * 2) + 磁盘 spindle 数 的 2-3 倍即可
      # 例如,4核服务器,可以设在 30 ~ 50 左右,具体需根据压测结果微调
      maximum-pool-size: 50
      
      # 2. 将最小空闲连接数设为与最大连接数相同(Fixed Size Pool)
      # 避免在虚拟线程洪峰到来时,因为动态创建物理连接而产生额外的延迟
      minimum-idle: 50
      
      # 3. 缩短连接超时时间
      # 在虚拟线程时代,如果 5 秒内拿不到连接,大概率后续也拿不到,不如快速失败(Fail-Fast),避免虚拟线程积压
      connection-timeout: 5000
      
      # 4. 保持连接活性检测,防止获取到失效连接
      keepalive-time: 30000
      max-lifetime: 1800000

策略三:异步化与读写分离

如果你的业务场景中,虚拟线程带来了极高频的只读查询,单靠优化连接池已经无法根本解决问题。此时应考虑:

  1. 引入多级缓存: 虚拟线程非常适合高并发的 Redis 读取。将热点数据移至 Redis,减少对 DB 的直接请求。
  2. 读写分离: 将读请求分流到只读从库,主库只保留写连接池,成倍提升系统的支撑能力。

总结

虚拟线程的引入,彻底解放了 Java 应用的 CPU 吞吐能力。但它同时也像一把双刃剑,将压力直接转移到了下游的物理资源(如数据库、网络带宽)。

在 Spring Boot 3.x 项目中开启虚拟线程后:

  • 不要指望通过无限放大 maximum-pool-size 来解决瓶颈。
  • 务必升级驱动,通过 -Djdk.tracePinnedThreads 监控并消除 Thread Pinning。
  • 在应用层引入 Semaphore 等限流器,对数据库访问进行合理的“削峰填谷”。

通过这些手段,我们才能真正享受到 Project Loom 带来的高并发红利,同时确保底层数据库的稳健与安全。

码农老张 虚拟线程HikariCP

评论点评