8.1 Cold Publisher:每次订阅重放

系统讲解 Cold Publisher 的语义:数据流与订阅绑定,每次 subscribe 独立启动新序列、重新执行副作用。用实跑输出对比 Flux.range 的重复订阅、Mono.fromCallable 的重复调用、Mono.cache 的成功与失败缓存、Flux.cache(n) 对后订阅者只回放最近 n 个元素,并区分 just 的急切求值与 fromCallable 的延迟求值。示例基于 reactor-core 3.8.8。

Cold Publisher 的定义

Cold Publisher:数据流与订阅绑定。每次 subscribe() 都会启动一个新的独立序列,数据从源头重新产生。

Flux<Integer> cold = Flux.range(1, 3);

cold.subscribe(i -> System.out.println("A: " + i));
cold.subscribe(i -> System.out.println("B: " + i));

实测输出:

A: 1
A: 2
A: 3
B: 1
B: 2
B: 3

两次订阅独立运行,各自消费完整的 1、2、3 序列。第二次订阅不是「从上次的位置继续」,而是从头再来。

特征

特征说明
订阅驱动不 subscribe 就不启动
独立序列每次订阅有独立的订阅状态与计数器
可重放同一数据源可以被重复消费
源头耦合每次订阅重新访问数据源(查询、读文件、发请求)

每次订阅都会重新执行副作用

Cold 的关键不是「值相同」,而是「生成值的动作会重新执行」:

AtomicInteger calls = new AtomicInteger();

Mono<Integer> mono = Mono.fromCallable(() -> {
    System.out.println("fetch " + calls.incrementAndGet());
    return 42;
});

mono.subscribe(v -> System.out.println("A: " + v));
mono.subscribe(v -> System.out.println("B: " + v));

实测输出:

fetch 1
A: 42
fetch 2
B: 42

重点是 fetch 出现了两次。如果这里换成 HTTP 调用或数据库查询,就是两次真实的远程请求。

这里有一个常被混淆的对照:

Mono<Integer> eager = Mono.just(expensiveCall());          // 装配时就执行
Mono<Integer> lazy = Mono.fromCallable(() -> expensiveCall()); // 每次订阅执行

Mono.just(...) 的参数在调用方法时就已经求值,代价发生在装配阶段;fromCallable 把调用包进 Supplier,订阅时才执行。想利用 Cold 的「每次订阅重新获取」,必须用后者。

典型 Cold 操作符

操作符数据源每次订阅的行为
Flux.just / Mono.just内存常量重放相同的值(值本身已在装配时确定)
Flux.range计数器重新计数
Flux.fromIterable集合重新遍历
Flux.create自定义重新执行 create 的 consumer
Mono.fromCallable调用重新调用
Mono.defer延迟创建重新执行 Supplier
Flux.using资源每次订阅创建并释放资源

Mono.defer 和 fromCallable 的区别在于延迟的层次:defer 延迟的是「创建哪个 Publisher」,适合每次订阅都要重新决定数据源的场景;fromCallable 延迟的是「执行哪个调用」,适合数据源本身已经确定。

cache():把结果留给后续订阅

如果意图是「只调用一次,结果共享给所有订阅者」,cache() 是最直接的手段:

AtomicInteger fetches = new AtomicInteger();

Mono<Integer> cached = Mono.fromCallable(() -> {
    System.out.println("fetch " + fetches.incrementAndGet());
    return 42;
}).cache();

cached.subscribe(v -> System.out.println("A: " + v));
cached.subscribe(v -> System.out.println("B: " + v));

实测输出:

fetch 1
A: 42
B: 42

第一次订阅触发实际调用,第二次直接拿到缓存值。Mono.cache() 缓存的是终止信号,成功和失败都缓存:

AtomicInteger attempts = new AtomicInteger();

Mono<Integer> failing = Mono.<Integer>fromCallable(() -> {
    attempts.incrementAndGet();
    throw new IllegalStateException("boom");
}).cache();

failing.subscribe(v -> {}, e -> System.out.println("A error: " + e));
failing.subscribe(v -> {}, e -> System.out.println("B error: " + e));
System.out.println("attempts=" + attempts.get());

实测输出:

A error: java.lang.IllegalStateException: boom
B error: java.lang.IllegalStateException: boom
attempts=1

第二次订阅不会重试,只会立刻收到同一个错误。需要失败后重试或按条件失效时,用 Mono.cache(ttl)、Mono.cacheInvalidateIf(...)、Mono.cacheInvalidateWhen(...) 这些重载,而不是 cache()。

Flux.cache 的重载与内存

Flux.cache() 在第一次订阅时把整个序列缓存下来,后订阅者从头回放。cache(int) 只保留最近 n 个元素给后订阅者:

Flux<Integer> full = Flux.range(1, 5).cache();
Flux<Integer> lastTwo = Flux.range(1, 5).cache(2);

两个序列都在第一次订阅时完整执行,第二次订阅的结果是:

cache(): A=[1, 2, 3, 4, 5] B=[1, 2, 3, 4, 5]
cache(2): A=[1, 2, 3, 4, 5] B=[4, 5]

注意 cache(2) 只是限制给后订阅者的历史长度,不限制正在订阅的消费者。它对无限流尤其重要:

// 危险:无界缓存 + 无限流
Flux.interval(Duration.ofMillis(100))
    .cache()
    .subscribe();

cache() 会保留所有已发出的元素,长时间运行必然 OOM。cache(int)、cache(Duration)、cache(int, Duration) 可以给出上界;但上界内的元素仍然常驻内存,容量要按业务峰值估算,而不是「应该不会太多」。

另外,cache() 的订阅发生在第一个订阅者出现时,不是装配时。只创建 cached 而不订阅,源不会执行。

Cold vs Hot

维度ColdHot
启动时机subscribe() 时connect() / autoConnect(n) / 第一个订阅者
订阅独立性每次独立共享同一条源流
订阅前的数据订阅后完整产生已产生但无人接收的部分丢失
内存按需产生,无共享缓冲取决于是否 replay,以及慢订阅者的队列

Cold 的内存不能用一个「订阅数 × 元素大小」的公式概括:元素是按需生成的,真正的占用来自在途元素、操作符的预取队列和你的缓冲策略。Flux.range(1, 1000) 两次订阅消耗的是两次遍历的临时状态,不会把 2000 个整数同时留在内存里。

何时选择 Cold

适合:

  • 每个订阅者都需要完整、独立的结果(按用户 ID 查询、读取文件);
  • 数据源可以低成本重复访问;
  • 不需要在订阅者之间共享状态。

不适合:

  • 实时行情、传感器、聊天这类「只关心订阅之后」的流——应该用 Hot;
  • 每次订阅都触发昂贵远程调用,且多个订阅者需要同一份结果——用 cache() 或有界 replay。

阅读导航

上一篇:7.3 背压策略:Error 与请求发射速率控制

下一篇:8.2 Hot Publisher:广播与共享

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南