☰
Mono.defer 底层原理与实战:从延迟求值到订阅语义
2026/10/12 21:44:36 网站建设 项目流程

对用过 Project Reactor 的人来说,Mono.defer绝对是个绕不开的老相识。它常被用来“延迟发射”“每次订阅重新执行”,但真要问一句“它底层到底怎么做到的?”或者“和Mono.just就差一个字,为什么行为差那么多?”,能一次讲清楚的人真不多。这篇文章不打算按官方文档念经,我直接结合源码、实际踩坑和几个高频场景,把Mono.defer从里到外拆一遍,看完你拿去写响应式代码,心里会踏实很多。

项目里有两个地方特别适合用它:一是“必须拿到订阅时才去创建的资源”(比如数据库连接、文件句柄),二是“每次订阅都希望拿到新的值”(比如当前时间戳、原子自增 ID)。如果不清楚延迟求值和立即求值的区别,很容易写出“看起来对、跑起来错”的响应式代码。这篇文章适合刚接触 Reactor 的初学者,也适合已经写了很久但要回头抠细节的人。

1. 先搞明白:Mono.defer 到底做了什么

1.1 一次订阅才执行一次的“懒加载”工厂

要理解Mono.defer,最关键的一步是扭转思维:普通 Java 方法在调用那一刻就执行,而Mono的很多操作是“描述出来的”,不是“立刻跑起来的”。

Mono.defer的入参是一个Supplier<? extends Mono<? extends T>>,你可以把它理解成一个“Mono 工厂”。这个工厂不会在调用defer方法时执行,而是在每次发生subscribe的时候才执行,并且每次订阅都会重新调用一次工厂方法,重新生成一个新的Mono实例。

我用大白话翻译一下:

  • Mono.just(xxx):在“定义 Mono”的时候,xxx就已经算好了,后面谁订阅都是同一个值。
  • Mono.defer(() -> Mono.just(xxx)):在“定义 Mono”的时候,什么都没干,等到有人订阅,才去执行() -> Mono.just(xxx),此时才算xxx的值,而且订阅几次就算几次。

这一个“求值时机”的差别,就是defer的灵魂。很多响应式编程里看着诡异的问题——比如“为什么每次订阅打印的值是一样的?”——根源往往就是用了立即求值的工厂方法,而不是defer。

1.2 三种常见构造方式对比:just、fromCallable、defer

我见过不少同学把Mono.just、Mono.fromCallable、Mono.defer混着用,觉得“反正都能发出一个值”。实际上这三种东西的语义差异很大,直接决定你代码的正确性。

构造方式求值时机每次订阅是否重新执行典型用途
Mono.just(value)定义时立即求值否,所有订阅共享同一值常量、已算好的结果
Mono.fromCallable(callable)订阅时执行是,每次订阅重新执行包装阻塞调用、读配置
Mono.defer(() -> Mono.just(xxx))订阅时执行工厂是,每次订阅重新生成整个 Mono延迟创建、每次订阅新值

注意到没有,fromCallable和defer表面上很像,但观察角度不同。fromCallable的核心是“把一次方法调用包装成异步信号”;而defer的核心是“延迟整个发布者的创建过程”。区别在于:你传给fromCallable的是一段会返回值的代码,传给defer的是一段会返回Mono的代码。

举个例子,某种情况下你可能要根据订阅时的状态决定“返回空 Mono”还是“返回有数据的 Mono”,用fromCallable就得在回调里判断然后特殊处理,用defer则天然支持,因为反正每次订阅都重新创建一次Mono。

2. 从源码角度拆解:它凭什么能“延迟”

2.1 核心机制:Supplier 与订阅时的调用链

Mono.defer在 Reactor 里的实现并没有想象中复杂,核心就是持有一个Supplier。当你调用:

Mono<T> result = Mono.defer(() -> createMono());

此刻,createMono()这个工厂方法并没有被调用,你只是创建了一个持有Supplier的MonoDefer实例。等真正有订阅者时,MonoDefer在subscribe调用链上会先调用这个Supplier拿到真正的Mono,然后再把订阅动作转发给这个新Mono。

这个设计背后运用了典型的“懒加载”策略——把“创建发布者”这一步延后到不得不做的时候。好处在于:无论这个Mono会不会被订阅,工厂的昂贵逻辑都不会被触发。比如工厂里要创建数据库连接池,而整个应用跑了一天后这个Mono都没被订阅,那这部分开销就完全省掉了。

