Spring Boot虚拟线程压测翻车?瓶颈竟在数据库连接池
2026/9/10 6:23:55 网站建设 项目流程

先交代一下背景。上周我把一个内部系统的 Spring Boot 服务从 JDK 17 升级到了 JDK 21,顺手把虚拟线程打开了,配置就一行spring.threads.virtual.enabled=true。当时想得很简单:既然虚拟线程号称能创建几十万个线程,那这个服务以后扛并发还不是小意思?结果压测一跑就翻车了——10 并发稳如老狗,50 并发开始出现偶发超时,到 100 并发,服务直接瘫痪,请求全卡住,日志里除了超时就是连接异常。这篇文章就把这次踩坑的排查过程、根源分析和最终方案完整记录下来,给同样在 Spring 里启用虚拟线程的朋友一个参考。尤其是那种“配置很简单、一压测就完蛋”的情况,大概率不是虚拟线程本身的问题,而是虚拟线程把原本隐藏的瓶颈全部暴露出来了。

1. 现象复现:100 并发一上来,服务直接瘫痪

1.1 踩坑环境与压测条件

先说环境和压测条件,方便你对照自己的场景。服务本身是 Spring Boot 3.3.x,JDK 21.0.2,内嵌 Tomcat,用的是 JPA + PostgreSQL,连接池是 Spring Boot 默认的 HikariCP。业务逻辑不复杂,主要就是几个标准的前后端交互接口,里面有数据库查询,也有少量调用内部 HTTP 服务的逻辑。压测工具用的 JMeter,线程组从 10 并发开始,每次增加 20,每档持续压 30 秒,观察响应时间、错误率和系统负载。

整个排查过程并没有引入什么新框架,纯粹是在既有工程上做配置调整和代码改造。这里先把结论放在前面:这个问题的直接原因是虚拟线程把 Web 层的并发接收能力放大了,但下游依赖——尤其是数据库连接池——完全没有跟上,最终所有请求都堵在“获取数据库连接”这一步上,表现就是服务卡死。

1.2 “卡死”的具体表现

用“卡死”这个词其实不太准确,因为进程并没有死,CPU 和内存看起来也正常,但请求就是拿不到响应。我当时的观测结果是这样的:

  • 10 并发:平均响应时间在 50ms 左右,错误率为 0,一切正常。
  • 50 并发:平均响应时间升到 400ms 左右,开始有极少数的 1 秒超时。
  • 100 并发:平均响应时间飙到 10 秒以上,大量请求直接报错,部分请求连 Tomcat 的响应头都没收到,客户端一直挂着等。

更诡异的是,用top看服务器资源,CPU 只用了不到 30%,内存也没到瓶颈。你可能会想,虚拟线程不是应该能轻松处理这种量级吗?问题恰恰出在这里:虚拟线程只负责“接收请求”和“执行任务”,它本身不提供数据库连接、HTTP 连接池或其他任何下游资源。当所有请求都去抢同一批有限的资源时,虚拟线程再多也救不了你。

注意:如果你启用虚拟线程后,压测一上量就出现“CPU 不高但请求全部超时”的现象,第一步别去调虚拟线程参数,先看线程栈和连接池状态。

2. 排查路径:线程转储和监控面板给出的线索

2.1 第一步永远先抓线程转储

卡死问题的第一现场就是线程转储(thread dump)。JDK 21 下,强烈建议用jcmd而不是老的jstack,因为jcmd能识别虚拟线程,输出的信息更完整:

# 先拿到 Java 进程 ID jps -l # 抓取完整线程转储,包含虚拟线程 jcmd <pid> Thread.dump_to_file -format=json /tmp/thread_dump.json # 如果习惯看文本格式 jstack <pid>

