news 2026/11/5 1:53:50

为什么给Tomcat开虚拟线程后,数据库连接反而更快被耗尽

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
为什么给Tomcat开虚拟线程后,数据库连接反而更快被耗尽

本文摘要:虚拟线程拆掉了 Tomcat 的线程上限,数据库连接池会在数秒内被打满。平台线程池原本是隐式限流,可用信号量等显式背压替代它。方案适用于下游连接数固定的服务,数据库已是瓶颈时应回到查询与索引优化。

一、问题与结论

Spring Boot 3.2.5 + JDK 21,在application.yml加一行spring.threads.virtual.enabled: true后压测:吞吐没有明显提升,P99 反而变差,日志开始刷:

HikariPool-1 - Connection is not available, request timed out after 30000ms

同一份代码、同一份连接池配置,把开关改回false后,连接池偶尔繁忙,但从未在几十秒内被彻底打满。

结论先行:这个开关只替换请求处理的线程模型,不改变任何下游资源池的容量。HikariCP 的maximum-pool-size仍是 10,数据库能承受的并发连接数也没变。虚拟线程移除了"最多 200 个请求同时执行"这道隐式限流,连接池因此以更快的速度被填满并压满——瓶颈从线程数平移到了连接数。

三个可观测的次生症状:

  • 吞吐不涨:请求卡在getConnection()上排队,CPU 花在调度而非执行上。
  • P99 变差:connection-timeout之内的排队时间全部计入请求耗时。
  • 日志串号:常见于把请求上下文放在可被载体线程复用的ThreadLocal(自定义线程包装、异步 appender 在别的线程读 MDC)。这与 pinning 无关,具体表现依日志框架版本而定,需在你的框架上单独验证。

二、排查与选择依据

先复现,再归因。用wrk对同一接口压两轮,第二轮切到带信号量的路径:

wrk-t4-c200-d30s--latencyhttp://localhost:8080/query wrk-t4-c200-d30s--latencyhttp://localhost:8080/query-limited

同时每秒抓一次连接池状态(第四节的/pool-status):虚拟线程模式下active立刻等于maximum-pool-size、waiting持续增长;平台线程模式下waiting上限被maxThreads(默认 200)压住。

若 P99 依旧异常,再排查 pinning。这是虚拟线程场景下另一类独立问题,JDK 21 可用启动参数直接抓:

java-Djdk.tracePinnedThreads=full-jardemo.jar

示意输出(行内容取决于真实堆栈,本文未在你的环境验证):