我调试 Reactor 源码时最喜欢看的一个点就在这里:MonoDefer本身几乎不参与数据发射,它更像一个代理层,真正的Mono是在运行期动态决定的。这也是为什么它可以做到“每次订阅生成不同的 Mono”,因为每次订阅都会重新走一遍Supplier.get()。

2.2 每次订阅都发生什么:热与冷语义的关键

理解Mono.defer的“每次订阅重新执行”,要弄明白响应式流里的冷流(Cold)和热流(Hot)概念。冷流的特点是:每次订阅者订阅时,数据从头到尾重新产生一遍;热流则是多个订阅者共享同一份数据。Mono.defer生成的基本上是冷语义,因为订阅一次,工厂执行一次。

注意一个容易绕晕的点:Mono.defer(() -> Mono.just(System.currentTimeMillis()))和直接Mono.just(System.currentTimeMillis())的区别并不是“冷/热”的区别,Mono.just也是冷流,也会有多个订阅者分别收到各自的订阅序列。它们的本质区别在于“值的计算时机”:前者在每次订阅时调用System.currentTimeMillis(),取到的是不同时间点;后者在定义时调用一次,所有订阅者收到同一个时间戳。

所以,什么时候用defer,一个很硬性的判断标准就是:你希不希望“产生这个值(或这个发布者)的副作用”在订阅时才发生。如果希望,就用defer;如果这个值本质上是一个常量,就不要用defer增加无谓的复杂度。

2.3 订阅一次就完了吗?重订阅行为验证

很多人写响应式代码时,默认“每个 Mono 只会被订阅一次”。但实际项目里,一个Mono变量可能被多个地方订阅,比如缓存回源、消息重试、多个消费者监听。这时候defer的价值就体现出来了。

举个实际例子,一个库存扣减操作,你希望“每次订阅时都从数据库读取当前库存并判断是否够扣”。如果写成:

Mono<Integer> stockMono = Mono.just(stockService.getCurrentStock());

这个getCurrentStock()在定义stockMono那一刻就执行了,之后不管订阅几次,用的都是旧库存。

如果改成:

Mono<Integer> stockMono = Mono.defer(() -> Mono.just(stockService.getCurrentStock()));

那每次订阅都会重新查一次数据库,拿到最新库存。这个差别在并发场景下可能直接决定业务是否产生超卖。

我之前做一个订单服务时,把库存查询从just改成defer后,同行说我“只是换了个包装”,但压测数据告诉我们:重订阅时的数据新鲜度从“第一次的陈旧值”变成了“每次的最新值”,问题直接消失。这就是defer最典型的落地场景。

3. 实操:Mono.defer 的典型应用场景与代码实例

3.1 场景一:延迟创建昂贵资源(数据库连接、大对象)

在做响应式改造时,我最常见到的一个需求是:系统启动阶段不能立刻建立数据库连接,因为连接池初始化很重,而且早期根本没人订阅这个流。这时候如果直接在声明处初始化资源,等于白白增加启动耗时。

用Mono.defer可以优雅解决:

public Mono<Connection> getConnection() { return Mono.defer(() -> { System.out.println("真正建立连接的时刻:" + System.currentTimeMillis()); return Mono.fromCallable(() -> connectionPool.getConnection()); }); }

这段代码有几个点值得圈出来:

  • 声明getConnection()时没有建立连接,只有调用方真正subscribe时,控制台才打印“真正建立连接的时刻”,连接池才被访问。
  • 如果同一个Mono被两个订阅者订阅,连接池的getConnection()会被调用两次,但每个订阅者拿到的是各自的连接。
  • 配合fromCallable,连接获取逻辑被包装成异步信号,不会阻塞调用线程。

注意这里有个运维层面的小坑:如果连接池本身的获取操作会阻塞 IO,建议在后续链路里配上subscribeOn或publishOn调度到专属线程,而不是默认线程上硬扛。这块后面我单独讲线程模型的时候再展开。

3.2 场景二:每次订阅获取“新鲜数据”(配置中心、时间戳、自增序列)

响应式项目中,经常要读取配置中心里的开关。配置中心的值可能被运维在后台修改,如果代码里写成Mono.just(config.get("feature.enabled")),那应用启动完以后,这个值就固定住了,改配置也不会生效。

用defer包装一下,每次订阅都会重新从配置中心拉取值:

public Mono<Boolean> isFeatureEnabled() { return Mono.defer(() -> Mono.just("true".equals(configClient.getConfig("feature.enabled")))); }

还有一个小场景:生成时间戳。比如打点上报时,希望每次订阅都拿到最新的时间,而不是整个应用启动时那一个时间。Mono.defer(() -> Mono.just(System.currentTimeMillis()))就能满足这个需求。