把线程转储拿下来之后,我第一件事是统计线程状态分布。正常情况,一个健康服务里大多数线程应该是WAITINGTIMED_WAITING,等待任务或等待 IO,这很合理。但当时我发现有大量虚拟线程集中卡在同一个栈上,反复出现HikariPool.getConnectionConnectionProxy相关的方法。这个信号非常明确:几乎所有请求都在等数据库连接。

我当时还在线程转储里看到了这样一段典型栈:

"VirtualThread[#123]" prio=5 cpu=12.34ms java.lang.VirtualThread.parkOnCarrierThread java.lang.LockSupport.parkNanos com.zaxxer.hikari.pool.HikariPool.getConnection com.zaxxer.hikari.pool.HikariPool.getConnection ...

看到HikariPool.getConnection连续出现,基本可以锁定:请求卡在连接池获取连接的阶段,而不是业务逻辑本身。

2.2 虚拟线程的堆栈信息怎么看

虚拟线程的线程转储和普通平台线程有个明显区别:它会额外显示虚拟线程和载体线程(Carrier Thread)的绑定关系。这里有个小技巧,你用jcmd Thread.dump_to_file -format=json导出的 JSON 文件里,每个虚拟线程会有carrierThread字段,方便你判断当前虚拟线程是否被钉扎在某个平台线程上。

我当时特意数了一下,卡在HikariPool.getConnection上的虚拟线程有 80 多个,而 HikariPool 的状态是:

HikariPool-1 - Pool stats (total=10, active=10, idle=0, waiting=80)

看到这行日志,问题已经非常清楚了:连接池总共就 10 个连接,全部被占用,还有 80 个请求在排队等待。HikariCP 默认的connectionTimeout是 30 秒,所以在 30 秒内这些请求看起来就是“卡死”,超过 30 秒就直接报SQLTransientConnectionException

2.3 顺带查一下 Tomcat 的接受队列

除了连接池,我还顺手查了 Tomcat 的接受队列状态。启用虚拟线程后,很多人的直觉是“Tomcat 线程数上限不再有意义”,这句话对了一半。Tomcat 在接受 TCP 连接时仍然有accept-countmax-connections的限制,只是不再用传统的max-threads控制请求处理并发数。如果连接池已经打满,Tomcat 的请求处理线程也会被占满,后续进来的连接会堆积在 Tomcat 的 Accept 队列里,表现就是客户端“连上了但没响应”。

这块可以从 Tomcat 的访问日志和server.tomcat.accept-count参数判断。我当时把accept-count调大到 500,效果有,但治标不治本,因为真正的瓶颈在数据库连接池。所以我的经验是:排查时先分三层看——连接池、Tomcat 队列、业务线程池,逐层排除,谁打满谁就是瓶颈。

3. 根源剖析:为什么虚拟线程也会“不够用”

3.1 数据库连接池——虚拟线程时代被忽略的头号瓶颈

虚拟线程的设计目标是把“每个请求一个线程”的成本降到极低,让你可以创建大量轻量级线程来承载高并发请求。但数据库连接是重量级资源,一个连接就是一条 TCP 连接加数据库端的一个会话,不可能无限创建。Spring Boot 默认的 HikariCP 连接池大小是 10,这个数值在传统线程模型下其实是合理的,因为传统模型下并发线程数本来就不高,Tomcat 默认max-threads是 200,但受限于每个线程 1MB 左右的内存开销,实际能跑多高并发是有限的。

但虚拟线程一开,情况完全变了。Tomcat 可以瞬间创建几千个虚拟线程来处理请求,每个虚拟线程都会去拿数据库连接。连接池总共才 10 条,结果就是 990 个请求排队,等 10 个连接释放。连接释放还要看单个请求占用连接的时间,如果某个接口的数据库查询需要 200ms,那这 10 个连接一秒钟最多处理 50 个请求,100 并发的场景下自然全堵住了。

这里分享一个粗略的估算方法。假设你预期的并发量是 N,单请求平均占用数据库连接的时间是 T(秒),你希望请求的排队时间不超过单个请求处理时间的 20%,那么理论上需要的连接数大约是:

