本文是 Spring Boot 4 系列第 14 篇 | 基于 Spring Boot 4.1.0 + spring-jdbc 7.0.8 源码 | 预计阅读 25 分钟
文末附「完整链路图」与「四个核心设计思想」,评估要不要开启这个配置时可直接对照。
写在前面
前面讲了虚拟线程:spring.threads.virtual.enabled=true 一开,Tomcat 线程数从 200 变成"要多少有多少"。但并发上去之后,监控图上另一条曲线先拉满了——HikariCP 的连接池。
原因出在一段很常见的代码:
@Transactional
public OrderDetail getOrderDetail(String orderId) {
OrderDetail cached = this.redisTemplate.get(CACHE_KEY + orderId); // ① 先查缓存
if (cached != null) {
return cached; // ② 命中,直接返回
}
OrderDetail detail = this.orderMapper.findById(orderId); // ③ 只有走到这才需要数据库
this.redisTemplate.set(CACHE_KEY + orderId, detail);
return detail;
}
虚拟线程把并发请求数放大了一个数量级,其中大量请求都走 ①→② 的"缓存命中"路径——它们根本不需要数据库,但 @Transactional 在方法进入时就向连接池借了一个连接,一直持有到方法返回。连接池被一批"借了不用"的请求占满,真正要查库的请求反而排队。
Spring Boot 4.1 的方案只有一行配置:
spring:
datasource:
connection-fetch: "lazy"
加上之后,连接池里的连接只会在第一条 SQL 真正执行时才被借出;事务开启、提交、回滚这些"事务控制"动作,在没有任何 Statement 的情况下,可以完全不碰数据库。本文把这行配置背后的四条链路拆开:配置定义、自动装配、JDK 动态代理、事务管理器。
内容速览
- 全景图:一行配置到"不取连接"的四条链路
- 属性定义:defaultValue 是 eager,惰性需要显式开启
- 一个常见误读:connectionFetch 并没有绑到 DataSourceProperties
- 自动装配:@ConditionalOnProperty + BeanPostProcessor,只包装 bean 名 "dataSource"
- LazyConnectionDataSourceProxy:getConnection() 返回的是动态代理,不是物理连接
- 代理分派三档:equals/hashCode 绕开、事务控制只存值、数据访问才借连接
- doBegin/doCommit 源码:事务全流程不碰池(首次默认值探测除外)
- 收益测算:80% 是"缓存命中型请求占比"的映射,不是官方承诺
- 五个边界与坑:JNDI/XA 不生效、TransactionAwareDataSourceProxy 顺序、泄漏检测、指标偏低
- 完整时序图与四个核心设计思想
一、全景图:一行配置到"不取连接"的四条链路
spring.datasource.connection-fetch=lazy
│ ① 配置层:additional-spring-configuration-metadata.json(defaultValue=eager,hints: lazy/eager)
▼
LazyConnectionDataSourceConfiguration(module/spring-boot-jdbc)
│ ② 自动装配:@ConditionalOnProperty(connection-fetch=lazy) + @ConditionalOnBean(DataSource)
▼
LazyConnectionDataSourceBeanPostProcessor(BeanPostProcessor,Ordered=0)
│ ③ 只包装 bean 名为 "dataSource" 的自动配置池 → new LazyConnectionDataSourceProxy(dataSource)
▼
LazyConnectionDataSourceProxy.getConnection()(spring-jdbc 7.0.8)
│ ④ 返回 JDK 动态代理(实现 ConnectionProxy),此时并未向池借连接
▼
代理方法分派(LazyConnectionInvocationHandler)
├─ 事务控制类(setAutoCommit/setReadOnly/setTransactionIsolation/commit/rollback/close…)
│ └─ 只存值 / 直接忽略 → 不碰池
└─ 数据访问类(createStatement/prepareStatement…)
└─ getTargetConnection() → 第一次真正向池借连接 → 回放事务设置
从上到下,每一层都有真实源码可以对照。下面逐层拆。
二、第一层:配置是怎么被定义的
2.1 属性元数据:defaultValue 是 eager
先看属性本身的定义。Spring Boot 4.1.0 的 module/spring-boot-jdbc/src/main/resources/META-INF/additional-spring-configuration-metadata.json:
{
"name": "spring.datasource.connection-fetch",
"type": "java.lang.String",
"description": "Whether the auto-configured DataSource should be wrapped in a proxy that fetches JDBC connections as late as possible.",
"defaultValue": "eager"
}
配套的 IDE 提示(同文件 hints 段)给出了两个合法值:
{
"name": "spring.datasource.connection-fetch",
"values": [
{ "value": "eager", "description": "Fetch JDBC connections directly." },
{ "value": "lazy", "description": "Fetch JDBC connections as late as possible, or not at all if no statement is executed." }
]
}
注意两个细节:
- 默认是
eager——惰性是显式开启的,不会悄悄改变老应用的行为; lazy的官方描述就说了"or not at all":如果从头到尾没有执行过 Statement,这个连接可能永远不会借出。这正是收益的来源。
2.2 一个常见误读:这个属性没有绑到 DataSourceProperties
网上很多文章会想当然地写"DataSourceProperties 新增了 connectionFetch 字段"。实际翻遍 module/spring-boot-jdbc/.../DataSourceProperties.java,没有任何 connectionFetch 字段——这个属性不参与配置绑定,而是被自动配置类的 @ConditionalOnProperty 直接读取(见下一节)。Boot 4.1 的模块化让"读配置"和"绑配置"分开了:需要做条件判断的属性直接用 @ConditionalOnProperty,需要暴露给业务代码的才进 Properties 类。
三、第二层:自动装配——一个 BeanPostProcessor 完成包装
3.1 触发条件与注册位置
module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/autoconfigure/LazyConnectionDataSourceConfiguration.java(该文件由 2026-03-23 的提交 "Add lazy JDBC connection fetching for pooled DataSources" 加入,是 4.1 新增特性):
@Configuration
@ConditionalOnBean(DataSource.class)
@ConditionalOnProperty(name = "spring.datasource.connection-fetch", havingValue = "lazy")
class LazyConnectionDataSourceConfiguration {
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static LazyConnectionDataSourceBeanPostProcessor lazyConnectionDataSourceBeanPostProcessor(
ObjectProvider<MBeanExporter> mbeanExporter) {
return new LazyConnectionDataSourceBeanPostProcessor(mbeanExporter);
}
// ...
}
两个条件:容器里已经有一个 DataSource + 配置显式写了 lazy。
它被谁加载?看 DataSourceAutoConfiguration(同样在 module/spring-boot-jdbc,@since 4.0.0):
@Configuration(proxyBeanMethods = false)
@Conditional(PooledDataSourceCondition.class)
@ConditionalOnMissingBean({ DataSource.class, XADataSource.class })
@Import({ DataSourceConfiguration.Hikari.class, DataSourceConfiguration.Tomcat.class,
DataSourceConfiguration.Dbcp2.class, DataSourceConfiguration.OracleUcp.class,
DataSourceConfiguration.Generic.class, DataSourceJmxConfiguration.class,
LazyConnectionDataSourceConfiguration.class })
protected static class PooledDataSourceConfiguration {
// ...
}
LazyConnectionDataSourceConfiguration 被 @Import 进 PooledDataSourceConfiguration——只有走"池化 DataSource 自动配置"分支才会生效。这也解释了官方文档(data/sql.adoc)为什么开宗明义写 "When a pooled DataSource is auto-configured"。
3.2 包装逻辑:只认名为 "dataSource" 的 Bean
再看核心的 BeanPostProcessor:
static class LazyConnectionDataSourceBeanPostProcessor implements BeanPostProcessor, Ordered {
@Override
public @Nullable Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (beanName.equals("dataSource") && bean instanceof DataSource dataSource) {
this.mbeanExporter.ifAvailable((exporter) -> {
if (JmxUtils.isMBean(dataSource.getClass())) {
exporter.registerManagedResource(dataSource, beanName);
}
});
return new LazyConnectionDataSourceProxy(dataSource);
}
return bean;
}
@Override
public int getOrder() {
return 0;
}
}
三个值得注意的设计:
- 按 bean 名精确匹配
"dataSource":只包装 Boot 自动配置出来的那个池,你自己注册的其他DataSource(比如读写分离的两个库)不受影响——这是"最小侵入"的边界; - 先注册 JMX MBean,再包装:如果目标是 HikariDataSource 这类 MBean,先把真实池以原名注册进 JMX 域,再包上代理——这样你从 JConsole 看到的依然是 HikariCP 的统计(活动连接数、池大小等),而不是代理对象;
Ordered = 0的正序:如果你还要叠加其他 DataSource 包装器(比如TransactionAwareDataSourceProxy),顺序很重要——官方文档专门有一条 TIP(见第七节)。
值得注意的是,包装不在 DataSourceConfiguration.dataSource() 方法里,而是用 BeanPostProcessor 在初始化之后替换 bean 实例。好处是池本身的创建逻辑(Hikari/Tomcat/Dbcp2/OracleUcp/Generic 五套)一行都不用动,同时 DataSourceBuilder、JNDI、XA 等分支也完全不用感知惰性这件事。
四、第三层:LazyConnectionDataSourceProxy——连接代理怎么做到"不取连接"
这一层是核心实现,来自 spring-jdbc 7.0.8 的 org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy(这个类在 Spring 里其实有二十年历史了,@since 1.1.4,但 Boot 4.1 第一次把它接到了自动配置上)。
4.1 getConnection() 返回的是代理对象,不是物理连接
@Override
public Connection getConnection() throws SQLException {
checkDefaultConnectionProperties();
return (Connection) Proxy.newProxyInstance(
ConnectionProxy.class.getClassLoader(),
new Class<?>[] {ConnectionProxy.class},
new LazyConnectionInvocationHandler());
}
关键点:getConnection() 在返回时没有向目标 DataSource 借出连接(唯一的例外是紧随其后的默认值探测,见下),而是 Proxy.newProxyInstance 造了一个实现 ConnectionProxy 接口的 JDK 动态代理。借连接的动作被推迟到了代理内部。
(一个细节:checkDefaultConnectionProperties() 会在"默认 auto-commit 和隔离级别未知"时真的向池借一次连接,读出 getAutoCommit()/getTransactionIsolation() 两个值后立刻 close() 归还——也就是说第一个 getConnection() 会访问池一次,之后默认值已知,就只造代理了。)
4.2 代理分派:哪些方法不取连接,哪些方法取
LazyConnectionInvocationHandler.invoke() 是整个机制的核心。当还没有持有物理连接(!hasTargetConnection())时,它按方法名分三档处理:
第一档:连代理都绕开——equals / hashCode / getTargetConnection / unwrap
case "equals" -> {
// We must avoid fetching a target Connection for "equals".
return (proxy == args[0]);
}
case "hashCode" -> {
// use hashCode of Connection proxy
return System.identityHashCode(proxy);
}
放进 HashMap、做对象比较这类高频操作不会误触借连接。
第二档:事务控制类方法——只存值或直接忽略,不碰池
case "setAutoCommit" -> { this.autoCommit = (Boolean) args[0]; return null; }
case "setTransactionIsolation" -> { this.transactionIsolation = (Integer) args[0]; return null; }
case "setReadOnly" -> { this.readOnly = (Boolean) args[0]; return null; }
case "commit", "rollback" -> {
// Ignore: no statements created yet.
return null;
}
case "close" -> {
// Ignore: no target connection yet.
this.closed = true;
return null;
}
case "getAutoCommit" -> {
if (this.autoCommit != null) { return this.autoCommit; }
// Else fetch actual Connection and check there
}
源码注释原文是 "Ignore: no statements created yet"。事务开启时的 setAutoCommit(false)、setReadOnly(true)、setTransactionIsolation(...) 全部只是写进代理的字段;没有 Statement 时 commit()/rollback()/close() 直接吞掉。对一个"借了不用"的连接来说,整个生命周期里代理层就能自洽,池根本不知道它的存在。
第三档:一切其他方法(数据访问类)——这才触发真正借连接
// Target Connection already fetched, or target Connection necessary for current operation
Connection conToUse = getTargetConnection(method);
return method.invoke(conToUse, args);
createStatement()、prepareStatement()、prepareCall() 这些方法没有特殊分支,落入 default,走 getTargetConnection(method)。看它的实现:
// Fetch physical Connection from DataSource.
DataSource dataSource = getDataSourceToUse();
target = (this.username != null ? dataSource.getConnection(this.username, this.password) :
dataSource.getConnection());
// ...
// Apply kept transaction settings, if any.
if (this.autoCommit != null && this.autoCommit != defaultAutoCommit()) {
target.setAutoCommit(this.autoCommit);
}
if (this.transactionIsolation != null &&
!this.transactionIsolation.equals(defaultTransactionIsolation())) {
target.setTransactionIsolation(this.transactionIsolation);
}
此刻才真正调用 dataSource.getConnection() 从池里借连接,然后把之前"攒着"的事务设置回放上去。注意回放时的两个 != default 判断:如果 setAutoCommit(false) 本来就等于池的默认配置,就不再调 setAutoCommit——少一次 JDBC 驱动往返(DataSourceTransactionManager.doBegin() 的注释里也提到过:手动切换 auto-commit 在某些驱动里很昂贵,"so we don't want to do it unnecessarily")。
4.3 怎么拿回真实的 DataSource:unwrap
包装之后,业务代码如果还需要操作真实池(比如改 HikariCP 连接数、看统计),官方文档给的方法是:
"To get access to the target auto-configured
DataSource, useDataSource.unwrap."
这条链路是通的:LazyConnectionDataSourceProxy 继承 DelegatingDataSource,而 DelegatingDataSource 覆写了 unwrap:
@Override
public <T> T unwrap(Class<T> iface) throws SQLException {
if (iface.isInstance(this)) {
return (T) this;
}
return obtainTargetDataSource().unwrap(iface);
}
所以 dataSource.unwrap(HikariDataSource.class) 会一路委托到真实池(LazyConnectionDataSourceConfigurationTests 里就是这么断言的)。Boot 自己的基础设施也在用类似机制——module/spring-boot-jdbc 的 DataSourceUnwrapper 被 DataSourcePoolMetricsAutoConfiguration(连接池指标)、DataSourceJmxConfiguration(JMX)和 HikariCheckpointRestoreLifecycle(CRaC 快照恢复)用来穿透代理拿到真实池;健康检查则直接通过代理执行校验 SQL(DataSourceHealthContributorAutoConfiguration 对路由数据源还会先 unwrap(AbstractRoutingDataSource.class) 判定),照常触发首次取连接,功能不受影响。指标、JMX、健康检查都不会被惰性代理挡住。
五、第四层:事务链路——doBegin 如何做到不碰池
5.1 DataSourceTransactionManager.doBegin()
@Transactional 走的是 DataSourceTransactionManager(spring-jdbc 7.0.8),事务开启的核心是 doBegin()。按"连接代理"的视角过一遍关键行:
protected void doBegin(Object transaction, TransactionDefinition definition) {
DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
Connection con = null;
try {
if (!txObject.hasConnectionHolder() ||
txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
Connection newCon = obtainDataSource().getConnection(); // ① 拿到的是懒代理
// ...
txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
}
// ...
Integer previousIsolationLevel = DataSourceUtils.prepareConnectionForTransaction(con,
definition.getIsolationLevel(),
(definition.isReadOnly() && !isDefaultReadOnly())); // ② 只读/隔离级别 → 存值
// ...
if (con.getAutoCommit()) { // ③ 读缓存的默认值
txObject.setMustRestoreAutoCommit(true);
con.setAutoCommit(false); // ④ 存值,不碰池
}
prepareTransactionalConnection(con, definition);
// ...
}
}
逐行对照 4.2 的分派表:
- ①
obtainDataSource().getConnection()→ 返回懒代理(默认值已知后不借连接); - ②
DataSourceUtils.prepareConnectionForTransaction(...)→ 内部setReadOnly(...)、getTransactionIsolation()/setTransactionIsolation(...)全部落入"只存值"分支; - ③
con.getAutoCommit()→ 返回代理缓存值,不需要物理连接; - ④
con.setAutoCommit(false)→ 存值。
除 4.1 节提到的首次默认值探测(借一次、读两个 getter 后立即归还)外,事务开启的完整流程不会向池借出连接。 官方文档(data/sql.adoc §Lazy Connection Proxy)的原话:
"With this feature enabled, JDBC Connections are only fetched from the pool when actually necessary. JDBC transaction control can happen without fetching a Connection from the pool or communicating with the database; this will be done lazily on the first creation of a JDBC Statement."
5.2 提交/回滚/清理阶段
再看事务结束的 doCommit:
protected void doCommit(DefaultTransactionStatus status) {
DataSourceTransactionObject txObject = (DataSourceTransactionObject) status.getTransaction();
Connection con = txObject.getConnectionHolder().getConnection();
con.commit();
}
con.commit() 在无 Statement 时被代理直接忽略("commit", "rollback" -> return null);清理阶段的 con.setAutoCommit(true) 与 resetConnectionAfterTransaction(恢复只读/隔离级别)同样只是存值,最后经 DataSourceUtils.releaseConnection 调用的 con.close() 也被忽略。整条链路:开事务 → 查缓存 → 命中返回 → 提交 → 关闭,池从未被借出连接(同样排除首次默认值探测那一次短暂借出)。
完整时序:
@Transactional 方法进入
└─ DataSourceTransactionManager.doBegin()
├─ dataSource.getConnection() → 懒代理(无物理连接)
├─ con.setAutoCommit(false) → 代理字段,不碰池
└─ TransactionSynchronizationManager.bindResource(...)
└─ 业务代码查缓存命中,返回
└─ DataSourceTransactionManager.doCommit()
└─ con.commit() → 无 Statement,代理忽略
└─ 清理:con.close() → 无物理连接可归还
═══════════════════════════════════════════════════
HikariCP.getConnection() 从未被调用(除首次默认值探测的一次短暂借出/立即归还)
有 SQL 的路径则变成:
└─ JdbcTemplate / MyBatis 执行 SQL
└─ con.prepareStatement(...) → 第一次触发
└─ getTargetConnection()
└─ HikariDataSource.getConnection() ← 真正借连接
└─ 回放 autoCommit / 隔离级别 / 只读设置
六、收益量化:省下的到底是什么
6.1 先纠正一个流传的说法
很多文章会说"4.1 默认开虚拟线程,所以连接池打满了"。这是错的:Spring Boot 4.1.0 的配置元数据里,spring.threads.virtual.enabled 的 defaultValue 是 false——虚拟线程需要显式开启。正确的因果链是:
- 你主动开了虚拟线程(
spring.threads.virtual.enabled=true)→ 并发请求数上限从"平台线程数"变成"几乎无上限"; - 并发上去了,每个请求进
@Transactional方法就借一个连接 → 连接池变成新的瓶颈; - 其中大量请求走"缓存命中即返回"路径,连接借了不用 → 池被无效占用;
- 惰性连接把这些请求的借连接动作去掉 → 池的"真实利用率"上来了。
换句话说:虚拟线程解决的是线程稀缺,connection-fetch=lazy 解决的是连接稀缺——两者是配套关系,不是因果关系。
6.2 示意测算(非官方数据)
以"查缓存→命中→返回"的只读路径为例,一个请求从进方法到提交,连接的生命周期对比:
| 阶段 | eager(默认) | lazy |
|---|---|---|
doBegin() 事务开启 | 立即借连接 | 不借(纯代理) |
| 查 Redis 缓存 | 占着连接 | 不借 |
| 命中返回 | 占着连接 | 不借 |
commit() 提交 | 归还连接 | 不借 |
| 池占用时间 | 整个方法执行期间 | 0 |
如果这类请求占全部请求的 80%,那连接池的"借了不用"占用就降掉了 80%——这就是"省下 80% 连接占用"说法的出处:它是"纯缓存命中型请求占比"的近似值,不是官方承诺的固定数字。真实收益 = 该比例 × 你的请求画像,缓存命中率越高的服务收益越大;写密集的服务(每个事务都执行 SQL)收益趋近于零。
严格说,首次
getConnection()会触发一次默认值探测(借一个连接、读完getAutoCommit()/getTransactionIsolation()立即归还),之后才进入真正的"零借出"状态——示意表格省略了这个一次性开销。
另外注意边界:惰性是"延后借",不是"缩短持有"。一旦某个事务执行了第一条 SQL,物理连接就借出了,并且照常持有到事务结束——它优化的是"从未借出"的部分,不是"借了之后尽快还"的部分。
七、边界与注意事项
-
只对自动配置的池生效:BeanPostProcessor 只匹配 bean 名
dataSource。注意 JNDI 与 XA 自动配置创建的 bean 也叫dataSource——它们不被包装的真正原因是LazyConnectionDataSourceConfiguration只被PooledDataSourceConfiguration导入,而后者在容器里已有DataSource/XADataSource时整体退出(JNDI 与 XA 都声明@AutoConfiguration(before = DataSourceAutoConfiguration.class),会先把自己的 bean 注册好);你自己 new 的HikariDataSource同理,不在覆盖范围内。 -
TransactionAwareDataSourceProxy的顺序:官方文档 TIP 原文——"If you are wrapping the target
DataSourcein aTransactionAwareDataSourceProxy, make sure to do so with aBeanPostProcessorthat has a positive order."因为惰性代理的 BeanPostProcessor 是
Ordered = 0,你的TransactionAwareDataSourceProxy包装器必须 order 大于 0(在其后执行),才能让它成为最外层。 -
HikariCP 7.0.2 与泄漏检测:Boot 4.1 管理的 HikariCP 版本是 7.0.2(本地 Gradle 缓存实证)。如果你配了 Hikari 的
leakDetectionThreshold,注意它的计时从物理借出时刻开始算——惰性模式下连接借得晚,泄漏检测的"借用时长"天然变短,但真正泄漏的连接(借了不还)依然会被正常检出,两者不冲突。 -
JMX 与指标不会失效:Boot 先把真实池注册进 JMX 域再包装;连接池指标(
hikaricp_connections_active等)通过DataSourceUnwrapper穿透代理,健康检查则直接经代理执行校验 SQL——两者都正常工作。唯一要留意的行为差异:hikaricp_connections_active在 lazy 模式下会普遍偏低——这正是它想要的效果,不要当成异常去排查。 -
只读事务还有一个进阶配置:
LazyConnectionDataSourceProxy从 Spring 6.1.2 起支持setReadOnlyDataSource——只读事务可以指定一个专用的只读数据源(比如只读副本),惰性代理在readOnly事务里从它借连接。Boot 4.1 的自动配置暂时只暴露了connection-fetch这一个开关,这个能力面向编程式使用。
八、总结
用一张图回顾这一行配置的完整链路:
spring.datasource.connection-fetch=lazy
→ @ConditionalOnProperty 命中(defaultValue 是 eager,显式开启)
→ PooledDataSourceConfiguration @Import LazyConnectionDataSourceConfiguration
→ BeanPostProcessor(Ordered=0) 只包装 bean 名 "dataSource"
→ LazyConnectionDataSourceProxy(先注册 JMX MBean,再包装)
→ getConnection() 返回 ConnectionProxy 动态代理(不借连接)
├─ 事务控制方法:存值 / 忽略("no statements created yet")
└─ 首个 Statement:getTargetConnection() 才真正向池借连接并回放设置
→ DataSourceTransactionManager.doBegin/doCommit 不碰池(首次默认值探测除外)
→ unwrap / DataSourceUnwrapper 让指标、健康检查、JMX 穿透代理
四个核心设计思想:
- 延迟绑定而不是新建机制:
LazyConnectionDataSourceProxy是 Spring 1.1.4 就有的老类,Boot 4.1 做的只是把它"接上自动配置"——框架复用了已有组件,没有新造一套; - BeanPostProcessor 的包装模式:不动五套池的创建代码,在初始化后按 bean 名精确替换,
Ordered排序留给叠加场景——这是自动配置里做"包装类特性"的标准做法; - 代理的分档设计:
equals/hashCode不能触发副作用,事务控制方法只存值、不碰网络,只有真正要数据的方法才付出代价——每一档都对应明确的性能语义; - 收益要对着请求画像算:80% 是"缓存命中型请求占比"的映射,不是固定数字。先看你的流量构成,再决定要不要开。