有同学会问:这个场景用Mono.fromCallable也能做啊,为什么非用defer?

逻辑上的区别是:fromCallable强调的是“把一次调用包装成 Mono”,defer强调的是“每次订阅重新生成整个发布者”。从效果看,单值场景两者都能实现“每次订阅重新取数”,但从表达意图上,defer更适合“发布者本身的生成带有状态或分支判断”的情况。

举一个稍微进阶一点的例子,动态返回空还是非空:

public Mono<User> getUserIfActive(String userId) { return Mono.defer(() -> { User user = userCache.get(userId); if (user == null || !user.isActive()) { return Mono.empty(); } return Mono.just(user); }); }

这种“订阅时根据实时状态决定到底返回什么 Mono”的模式,用fromCallable会写得比较别扭,因为你要在回调里自建Mono,而defer天然就是干这个的。

3.3 场景三:包装工厂方法,隔离构建副作用

还有一种很隐晦的用法,是用来“隔离构建副作用”。有些第三方 SDK 的工厂方法内部会干一些你不希望提前触发的动作,比如注册回调、初始化线程池、打开文件句柄。如果你在声明响应式链路的顶层直接调用这个工厂方法,副作用立刻发生,很难追踪。

打个比方,某 API 客户端要求你传一个Mono<Request>进去。如果你直接:

Request req = buildRequest(); Mono<Response> responseMono = client.call(Mono.just(req));

那buildRequest()立刻执行,链路上任何filter、flatMap还没开始跑,请求对象就已经被构建好了。

如果用defer:

Mono<Response> responseMono = client.call( Mono.defer(() -> Mono.just(buildRequest())) );

buildRequest()会延迟到订阅阶段才执行。如果这个Mono一直没被订阅,那这个请求对象永远不会被创建。这对于控制副作用、排查问题时圈定责任范围,会清晰很多。

我从实践里的体感是:响应式代码最大的问题不是“写不出来”,而是“不知道哪一行真正执行了”。利用defer把执行边界后移,等于给整个链路加了一个“这里才开始真正干活”的标记,调试时心智负担小不少。

3.4 和其他操作符的组合套路:defer + flatMap + fromCallable

实际业务链路很少直接用defer,更多是跟flatMap、fromCallable组合。我分享一个自己常用的“延迟加载 + 异步处理”结构:

public Mono<OrderVO> buildOrderDetail(String orderId) { return Mono.defer(() -> { Order order = orderRepository.findByOrderId(orderId); if (order == null) { return Mono.error(new OrderNotFoundException(orderId)); } return Mono.just(order); }).flatMap(order -> Mono.fromCallable(() -> assembleOrderDetail(order)) .subscribeOn(Schedulers.boundedElastic()) ); }

这里defer保证每次订阅都从数据库重新拉取订单,flatMap负责把Mono<Order>转成Mono<OrderVO>,而fromCallable+subscribeOn把耗时的对象组装操作扔到弹性线程池,避免阻塞 Netty 事件循环线程。

这套组合的好处是:整个链路从“取数”到“组装”都是延迟触发的,没有任何一步在声明期间偷跑。如果日志里想确认触发了哪一步,只需要在defer的工厂方法里打一条日志即可,排查问题的范围会被压得很小。

4. 避坑指南:Mono.defer 使用中常见的误区和排查经验

4.1 误区一:以为 defer 能强制“每次订阅都从头走完链路”

这是一个非常经典的坑。Mono.defer能保证的是“每次订阅,工厂方法重新执行”。但它不能保证整条响应式链路里的每一步都重新执行。例如:

Mono<Long> mono = Mono.defer(() -> Mono.just(System.currentTimeMillis())) .map(ts -> ts + 1);

每次订阅mono时,defer工厂都会重新取当前时间戳,然后map会对新值加一。这个组合相当于“整条链路重新构建”,因为是先构建再map,所以整体上还是每次订阅都会产生一个新的时间戳。

但如果你在defer外面再包一个cache:

Mono<Long> mono = Mono.defer(() -> Mono.just(System.currentTimeMillis())) .map(ts -> ts + 1) .cache();

cache会把第一次订阅的结果缓存下来,后续订阅直接在缓存上重放,defer就不会再执行了。准确地说,是defer生成的第一个Mono被缓存了。这个行为很多人不知道,一查原因往往觉得“defer 失效了”,其实不是,是下游的缓存操作符把发布者形态变成了“热流”。

所以要记住:defer的“每次重新执行”是相对于“没有被上游缓存、直接订阅原始Mono”而言的。如果链路中间穿插了cache、replay这类带有缓存语义的操作符,那“重新执行”的效果会被打破。