连接数 ≈ N × T / (T × 1.2)

简化一下:如果你有 100 并发,单请求查库耗时 100ms,目标响应时间在 120ms 以内,那连接池至少要 100 × 100 / 120 ≈ 84 个连接。当然实际情况下不会每个请求都同时占用连接,业务上有快有慢,但至少说明默认的 10 个连接在高并发虚拟线程场景下是远远不够的。

3.2 synchronized 与 ThreadLocal 导致的钉扎现象

连接池是这次卡死的主因,但我在排查过程中也顺手解决了一个潜在隐患——虚拟线程的钉扎(Pinning)问题。所谓钉扎,就是虚拟线程在执行某些操作时会被强制绑定到当前的载体线程上,一旦绑定,这个载体线程就被占住了,无法再调度其他虚拟线程。最常见的触发场景就是synchronized同步块和ThreadLocal的某些用法。

你可能要问:钉扎会怎样?简单说,本来虚拟线程的优势是“线程数很多,随便阻塞”,但如果代码里用了大段的synchronized块,虚拟线程在进入临界区后如果发生了阻塞,它会一直占着载体线程不释放。载体线程数量是有限的,默认跟 CPU 核心数有关,假设机器是 4 核,那载体线程可能就只有 8 个。一旦多个虚拟线程同时钉扎在这 8 个载体线程上,后续虚拟线程就没有载体线程可用了,整体并发能力反而比之前的平台线程模型更差。

当时我在项目里发现一个老接口用synchronized做简单的并发去重控制,压测时这部分的响应时间非常不稳定。后来改成ReentrantLock并控制了临界区范围,这个问题才消除。这里补充一个关键区别:ReentrantLock在虚拟线程里阻塞时,会让出载体线程,不会造成钉扎;而synchronized是会钉扎载体线程的。所以虚拟线程环境下,synchronized换成ReentrantLockStampedLock是更稳妥的选择。

3.3 业务线程池仍然按老逻辑排队

还有一个很容易被忽视的地方:虚拟线程默认只作用于 Tomcat 等 Web 容器收到的请求处理过程。如果你在业务代码里用了自定义线程池,比如ExecutorService或者CompletableFuture.supplyAsync(),这些线程池默认用的还是普通平台线程。这种情况下,请求虽然由虚拟线程接住了,但后续的业务处理又塞回了固定大小的线程池,瓶颈依然在那里。

我当时查到一个定时任务用了@Async,底层线程池大小默认是 8。并发一高,这部分任务也全堵住了。Spring Boot 里@Async@Scheduled对虚拟线程的支持需要额外配置,不是说你开了spring.threads.virtual.enabled=true,所有线程池就都自动虚拟线程化了。这一点在官方文档里有说明,但很多人不会细看。

4. 解决方案:连接池、锁与线程池的三重治理

4.1 第一步:调大并测算 HikariCP 连接池

首先解决最核心的数据库连接池问题。我的建议不是盲目把连接池调到 500,而是根据数据库的承载能力和业务场景逐步调整。当时的服务数据库是 PostgreSQL,数据库最大连接数限制在 200,所以我给连接池设定了一个比较保守的上限 50。调整后的 HikariCP 配置如下:

spring: datasource: hikari: pool-name: MyAppHikariPool minimum-idle: 10 maximum-pool-size: 50 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000

这里说下为什么maximum-pool-size取 50 而不是 100:连接池不是越大越好,因为数据库服务端每个连接都要占用内存和进程资源,连接过多反而会把数据库拖垮。我当时考虑了三个因素:

  1. 预期业务并发量是 100 到 200,不是所有请求都要同时访问数据库。
  2. 单请求数据库资源占用时间平均在 50ms 到 100ms 之间。
  3. 数据库自身限制是 200 连接,连接池最多只能占 1/4 到 1/2。

调整之后,100 并发压测的响应时间从“超时”降到了平均 300ms 左右,错误率归零。这个结果说明连接池确实是当时的头号瓶颈。

