本文摘要:虚拟线程 1 万并发,HikariCP 默认 10 连接耗尽。池大小按目标 TPS 和 SQL 耗时反推,受 MySQL 上限约束。公式仅适配 JDBC 同步模型,异步或读多写少场景需另选。
一、问题与结论
一台服务从 Spring Boot 3.2.0 升到 3.2.5,加了一行spring.threads.virtual.enabled=true,期望靠虚拟线程扛住压测。压测脚本打到 1 万并发后,接口大面积返回 503,日志里反复出现同一行:
SQLTransientConnectionException: main-pool - Connection is not available, request timed out after 5000ms.(该报错为按下方步骤推演的预期结果,本机未跑满 1 万并发实测,属未验证。)
复现命令:
# 环境:JDK 21、Spring Boot 3.2.5、Docker MySQL 8.0dockerrun-d--namemysql-pool-eMYSQL_ROOT_PASSWORD=root-eMYSQL_DATABASE=testdb-p3306:3306 mysql:8.0curl"http://localhost:8080/stress?concurrency=10000"结论先摆出来:连接池大小不要跟随虚拟线程数量,要按目标 TPS × 单条 SQL 平均持有连接时间反推,再受 MySQL 的max_connections封顶。默认 10 个连接在 1 万并发下必然耗尽;但把maximum-pool-size直接调到 1000 同样失败,数据库侧会先拒绝连接。
二、排查与选择依据
先确认瓶颈是不是连接池,而不是 SQL 本身。压测期间读hikaricp.connections.active与hikaricp.connections.pending两个指标:pending接近 9990、active恒等于maximum-pool-size,说明请求全部堵在取连接这一步。
计算路径
四步走,每步都有可观测输入:
- 定目标 TPS:压测基线或业务峰值估算,例:2000。
- 测单条 SQL 平均持有连接时间:含事务边界,用 APM span 或 MySQL 侧统计,例:10ms,即
0.01s。 - 算下界:
池大小 ≥ 目标 TPS × 持有时间秒 = 2000 × 0.01 = 20。 - 校验上界与排队:MySQL 8.0 默认
max_connections=151,可用数还要减去运维连接;同时并发数 × 持有时间 / 池大小必须小于connection-timeout,本例10000 × 0.01 / 20 = 5s,等于默认 5000ms 的临界值,要么把connection-timeout提到30000,要么在入口限流。
两个失败方向要同时看:池太小 → 超时报 503;池太大 → 启动或运行时抛Too many connections,因为 MySQL 侧先到顶。
替代方案与取舍
| 方案 | 选择条件 | 代价 | 边界 |
|---|---|---|---|
| 按 TPS 反推池大小(本文) | JDBC 同步阻塞、SQL 耗时稳定 | 高并发下排队延迟上升 | 持有时间波动大时会击穿超时 |
R2DBC +r2dbc-pool | 全异步非阻塞栈、WebFlux | 全链路改写、团队学习成本 | 只适配响应式栈 |
| 读写分离 + 缓存 | 读多写少、单条 SQL 耗时高 | 引入一致性与缓存失效复杂度 | 写路径基本无收益 |
| 入口信号量限流 | 需要提前拒绝、保护下游 | 与连接池内部排队重复一层 | 必须自定义背压与降级策略 |
单条 SQL 耗时波动剧烈、或事务里夹了远程调用时,不该用本文的平均值公式,此时持有时间不可预测,池大小算得再准也会被长尾拖垮。
三、关键原理
HikariCP 官方 wiki 的经验公式是connections = ((core_count * 2) + effective_spindle_count)。它假设每个平台线程持有一个连接、且瓶颈在数据库 CPU,因此按核数加倍。这个前提在虚拟线程下不成立:1 万个虚拟线程同时存在,但 JDBC 驱动依然是阻塞模型,一个连接同一时刻只服务一个请求。
所以约束方换了位置。虚拟线程消掉的是应用侧的并发上限,连接这类外部有限资源仍然稀缺(JDK 21 文档对此有明确说明)。吞吐上限近似为池大小 ÷ 单连接持有时间:持有 10ms,20 个连接的理论上限就是 2000 TPS。再加连接,吞吐不增,只会让排队变长。
另一个 JDK 21 的坑:业务代码里出现synchronized(this) { ... jdbc.query(...) },虚拟线程无法从载体线程上卸载(即 pinning),实际并发度仍被平台线程池限制。排查池大小前,先用 JDK 22 或改用ReentrantLock排除这一层干扰。
四、可运行示例
application.yml:
spring:threads:virtual:enabled:truedatasource:url:jdbc:mysql://localhost:3306/testdbusername:rootpassword:roothikari:pool-name:main-poolmaximum-pool-size:10minimum-idle:5connection-timeout:5000压测接口:
@RestControllerpublicclassStressController{privatefinalJdbcTemplatejdbc;privatefinalExecutorServicevt=Executors.newVirtualThreadPerTaskExecutor();publicStressController(JdbcTemplatejdbc){this.jdbc=jdbc;}@GetMapping("/stress")publicStringstress(@RequestParam(defaultValue="10000")intn)throwsException{CountDownLatchlatch=newCountDownLatch(n);AtomicIntegerok=newAtomicInteger();AtomicIntegerfail=newAtomicInteger();longt0=System.nanoTime();for(inti=0;i<n;i++){vt.submit(()->{try{jdbc.queryForObject("SELECT id FROM t1 WHERE id = ?",Long.class,1L);ok.incrementAndGet();}catch(Exceptione){fail.incrementAndGet();}finally{latch.countDown();}});}latch.await(60,TimeUnit.SECONDS);longms=(System.nanoTime()-t0)/1_000_000;return"concurrency=%d, success=%d, failed=%d, elapsed=%dms".formatted(n,ok.get(),fail.get(),ms);}}建表与数据:
dockerexecmysql-pool mysql-uroot-proot-e"USE testdb; CREATE TABLE IF NOT EXISTS t1(id BIGINT PRIMARY KEY); INSERT IGNORE INTO t1 VALUES(1);"启动后先打/stress?concurrency=1000摸底,再逐级到 10000。
五、验证结果与边界
预期输出(maximum-pool-size: 10、connection-timeout: 5000、单条 SQL 约 10ms、1 万并发):
concurrency=10000, success≈5000, failed≈5000, elapsed≈5100ms成功数按池大小 × connection-timeout ÷ 单条 SQL 耗时估算:10 × 5000ms ÷ 10ms = 5000,即约一半请求在 5 秒内拿到连接完成查询,其余等满超时后返回 503。同时应观察到hikaricp.connections.pending接近 5000、hikaricp.connections.active恒为 10。
实际输出:本机未跑满 1 万并发压测,上述数字为按公式推算的预期值,属未验证,请按第四节步骤本地复现后填入。
常见失败:把maximum-pool-size调到 1000 后,HikariCP 建连时报java.sql.SQLNonTransientConnectionException: Too many connections。原因是 MySQL 8.0 默认max_connections=151,数据库先拒绝。先确认当前上限再调:
dockerexecmysql-pool mysql-uroot-proot-e"SHOW VARIABLES LIKE 'max_connections'; SHOW STATUS LIKE 'Threads_connected';"修复方式是按上界校验结果把池压回 151 以内,或在my.cnf中调大max_connections并同步评估服务器内存与每连接的sort_buffer_size类开销。调参顺序固定为:测持有时间 → 定目标 TPS → 算下界 → 校验max_connections→ 分阶段放量。
思考
- 单条 SQL 耗时随数据量波动时,按平均值算出的池大小会不会周期性击穿
connection-timeout? - 连接池排队与入口信号量都提供背压,二者叠用时应该由哪一层先拒绝请求?
参考资料
- HikariCP Wiki:About Pool Sizing
- JDK 21 官方文档:Virtual Threads