☰
Boot3.5虚拟线程后Hikari按库定容
2026/9/27 13:58:32 网站建设 项目流程

# Spring Boot 3.5 开虚拟线程后:Hikari 连接池按库能力定容,别按 VT 数量扩池

开虚拟线程后 Tomcat 池顶消失,瓶颈常落到 Hikari;按库能力定小池、fail-fast,别按 VT 数扩连接。

## 一、痛点:VT 一开,排队从「线程池」挪到了「连接池」

Spring Boot 3.5 里把 `spring.threads.virtual.enabled=true`(需 Java 21+)一开,任务执行与调度会切到基于虚拟线程的 `SimpleAsyncTaskExecutor` / `SimpleAsyncTaskScheduler`。请求侧「一请求一 VT」变得便宜,Tomcat 那套平台线程池的硬顶不再是第一瓶颈。

接下来的问题往往是:大量 VT 同时进业务、同时借 JDBC 连接,Hikari 默认 `maximum-pool-size=10` 被打满,后面的请求全堵在借连接这一步。要是再把池调成「跟 VT 一样大」,压力会原样砸到数据库的会话、锁和 CPU 上。InfoQ 归纳 JDK 24 之后的虚拟线程落地情况,也指向同一类模式:**升级后出问题,多半是下游资源先耗尽(连接池、文件描述符、限流),很少是「虚拟线程本身算不动」**。

很多人以为「开了 VT = 自动获得更高吞吐」,其实只去掉了**平台线程池这一层天花板**。数据库连接、远端 HTTP、消息分区,每一层仍是有界资源。哪一层先饱和,哪一层就变成新的排队点。开 VT 之后,最容易被忽略、也最容易被误扩的,就是 Hikari。

这篇只管 **Boot 3.5 开 VT 之后,Hikari 怎么定容**。上下文在 VT 间怎么传,前两篇讲过:2026-09-21 虚拟线程与 ThreadLocal、2026-09-25 JDK 25 Scoped Values。那两篇走「上下文」线,这篇走「池与下游容量」线,线程局部变量就不再翻了。

【配图:虚拟线程开启后瓶颈移到 Hikari/DB】

## 二、机制:开 VT 不会自动扩 Hikari

官方文档(Task Execution and Scheduling)里,`spring.threads.virtual.enabled=true` 管的是 **执行器 / 调度器(以及内嵌 Web 容器的请求线程)是否使用虚拟线程**,文档没有把它和数据源关联。Hikari 走的是另一条数据源自动配置链,**不会**因为打开虚拟线程开关就改写 `maximum-pool-size`、`minimum-idle` 或超时项。

默认值仍要记住两处(HikariCP 与 Boot 数据源绑定的常见默认):

| 项 | 默认 | 含义 |

|----|------|------|

| `maximum-pool-size` | `10` | 池内最多同时借出的连接数 |

| `connection-timeout` | `30000` ms | 借不到连接时最长等待,超时抛异常 |

VT 把「能同时跑起来的业务单元」抬高了,**连接借还规则完全没变**。瓶颈只是从「平台线程不够」平移到「连接不够 / 数据库不够」。落地时最常见的误会,就是把这两件事当成同一个「性能开关」。

顺带说个边界,免得误读社区 issue:GitHub spring-boot **#49919** 里出现过「开 VT 后 Hikari 启动挂起」的讨论,结论是 **环境 / 数据库配置问题**,别写成「Boot 开虚拟线程必然卡死连接池」。排障先查数据库可达性、认证、SSL 和连接串,别急着怪虚拟线程开关。

可以做个对照实验:同一套业务压测脚本,只改 `spring.threads.virtual.enabled`,Hikari 参数不动。如果平台线程时代的瓶颈在 Tomcat 池,打开 VT 后吞吐往往先上升。随后要是冒出大量 `SQLTransientConnectionException`,或借连接等待飙升,说明瓶颈已经交到了连接池 / 数据库。这组对照能直接说明:「VT 开关」和「池大小」是两套旋钮,别绑在一起调。

## 三、定池:用 Hikari wiki 公式,不用 VT 计数

HikariCP wiki《About Pool Sizing》给的起点是:

```text

connections = ((core_count * 2) + effective_spindle_count)

```

意思是**按数据库主机的 CPU 核数与有效磁盘轴数估一个小池**,让连接尽量都在干活、池保持饱和,池容量不用去对齐前端并发。SSD 场景 `effective_spindle_count` 往往取很小(常见按 0~1 估),4 核库大约先落在个位数到十余个连接,再靠监控微调。wiki 反复强调:**要小而饱和的池,别按前端并发放大池**。

配置示例(YAML,按库规格改数字即可):

```yaml

spring:

threads:

virtual:

enabled: true

datasource:

hikari:

# 例:4 核 + SSD → 先试 10;勿写成「预期 VT 数」

maximum-pool-size: 10

minimum-idle: 10

# 默认 30000ms;生产可收紧做 fail-fast

connection-timeout: 3000

pool-name: app-hikari

```

落地时抓住三点:

