Spring Boot虚拟线程压测翻车?瓶颈竟在数据库连接池

📅 发布时间:2026/9/10 6:24:00
Spring Boot虚拟线程压测翻车?瓶颈竟在数据库连接池
先交代一下背景。上周我把一个内部系统的 Spring Boot 服务从 JDK 17 升级到了 JDK 21顺手把虚拟线程打开了配置就一行spring.threads.virtual.enabledtrue。当时想得很简单既然虚拟线程号称能创建几十万个线程那这个服务以后扛并发还不是小意思结果压测一跑就翻车了——10 并发稳如老狗50 并发开始出现偶发超时到 100 并发服务直接瘫痪请求全卡住日志里除了超时就是连接异常。这篇文章就把这次踩坑的排查过程、根源分析和最终方案完整记录下来给同样在 Spring 里启用虚拟线程的朋友一个参考。尤其是那种“配置很简单、一压测就完蛋”的情况大概率不是虚拟线程本身的问题而是虚拟线程把原本隐藏的瓶颈全部暴露出来了。1. 现象复现100 并发一上来服务直接瘫痪1.1 踩坑环境与压测条件先说环境和压测条件方便你对照自己的场景。服务本身是 Spring Boot 3.3.xJDK 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 -formatjson /tmp/thread_dump.json # 如果习惯看文本格式 jstack pid把线程转储拿下来之后我第一件事是统计线程状态分布。正常情况一个健康服务里大多数线程应该是WAITING或TIMED_WAITING等待任务或等待 IO这很合理。但当时我发现有大量虚拟线程集中卡在同一个栈上反复出现HikariPool.getConnection和ConnectionProxy相关的方法。这个信号非常明确几乎所有请求都在等数据库连接。我当时还在线程转储里看到了这样一段典型栈VirtualThread[#123] prio5 cpu12.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 -formatjson导出的 JSON 文件里每个虚拟线程会有carrierThread字段方便你判断当前虚拟线程是否被钉扎在某个平台线程上。我当时特意数了一下卡在HikariPool.getConnection上的虚拟线程有 80 多个而 HikariPool 的状态是HikariPool-1 - Pool stats (total10, active10, idle0, waiting80)看到这行日志问题已经非常清楚了连接池总共就 10 个连接全部被占用还有 80 个请求在排队等待。HikariCP 默认的connectionTimeout是 30 秒所以在 30 秒内这些请求看起来就是“卡死”超过 30 秒就直接报SQLTransientConnectionException。2.3 顺带查一下 Tomcat 的接受队列除了连接池我还顺手查了 Tomcat 的接受队列状态。启用虚拟线程后很多人的直觉是“Tomcat 线程数上限不再有意义”这句话对了一半。Tomcat 在接受 TCP 连接时仍然有accept-count和max-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换成ReentrantLock或StampedLock是更稳妥的选择。3.3 业务线程池仍然按老逻辑排队还有一个很容易被忽视的地方虚拟线程默认只作用于 Tomcat 等 Web 容器收到的请求处理过程。如果你在业务代码里用了自定义线程池比如ExecutorService或者CompletableFuture.supplyAsync()这些线程池默认用的还是普通平台线程。这种情况下请求虽然由虚拟线程接住了但后续的业务处理又塞回了固定大小的线程池瓶颈依然在那里。我当时查到一个定时任务用了Async底层线程池大小默认是 8。并发一高这部分任务也全堵住了。Spring Boot 里Async和Scheduled对虚拟线程的支持需要额外配置不是说你开了spring.threads.virtual.enabledtrue所有线程池就都自动虚拟线程化了。这一点在官方文档里有说明但很多人不会细看。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连接池不是越大越好因为数据库服务端每个连接都要占用内存和进程资源连接过多反而会把数据库拖垮。我当时考虑了三个因素预期业务并发量是 100 到 200不是所有请求都要同时访问数据库。单请求数据库资源占用时间平均在 50ms 到 100ms 之间。数据库自身限制是 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.enabledtrueHikariCP 最大连接数 50Tomcat 的accept-count调整为 500消除了业务代码中的长synchronized临界区异步任务改为虚拟线程执行器压测结果100 并发持续 10 分钟平均响应时间 280msP95 在 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确认 totalactive调大maximum-pool-size优化 SQL 减少连接占用时间请求超时但 CPU 很低等待下游资源如 HTTP 连接池、Redis 连接池抓线程转储看线程卡在哪个栈调整下游连接池大小或减少对下游的同步调用线程转储中大量 BLOCKED 状态synchronized导致虚拟线程钉扎看虚拟线程栈里是否有 synchronized 关键字改用 ReentrantLock缩小临界区范围异步任务仍然卡顿Async默认还是平台线程池查看异步执行器线程数使用Executors.newVirtualThreadPerTaskExecutor()创建执行器ThreadLocal 数据丢失跨线程传递未处理在子线程打印上下文信息使用ScopedValue或者手动传递上下文数据库连接耗尽报 SQLTransientConnectionException连接池配置过小或连接未释放看连接池活跃数和等待数检查代码中的连接获取是否在 finally 中关闭调大连接池6.2 一份可直接参考的最小改造清单如果你也想在 Spring Boot 里启用虚拟线程又不想踩一遍我踩过的坑这里列一个最小改造清单确保版本Spring Boot 3.2JDK 21。开启虚拟线程spring: threads: virtual: enabled: true评估数据库连接池把maximum-pool-size从默认的 10 调大具体数值根据数据库规格和业务并发算至少先提到 50 观察一下。扫描同步块重点找临界区内有 IO 操作的synchronized替换为ReentrantLock。梳理线程池业务代码里的ExecutorService、Async、CompletableFuture执行器根据实际需要改为虚拟线程执行器。关注外部依赖HTTP 连接池、Redis 连接池、消息队列连接数都需要同步评估不能只盯着 Tomcat。压测验证压测时不要只看平均响应时间重点看 P95 和 P99以及线程转储里是否存在集中等待。最后再分享一个实际体会虚拟线程在 Spring 里的价值是真实存在的但它解决的是“请求处理线程的成本”问题而不是“下游资源容量”问题。连接池、数据库、外部接口这些硬资源该评估还是要评估。不要把虚拟线程当成一个万能开关开了就以为并发无忧。我这次踩坑最深的教训就是升级之前觉得“技术上应该没问题”结果压测数据无情地告诉我瓶颈从来不会因为换一种线程模型就消失它只是换了一个位置等你发现。