深入 Spring E05:虚拟线程、MVC 与 WebFlux 的资源上限
能接受更多任务,不代表数据库能多执行查询
同一条需要等待数据库的请求,分别使用 MVC 平台线程、MVC 虚拟线程和 WebFlux。三个入口都返回字符串 42,数据库连接上限都为 2。每轮同时发出 12 个 HTTP 请求,三个模式都出现资源等待;虚拟线程使等待任务更容易进入应用,却没有把两条连接变成十二条。
这个实验关注业务结果、资源租约、等待和取消终态,不给框架排性能名次。线程模式改变了应用在等待时如何占用执行资源,但 SQL 本身的耗时、数据库连接容量以及请求进入速率仍然存在。
运行基线为 JDK 21.0.10、Boot 3.5.6、Framework 6.2.11。MVC 使用 Tomcat 10.1.46 和 pgJDBC 42.7.7,WebFlux 使用 Reactor Netty 1.2.10、Reactor Core 3.7.11 与 r2dbc-postgresql 1.0.7.RELEASE。JDBC 池为 HikariCP 6.3.3,响应式池为 r2dbc-pool 1.0.2.RELEASE。它们都由同一个 Boot BOM 管理,完整实际依赖树随实验保留。
对照必须先锁定业务工作量
每个模式先预热一次,然后执行三轮负载,每轮 12 个并发 HTTP GET。业务 SQL 相同:
1 | |
它在 PostgreSQL 中执行约 30 毫秒等待,然后返回同一个数字。服务端都通过真实数据库驱动执行该语句,客户端都检查 HTTP 状态和完整响应内容。每个模式在取消场景之前完成 37 次数据库操作,包括一次预热和 36 次正式请求。
这个 SQL 用于构造可观察的 I/O 等待,不是生产订单查询。它没有扫描大表、索引命中率或业务锁竞争。WebFlux 与 MVC 的驱动和服务器也不同,因此实验是三套运行方式在同一业务与资源约束下的行为对照,不能把时间差完全归因于线程调度。
平台 MVC 显式限制 Tomcat 工作线程数为 4;虚拟线程组启用 spring.threads.virtual.enabled=true。两组处理器都检查实际 Thread.currentThread().isVirtual(),不能仅根据配置文件中的布尔值声称虚拟线程已运行。WebFlux 在独立 ApplicationContext 和 Netty 事件循环上启动,避免把同时存在于 classpath 的两个 Web 模块混称成同一个服务器栈。
虚拟线程改变等待成本,不改变外部容量
Java 虚拟线程仍然使用 Thread 抽象,但调度到平台载体线程上执行。适合等待型任务的原因是某些阻塞期间可以卸载载体,降低大量并发等待所需的平台线程数量。它不使 CPU 指令或数据库 SQL 自动变快。JDK 21 虚拟线程说明
业务处理器继续使用同步 JDBC 和 try-with-resources。取连接、执行 SQL、读取结果、归还连接的顺序没有变化。虚拟线程可以减少等待占用的平台线程,却不能绕过连接池上限 maximumPoolSize=2。新增请求仍然要排队取得资源。
这也解释为什么单纯扩大请求并发不一定改善延迟。若数据库服务速率已经受连接数与查询耗时限制,更多任务进入应用后主要增加等待时间和在途对象。限流、超时和有界资源策略应继续存在,不能因为线程变轻就删掉它们。
JDK 21 还有需要单独观测的 pinning 情况。本实验没有记录 JFR pinning 事件,不能据此声称某驱动完全不会固定载体,也没有把后续 JDK 的改进倒推到这个基线。迁移时应在目标 JDK、实际驱动和真实调用路径上检查。
等待队列的位置发生了变化
采样器每毫秒读取一次池的已租用数量与等待数量,并保存本次运行的最大采样值。平台 MVC 只有有限工作线程能进入连接池,其他 HTTP 请求还可能等待服务器调度;虚拟线程模式可以让更多处理任务同时等待池连接。WebFlux 则由响应式池记录挂起的 acquire 请求。
三个模式的租约最大采样值都为 2,等待最大值均大于零。这样的计数证据比“线程数很多”更接近问题:数据库并发确实被约束住,而输入仍然产生排队。
这些指标不构成原子快照。等待数和已租用数由不同方法读取,线程同时改变状态,最大值也可能来自不同时间点。因此不能把 maxWait 和 maxLease 相加,宣称某一纳秒正好存在那么多工作。需要精确事件关联时,应增加请求身份与 acquire/release 事件,而不是把采样峰值当作守恒方程。
WebFlux 的事件循环没有执行 JDBC 阻塞查询。处理器返回 R2DBC DatabaseClient 产生的 Mono,由响应式 HTTP 写出链路订阅。若把相同代码换成在事件循环里执行 JDBC,本实验的“响应式组”就已经变了;使用 WebFlux 注解本身不能保证调用链没有阻塞。
三轮时间只支持当前场景的有限结论
原始日志逐轮记录请求数、纳秒耗时与资源峰值。例如一次平台线程运行的三轮约为 203、196、198 毫秒,连接峰值为 2。每轮包含六组左右的数据库等待批次,再叠加 HTTP、驱动和调度开销,所以这组时间与外部容量约束相容。
它不能证明平台线程比虚拟线程快,也不能证明 WebFlux 一定慢。每个模式的预热有限,服务按固定顺序运行,数据库与机器还有共享负载;JIT、连接建立、缓存、GC 和事件循环初始化都可能影响短样本。原始文件保留各轮结果,不选择最小值构造一个排行榜。
有意义的迁移测量应明确目标:同一资源预算下能否承受更高并发、尾延迟是否满足业务约束、超时后是否及时释放资源、故障恢复是否可控。本篇只验证这一组固定负载的等价结果与资源边界,没有给出生产容量数字。
客户端取消之后,服务器可能继续执行
取消组使用相同接口,但将 PostgreSQL 等待延长到 0.5 秒。客户端同时发送两个请求,采样确认服务器占据两条连接且两个 HTTP Future 尚未完成,随后调用 cancel。两个 Future 都进入异常完成状态,再观察服务端连接和池等待归零。
这里把“请求取消动作”“客户端 Future 终态”“服务器业务完成”“资源归还”分开记录。JDK HttpClient 返回的异步对象与底层交换取消有关联,但本实验不把 isCancelled() 的单个布尔值当作整个网络与服务器事务已经取消的证明。
实际运行中,两个 MVC 模式的数据库完成计数从 37 增加到 39,连接大约在 0.5 秒查询结束后归还。客户端停止等待,并没有中断这两条 JDBC SQL。它们继续执行后尝试完成响应,是本场景的可见行为,不应被包装成“取消立即释放一切”。
WebFlux 组记录了两个 CANCEL 信号,完成计数仍为 37,连接随后归零。这说明当前 Netty、R2DBC 驱动与池组合能够把这次断开向下游传播,并完成资源清理。它没有证明任意业务 Publisher、外部客户端或已经提交的副作用都能被撤销。
如果请求已经执行扣款并提交,随后才断开响应,取消不能将提交过的账务自动回滚。业务幂等与结果查询仍然需要独立设计;线程模型不是这些协议的替代品。
资源关闭也属于实验结果
每个 MVC 上下文结束后关闭自己的 Hikari 池。WebFlux 组关闭服务器、ApplicationContext、事件循环与 R2DBC 池。采样执行器在 finally 中停止,并检查终止状态。共享的 PostgreSQL 55432 实例由整套实验管理,单篇不关闭它。
取消后的验收要求 acquired=0 且 waiting=0。仅等待客户端 Future 结束可能漏掉仍在运行的 SQL,仅检查池是否关闭又可能把请求结束前的泄漏隐藏在应用退出中。两个阶段必须分别观察。
本实验使用读取型 SQL,未产生业务写入。需要验证写事务取消时,应结合第 36 篇的独立数据库读回;完成信号、回滚信号与业务行存在性分别提供证据。
复现与预测
先按实验工程 db/README.md 准备共享 PostgreSQL,随后使用 JDK 21,在实验根目录执行:
1 | |
原始输出位于 evidence/E05/local-20261002/run.txt。每个模式包含三条 SAMPLE、一次 CANCEL 记录、业务结果与资源终态断言,最终输出 CHAPTER E05 PASS,退出码为 0。
修改练习:保持十二个并发请求,将三个模式的连接上限都改成 1,并把峰值断言同步改为 maxLease==1。此轮只执行预热和负载段,跳过依赖两条连接同时占用的取消场景;原样保留 leased==2 会在进入取消观测前失败。预测 SQL 工作量不变、业务返回不变,租约峰值降为 1,排队时间可能增加。然后只改变平台组的服务器线程上限,观察等待是否从 Tomcat 层移动到连接池;不能只比较池等待数就断定系统总排队减少。
前置阅读:深入 Spring 38:Boot 自动配置的输入、退让与属性绑定 与 深入 Spring 40:观测事件、资源等待与业务终态。