1. **先小后调**:默认 `10` 往往已经接近很多 OLTP 库的甜点区;观察活跃连接、等待时长、数据库 CPU 与锁,再加减 `2`~`4`,别一次跳到上百。

2. **fail-fast**:`connection-timeout` 设得过长,只是把失败推迟成「虚拟线程干等」。宁可快点失败、触发限流或降级,也不要让请求无限堆在借连接队列里。

3. **多实例算总账**:`实例数 × maximum-pool-size` 必须小于数据库 `max_connections` 的安全余量;单实例「看起来不大」的池,乘上滚动发布中的实例数就会顶满库。

公式只是起点,不是终值。读写比例、是否大量持锁更新、连接是否走代理(中间件会再占一层会话),都会让甜点区偏移。稳一点的做法:压测时盯「池利用率接近饱和、数据库 CPU / 锁还没打满」这个窗口。一旦数据库侧先红,再加池只会更糟,该回头减入口并发或优化 SQL。反过来,若池长期空闲、数据库也很闲,而业务仍慢,瓶颈多半不在 JDBC,别用扩池当安慰剂。

【配图:Hikari 定容决策:按库能力 vs 按 VT 数量】

## 四、反模式:事务跨 HTTP,连接被「长租」

池再小也扛不住「借了不还」。最常见的写法是:`@Transactional` 包住整段业务,中间再调 RestClient / WebClient 访问下游。事务不提交,连接就不归还;虚拟线程再便宜,也只是让更多请求同时踩中这条路径,把默认十个连接全部长租出去。表面看是「Hikari 排队变长」,根因是**租约时间被远端 RTT 拉长**,跟池偏不偏小关系不大。

反例(结构示意,勿照抄到生产):

```java

@Service

public class OrderFacade {

private final OrderRepository orders;

private final RestClient billing;

public OrderFacade(OrderRepository orders, RestClient billing) {

this.orders = orders;

this.billing = billing;

}

// 反模式:事务边界罩住 HTTP,连接被长租

@Transactional

public void place(OrderCmd cmd) {

orders.insert(cmd); // 已占用连接

billing.post()

.uri("/invoice")

.body(cmd)

.retrieve()

.toBodilessEntity(); // 网络等待期间连接仍被占用

orders.markPlaced(cmd.id());

}

}

```

改法原则:

- **事务尽量短**:只包本地读写;HTTP、消息、文件 I/O 放到事务外。

- **先本地落库再异步对账**,或用 Outbox,避免「库连接 + 远端往返」叠在同一租约里。

- 若必须「读库 → 调远端 → 写库」,拆成两段短事务,接受中间态并用幂等收敛,别用一条长事务绑死连接。

正例骨架:

```java

@Service

public class OrderFacade {

private final OrderRepository orders;

private final RestClient billing;

public OrderFacade(OrderRepository orders, RestClient billing) {

this.orders = orders;

this.billing = billing;

}

public void place(OrderCmd cmd) {

Long id = orders.insertPending(cmd); // 短事务在 Repository 内提交

billing.post().uri("/invoice").body(cmd).retrieve().toBodilessEntity();

orders.markPlaced(id); // 另一段短事务

}

}

```

开 VT 之后,这类反模式会更容易被流量放大:以前平台线程池把并发硬顶在几十,长租连接的危害被「线程不够」掩盖;现在 VT 放开并发,长租会更快把小池掏空。定容公式管「池该多大」,短事务管「连接占多久」,两件事得一起改。

## 五、落地清单与监控

上线或回归时逐条勾一遍:

1. **确认 VT 开关与池配置分离**:`spring.threads.virtual.enabled` 与 `spring.datasource.hikari.*` 分开评审,禁止「开 VT 顺手把 pool 调到 200」。

2. **用公式给初值**:按数据库核数与磁盘估 `maximum-pool-size`;多实例时核对总连接上限。

3. **超时策略**:`connection-timeout` 保持秒级 fail-fast;业务侧对借连接失败做限流与快速失败响应,避免无脑重试。

4. **代码审计**:搜 `@Transactional` 方法内是否有 HTTP 或长 RPC;有则拆边界。

5. **监控面板**:Hikari 活跃连接、空闲连接、等待获取的线程数;数据库侧会话数、锁等待、CPU。池打满但数据库 CPU 仍低,优先查「长租连接」;池打满且数据库 CPU / 锁已高,再谈是否略增池或削减入口并发。

6. **回滚预案**:若线上因扩池过大导致数据库会话打满,优先把 `maximum-pool-size` 收回公式附近,并临时收紧入口限流;不要在未看数据库指标前继续加池。

7. **与上下文改造分工**:ThreadLocal / Scoped Values 解决的是请求上下文在 VT 间的传递成本;Hikari 定容解决的是下游有界资源。两者可以同一迭代推进,但评审清单要分开打勾,避免「改了上下文就顺手把池调爆」。

最后一句:**虚拟线程放大的是并发单元数量,数据库能力一分没涨。** Hikari 继续按库定容、小池饱和、超时快速失败。排队就留在应用侧看得见的地方,别用「按 VT 扩池」把压力偷偷甩给数据库。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询