先说个真实经历。有次我给一个老接口做响应式改造代码写完单测全绿结果联调阶段对端反馈“同一个请求两次返回的时间不一样而且差得离谱”。我查了两天一夜最后定位到一行Mono.just(System.currentTimeMillis())—— 这行代码在同步时代是每次调用都能拿到当前时间改成响应式之后却变成了在建链时完成求值、后续所有订阅共享同一份旧值的“假响应式”。要彻底讲明白这个坑绕不开的就是Mono.defer。简单说Mono.defer是一个惰性工厂方法它接收一个返回Mono的函数真正调用这个函数、构造内部Mono的时刻被推迟到subscribe()发生的时候。它解决的痛点是“链的构建时机”和“链的执行时机”之间的错位。这篇文章适合刚接触 Project Reactor 的新手也适合已经写了几个月响应式代码、但被“订阅了没执行”“每个订阅者拿到的数据一模一样”这些问题折磨过的同学。我会用几个真实场景把执行时机、选型边界和常见坑一次讲完。1. 先看一段“自以为是”的响应式代码执行时机是怎么悄悄前移的1.1 同步语义与响应式语义的错位很多从传统同步代码转过来的开发者上手 Reactor 时会写类似下面这行MonoLong now Mono.just(System.currentTimeMillis());直觉告诉我们我创建了一个“表示获取当前时间”的 Mono之后每个订阅者订阅它时都会得到当时的时间。但这个直觉是错的。Mono.just(T value)接收的是一个已经算好的具体值不是一段“将来再去算”的代码。也就是说当程序执行到System.currentTimeMillis()这个表达式的那一刻时间已经固定了后续所有订阅拿到的都是同一帧的旧值。这里问题的本质是响应式链把“订阅触发执行”这件事讲得很清楚但just这个工厂方法在订阅之前就把参数求值做完了。1.2 用时间戳案例还原问题现场看这段代码MonoLong timeAtBuild Mono.just(System.currentTimeMillis()); Thread.sleep(5000); // 模拟业务等待 timeAtBuild.subscribe(t - System.out.println(first subscribe: t)); Thread.sleep(1000); timeAtBuild.subscribe(t - System.out.println(second subscribe: t));如果你跑一遍会发现两次输出几乎一模一样都是 5 秒前构造时刻的时间戳。这其实不奇怪因为Mono.just的参数在方法调用那行就被求值了。改成 defer 之后MonoLong timeAtSubscribe Mono.defer(() - Mono.just(System.currentTimeMillis())); Thread.sleep(5000); timeAtSubscribe.subscribe(t - System.out.println(first subscribe: t)); Thread.sleep(1000); timeAtSubscribe.subscribe(t - System.out.println(second subscribe: t));这次两次输出的时间戳差值大约 1 秒完全符合真实发生订阅的间隔。defer 做的唯一一件事就是把Mono.just(System.currentTimeMillis())这段构建逻辑包进了一个Supplier订阅发生时才执行这个Supplier每个订阅者都会触发一次新的构建自然拿到新值。1.3 为什么 Mono.just 是急切的defer 是惰性的用生活类比解释就是Mono.just相当于你在外卖平台提前下单后厨做好之后打包放冰箱之后每次订阅都是把同一份冻菜拿出来热一热端给顾客Mono.defer则是顾客每次上门后厨才收到菜单开始现炒。同样是“做菜”一个把菜锁死在出锅那一刻一个把做菜动作推迟到上菜那一刻。这种“急切 vs 惰性”的差异在只有一层just时看起来只是时间戳对不对的问题但一旦内层逻辑变成rpcClient.call(...)、db.query(...)、configManager.load(...)问题就会被放大你以为订阅时才发请求实际上请求在链构造时就发出了甚至可能一个请求还没发链已经构建了几百次或者反过来——链只构建了一次几百个订阅者共享一次调用的结果。理解了这一点后面所有场景和坑都有了解释。提示判断一段链是“急切”还是“惰性”不要只看有没有just要看构建链这个动作本身是否携带副作用。Mono.defer的核心价值就是允许你把“带副作用的构建动作”也变成订阅时才执行。2. 从冷热发布者的角度理解 defer 的“惰性外壳”2.1 冷源与热源的区别简述Reactor 里的 Publisher 有冷热之分。冷源是“每个订阅者都独立触发一次完整执行”类似视频点播每个人从头看都是自己的进度热源是“所有订阅者共享同一个事件流”类似直播晚到的人只能看到当下内容错过就错过了。你可能听过这样的说法“defer 能把一个 Mono 变成冷源。”这句话对了一半容易误导人。Mono.defer给内部发布者加了“每次订阅重新构建”的惰性外壳但如果它内部包装的是一个热源比如某个共享的Sinks.many()那两个订阅者照样会共享同一份信号流。defer 改变的是“外层工厂方法何时调用”不会改变内层发布者自身的冷热属性。2.2 defer 做的事情把“构造”也变成订阅的一部分我们重新看一遍订阅流程。普通Mono.just的流程是先构造一个值 → 订阅 → 管内已准备好的值传递给订阅者。而Mono.defer的流程是订阅 → 调用工厂方法 → 工厂方法返回一个新 Mono → 新 Mono 接管后续信号流转。用代码观察一下执行顺序Mono.defer(() - { System.out.println(constructing inner mono); return Mono.just(hello); }) .doOnSubscribe(s - System.out.println(subscribed)) .doOnNext(v - System.out.println(got: v)) .subscribe();控制台输出顺序大致是“subscribed”先出现随后才出现“constructing inner mono”再出现“got: hello”。这说明内层 Mono 的构造确实发生在下游订阅管线建立之后。这个顺序很关键它不是“先造好 Mono 再订阅”而是“订阅触发后现造 Mono”。2.3 订阅发生时到底发生了什么信号时序梳理一次defer订阅引发的完整信号流转可以分成四步下游调用subscribe()建立整个响应式链的订阅关系此时下游已经准备好接收信号。请求信号向上游传播defer收到订阅请求后调用外部传入的Supplier。Supplier返回一个全新的内层 Monodefer随即让该 Mono 进入订阅流程。内层 Mono 的onNext、onComplete或onError信号按正常规则流向下游。这里有一个容易被忽略的附带好处因为工厂方法每次都在订阅路径上执行所以每次订阅时你都能访问到“当下的环境状态”比如Mono.deferContextual(ctx - ...)这种进阶写法还能在构建阶段读取到 Reactor Context 里的用户信息、链路标识等上下文数据。如果不用 defer这些数据往往只能在外层捕获一次无法在每次订阅时动态刷新。3. 三个可以直接抄作业的使用场景3.1 场景一每次订阅都要捕获“当下状态”最典型的场景是组装事件消息订阅发生时需要把当前线程的 traceId、当前用户、当前时间戳一起塞进消息体。如果不用 defer这些值会在链构建时就固定下来等于整条链路都给了同一个 traceId排查问题时所有请求共用一条链路日志非常痛苦。public MonoVoid sendOrderCreatedEvent(Order order) { return Mono.defer(() - { String traceId MDC.get(traceId); long createdAt System.currentTimeMillis(); return eventClient.sendEvent(order, traceId, createdAt); }); }这个写法保证每次订阅都会重新从 MDC 里读 traceId从时钟拿 createdAt。如果这段代码被flatMap展开到多个并行子流程里每个子流程订阅时都会拿到属于自己的 traceId而不是共用外层那个。3.2 场景二分页、动态条件等“每次调用都要新东西”的查询另一种高频场景是分页拉取。假设有个文章列表服务每次调用返回下一页页码由一个计数器维护private final AtomicInteger pageIndex new AtomicInteger(0); public MonoListArticle pullNextPage() { return Mono.defer(() - articleRepo.queryPage(pageIndex.getAndIncrement(), PAGE_SIZE)); }这里的关键在于如果没有 deferpageIndex.getAndIncrement()会在pullNextPage()被调用的那一行立即执行返回的 Mono 在构造时就把页码锁死了。如果同一个 Mono 被订阅三次三次都是同一个页码。加上 defer 后计数器递增动作被推后到每次订阅时执行三个订阅者拿到的页码分别是 0、1、2分页语义才是对的。动态条件查询同理。比如查询条件是当前配置中心的状态订阅时可能配置已经变化public MonoConfig loadEffectiveConfig() { return Mono.defer(() - configClient.get(configVersionSupplier.get())); }这个写法把“读取版本号”和“发起查询”都推后到订阅时让每次订阅都拿到最新的配置版本。3.3 场景三修正一个“构造时就被锁死”的已有 Mono有些方法本身返回的就是 Mono但方法内部在返回前已经做了不少工作比如快照了配置、生成了签名、绑定了回调。这类方法单独调用没问题一旦你希望“每次订阅都重新走一遍方法逻辑”就得在外层套 defer// 错误loadConfig() 和 client.call 都会在这一行立即执行 MonoRemoteResult result client.call(loadConfig(), orderId); // 修正每次订阅重新加载配置、重新调用 client.call MonoRemoteResult result Mono.defer(() - client.call(loadConfig(), orderId));这个方法我特别推荐给那些接入第三方 SDK 的同学。很多 SDK 的响应式客户端在方法返回 Mono 之前已经完成了参数校验、签名计算、连接池选择等副作用操作。如果你把返回的 Mono 缓存下来给多个订阅者复用等于所有订阅者共享同一个连接和同一份签名服务端验签可能直接把你判定为重放攻击。用 defer 包一层每次订阅都会重新执行 SDK 的调用方法副作用也被重新放行一次。4. 与 just、fromCallable、fromSupplier 的选型边界4.1 一次说清三个工厂方法的求值时机不少初学者混着用just、fromCallable、fromSupplier、defer其实它们各自服务不同的求值时机。我把行为和适用场景整理成一张表方法求值时机返回形态每次订阅是否重新求值典型场景Mono.just(v)构造时立即固定值否值已锁死静态常量、构造前已经算好的结果Mono.fromSupplier(s)订阅时单个值是纯同步计算不需要抛受检异常Mono.fromCallable(c)订阅时单个值是同步计算需要允许受检异常Mono.defer(p)订阅时一个新的 Publisher是动态构造流程、返回新的 Mono/Flux注意区分“返回单个值”和“返回一个 Publisher”。fromCallable和fromSupplier的供应商函数最终返回的是值执行完就直接作为onNext信号发出defer的供应商函数返回的是另一个 Monodefer 拿到它之后还要再走一遍订阅流程才能真正发出信号。所以如果内层逻辑是“同步算一个数”用fromCallable更直接如果内层逻辑是“发起一个异步调用并返回 Mono”就必须用defer。4.2 retry 和 repeat 场景下的行为差异这个差异平时不明显一旦配合retry或repeat就会被放大。just构建的值是固定的重试时重放同一个值而defer和fromCallable这类惰性来源在每次重试时都会重新执行供应商逻辑每次重试都能拿到新状态。AtomicInteger n new AtomicInteger(0); MonoString m Mono.defer(() - { int current n.incrementAndGet(); if (current 3) { throw new RuntimeException(try again); } return Mono.just(ok); }).retry(5);这段代码在第三次尝试时成功最终输出ok。如果这里换成Mono.just第一次构建后值就固定了重试永远只会重放同一个结果。这种“每次重试重新取状态”的特性用对地方就是容错利器用错地方就是灾难——我曾见有人把数据库主键生成放进 defer 的 supplier 里结果重试时每次都生成新主键业务幂等性当场破裂。4.3 选型决策思路先判断“值”还是“过程”我在给团队做 code review 时总结了一个很朴素的决策思路这里分享出来如果值在构造时就已经确定并且希望所有订阅者共享同一个值用just。如果值是每次订阅时同步计算出来的没有受检异常用fromSupplier有受检异常或需要包装阻塞调用用fromCallable。如果手里已经有一个“返回 Mono 的方法”并且希望每次订阅都重新调用这个方法用defer。如果构建逻辑需要读取 Reactor Context用deferContextual。先分清“要的是一个固化数据还是每次执行的一段过程”选型就顺理成章了。5. 实战踩坑记录从“执行次数对不上”到“订阅线程卡死”5.1 坑一defer supplier 里做阻塞调用把订阅线程一起带走Mono.defer的供应商函数是在订阅线程上执行的它本身不会帮你切换调度器。如果你在 supplier 里写了Thread.sleep、同步 Redis 客户端、同步 HTTP 客户端这类阻塞调用执行阻塞的线程就是发起订阅的那个线程。在 WebFlux 这类基于少量事件循环线程的框架里这么干会把整个响应式吞吐量拖垮。Mono.defer(() - { Object value syncCacheClient.get(key); // 阻塞式调用 return Mono.just(value); });正确做法是不要把阻塞调用放进 defer 的 supplier而是用Mono.fromCallable包阻塞逻辑再配合subscribeOn(Schedulers.boundedElastic())把它丢到弹性线程池里Mono.fromCallable(() - syncCacheClient.get(key)) .subscribeOn(Schedulers.boundedElastic());记住defer 只解决“构建时机”问题不解决“执行线程”问题。这两件事经常被混在一起导致代码看着没问题实际运行却卡到爆炸。5.2 坑二defer 包装了一个会共享状态的“伪冷源”这是我在实际项目里踩得最疼的一个坑。有人以为只要在Mono.defer里 return 一个 Mono这个 Mono 就会变成冷源于是写出这种代码MonoLong sharedTime Mono.just(System.currentTimeMillis()); MonoLong wrapped Mono.defer(() - sharedTime);表面上看每次订阅都会调用 suppliersupplier 又把sharedTime返回出来好像没问题。但问题在于sharedTime本身是在 defer 之外构建的它内部的值老早就锁死了。defer 推迟的只是“把 sharedTime 拿出来”这一动作并没有推迟sharedTime的构建也没有改变它内部发布者的性质。更隐蔽的情况是包装一个热源Sinks.ManyString hot Sinks.many().multicast().directBestEffort(); MonoString wrapped Mono.defer(() - hot.asMono());两个订阅者订阅wrapped最终订阅到的仍然是同一个热 Sink信号照样广播共享。defer 不会魔法般让热源变冷。想要真正的冷源必须让 supplier 里每次构建一个全新的发布者把“状态创建”这一步也放进 defer 内部。5.3 坑三分析执行次数时的调试误区用 log 和 doOnSubscribe 准确观察我发现很多人在排查“为什么订阅了却好像没执行”时会习惯性在构建处打日志。这个习惯在同步代码里没问题在响应式代码里很容易误导人。MonoLong m Mono.defer(() - { System.out.println(supplier called); return Mono.just(System.currentTimeMillis()); }); m.subscribe(); m.subscribe(); // supplier called 出现两次但如果是这种MonoLong m Mono.just(System.currentTimeMillis()); m.subscribe(); m.subscribe(); // 控制台没有任何“构建日志”因为构建发生在订阅之前那一行用构建日志去推测订阅次数会得到完全错误的结论。我自己现在的排查习惯是遇到“数量对不上”的问题直接在关键节点挂一个.log()或.doOnSubscribe(...)观察onSubscribe、onNext、onComplete的顺序和次数。只要看到onSubscribe已经出现但 supplier 还没被调用说明延迟逻辑正常如果 supplier 被调用了但订阅者的回调没触发问题就在上游发布者不在 defer 本身。另外提一句Mono.defer配合一次性发布者时要注意如果 supplier 每次返回的是同一个只能被订阅一次的发布者第二次订阅依然会收到错误或直接无信号。defer 只是推迟构建不会把“一次性的热发布者”变成“每次可重订的冷源”遇到这种情况要回到 5.2 的结论——确保每次构建的都是新实例。最后说一个我自己的排查心得凡是遇到“订阅了却像没执行”“多个订阅者数据一模一样”这类问题我会先问三个问题——值是什么时候算出来的方法是什么时候被调用的线程有没有被阻塞这三个问题顺序排下来九成都能定位到just用得过度、defer 用得不够或者 supplier 里混进了阻塞调用。这个习惯帮我少熬了好几个通宵也希望你能在踩坑之前就看到这篇内容。