4.2 误区二:把阻塞逻辑直接塞进 defer 工厂

defer每次订阅时同步执行,如果你在工厂里放了一个很慢的同步 IO 操作——比如查数据库、调远程 HTTP——那么这个操作会堵塞调用subscribe的线程。

多一点解释:很多人以为“我用了响应式编程,代码就不会堵塞”。实际上 Reactor 只是提供了一套异步编排能力,如果你在错误的地方放了阻塞代码,线程照样被卡住。

正确做法有两种:

// 错误示范:阻塞代码直接在 defer 工厂里 Mono<Void> bad = Mono.defer(() -> { Thread.sleep(5000); // 阻塞! return Mono.empty(); }); // 正确示范:defer 只负责提供发布者,阻塞工作交给 fromCallable + subscribeOn Mono<Void> good = Mono.defer(() -> Mono.fromCallable(() -> { Thread.sleep(5000); return ""; }).subscribeOn(Schedulers.boundedElastic()) ).then();

defer专注于“延迟创建发布者”,fromCallable专注于“安全地包装有返回值的同步操作”。两者配合,才能既做到延迟执行,又不阻塞主线程。这一段我在代码评审里反复强调过很多次,大家往往第一次都写错,后来才逐渐养成习惯。

4.3 误区三:defer 不捕获异常,异常得靠 onErrorXxx 兜底

defer的工厂方法如果抛出异常,这个异常会在订阅阶段被捕获,通常出现在subscribe的onError回调里。但这不代表你可以不处理异常,很多新人以为“defer 会自动把异常变成错误信号”,现实是它确实会传播为错误信号,但你的Subscriber必须显式处理。

举个例子:

Mono<String> task = Mono.defer(() -> { if (!isReady()) { throw new IllegalStateException("系统未就绪"); } return Mono.just("ready"); }); task.subscribe( data -> System.out.println("收到数据: " + data), error -> System.err.println("错误: " + error.getMessage()) );

这个抛出的异常最终会走到error -> ...回调里,看起来像是“被转成了错误信号”,但前提是你订阅时传了onError参数。如果订阅时只关心数据,不传错误回调,异常可能会直接打入日志,情况不可控。

更规范的做法是给defer加一层onErrorResume或onErrorReturn,让边界条件变成业务逻辑的一部分:

Mono<String> safe = Mono.defer(() -> { if (!isReady()) { return Mono.error(new IllegalStateException("系统未就绪")); } return Mono.just("ready"); }).onErrorReturn("fallback");

这样,订阅方只需要处理正常数据,异常兜底逻辑统一收敛在生成Mono的那一层,整个链路看起来会干净不少。

4.4 误区四:在并发订阅或多订阅者场景下误判“只执行一次”

最后聊一个实际排障时遇到的坑:有一个Mono用defer包装了内部的状态初始化逻辑,结果被多个下游flatMap同时订阅,状态被初始化了多次,数据出现错乱。排查了半天,一开始还怀疑是flatMap的并发问题,最后才发现是defer的“每次订阅重新执行”语义导致的。

如果业务要求“不管被订阅多少次,只初始化一次,但初始化的时机要延迟到第一次订阅”,那不能光用defer,得配合cache或者自行做单例控制:

Mono<Void> globalInit = Mono.defer(() -> { System.out.println("初始化执行"); return Mono.fromRunnable(() -> initEnv()); }).cache();

这里的cache()确保多个订阅者共享同一次执行结果,而defer确保“第一次订阅前不初始化”。两者叠加,才是“延迟到首次订阅 + 全局只跑一次”的完整语义。

如果你对“热流/冷流”已经有一定概念,可以这么理解:defer本身是冷流,cache让冷流局部变热。实际使用时要根据业务需求判断,到底是要“每次新数据”(保持冷)还是“只处理一次、广播给多人”(变热)。混淆这两个需求,是并发场景下最常见的查不出来原因的 bug 来源之一。

5. 一个贯穿全流程的完整 Demo:动态加载配置并处理异常

到这里,把前面讲到的点串成一个贴近真实业务的完整 Demo,方便照抄。场景是:接入了一个配置中心,配置值可能随时变化,而且读取配置可能失败;要求在每次订阅时读取最新配置,失败时返回默认值,并且整个过程不阻塞事件循环线程。

public Mono<Integer> getTimeoutSeconds() { return Mono.defer(() -> Mono.fromCallable(() -> configClient.getInt("http.timeout")) .subscribeOn(Schedulers.boundedElastic()) .onErrorReturn(3000) ); }