4.2 第二步:替换同步代码,消除钉扎

连接池问题解决后,我继续对代码里的synchronized块做了一次全面排查。这一步不是必须的,如果你的服务里synchronized块很小且执行很快,钉扎造成的影响可以忽略。但我们在前面提到的那段并发去重代码,临界区内部有数据库操作,执行时间较长,属于典型的“高风险钉扎区”。

改造方式很简单,把synchronized换成ReentrantLock

改造前:

synchronized (lock) { // 查数据库 // 处理业务 // 更新数据库 }

改造后:

private final ReentrantLock lock = new ReentrantLock(); public void handleRequest() { lock.lock(); try { // 查数据库 // 处理业务 // 更新数据库 } finally { lock.unlock(); } }

注意,ReentrantLock的使用一定要在finally里释放锁,否则异常会导致锁永远不释放,这个错误比synchronized时代严重得多。改完之后,我用压测确认了这一处的响应时间不再出现明显毛刺。

4.3 第三步:对外部调用与异步任务的虚拟线程化

数据库之外,我还梳理了服务里的两类线程使用场景。第一类是调用内部 HTTP 服务的接口,这部分用的是 RestTemplate 加连接池,连接池大小同样有上限。第二类是@Async的异步任务和定时任务。

对于@Async,网上很多建议是直接用虚拟线程做执行器。实际操作时,我配置了一个简单的虚拟线程执行器:

@Bean(name = "virtualThreadExecutor") public Executor virtualThreadExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); }

然后在异步方法上指定执行器:

@Async("virtualThreadExecutor") public void sendNotification(String userId) { // 异步发送通知 }

这样做的意义是让异步任务也能享受虚拟线程的低成本优势,但需要注意的是,异步任务里如果也要访问数据库或者其他外部资源,连接池的容量依然决定最终并发上限。也就是说,虚拟线程化异步任务,并不会绕过数据库连接池的限制。

4.4 参数计算与压测结果

整个调整结束后的配置状态如下:

  • Spring Boot 3.3.x + JDK 21
  • 开启了spring.threads.virtual.enabled=true
  • HikariCP 最大连接数 50
  • Tomcat 的accept-count调整为 500
  • 消除了业务代码中的长synchronized临界区
  • 异步任务改为虚拟线程执行器

压测结果:100 并发持续 10 分钟,平均响应时间 280ms,P95 在 500ms 以内,错误率 0.01%,服务 CPU 占用 45% 左右,数据库连接池稳定在 20 到 30 个活跃连接。整体来说,问题解决。

5. 值得注意的隐藏坑:虚拟线程不是万能药

5.1 小心 ThreadLocal 的内存膨胀

虚拟线程的数量可以很大,如果你在代码里使用ThreadLocal存储数据,比如用户信息、请求 ID、上下文对象,那么每个虚拟线程都会持有自己的ThreadLocal副本。虚拟线程任务一旦执行完,虚拟线程对象本身会被回收,但如果线程池复用了虚拟线程,或者异步任务没有清理ThreadLocal,就可能造成内存泄漏或者上下文串号。

这个问题在平台线程时代不明显,因为平台线程数量有限,可虚拟线程可能成千上万,ThreadLocal 数量和虚拟线程数量成正比,内存压力会成倍增加。如果是 JDK 21 环境,可以考虑用ScopedValue替代ThreadLocal来传递不可变上下文;如果暂时不想动代码,至少记得在 finally 块里调用ThreadLocal.remove()

5.2 Spring Security、拦截器与虚拟线程的相容性

Spring Security 的SecurityContextHolder默认使用ThreadLocal保存安全上下文。在虚拟线程场景下,每个请求都由不同的虚拟线程处理,安全性上下文默认是可以正常获取的,但只要出现异步调用、跨线程传递,就很容易丢上下文。比较典型的场景是服务里用CompletableFuture并行调用多个接口,子线程里拿不到登录用户信息。

我记得 Spring Security 6 里提供了DelegatingSecurityContextExecutor这样的工具,可以包装线程池,把安全上下文从主线程传递到子线程。如果你在项目里碰到“虚拟线程模式下登录用户信息偶尔丢失”的问题,先检查是不是线程池没有做安全上下文的传递。

5.3 JDK 版本和 Spring Boot 版本匹配

虚拟线程是 Java 21 正式引入的功能,但 Spring Boot 的完整支持是从 3.2 版本开始。如果你用的 Spring Boot 还是 3.1 或者更早,启用虚拟线程的配置项可能不生效,或者需要额外的配置类。我在排查过程中一度看到网上的配置写法五花八门,有直接改 Tomcat Connector 的,也有写配置类注册TomcatProtocolHandlerCustomizer的,其实都不用,Spring Boot 3.2 之后一行配置就够。

另外,JDK 建议直接用最新稳定版,比如 21.0.2 之后的版本。早期版本在某些平台上存在虚拟线程调度器问题,可能导致部分 IO 操作表现不稳定。这些细节平时注意不到,但在高并发压测下会被放大。

6. 常见问题与排查速查表

6.1 高频报错与排查方向

现象可能原因排查方法解决方案
启用虚拟线程后 100 并发卡死数据库连接池被打满看 HikariPool 的 pool stats,确认 total=active调大maximum-pool-size,优化 SQL 减少连接占用时间
请求超时但 CPU 很低等待下游资源,如 HTTP 连接池、Redis 连接池抓线程转储,看线程卡在哪个栈调整下游连接池大小,或减少对下游的同步调用
线程转储中大量 BLOCKED 状态synchronized导致虚拟线程钉扎看虚拟线程栈里是否有 synchronized 关键字改用 ReentrantLock,缩小临界区范围
异步任务仍然卡顿@Async默认还是平台线程池查看异步执行器线程数使用Executors.newVirtualThreadPerTaskExecutor()创建执行器
ThreadLocal 数据丢失跨线程传递未处理在子线程打印上下文信息使用ScopedValue或者手动传递上下文
数据库连接耗尽报 SQLTransientConnectionException连接池配置过小,或连接未释放看连接池活跃数和等待数检查代码中的连接获取是否在 finally 中关闭,调大连接池

6.2 一份可直接参考的最小改造清单

如果你也想在 Spring Boot 里启用虚拟线程,又不想踩一遍我踩过的坑,这里列一个最小改造清单:

  1. 确保版本:Spring Boot 3.2+,JDK 21+。
  2. 开启虚拟线程
    spring: threads: virtual: enabled: true
  3. 评估数据库连接池:把maximum-pool-size从默认的 10 调大,具体数值根据数据库规格和业务并发算,至少先提到 50 观察一下。
  4. 扫描同步块:重点找临界区内有 IO 操作的synchronized,替换为ReentrantLock
  5. 梳理线程池:业务代码里的ExecutorService@AsyncCompletableFuture执行器,根据实际需要改为虚拟线程执行器。
  6. 关注外部依赖:HTTP 连接池、Redis 连接池、消息队列连接数,都需要同步评估,不能只盯着 Tomcat。
  7. 压测验证:压测时不要只看平均响应时间,重点看 P95 和 P99,以及线程转储里是否存在集中等待。

最后再分享一个实际体会:虚拟线程在 Spring 里的价值是真实存在的,但它解决的是“请求处理线程的成本”问题,而不是“下游资源容量”问题。连接池、数据库、外部接口这些硬资源,该评估还是要评估。不要把虚拟线程当成一个万能开关,开了就以为并发无忧。我这次踩坑最深的教训就是,升级之前觉得“技术上应该没问题”,结果压测数据无情地告诉我:瓶颈从来不会因为换一种线程模型就消失,它只是换了一个位置等你发现。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询