资讯详情

资讯详情

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

本文摘要开启虚拟线程后线程告警消失HikariCP 连接池超时却更早出现。先用 JFR 事件排除synchronized阻塞再按并发预算重算连接池容量。一、问题与结论凌晨告警http-nio-8080-exec-*200 个处理线程全部阻塞接口排队日志反复出现HikariCP - Connection is not available, request timed out after 30000ms。团队提议打开spring.threads.virtual.enabled但上线前必须先分清一件事——瓶颈是线程不够还是连接池不够。结论先摆三条200 线程跑满的告警内容其实是「下游资源不足以支撑当前并发」换线程模型只会把这个告警换个位置报出来。虚拟线程把并发上限从线程数搬到了下游最小资源池HikariCP 的maximumPoolSize往往就是那个最小的池它会取代线程池成为新的串行点。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 λWL是目标并发连接数λ是目标 QPSW是单请求持有连接的时长。原来 200 个平台线程天然把L卡在 200 以内虚拟线程放开后L只受W和λ约束。可复用的判读规则给分支不给数字/lock路径 pin 事件为 0、/sync明显非 0 → pin 结论成立改锁。两条路径 pin 事件都为 0连接池超时照旧 → 结论是池容量与 pin 无关。关虚拟线程能扛住的 QPS开之后更快超时 → 并发预算没有重新分配。替代方案与取舍方案选择条件代价边界什么情况下不该用保持 200 平台线程只调池与超时请求内无长阻塞风险最低低改配置加回归请求阻塞超过数秒时线程天然限流会推高尾延迟开虚拟线程并按L λW重算池压测能给出WDB 侧能承受更高连接数中需压测与 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:trueRestControllerpublicclassDemoController{privatefinalJdbcTemplatejdbc;privatefinalObjectmonitornewObject();privatefinalReentrantLocklocknewReentrantLock();publicDemoController(JdbcTemplatejdbc){this.jdbcjdbc;}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:StartFlightRecordingduration120s,filenameapp.jfr-jarapp.jar# 或快速兜底java-Djdk.tracePinnedThreadsshort-jarapp.jar压测/sync与/lock各 120 秒再执行jcmd pid Thread.print -l观察等待连接的线程。常见失败及其处理把池容量问题当 pin 问题花两天把synchronized全换成Lockpin 事件归零但超时一条没少——原因是池只有 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生产代码不要依赖。思考虚拟线程下连接池容量该按L λW重算还是按 DB 实例规格反推上限后取两者较小值监控口径从线程占用率切到连接池等待队列时告警阈值该如何设才能区分正常排队与真实瓶颈参考资料JEP 444: Virtual Threads (JDK 21)JEP 491: Synchronize Virtual Threads without PinningSpring Boot Reference: Servlet Web Applications (Ability to use Virtual Threads)Apache Tomcat 10.1: Executor ConfigurationHikariCP Wiki: About Pool SizingJDK 21 Tools Reference: jcmd
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →