虚拟线程压测事故全复盘:Spring Boot 3.2连接池与synchronized陷阱
上周我刚从一个线上事故里爬出来准确说是一次压测事故JDK 21 Spring Boot 3.2随手打开虚拟线程JMeter 跑 100 并发结果接口直接挂掉日志里全是连接等待超时应用像是睡着了。当时第一反应是“这不科学”虚拟线程不是号称能吃下几十万并发吗怎么 100 个请求就卡死了。排查两天最后发现根子不在虚拟线程本身而在我们这些靠着虚拟线程“起飞”的人根本没搞懂它背后的资源边界。这篇文章就把整个过程复盘一遍现象、根因、Thread Dump 怎么定位、配置怎么改、代码怎么修全给你讲透。适合正在用或者准备用 Spring Boot 3.2 JDK 21 的朋友尤其是做 Web 服务、对并发有要求的团队看完应该能少走不少弯路。1. 事故现场100 并发就把虚拟线程“打瘫”了1.1 复现路径从启动参数到 JMeter 压测先交代一下技术栈。服务是基于 JDK 21 的 Spring Boot 3.2.5Web 容器用的内置 Tomcat数据库是 MySQL连接池用的 HikariCP。启用虚拟线程的方式非常简单就是在配置文件里加一行spring.threads.virtual.enabledtrue这个配置是 Spring Boot 3.2 开始提供的打开之后Tomcat 接收请求的工作线程就会切换成虚拟线程。注意这里切换的是 Web 请求处理线程不涉及你代码里自己 new 的线程池。压测我用的是 JMeter线程组设置为 100 个线程Ramp-Up 时间 5 秒循环次数 50。接口是一个很普通的订单列表查询SQL 逻辑不复杂单次查询大概在 30 到 80 毫秒。这个量级平时用平台线程跑 100 并发也毫无压力所以我一开始压根没想过会出问题。结果跑起来就魔幻了。前 10 秒左右请求还能陆续返回之后响应时间开始直线上升从几百毫秒涨到 20 秒、30 秒最后 JMeter 里直接飘红超时。应用日志里疯狂刷异常典型的报错是这样HikariPool-1 - Connection is not available, request timed out after 30000ms.最诡异的是系统 CPU 占用率只有 20% 左右内存也正常没有频繁 Full GC进程也活着但接口就是堵死。这种“活死人”状态是最难排查的因为它不是崩溃而是所有线程都堵在某处。1.2 卡死前的三个典型征兆复盘时我发现卡死并不是一瞬间发生的中间有几个非常典型的征兆如果早一点留意能少走很多弯路。第一个征兆是请求明明进入了 Controller但后续却没有任何日志输出。我们每个接口在入口和出口都会打日志当时入口日志一直在刷出口日志却迟迟不来。这说明请求没有被拒绝而是卡在了处理链路中间某个环节。第二个征兆是数据库连接池相关报错开始密集出现。Hikari 默认的 connectionTimeout 是 30 秒所以大量请求表现成“等待 30 秒后报错”而不是立即失败。第三个征兆是应用占用的线程数出现了“假性暴涨”。用 jstack 看线程时能看到成百上千的虚拟线程被阻塞在获取连接的代码上因为虚拟线程本身很轻量JVM 不会拒绝创建它们所以看起来像线程很多、很活跃实际上全是排队等资源的。这三个征兆组合在一起基本可以确定问题不在 Web 容器层而在更底层的资源竞争上。2. 为什么会卡死虚拟线程的底层逻辑与资源边界2.1 虚拟线程不是“更多线程”要理解卡死的原因得先搞清楚一个容易被误解的点虚拟线程并不是让你的 CPU 能同时干更多活儿它只是把“线程”这个单位变得更廉价了。平台线程是操作系统级别的线程每个线程都对应一个内核线程创建成本、上下文切换成本都很高。而虚拟线程是 JVM 自己管理的轻量级线程底层复用一组平台线程来执行任务。当一个虚拟线程遇到 IO 阻塞时JVM 会把它从平台线程上卸下来让那个平台线程去跑其他虚拟线程。我用一个比较形象的方式解释平台线程就像餐厅里的工位虚拟线程就像排队的食客。食客在等菜IO的时候不需要一直占着工位可以先去旁边等工位腾出来给下一个食客。这个机制让系统能够同时接待海量“等待中”的请求。但这带来一个新的问题既然虚拟线程这么轻量为什么还会卡死答案在于有些资源是虚拟线程共享的比如数据库连接、外部接口连接池、锁。虚拟线程解决的是“线程数量”的瓶颈解决不了“共享资源数量”的瓶颈。2.2 真正的元凶之一固定Pinning问题这里要引入一个关键概念固定Pinning。当虚拟线程进入 synchronized 同步块时在 JDK 21 这个版本里它会被“钉”在底层平台线程上不能释放。简单说一旦虚拟线程在 synchronized 块里执行阻塞操作它就不会“让位”给其他虚拟线程了。还是拿餐厅打比方有些食客进了包厢synchronized点完菜之后就一直坐在包厢里等菜服务员没办法让其他人进包厢。外面排队的食客再多也只能干瞪眼。JDK 21 的虚拟线程实现中如果 synchronized 块内部是一个阻塞操作比如 JDBC 查询那么这个虚拟线程就会一直占用一个平台线程。平台线程是有限的通常取决于 CPU 核数。假设服务器 8 核 16 线程一旦大量虚拟线程同时进入 synchronized 块并执行数据库查询16 个平台线程全被占住后面的虚拟线程就彻底无法调度了。我们的代码里确实存在这种写法比如在查询商户信息的地方用了synchronized (cacheLock)包裹以防止缓存击穿。问题就出在这里同步块内部包含了 DB 查询操作在虚拟线程环境下这个锁会无限放大阻塞范围。2.3 被忽略的第二元凶数据库连接池如果说 synchronized 让平台线程被“钉死”那数据库连接池就是把钉死的结果变成灾难的第二根稻草。HikariCP 默认的 maximumPoolSize 是 10。也就是说无论你开启多少虚拟线程能同时执行的数据库查询最多只有 10 个。100 个并发请求进来假设每个请求都需要一个数据库连接那么最多只有 10 个请求能拿到连接剩下 90 个全部在连接池外面排队等待。虚拟线程的好处在于等待连接时它会释放平台线程理论上不应该卡死。但如果等待的线程同时又被 synchronized 钉住那就变成平台线程也被耗尽整个 Tomcat 处理线程全部堵塞。两个问题叠加100 并发直接击穿整个服务。这里我想强调一个很多教程不会说的点开启虚拟线程这件事只是解决了 Web 容器线程数量的问题但它把瓶颈转移到了下游依赖上。你是用同步 JDBC 还是异步 R2DBC、连接池够不够大、锁的粒度控制得好不好这些决定了下游瓶颈有多严重。2.4 其他几个隐藏坑除了上面两个主因还有几个常见问题会让虚拟线程下的故障被进一步放大。ThreadLocal 算一个。虚拟线程复用了平台线程来执行任务所以同一个平台线程可能被多个虚拟线程使用。如果你在代码里用 ThreadLocal 存储用户上下文、请求 ID这些数据可能在虚拟线程调度时被“继承”下来造成脏数据。JDK 21 中虚拟线程支持 ThreadLocal但建议谨慎使用尤其是线程池场景。Tomcat 的server.tomcat.threads.max配置也容易误导人。启用虚拟线程后这个配置不再表示真实线程数而是作为一个信号量控制同时处理请求的数量。默认值是 200意思是同时最多 200 个请求进入应用逻辑。如果你设置了 50那理论上 100 并发时会有 50 个请求直接被拒表现也是卡死或超时。还有并行流。IntStream.range(1, 100).parallel()使用的是公共 ForkJoinPool默认线程数等于 CPU 核数减 1。虚拟线程模式下并行流并不会自动使用虚拟线程如果里面有阻塞操作该堵还是堵。3. 排查实录从监控指标到 Thread Dump 定位3.1 用接口耗时数据快速区分瓶颈排查时不要一上来就抓 Thread Dump先用粗粒度数据缩小范围。我在出问题的接口里加了两段耗时统计一段记录 Service 层总耗时一段记录数据库查询耗时。把日志打出来之后对比非常明显正常情况总耗时 50ms查询耗时 40ms误差很小。故障情况总耗时 30000ms查询耗时 40ms剩下 29960ms 全部消耗在“获取连接”这一步。这意味着 SQL 本身不慢真正慢的是连接池拿不到连接。这个数据一出来排查方向就从“代码逻辑慢”变成了“资源竞争”问题基本锁定在连接池或者锁上。如果你想在代码里做类似埋点可以参考这个方式long start System.currentTimeMillis(); // 核心业务逻辑 long sqlStart System.currentTimeMillis(); ListOrder orders orderMapper.selectList(query); long sqlEnd System.currentTimeMillis(); // 结束 long end System.currentTimeMillis(); log.info(total{}ms, sql{}ms, wait{}ms, end - start, sqlEnd - sqlStart, (end - start) - (sqlEnd - sqlStart));如果 wait 部分远大于 sql 部分那么恭喜你问题八成不在数据库本身而是资源获取环节。3.2 Thread Dump 定位具体卡点在哪个锁上粗定位之后就需要精确到代码行。最有效的手段是抓 Thread Dump。JDK 21 提供了比较好用的命令jcmd pid Thread.dump_to_file -formatjson dump.json这个命令会把所有线程包括虚拟线程、平台线程的堆栈输出成 JSON 文件。打开后搜关键词VirtualThread或者parkOnCarrierThread能看到大量虚拟线程阻塞在什么位置。以我们的情况为例dump 文件里有大量线程栈停在这么一行java.lang.VirtualThread.parkOnCarrierThread再往下翻能看到 HikariCP 的连接获取代码com.zaxxer.hikari.pool.HikariPool.getConnection结合 synchronized 锁的栈帧就能定位到具体是哪个类、哪一行代码在锁里面拿了连接。这一步特别重要因为它把“系统卡死”这个抽象问题变成了“某一行代码导致”的具体问题。3.3 用连接池监控指标实锤如果你启用了 Micrometer可以直接看 Hikari 的监控指标。在配置文件里加上spring.datasource.hikari.metrics.enabledtrue然后调用 Actuator 的/actuator/metrics/hikaricp.connections.active和/actuator/metrics/hikaricp.connections.pending两个端点。故障期间active 指标稳定在 10连接池上限pending 指标飙到 80 以上。看到这个数据基本不用再怀疑其他环节了就是连接池被塞满。这个排查流程建议收藏一下先看接口耗时分布再看连接池指标最后抓 Thread Dump 定位锁三步下来基本能把虚拟线程相关的坑摸得明明白白。4. 解决方案从参数调整到架构避坑4.1 先治标把连接池参数调到一个合理水位当时为了让线上先恢复我做了两个紧急改动。第一是调大 HikariCP 连接池大小。这个值不能盲目调大数据库毕竟有上限通常经验值是核数乘 2 加磁盘数但也要看数据库压测结果。我们的 Toufic 服务在数据库压力测试下能稳定扛住 50 个并发连接所以我把配成了spring.datasource.hikari.maximum-pool-size50 spring.datasource.hikari.minimum-idle10 spring.datasource.hikari.connection-timeout5000把 connectionTimeout 从默认的 30 秒调低到 5 秒也是故意为止。与其让请求傻等 30 秒然后超时不如快速失败至少让用户感知到错误重试而不是一直转圈。但注意这只是一种取舍如果你的下游有重试机制这个策略是合理的如果没有重试机制调低连接超时会让用户直接看到报错。第二是调整 Tomcat 的信号量限制。前面说过server.tomcat.threads.max在虚拟线程模式下变成了信号量默认 200。我们的服务模块较多单机 QPS 不算高200 本来够用但为了保险还是改成server.tomcat.threads.max200 server.tomcat.threads.min-spare50其实默认就是 200我不改也没关系。但如果你的服务对并发要求高可以把这个值调大。它不消耗平台线程只是控制同时进入应用逻辑的请求数量可以理解为网关处的信号量。这两步改完100 并发压测立刻好了很多接口不再大面积超时但响应时间仍然不稳定偶尔会有 1 到 2 秒的毛刺。治标完成接下来必须处理根因。4.2 根因修复从 synchronized 走向 ReentrantLock前面提到 synchronized 在 JDK 21 虚拟线程下存在 pinning 问题所以根因修复必须从锁入手。JDK 24 已经通过 JEP 491 修复了 synchronized 固定虚拟线程的问题但如果你还在用 JDK 21 或 22这个坑必须自己绕开。绕开方式很简单不要用 synchronized改用 ReentrantLock。ReentrantLock 是基于 JUC 的锁虚拟线程阻塞在lock.lock()时不会被钉在平台线程上它会正确地让出底层线程等锁可用后再被调度回来。把代码里所有同步块替换成 ReentrantLock 并不复杂但要注意几个细节。如果锁是用来做单例初始化或者缓存更新的可以用ReentrantLock配合tryLock做限时等待private final ReentrantLock lock new ReentrantLock(); public Order queryOrder(String orderId) { if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // 查缓存查数据库 } finally { lock.unlock(); } } else { // 获取锁超时直接查数据库或返回默认策略 } }这里有一个容易踩的坑ReentrantLock 必须在finally中释放锁否则一旦业务代码抛异常锁会一直不释放造成死锁。synchronized 依靠 JVM 自动释放ReentrantLock 没有这个特性团队里管不住手的人很容易写漏。另外如果锁的保护范围特别小只是保护一个变量的读写可以考虑用AtomicReference替代整个锁。虚拟线程场景下锁的竞争成本被放大了能不用锁就不用锁能缩小范围就缩小范围。4.3 架构层面给下游资源加上限流改完锁之后服务已经恢复正常。但我在复盘时意识到即使没有 synchronized 的问题100 并发打 10 个数据库连接也只是时间问题不一定卡死但响应时间一定会恶化。虚拟线程数量可以无限大可数据库连接数量是固定的。这就好比餐厅可以有无限多把椅子虚拟线程但后厨只有 10 个炉子数据库连接。食客再多后厨也做不出菜。更好的做法是在连接池之上再加一层业务级信号量限流控制“真正会访问数据库”的并发量。这里我用了一个比较稳妥的方案private final Semaphore dbSemaphore new Semaphore(30); public ListOrder queryOrders(QueryRequest request) { if (!dbSemaphore.tryAcquire(3, TimeUnit.SECONDS)) { throw new BizException(系统繁忙请稍后重试); } try { return orderMapper.selectList(buildQuery(request)); } finally { dbSemaphore.release(); } }这样做的意义在于不管 Tomcat 接收了多少请求、虚拟线程创建了多少个真正打到数据库的并发会被限制在 30 以内。多出来的请求快速失败或者排队不会把连接池和数据库打爆。如果你的团队愿意做更大的改动可以考虑把同步 JDBC 换成响应式客户端R2DBC或者引入 WebClient 处理外部 HTTP 调用。但我不建议为了虚拟线程把所有代码都改成异步范式那等于放弃了虚拟线程带来的编程模型简化优势。对于大多数业务虚拟线程 连接池限流已经是性价比很高的组合。4.4 关于升级 JDK 24 的取舍如果你现在还在用 JDK 21又不想大面积改代码可以考虑直接升级到 JDK 24。JDK 24 通过 JEP 491 解决了 synchronized 固定虚拟线程的问题这意味着 synchronized 代码块可以放心用了。但要注意JDK 24 是比较新的版本相关的生态兼容性需要确认比如 Spring Boot 版本是否支持、字节码工具如 Cglib 修改字节码的框架是否兼容、监控工具是否支持 JDK 24 的线程模型。我们出于稳定考虑没有在生产环境直接升级而是在测试环境验证过后续再考虑。如果你决定不升级那么记住一条红线在 JDK 21 / 22 下不要在 synchronized 块内执行阻塞 IO 操作。这条红线比任何参数调优都重要它可以解释虚拟线程环境下一半以上的“莫名卡死”问题。5. 压测时怎么确认系统的“真实并发数”5.1 线程组数不等于并发数这个坑在压测虚拟线程服务时特别明显。很多人用 JMeter 压测时习惯性认为“我设置了 100 个线程就有 100 并发”。这个认知在虚拟线程环境下是不准确的。JMeter 的线程数代表的是你能发起的请求线程数但系统真正的并发度取决于活跃请求数。压测时要关注的指标是 TPS每秒事务数和响应时间曲线。当你不断加大线程数TPS 先上升、然后进入平台期、最后开始下降那个“平台期的起点”就是系统真实并发容量的参考值。比如我们用 100、200、500 三组线程分别压测100 线程时 TPS 稳定在 800200 线程时 TPS 只涨到 850500 线程时 TPS 反而跌到 600。这个结果表明系统的业务并发上限大约在 200 左右继续加线程只会增加排队和上下文切换成本。虚拟线程场景下由于线程数量不是瓶颈压测时更要注意「下游资源」是否成了隐形的限制条件。最好在压测前先确认数据库连接池最大连接数、外部接口 QPS 上限、缓存服务的连接上限然后按照从下游到上游的顺序逐层压测。5.2 虚拟线程压测的观察重点压测虚拟线程服务时我建议重点观察四个指标数据库连接池 active 和 pending 数量看是否打满连接池。平台线程carrier的使用率看是否存在 pinning 问题可以用 jstack 观察到线程名称中带有ForkJoinPool或直接是虚拟线程调度器的线程。响应时间的 P99 和 P99.9虚拟线程在低并发下 P99 通常很好但资源争抢激烈时 P99 会快速恶化。GC 指标尤其是发生 Full GC 时的响应时间毛刺。如果你观察到一个奇怪现象CPU 不高、线程很多、TPS 上不去那大概率是某个共享资源被打满了。这时候先看连接池指标再看锁竞争而不是傻傻地加机器。5.3 一个可以落地的压测检查清单我整理了一份自己每次压测前都会过的清单这里分享出来检查项预期结果异常时的排查方向Web 容器信号量配置实际进入逻辑的并发 ≤ 配置值调大 server.tomcat.threads.max数据库连接池大小active 接近 maximumPoolSize 但 pending 不高调大连接池或加业务限流synchronized 使用范围同步块内没有阻塞 IO改用 ReentrantLock连接超时配置最多等待 5 秒优化 SQL 或加索引carrier 线程利用率没有大量虚拟线程被 pinnedThread Dump 分析栈帧下游依赖 QPS 上限压测量级在下游承受范围内对下游调用做降级/重试策略压测永远是在验证系统的一个真实边界而不是验证 JMeter 线程组数据。你把虚拟线程说得再好压测数据摆出来才能说话。6. 常见问题速查与避坑心得最后把我这次排查中遇到的和朋友交流时听到的典型问题整理成速查表方便大家遇到类似问题直接对号入座。现象可能原因首选排查手段解决方案100 并发就大面积超时Hikari 连接池打满看 hikaricp.connections.active / pending调大连接池、加信号量限流CPU 不高但接口无响应synchronized 内阻塞 IO 导致 pinnedjcmd Thread.dump_to_file 分析栈帧换 ReentrantLock 或升级 JDK 24日志出现 Tomcat threads max 相关警告Tomcat 信号量过小检查 server.tomcat.threads.max调大该配置它只当信号量用ThreadLocal 值混乱虚拟线程复用平台线程打日志观察用户上下文改用方法参数传递或谨慎清理并行流执行很慢公共 ForkJoinPool 线程不足查看 ForkJoinPool 活动线程数自定义线程池不用公共池请求快速失败大量报错connection-timeout 设置过短查看错误码分布配合重试机制或调大超时时间再补充一个我在实际踩坑中发现的细节很多从 JDK 17 升到 JDK 21 的项目会习惯性地用newVirtualThreadPerTaskExecutor替换原有线程池这没问题但要注意这个执行器默认是无限创建虚拟线程的。如果你用一个不设上限的执行器去处理消息队列任务而每个任务都访问数据库那连接池会被瞬间打满表现和这次事故非常像。建议给虚拟线程执行器也加上信号量控制或者用一个Semaphore限制提交的任务量。ExecutorService executor Executors.newVirtualThreadPerTaskExecutor(); Semaphore semaphore new Semaphore(100); public void submitTask(Runnable task) { semaphore.acquire(); executor.submit(() - { try { task.run(); } finally { semaphore.release(); } }); }这个模式在接入虚拟线程的初期阶段非常实用它不会让你错过虚拟线程带来的红利同时又给系统留了一层保护。踩过这个坑之后我对虚拟线程的理解发生了很大变化。它确实是一项革命性的技术让“线程成本趋近于零”变成了现实但线程成本趋近于零不意味着所有资源成本都趋近于零。数据库连接、外部 IO 连接、锁的竞争、连接池的容量这些共享资源的边界并不会因为虚拟线程而消失它们只是被推到了你必须主动面对的位置。现在团队内部有一个不成文的规定上线前先查三件事——有没有 synchronized 包着阻塞操作、数据库连接池参数是多少、下游依赖有没有限流。如果这三样都过关虚拟线程才真的能让我们睡得安稳。