对用过 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。当你调用MonoT 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的价值就体现出来了。举个实际例子一个库存扣减操作你希望“每次订阅时都从数据库读取当前库存并判断是否够扣”。如果写成MonoInteger stockMono Mono.just(stockService.getCurrentStock());这个getCurrentStock()在定义stockMono那一刻就执行了之后不管订阅几次用的都是旧库存。如果改成MonoInteger stockMono Mono.defer(() - Mono.just(stockService.getCurrentStock()));那每次订阅都会重新查一次数据库拿到最新库存。这个差别在并发场景下可能直接决定业务是否产生超卖。我之前做一个订单服务时把库存查询从just改成defer后同行说我“只是换了个包装”但压测数据告诉我们重订阅时的数据新鲜度从“第一次的陈旧值”变成了“每次的最新值”问题直接消失。这就是defer最典型的落地场景。3. 实操Mono.defer 的典型应用场景与代码实例3.1 场景一延迟创建昂贵资源数据库连接、大对象在做响应式改造时我最常见到的一个需求是系统启动阶段不能立刻建立数据库连接因为连接池初始化很重而且早期根本没人订阅这个流。这时候如果直接在声明处初始化资源等于白白增加启动耗时。用Mono.defer可以优雅解决public MonoConnection 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 MonoBoolean 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 MonoUser 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 客户端要求你传一个MonoRequest进去。如果你直接Request req buildRequest(); MonoResponse responseMono client.call(Mono.just(req));那buildRequest()立刻执行链路上任何filter、flatMap还没开始跑请求对象就已经被构建好了。如果用deferMonoResponse responseMono client.call( Mono.defer(() - Mono.just(buildRequest())) );buildRequest()会延迟到订阅阶段才执行。如果这个Mono一直没被订阅那这个请求对象永远不会被创建。这对于控制副作用、排查问题时圈定责任范围会清晰很多。我从实践里的体感是响应式代码最大的问题不是“写不出来”而是“不知道哪一行真正执行了”。利用defer把执行边界后移等于给整个链路加了一个“这里才开始真正干活”的标记调试时心智负担小不少。3.4 和其他操作符的组合套路defer flatMap fromCallable实际业务链路很少直接用defer更多是跟flatMap、fromCallable组合。我分享一个自己常用的“延迟加载 异步处理”结构public MonoOrderVO 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负责把MonoOrder转成MonoOrderVO而fromCallablesubscribeOn把耗时的对象组装操作扔到弹性线程池避免阻塞 Netty 事件循环线程。这套组合的好处是整个链路从“取数”到“组装”都是延迟触发的没有任何一步在声明期间偷跑。如果日志里想确认触发了哪一步只需要在defer的工厂方法里打一条日志即可排查问题的范围会被压得很小。4. 避坑指南Mono.defer 使用中常见的误区和排查经验4.1 误区一以为 defer 能强制“每次订阅都从头走完链路”这是一个非常经典的坑。Mono.defer能保证的是“每次订阅工厂方法重新执行”。但它不能保证整条响应式链路里的每一步都重新执行。例如MonoLong mono Mono.defer(() - Mono.just(System.currentTimeMillis())) .map(ts - ts 1);每次订阅mono时defer工厂都会重新取当前时间戳然后map会对新值加一。这个组合相当于“整条链路重新构建”因为是先构建再map所以整体上还是每次订阅都会产生一个新的时间戳。但如果你在defer外面再包一个cacheMonoLong 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 工厂里 MonoVoid bad Mono.defer(() - { Thread.sleep(5000); // 阻塞 return Mono.empty(); }); // 正确示范defer 只负责提供发布者阻塞工作交给 fromCallable subscribeOn MonoVoid good Mono.defer(() - Mono.fromCallable(() - { Thread.sleep(5000); return ; }).subscribeOn(Schedulers.boundedElastic()) ).then();defer专注于“延迟创建发布者”fromCallable专注于“安全地包装有返回值的同步操作”。两者配合才能既做到延迟执行又不阻塞主线程。这一段我在代码评审里反复强调过很多次大家往往第一次都写错后来才逐渐养成习惯。4.3 误区三defer 不捕获异常异常得靠 onErrorXxx 兜底defer的工厂方法如果抛出异常这个异常会在订阅阶段被捕获通常出现在subscribe的onError回调里。但这不代表你可以不处理异常很多新人以为“defer 会自动把异常变成错误信号”现实是它确实会传播为错误信号但你的Subscriber必须显式处理。举个例子MonoString 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让边界条件变成业务逻辑的一部分MonoString 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或者自行做单例控制MonoVoid globalInit Mono.defer(() - { System.out.println(初始化执行); return Mono.fromRunnable(() - initEnv()); }).cache();这里的cache()确保多个订阅者共享同一次执行结果而defer确保“第一次订阅前不初始化”。两者叠加才是“延迟到首次订阅 全局只跑一次”的完整语义。如果你对“热流/冷流”已经有一定概念可以这么理解defer本身是冷流cache让冷流局部变热。实际使用时要根据业务需求判断到底是要“每次新数据”保持冷还是“只处理一次、广播给多人”变热。混淆这两个需求是并发场景下最常见的查不出来原因的 bug 来源之一。5. 一个贯穿全流程的完整 Demo动态加载配置并处理异常到这里把前面讲到的点串成一个贴近真实业务的完整 Demo方便照抄。场景是接入了一个配置中心配置值可能随时变化而且读取配置可能失败要求在每次订阅时读取最新配置失败时返回默认值并且整个过程不阻塞事件循环线程。public MonoInteger 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(): MonoString 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。这个改动本身不复杂但它传递的语义会非常明确——这个地方的数据是每次订阅动态生成的不是定义时固定的。时间久了团队里每个人的响应式思维都会因此上一个台阶。