Thread[#42,ForkJoinPool-1-worker-1] is pinned at: com.demo.DemoController.sleep(DemoController.java:41) com.demo.DemoController.query(DemoController.java:35) java.base@21/java.lang.VirtualThread.run(VirtualThread.java:592)

三类常见成因逐条说清:

  1. synchronized:JDK 21 中,虚拟线程进入synchronized块后无法从载体线程卸载,整个块执行期间占死一个载体线程。JDK 24 起不再由synchronized触发 pinning,但跑在 JDK 21 上仍需自己规避。
  2. JNI 调用:native 方法内部如果做了阻塞 I/O(老版本数据库驱动的 socket 读),同样无法卸载,载体线程被长期占用。
  3. ThreadLocal:这是最容易被误判的一项——读写ThreadLocal本身不会导致 pinning,每个虚拟线程都有独立的ThreadLocal表。它的真实代价是内存(每个虚拟线程一份)与上下文传递(异步线程上取不到),排查时不要把它当卡顿根因。

替代方案与取舍

做法选择条件代价与边界
Semaphore显式背空(本文方案)下游连接池容量固定,想保留虚拟线程的低成本等待需保证异常路径也release();每个访问下游的入口都要包
回退平台线程并调大maxThreads瓶颈本就不在应用线程,I/O 等待不多线程栈内存开销大,上限受 OS 约束
R2DBC 非阻塞数据访问并发极高且能接受整层重写事务模型不同,迁移与学习成本高

不该用本文方案的两种情况:一是数据库本身已是瓶颈(慢查询、锁竞争),加任何应用层控制都是掩盖问题,应先做索引与查询优化;二是请求几乎不碰数据库(纯外部 HTTP 调用),虚拟线程的收益本就集中在这里,不必额外引入信号量。

三、关键原理

平台线程模式下,Tomcat 的 200 个线程是一道天然减速带:最多 200 个请求在执行,最多 200 个请求持有连接,其余在 accept 队列排队。虚拟线程让"等待"几乎免费——初始栈仅数百字节,挂起与恢复由 JVM 调度器接管,不占操作系统线程。于是更多请求毫无代价地涌进getConnection()的等待队列,队列膨胀速度远高于平台线程模式;同时数千虚拟线程并发竞争 HikariCP 的ConcurrentBag,CAS 与内部锁的争用在 200 并发下可忽略,在数千并发下变成可观测损耗。

一句话:背压不会凭空消失,只会在你没设防的位置堆起来。

四、可运行示例

最小工程(JDK 21、Spring Boot 3.2.5、内嵌 H2)。在spring-boot-starter-parent3.2.5 的基础上增加两个依赖:

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency><dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>

配置文件application.yml:

spring:threads:virtual:enabled:truedatasource:url:jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1driver-class-name:org.h2.Driverusername:sapassword:""hikari:maximum-pool-size:5connection-timeout:10000pool-name:demo-pool

控制器:

importcom.zaxxer.hikari.HikariDataSource;importorg.springframework.jdbc.core.JdbcTemplate;importorg.springframework.transaction.support.TransactionTemplate;importorg.springframework.web.bind.annotation.*;importjava.util.Map;importjava.util.concurrent.Semaphore;@RestControllerpublicclassDemoController{privatefinalJdbcTemplatejdbc;privatefinalTransactionTemplatetx;privatefinalHikariDataSourceds;privatefinalSemaphorelimit;// permits 取连接池上限,避免无效排队publicDemoController(JdbcTemplatejdbc,TransactionTemplatetx,HikariDataSourceds){this.jdbc=jdbc;this.tx=tx;this.ds=ds;this.limit=newSemaphore(ds.getMaximumPoolSize());}@GetMapping("/query")publicStringquery(){returntx.execute(s->{jdbc.queryForList("SELECT 1");try{Thread.sleep(3000);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}return"done";});}@GetMapping("/query-limited")publicStringqueryLimited()throwsInterruptedException{limit.acquire();try{returnquery();}finally{limit.release();}}@GetMapping("/pool-status")publicMap<String,Object>poolStatus(){varp=ds.getHikariPoolMXBean();returnMap.of("active",p.getActiveConnections(),"idle",p.getIdleConnections(),"total",p.getTotalConnections(),"waiting",p.getThreadsAwaitingConnection());}}

启动并压测:mvn spring-boot:run,再执行第二节的两条wrk命令。/query在事务里持有连接 3 秒,用来放大排队效应。

一个常见失败:把limit.acquire()写进try块内部,异常路径漏掉release(),信号量几轮请求后泄漏并永久卡死。修复方式是固定"先acquire()、try/finally中release()"的顺序,如queryLimited()所示。

五、验证结果与边界

预期输出:/query下active恒为 5、waiting稳定在并发数减 5 的水平,wrk 的 P99 逼近connection-timeout;切到/query-limited后waiting不再增长,P99 回落到约 3 秒,超时报错消失。

实际输出:本文示例在 H2 内存库与本地wrk下验证过"连接池秒级被打满、信号量路径消除等待堆积"这一现象;tracePinnedThreads的具体堆栈与生产驱动相关,上文输出仅为格式示意,未在你的环境验证。启用 pinning 排查前,先确认自己确实在 JDK 21 上运行。

参考资料

  • JEP 444: Virtual Threads(JDK 21)
  • Spring Boot Reference Documentation: Virtual Threads
  • Apache Tomcat 10.1 Documentation
  • HikariCP Wiki
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 1:53:37

高数题目:等价无穷小求参数09p2

高数题目&#xff1a;等价无穷小求参数 题目 当 x→0x \to 0x→0 时&#xff0c;f(x)x−sin⁡axf(x) x - \sin axf(x)x−sinax 与 g(x)x2ln⁡(1−bx)g(x) x^2 \ln(1 - bx)g(x)x2ln(1−bx) 是等价无穷小&#xff0c;则&#xff08;&#xff09; (A) a1,b−16a1,b-\dfrac16a1,b…

作者头像 李华
网站建设 2026/11/5 1:52:00

三月七小助手:免费一键托管星穹铁道日常与周常

三月七小助手&#xff1a;免费一键托管星穹铁道日常与周常 【免费下载链接】March7thAssistant 日常交给三月七&#xff0c;开拓留给你。《崩坏&#xff1a;星穹铁道》开源自动化助手。 项目地址: https://gitcode.com/gh_mirrors/ma/March7thAssistant 三月七小助手&am…

作者头像 李华
网站建设 2026/11/5 1:51:34

A Wrong Turn Does Not Ruin the Journey 论文总结与翻译

# A Wrong Turn Does Not Ruin the Journey 论文总结与翻译> 论文arXiv链接:[https://arxiv.org/abs/2609.29154](https://arxiv.org/abs/2609.29154)> 标题:*A Wrong Turn Does Not Ruin the Journey: Deviation‑Guided Skill Self‑Evolution for LLM Agents*> 作…

作者头像 李华