资讯动态

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

发布时间:2026/10/12 23:52:34 来源:尧图企业网站定制
几位同事在排查同一个问题为什么一个返回UUID的响应式接口在网关里明明只发起了一次请求日志里却出现了两次相同的执行结果参数一模一样时间戳一模一样连生成的UUID都一模一样。这种“看着像是被缓存了”的现象在反应式编程里其实非常典型十有八九是因为把本应在订阅时才执行的逻辑提前放到了构建阶段。Mono.defer就是解决这一类问题的关键操作符。很多人知道它是“延迟创建”但真正遇到场景时还是容易用错或者不知道它和Mono.just、Mono.fromSupplier之间的边界在哪里。这篇文章从一次重复订阅的现象出发把Mono.defer的原理、源码行为、实战用法和踩坑记录一次性说透适合刚接触Reactor生态的开发者也适合那些已经在写响应式代码但偶尔被“同一结果订阅两次”困扰的同学。1. Mono.defer到底是干嘛的一次重复订阅引发的思考1.1 从Mono.just暴露的问题说起先还原一下当时的场景。某个微服务对外提供查询接口内部大致这么写public MonoString 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 MonoT defer(Supplier? extends Mono? extends T supplier)参数是一个Supplier它的返回值被限定为Mono的子类型。这意味着你必须在Supplier内部构造出一个Mono来返回而不能像fromSupplier那样直接返回一个普通的值。这个限定很关键它决定了Mono.defer的应用场景。fromSupplier适合“延迟一个无副作用的取值”比如读配置、读时间defer适合“延迟整个反应式流程的搭建”比如根据不同的上下文决定返回Mono.just(...)还是Mono.error(...)或者返回一个需要建立数据库连接才能得到的Mono。举个实际场景。有一个方法要根据传入的用户渠道决定走哪个服务大多数人一开始会这么写MonoUserInfo 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每次订阅重新触发suppliersupplier返回的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 MonoString 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 MonoListUserRecord 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能实现“每隔一段时间重新获取最新数据”的效果。如果不加deferrepeat再多遍也是返回同一个旧值。flatMap里面也有类似的坑。flatMap的操作符参数是一个Function它接收上游的值返回一个Publisher。如果这个Function里调用了一个返回Mono的方法而这个方法内部用了Mono.just(expensive(...))那expensive的调用实际上在Function调用时就发生了并不是在inner Mono被订阅时才发生。很多教程说“flatMap是异步的”容易给人错觉以为内部逻辑都会延迟实际上flatMap只负责合并并不负责延迟创建。真正要做到延迟创建依旧是在内层使用Mono.defer。4.4 性能开销与滥用警告说完好处得泼一盆冷水。Mono.defer并不是零成本的它多了一个供应商调用和一次Mono对象分配。在极高频的路径上比如每秒几十万次调用的网关过滤链如果每个环节都套好几层deferGC压力会增加不少。我记得有一次性能压测某个接口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这一行为就构成了一条完整的冷流链路。理解了这个链路那些“订阅两次结果一模一样”的怪问题基本就不会再困惑你了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