Spring Boot 4.1 JDBC 惰性连接源码剖析:一行配置怎么省下 80% 连接占用

149 阅读14分钟

本文是 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." }
  ]
}

注意两个细节:

  1. 默认是 eager——惰性是显式开启的,不会悄悄改变老应用的行为;
  2. 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;
	}
}

三个值得注意的设计:

  1. 按 bean 名精确匹配 "dataSource":只包装 Boot 自动配置出来的那个池,你自己注册的其他 DataSource(比如读写分离的两个库)不受影响——这是"最小侵入"的边界;
  2. 先注册 JMX MBean,再包装:如果目标是 HikariDataSource 这类 MBean,先把真实池以原名注册进 JMX 域,再包上代理——这样你从 JConsole 看到的依然是 HikariCP 的统计(活动连接数、池大小等),而不是代理对象;
  3. 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, use DataSource.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——虚拟线程需要显式开启。正确的因果链是:

  1. 你主动开了虚拟线程(spring.threads.virtual.enabled=true)→ 并发请求数上限从"平台线程数"变成"几乎无上限";
  2. 并发上去了,每个请求进 @Transactional 方法就借一个连接 → 连接池变成新的瓶颈;
  3. 其中大量请求走"缓存命中即返回"路径,连接借了不用 → 池被无效占用;
  4. 惰性连接把这些请求的借连接动作去掉 → 池的"真实利用率"上来了。

换句话说:虚拟线程解决的是线程稀缺,connection-fetch=lazy 解决的是连接稀缺——两者是配套关系,不是因果关系。

6.2 示意测算(非官方数据)

以"查缓存→命中→返回"的只读路径为例,一个请求从进方法到提交,连接的生命周期对比:

阶段eager(默认)lazy
doBegin() 事务开启立即借连接不借(纯代理)
查 Redis 缓存占着连接不借
命中返回占着连接不借
commit() 提交归还连接不借
池占用时间整个方法执行期间0

如果这类请求占全部请求的 80%,那连接池的"借了不用"占用就降掉了 80%——这就是"省下 80% 连接占用"说法的出处:它是"纯缓存命中型请求占比"的近似值,不是官方承诺的固定数字。真实收益 = 该比例 × 你的请求画像,缓存命中率越高的服务收益越大;写密集的服务(每个事务都执行 SQL)收益趋近于零。

严格说,首次 getConnection() 会触发一次默认值探测(借一个连接、读完 getAutoCommit()/getTransactionIsolation() 立即归还),之后才进入真正的"零借出"状态——示意表格省略了这个一次性开销。

另外注意边界:惰性是"延后借",不是"缩短持有"。一旦某个事务执行了第一条 SQL,物理连接就借出了,并且照常持有到事务结束——它优化的是"从未借出"的部分,不是"借了之后尽快还"的部分。


七、边界与注意事项

  1. 只对自动配置的池生效:BeanPostProcessor 只匹配 bean 名 dataSource。注意 JNDI 与 XA 自动配置创建的 bean 也叫 dataSource——它们不被包装的真正原因是 LazyConnectionDataSourceConfiguration 只被 PooledDataSourceConfiguration 导入,而后者在容器里已有 DataSource/XADataSource 时整体退出(JNDI 与 XA 都声明 @AutoConfiguration(before = DataSourceAutoConfiguration.class),会先把自己的 bean 注册好);你自己 new 的 HikariDataSource 同理,不在覆盖范围内。

  2. TransactionAwareDataSourceProxy 的顺序:官方文档 TIP 原文——

    "If you are wrapping the target DataSource in a TransactionAwareDataSourceProxy, make sure to do so with a BeanPostProcessor that has a positive order."

    因为惰性代理的 BeanPostProcessor 是 Ordered = 0,你的 TransactionAwareDataSourceProxy 包装器必须 order 大于 0(在其后执行),才能让它成为最外层。

  3. HikariCP 7.0.2 与泄漏检测:Boot 4.1 管理的 HikariCP 版本是 7.0.2(本地 Gradle 缓存实证)。如果你配了 Hikari 的 leakDetectionThreshold,注意它的计时从物理借出时刻开始算——惰性模式下连接借得晚,泄漏检测的"借用时长"天然变短,但真正泄漏的连接(借了不还)依然会被正常检出,两者不冲突。

  4. JMX 与指标不会失效:Boot 先把真实池注册进 JMX 域再包装;连接池指标(hikaricp_connections_active 等)通过 DataSourceUnwrapper 穿透代理,健康检查则直接经代理执行校验 SQL——两者都正常工作。唯一要留意的行为差异:hikaricp_connections_active 在 lazy 模式下会普遍偏低——这正是它想要的效果,不要当成异常去排查。

  5. 只读事务还有一个进阶配置: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 穿透代理

四个核心设计思想:

  1. 延迟绑定而不是新建机制:LazyConnectionDataSourceProxy 是 Spring 1.1.4 就有的老类,Boot 4.1 做的只是把它"接上自动配置"——框架复用了已有组件,没有新造一套;
  2. BeanPostProcessor 的包装模式:不动五套池的创建代码,在初始化后按 bean 名精确替换,Ordered 排序留给叠加场景——这是自动配置里做"包装类特性"的标准做法;
  3. 代理的分档设计:equals/hashCode 不能触发副作用,事务控制方法只存值、不碰网络,只有真正要数据的方法才付出代价——每一档都对应明确的性能语义;
  4. 收益要对着请求画像算:80% 是"缓存命中型请求占比"的映射,不是固定数字。先看你的流量构成,再决定要不要开。