ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入理解Mono.defer:解决响应式编程中重复订阅结果相同的诡异问题

深入理解Mono.defer:解决响应式编程中重复订阅结果相同的诡异问题

几位同事在排查同一个问题:为什么一个返回UUID的响应式接口,在网关里明明只发起了一次请求,日志里却出现了两次相同的执行结果?参数一模一样,时间戳一模一样,连生成的UUID都一模一样。这种“看着像是被缓存了”的现象,在反应式编程里其实非常典型,十有八九是因为把本应在订阅时才执行的逻辑,提前放到了构建阶段。

Mono.defer就是解决这一类问题的关键操作符。很多人知道它是“延迟创建”,但真正遇到场景时还是容易用错,或者不知道它和Mono.just、Mono.fromSupplier之间的边界在哪里。这篇文章从一次重复订阅的现象出发,把Mono.defer的原理、源码行为、实战用法和踩坑记录一次性说透,适合刚接触Reactor生态的开发者,也适合那些已经在写响应式代码但偶尔被“同一结果订阅两次”困扰的同学。

1. Mono.defer到底是干嘛的:一次重复订阅引发的思考

1.1 从Mono.just暴露的问题说起

先还原一下当时的场景。某个微服务对外提供查询接口,内部大致这么写:

public Mono<String> queryCurrentTransactionId() { String transactionId = generateTransactionId(); // 执行一些耗时逻辑 return Mono.just(transactionId); }

这段代码表面看没什么问题,但其实在方法被调用的那一刻,generateTransactionId()就已经执行完了。Mono.just(...)只是把已经算好的结果装进一个反应式包装盒里,后续无论订阅多少次,拿到都是同一个对象,不会重新调用生成逻辑。

网关层面做了重试或者熔断后的半开探测时,会出现两次订阅。下游接口日志里看到同一个事务ID出现两次,有些同学第一反应是“缓存了”,第二反应是“序列化出错了”,很少有人会想到问题出在创建Mono的时机上。

更隐蔽的是,如果generateTransactionId()里面有副作用,比如写入一条日志、更新一个计数器,那么它在方法调用时就已经发生,跟订阅完全不挂钩。这在普通命令式编程里很正常,但在响应式编程里,我们默认的逻辑是“流被订阅时才执行”。这种心智模型的错位,是很多反应式代码难以调试的根源。

1.2 冷信号与热信号:defer提供了什么语义

要理解Mono.defer,必须先理解冷信号和热信号的区别。热信号像直播间的画面,直播已经开始,你随时进去看到的是当前这一刻的画面,错过的不再重播。冷信号像点播视频,每个观众点击播放,都从头开始播一遍,观众之间互不影响。

Mono.just(1)是一个已算好的结果,虽然它本身不是严格意义上的热信号,但它的值在构建时就固定了,每个订阅者拿到的都是同一个值。Mono.fromSupplier(() -> computeValue())稍微好一点,它会在每次订阅时调用Supplier重新取值,但如果Supplier里返回的是一个Mono对象,那就有区别了。

Mono.defer(() -> Mono.just(computeValue()))才算真正实现了冷信号语义。外层defer接收一个Supplier,但它要求Supplier返回一个Mono,而不是直接返回数据。更关键的是,每次订阅时defer都会重新调用这个Supplier,拿到一个全新的Mono再订阅。也就是说,从“创建流”到“订阅流”的整个过程,全部被推迟到了订阅发生的那一刻。

这个冷热信号的区别,直接决定了多次订阅时看到的是同一个老值,还是每次都是新值。遇到“重复订阅时结果相同”这类问题,优先怀疑有没有把副作用提前到构建阶段,基本错不了。

2. Mono.defer工作原理:源码层面的延迟工厂

2.1 函数签名与类型推导

先看一下defer的方法签名:

public static <T> Mono<T> defer(Supplier<? extends Mono<? extends T>> supplier)

参数是一个Supplier,它的返回值被限定为Mono的子类型。这意味着你必须在Supplier内部构造出一个Mono来返回,而不能像fromSupplier那样直接返回一个普通的值。

这个限定很关键,它决定了Mono.defer的应用场景。fromSupplier适合“延迟一个无副作用的取值”,比如读配置、读时间;defer适合“延迟整个反应式流程的搭建”,比如根据不同的上下文决定返回Mono.just(...)还是Mono.error(...),或者返回一个需要建立数据库连接才能得到的Mono。

举个实际场景。有一个方法要根据传入的用户渠道决定走哪个服务,大多数人一开始会这么写:

Mono<UserInfo> result; if ("vip".equals(channel)) { result = queryVipUser(userId); } else { result = queryNormalUser(userId); } return result;

这种写法的缺点是:queryVipUser和queryNormalUser在if判断的时候就已经被调用了,两个分支的代码都会执行,只是其中一个结果被丢弃。如果这两个方法内部有数据库连接、缓存预加载、异步任务开启等副作用,那就是白白浪费资源。

改成defer就能避免:

return Mono.defer(() -> "vip".equals(channel) ? queryVipUser(userId) : queryNormalUser(userId));

由于defer的Supplier是延迟执行的,if判断被推迟到了订阅时才发生,所以订阅前,两个分支都不会被调用。

2.2 每次订阅发生了什么

从Reactor的实现来看,Mono.defer返回的是一个MonoDefer实例,它最核心的逻辑在subscribeOrReturn方法里。我简化一下实际的内部行为:

当外部调用subscribe()时,MonoDefer会先调用你传入的Supplier,拿到一个新的Mono实例,然后再调用这个新Mono的subscribe方法。

这意味着两件事。第一,Supplier不是普通的懒加载变量,它不会缓存结果;每次订阅都会重新调用。如果用别的内部状态来缓存supplier的结果,比如把第一个Mono实例存到字段里,第二次订阅返回同一个实例,那就破坏了defer的语义。第二,在创建Mono和订阅Mono之间,如果supplier内部涉及线程切换或者异步资源获取,那么这些耗时操作的启动时机就取决于订阅时机,而不是构建时机。

之前有位朋友写了一个自定义的Mono,想在defer的supplier里去构造一个很昂贵的连接池,但连接池构建后用一个静态常量缓存了。结果第一次订阅没问题,第二次订阅复用了旧连接池,业务上数据就读到了过期配置。这就是没有理解“每次订阅重新调用supplier”这个约定造成的。

2.3 一个容易被忽略的细节:supplier的返回值

使用defer时,supplier返回null会直接触发NullPointerException。这个异常和普通NPE不太一样,它是在订阅阶段抛出的,而不是在创建阶段。如果是用Mono.just(fetchData()),fetchData里抛异常是什么事,什么时候抛,完全取决于那段代码怎么写的;但defer内部如果返回null,则一定是订阅时才爆出来。

另外注意,supplier返回的Mono本身也可以是非惰性的。啥意思呢?如果你在supplier里写的是Mono.just(System.currentTimeMillis()),那没问题,因为System.currentTimeMillis()本来就要求每次执行都重新读取一次。但如果你写的是:

long start = System.currentTimeMillis(); return Mono.defer(() -> Mono.just(start));

那你延迟了Mono的创建,却没有延迟时间的读取。start在defer调用前就固定了,这个初学者最容易踩。supplier负责延迟的是返回值的计算过程,而不是把已经算出来的变量魔法般变成实时数据。

所以正确做法是把所有计算逻辑都放进supplier里:

return Mono.defer(() -> Mono.just(System.currentTimeMillis()));

到这一步,Mono.defer的语义就比较完整了:构建时不执行逻辑,订阅时重建Mono,每次订阅重新触发supplier,supplier返回的Mono可以是任何复杂结构,甚至是错误流。

3. Mono.defer的实战应用:哪些场景非它不可

3.1 延迟执行耗时计算与模块初始化

不是所有耗时计算都必须用defer,但有些情况下defer对资源敏感型接口有明显收益。假设有一个配置加载的逻辑,启动时要读取本地文件,解析JSON,涉及几百个字段。如果直接Mono.just(loadConfig()),那启动时即使没人订阅,loadConfig也会执行。

有的团队会故意用Mono.just(...)包装“必须启动时就加载”的配置,因为它简单;但遇到那些“可能用、可能不用”的配置,比如某个灰度开关背后的配置,就得用defer把它延迟到首次订阅。

一次实际开发中,我给某个接口加了一个性能监控状态查询,这个查询依赖Metrics数据。最开始实现是Mono.just(metricCollector.collect()),结果这个collect方法在Spring容器启动阶段就被执行了,导致当时所有Metrics还是空的,查回来的状态永远不对。改成Mono.defer(() -> Mono.just(metricCollector.collect()))后,每次请求都是实时采集数据,问题直接消失。

这个例子很典型:只要数据源不是静态不变的,就不要用just提前取值;如果你写的返回类型是Mono,那么业务含义就是“我提供的是一个可订阅的流”,而不是“我把结果算好给你拿”。

3.2 包装受检异常:把异常变成订阅时才发生

受检异常和反应式流的结合一直很尴尬。Java要求捕获或者抛出受检异常,但在Mono.error()里传异常是延迟构造的,如果你在构建期就创建异常对象,异常栈会被提前保留,后续排查时看到的调用栈会指到构建位置而不是订阅位置,误导排查方向。

一个相对干净的模式是这样:

public Mono<String> readFileContent(Path path) { return Mono.defer(() -> { try { return Mono.just(Files.readString(path)); } catch (IOException e) { return Mono.error(new IllegalStateException("文件读取失败", e)); } }); }

Files.readString是受检异常,必须在try-catch里处理。用defer包一层后,文件读取的时机被推迟到订阅时。如果这个方法返回的Mono在业务里因为上游校验失败而没有被订阅,那么文件根本不会访问,这在批量任务中尤为有用。

你可以想象一个场景:一个服务有十个数据源,每个数据源对应一个文件,某个请求可能只用到其中一两个。如果所有文件的读取在接口入口就全做了,那磁盘IO开销会放大好几倍;用defer只在实际订阅时才读取需要的文件,整体IO自然降下来。

3.3 实现动态条件路由和依赖注入

前面提到的根据渠道选择服务,本质上是“在订阅时做路由”。这个模式在权限过滤、多租户、A/B测试等场景特别常见。

举例说明。有一个用户数据查询接口,内部要按租户ID选择不同的数据库表,而选择逻辑依赖请求头里的上下文:

public Mono<List<UserRecord>> queryByTenant(String tenantId) { return Mono.defer(() -> { if (tenantId.startsWith("vip-")) { return queryFromVipDatabase(tenantId); } else { return queryFromNormalDatabase(tenantId); } }); }

这里关键在于,queryFromVipDatabase返回的是一个Mono,这个Mono内部的数据库表名可能是在方法内部根据tenantId拼出来的。如果不用defer,那么在构建阶段就会执行这个拼接操作,而拼接操作依赖的请求上下文可能在订阅时已经发生了变化,导致数据错乱。

动态路由的核心价值是:把“选哪条路”的决定时间点,推迟到真正需要出发的那一刻。类似地,如果业务中需要根据某种状态多次切换数据源,那么每次订阅时重新走一遍路由逻辑是符合预期的;而用Mono.just则把路由结果固定死,后续状态变化不会生效。

依赖注入里也有类似场景。有些框架的上下文对象是存储在ThreadLocal中的,比如某个请求ID追踪器。如果直接在构建期读取ThreadLocal并塞进Mono.just,那后续线程切换后拿到的还是旧请求ID。配合defer再把ThreadLocal的读取放进supplier,才能做到每次订阅时都读取当前线程的值。当然实际用ThreadLocal配合异步要做更多上下文传递处理,这里不展开,但思路是一致的。

3.4 每次订阅都要不同的响应:冷流语义

有一类需求希望每次调用接口都返回一个随机值、当前时间戳或者UUID。如果写成Mono.just(UUID.randomUUID()),那么多次订阅得到的UUID完全一样,这对需要“每次请求一个唯一标识”的业务是致命的。

正确写法从Mono.just(...)改成Mono.defer(() -> Mono.just(UUID.randomUUID())),这样每次订阅都会触发UUID.randomUUID(),每次拿到的值都不同。

这种写法同样适用于“每次订阅重新执行数据库查询”的场景。设想一个模型在服务启动时加载了一个配置,但数据库里这个配置被后台任务修改了。第一次订阅用的配置是从数据库读的,第二次订阅时defer重新走一遍查询,能拿到最新值。如果你的业务允许短暂不一致,那用defer完全可以省掉手动刷新缓存的操作。

但要注意冷流语义不是银弹。如果你确实只想要一个固定值,或者值一旦生成就不希望再变,那就别用defer。defer的每次重建也是有开销的,虽然一般不大,但高频热路径上能省则省。

4. Mono.defer的坑和最佳实践:排查实录与经验总结

4.1 常见误区速查表

我根据日常帮人排查代码的经历,整理了一份高频误区清单,基本都是新手期容易踩的。

误区现象正确理解
以为defer会延迟supplier内部Mono的构建supplier里调用Mono.just(System.currentTimeMillis()),多次订阅拿时间不同,但supplier里若有静态变量缓存则可能相同defer只延迟supplier调用,supplier内部的代码逻辑是否延迟取决于你怎么写
在defer外面捕获变量外部先算出值,再传入lambda,延迟失效所有计算逻辑要放到lambda内部
错误地认为Mono.defer(() -> Mono.just(1))比Mono.just(1)快反而多了一层包装,性能没有优势defer的价值是延迟语义,不是性能优化
在supplier里返回null订阅时NPE,且堆栈指向订阅点supplier一定要返回非空Mono
希望defer能重复消费同一个Mono实例多次订阅到同一个日志输出每次订阅都会新建Mono,想复用得自己加缓存

最后一个误区其实挺有意思。比如你有一个Mono,里面带有doOnNext(x -> log.info("executed")),你用defer包了两次,以为只打印一次日志,结果打印了两次。原因很简单:defer每次订阅都会重新生成Mono,所以每次订阅都会从头走一遍整个流。这在某些情况下是优点,比如重试;但在某些情况下是缺点,比如你希望一个请求只触发一次副作用。如果你希望副作用只发生一次,请在构建期创建好Mono,而不是在defer里重建。

4.2 调试技巧:日志、doOnSubscribe和全局Hook

遇到“订阅时机不对”的问题时,怎么确认逻辑到底是在构建时执行还是订阅时执行?我一般用三个手段。

第一步,在代码的关键位置打日志:

Mono.defer(() -> { System.out.println("=== supplier executed ==="); return Mono.just("data"); });

Mono.just(...)构建时不打印任何内容。如果把日志放在defer的supplier里,只有当你订阅这个Mono时才会打印。如果日志在订阅前就出现,说明逻辑被提前执行了,这时候检查有无外部调用,比如在Controller里调用了block()、subscribe()或者框架隐式订阅。

第二步,用doOnSubscribe观察订阅动作:

Mono.defer(() -> Mono.just(fetchData())) .doOnSubscribe(s -> System.out.println("subscribed"));

这个日志会准确反映出订阅发生的时间和次数,配合subscribe()调用点一起看,能快速定位重复订阅的来源。有次排查网关重试问题,就是用doOnSubscribe发现同一个Mono被订阅了三次,扒出来是路由器配置了多重重试策略。

第三步,如果项目规模大,可以在启动类里注册Reactor的Hooks.onEachOperator全局钩子,统计某个操作符的订阅次数。但注意全局钩子会增加所有流的开销,生产环境慎用,通常只用于压测或者灰度验证阶段。

4.3 和flatMap与重试机制的联动

Mono.defer经常与retry、repeat配合使用,这也是最容易困惑的地方。

retry的实现机制是重新订阅上游。如果你在retry上游直接放了一个Mono.just(fetchConfig()),那么fetchConfig在流构建时就执行了一次,重试订阅拿到的还是同一个结果。重试看起来成功了,但数据没更新,等于没重试。把上游改成Mono.defer(() -> Mono.just(fetchConfig())),每次重试订阅都会重新执行fetchConfig,这才是真正的重试。

return Mono.defer(() -> Mono.just(fetchConfig())) .retry(3) .onErrorResume(e -> Mono.empty());

同样的原理也适用于repeat。repeat会多次订阅同一个Mono,配合defer能实现“每隔一段时间重新获取最新数据”的效果。如果不加defer,repeat再多遍也是返回同一个旧值。

flatMap里面也有类似的坑。flatMap的操作符参数是一个Function,它接收上游的值返回一个Publisher。如果这个Function里调用了一个返回Mono的方法,而这个方法内部用了Mono.just(expensive(...)),那expensive的调用实际上在Function调用时就发生了,并不是在inner Mono被订阅时才发生。很多教程说“flatMap是异步的”,容易给人错觉以为内部逻辑都会延迟,实际上flatMap只负责合并,并不负责延迟创建。真正要做到延迟创建,依旧是在内层使用Mono.defer。

4.4 性能开销与滥用警告

说完好处,得泼一盆冷水。Mono.defer并不是零成本的,它多了一个供应商调用和一次Mono对象分配。在极高频的路径上,比如每秒几十万次调用的网关过滤链,如果每个环节都套好几层defer,GC压力会增加不少。

我记得有一次性能压测,某个接口TPS上不去,火焰图里大量时间花在了MonoDefer的subscribeOrReturn方法和随之而来的对象分配上。后来优化的方式很简单:把不需要延迟的Mono提前构建成常量,只对真正需要每次重新创建的部分用defer。

建议是:默认用Mono.just或Mono.fromSupplier就好,只有当遇到以下三种情况再考虑defer:一是需要每次订阅都重新选择路由;二是需要延迟耗时计算或副作用到订阅时;三是需要配合retry/repeat保障每次重试都拿到新数据。

如果确实因为性能敏感,想复用同一个延迟Mono,可以考虑自己用volatile字段缓存supplier的结果,实现类似“惰性单例”的效果。但这样你就放弃了多次订阅始终新值的冷流语义,取舍要看业务侧重点。

4.5 从一次具体问题到通用排查思路

回到开头那个UUID重复的问题,最终的排查路径是这样的:先确认接口被调用了两次,还是Mono被订阅了两次;然后检查构建Mono的代码路径里有没有在方法入口就调用了生成UUID的逻辑;最后把Mono.just(generateTransactionId())改成Mono.defer(() -> Mono.just(generateTransactionId())),问题消失。

这个案例给了一个通用排查思路:面对“反应式接口返回的数据在多次执行时一样”的问题,先不要怀疑缓存或框架bug,先确认数据和订阅时机的关系。如果数据是构建时算好的,那结果相同是正常的;如果业务上要求每次订阅都是新数据,那就需要用defer把计算逻辑推到订阅时。

真正理解了这一点,再看网上关于Mono.defer的各种讲解,就会发现大家反复强调的无非是同一件事:在响应式世界里,明确“你在什么时候执行了什么逻辑”,比任何高级操作符都重要。defer把执行的开关从“构建”挪到了“订阅”,再配合每次订阅重新创建Mono这一行为,就构成了一条完整的冷流链路。理解了这个链路,那些“订阅两次结果一模一样”的怪问题,基本就不会再困惑你了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进