ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

生产环境 Tomcat 200 线程跑满并报连接池超时:一次虚拟线程阻塞点排查

生产环境 Tomcat 200 线程跑满并报连接池超时:一次虚拟线程阻塞点排查

本文摘要:开启虚拟线程后线程告警消失,HikariCP 连接池超时却更早出现。先用 JFR 事件排除synchronized阻塞,再按并发预算重算连接池容量。

一、问题与结论

凌晨告警:http-nio-8080-exec-*200 个处理线程全部阻塞,接口排队,日志反复出现HikariCP - Connection is not available, request timed out after 30000ms。团队提议打开spring.threads.virtual.enabled,但上线前必须先分清一件事——瓶颈是线程不够,还是连接池不够。

结论先摆三条:

  1. 200 线程跑满的告警内容其实是「下游资源不足以支撑当前并发」,换线程模型只会把这个告警换个位置报出来。
  2. 虚拟线程把并发上限从线程数搬到了下游最小资源池,HikariCP 的maximumPoolSize往往就是那个最小的池,它会取代线程池成为新的串行点。
  3. JDK 21 上synchronized确实会 pin 住 carrier 线程,但它不是连接池超时的默认解释,必须靠 JFR 事件取证,不能靠猜。

二、排查与选择依据

按顺序做三步,每步先给结论再给证据。

第一步,确认超时来自池容量而不是线程排队。给spring.datasource.hikari加add-max-connection-error-message: true,让超时异常带出当时的活跃连接数、空闲连接数与等待线程数。若等待线程数远大于maximumPoolSize,并发预算问题成立,与锁无关。

第二步,用 JFR 排除 pin。压测期间录制jdk.VirtualThreadPinned事件:事件为 0,则排除 pin;非 0,按事件里的栈去定位synchronized。JDK 21 下还可用-Djdk.tracePinnedThreads打印 pin 栈作为兜底。

第三步,重算并发预算。用L = λW:L是目标并发连接数,λ是目标 QPS,W是单请求持有连接的时长。原来 200 个平台线程天然把L卡在 200 以内,虚拟线程放开后L只受W和λ约束。

可复用的判读规则(给分支,不给数字):

  • /lock路径 pin 事件为 0、/sync明显非 0 → pin 结论成立,改锁。
  • 两条路径 pin 事件都为 0,连接池超时照旧 → 结论是池容量,与 pin 无关。
  • 关虚拟线程能扛住的 QPS,开之后更快超时 → 并发预算没有重新分配。

替代方案与取舍

方案选择条件代价边界(什么情况下不该用)
保持 200 平台线程,只调池与超时请求内无长阻塞,风险最低低:改配置加回归请求阻塞超过数秒时,线程天然限流会推高尾延迟
开虚拟线程并按L = λW重算池压测能给出W,DB 侧能承受更高连接数中:需压测与 DB 容量评估连接数受授权或实例规格限制、下游已有自身瓶颈时不该用
缩短连接持有时间(查询下推、缓存、拆@Transactional边界)能从业务上降低W中高:涉及事务边界重构强一致逐条读写、无法合并的写入不适用
入口限流或队列削峰保护下游,牺牲部分请求及时性低中:引入网关或 MQ交互式接口不能排队

三、关键原理

并发预算搬移。200 个平台线程把在途请求卡在 200,下游从未被真正压过;虚拟线程把线程变成廉价资源,在途并发可能涨到数千,等待队列按L = λW线性放大。虚拟线程解决的是「线程太贵」,不是「资源太少」。

pin 的边界。JEP 444 明确:Thread.sleep、Object.wait、LockSupport.park、阻塞式 socket IO 都不会 pin;而持有 monitor 期间阻塞会 pin 住 carrier,默认 carrier 数等于 CPU 核数,pin 满即吞吐塌陷。JEP 491 处理了synchronized的 pin 问题,具体修复到的 JDK 版本需按官方入口核对后再写入变更单。注意 JDBC 等待连接本身不 pin,所以「HikariCP 超时」大概率是池问题。

监控口径失效。虚拟线程模式下「200/200 跑满」失去意义,部分线程计数器也不统计虚拟线程。同一故障会改以「RT 抬高 + 连接池等待堆积 + DB 连接数打满」的形式出现;告警消失可能等于指标失效。

四、可运行示例

环境:JDK 21、Spring Boot 3.x、Tomcat、PostgreSQL;输入为同一份代码分别以spring.threads.virtual.enabled的true/false各跑一轮。

spring:datasource:url:jdbc:postgresql://localhost:5432/demohikari:maximum-pool-size:5connection-timeout:5000add-max-connection-error-message:truethreads:virtual:enabled:true
@RestControllerpublicclassDemoController{privatefinalJdbcTemplatejdbc;privatefinalObjectmonitor=newObject();privatefinalReentrantLocklock=newReentrantLock();publicDemoController(JdbcTemplatejdbc){this.jdbc=jdbc;}@GetMapping("/sync")publicStringwithSynchronized(){synchronized(monitor){returnString.valueOf(jdbc.queryForObject("select count(*) from t",Long.class));}}@GetMapping("/lock")publicStringwithLock(){lock.lock();try{returnString.valueOf(jdbc.queryForObject("select count(*) from t",Long.class));}finally{lock.unlock();}}}

操作步骤:

java-XX:StartFlightRecording=duration=120s,filename=app.jfr-jarapp.jar# 或快速兜底java-Djdk.tracePinnedThreads=short-jarapp.jar

压测/sync与/lock各 120 秒,再执行jcmd <pid> Thread.print -l观察等待连接的线程。

常见失败及其处理:把池容量问题当 pin 问题,花两天把synchronized全换成Lock,pin 事件归零但超时一条没少——原因是池只有 5~10 个连接,修复方法是先按L = λW重算maximumPoolSize,再动锁。

五、验证结果与边界

预期输出:/sync路径的 JFRjdk.VirtualThreadPinned事件非 0 且栈中出现DemoController.withSynchronized,/lock路径为 0;把maximum-pool-size调大后超时停止。此为依据 JEP 444 行为的推断,未验证。

实际输出:本文未提供任何实测 QPS、耗时或事件计数,上述结果需在你的环境中按同样步骤压测取得;若两条路径 pin 事件均为 0 而超时依旧,应改判为池容量问题。

边界:连接数受授权或实例规格限制、下游已有自身瓶颈、团队不熟悉阻塞与非阻塞混合调试时,不应直接开虚拟线程;升级到含 JEP 491 的 JDK 只消除 pin,不解决连接池超时,别把它当本题答案。另外StructuredTaskScope在 JDK 21 仍是 preview,生产代码不要依赖。

思考

  1. 虚拟线程下连接池容量该按L = λW重算,还是按 DB 实例规格反推上限后取两者较小值?
  2. 监控口径从线程占用率切到连接池等待队列时,告警阈值该如何设才能区分正常排队与真实瓶颈?

参考资料

  • JEP 444: Virtual Threads (JDK 21)
  • JEP 491: Synchronize Virtual Threads without Pinning
  • Spring Boot Reference: Servlet Web Applications (Ability to use Virtual Threads)
  • Apache Tomcat 10.1: Executor Configuration
  • HikariCP Wiki: About Pool Sizing
  • JDK 21 Tools Reference: jcmd
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进