这个代码拆解一下:

  • Mono.defer:保证每次订阅,都会重新走到fromCallable里去读取配置中心。
  • Mono.fromCallable:包装一次可能抛出异常的同步调用。
  • subscribeOn(Schedulers.boundedElastic()):让配置读取发生在弹性线程池,不阻塞主线程或事件循环线程。
  • onErrorReturn(3000):读取失败时返回默认值 3000 毫秒,而不是把异常抛给上游。

调用方可以这样用:

getTimeoutSeconds() .flatMap(timeout -> callRemoteService(timeout)) .subscribe( result -> log.info("请求成功: {}", result), error -> log.error("请求失败", error) );

这个 Demo 虽然简单,但涵盖了defer的核心价值:延迟执行 + 每次订阅重新执行 + 安全的异常兜底 + 不阻塞线程。如果你能把这个模式跑通,再结合前面提到的重订阅场景,基本就能在项目里灵活使用了。

我再提醒一次写响应式代码时最容易被忽略的一点:日志。建议在defer的工厂方法里,至少留一行关键日志,记录“订阅发生时进入了 onCreate 流程”。有了这个日志,你在生产环境排查“为什么数据不是新的”会轻松十倍。很多线上问题查半天,最后发现根本不是defer的锅,而是某个环节在声明期间已经偷跑了,但从日志上看不出来。

6. 对 Reactor 线程模型的一点提醒:defer 在哪个线程执行

很多新手在使用defer时都会困惑:这个工厂方法到底在哪个线程上执行?答案很简单:在订阅发生时的调用线程上同步执行。如果你在一个 Netty 事件循环线程里subscribe,那defer工厂就会在这个事件循环线程里同步执行完,然后才走后续的响应式链路。

这一点对 WebFlux 接口特别要命。WebFlux 默认情况下在 Netty 事件循环线程处理请求,如果defer工厂里放了耗时的同步操作,你的事件循环线程就会被占用,接口整体吞吐量直接塌方。

所以我再次强调那个组合拳:

Mono.defer(() -> Mono.fromCallable(() -> slowMethod()) .subscribeOn(Schedulers.boundedElastic()));

这样defer工厂本身还是很快的——它只是负责“构建一个发布者”,真正的慢操作被fromCallable捕获,并被调度到boundedElastic线程池执行。事件循环线程只负责快速构建链条,不会被慢操作拖住。

如果你用的是虚拟线程(JDK 21+),也可以用Schedulers.boundedElastic()之外的虚拟线程调度器,但核心思想不变:不要让慢操作发生在subscribe现场的调用线程上,这是一个铁律。

7. 关于 Kotlin 协程场景下的 defer:怎么配合 suspend 函数

很多团队现在用 Kotlin + Reactor,尤其是 WebFlux 项目中,会在defer里写suspend函数。这里有个常见坑:在defer工厂里直接调用suspend函数是不行的,因为defer的Supplier是同步的,而suspend函数是协程上下文敏感的。

推荐做法有两种。第一种,用mono { ... }构建器:

fun getConfig(): Mono<String> = mono { val value = configService.getFromRemote() // suspend 函数 value }

mono { }构建器本身就有延迟执行的特性,它会等到订阅时才启动协程执行,天然具备类似defer的“延迟创建”能力,而且可以在内部调用suspend函数。

第二种,在defer里包一层Mono.fromCallable再用block等待协程结果——但这种方式要小心,等于把一个异步协程调用给阻塞掉,很不推荐,除非你明确知道自己在做什么。

我的建议是:Kotlin 场景优先用mono {}构建器,不要在响应式链条里生硬地混用defer和协程。两者都能实现延迟执行,但mono {}更贴合协程生态,错误处理、线程切换也都更自然。Java 项目里坚持用Mono.defer没问题,语言选型不同的时候,就该用生态里最舒服的工具。

8. 最后的实操心得

做响应式开发这几年,我最深的感触是:大多数 bug 不是“响应式难”,而是“对执行时机理解错位”。Mono.defer恰好是帮助你建立“时机感”的一个极好的切口。每次写响应式代码之前,先问自己一句:这段逻辑应该在声明时执行,还是订阅时执行?如果不太确定,那就先用defer包一层,你会发现后续调整起来成本低很多。我自己在代码评审时,看到那种把可变数据用Mono.just包装的写法,一般都会建议换defer。这个改动本身不复杂,但它传递的语义会非常明确——这个地方的数据是每次订阅动态生成的,不是定义时固定的。时间久了,团队里每个人的响应式思维都会因此上一个台阶。

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

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

立即咨询