8.3 refCount、autoConnect 与 cache

对比 refCount(n)、autoConnect(n)、replay()、replay(n) 与 cache() 的启动时机、断开条件、后订阅者行为与内存代价。实测 refCount(1) 在订阅归零时取消源、在源完成后重新订阅源,autoConnect(1) 在订阅者全部取消后仍持续运行,replay(2) 只给后订阅者回放最近 2 个元素,cache(2) 只保留最近 2 个元素给后订阅者。示例基于 reactor-core 3.8.8。

四种模式对比

模式何时连接源何时断开后订阅者看到什么内存代价
refCount(n)累计 n 个订阅者订阅者数量归零订阅之后的数据低
autoConnect(n)累计 n 个订阅者不会因订阅者归零而断开订阅之后的数据低,但源可能空转
replay(n).autoConnect()累计 n 个订阅者(默认 1)不会因订阅者归零而断开最近 n 个历史 + 新数据中,受 n 限制
replay().autoConnect()累计 1 个订阅者不会因订阅者归零而断开全部历史 + 新数据高,无界
cache() / cache(n)第一个订阅者源完成后保留缓存全部 / 最近 n 个缓存高 / 有界

这张表里最容易记错的是两点:autoConnect(n) 的 n 只控制连接,不控制断开;refCount(n) 的 n 也只控制连接,断开阈值始终是 0。

refCount(n):订阅归零就断开

先看一个同步源,确认「源完成后新订阅者会重新触发订阅」:

AtomicInteger starts = new AtomicInteger();

Flux<Integer> hot = Flux.range(1, 3)
    .doOnSubscribe(s -> starts.incrementAndGet())
    .publish()
    .refCount(1);

List<Integer> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();

hot.subscribe(a::add);
hot.subscribe(b::add);

System.out.println("A=" + a + " B=" + b + " starts=" + starts.get());

实测输出:

A=[1, 2, 3] B=[1, 2, 3] starts=2

A 订阅时连接,range 同步发射完并终止;B 订阅时连接已经结束,refCount 重新订阅源,所以 B 又拿到完整的 1、2、3。refCount 不是「只订阅一次」的缓存,源终止后它会把下一次订阅当作新一轮。

对无限源,可以看到归零断开:

AtomicInteger starts = new AtomicInteger();

Flux<Long> hot = Flux.interval(Duration.ofMillis(100))
    .doOnSubscribe(s -> {
        starts.incrementAndGet();
        System.out.println("source subscribed");
    })
    .doOnCancel(() -> System.out.println("source cancelled"))
    .publish()
    .refCount(1);

List<Long> a = new ArrayList<>();
Disposable d1 = hot.subscribe(v -> { a.add(v); System.out.println("A: " + v); });
Thread.sleep(250);
d1.dispose();

Thread.sleep(150);

List<Long> b = new ArrayList<>();
Disposable d2 = hot.subscribe(v -> { b.add(v); System.out.println("B: " + v); });
Thread.sleep(250);
d2.dispose();

某次实测(100ms 间隔,250ms 窗口):

source subscribed
A: 0
A: 1
source cancelled
source subscribed
B: 0
B: 1
source cancelled

refCount(2) 的连接条件是 2 个订阅者,但断开条件仍然是 0:从 2 个降到 1 个不会断开,只有最后一个订阅者取消才取消源。这个设计避免了「订阅者数在 n 附近抖动导致反复连接」的问题。

autoConnect(n):连上就不放手

autoConnect(n) 同样等到 n 个订阅者才连接,但之后不跟踪订阅者数量:

AtomicInteger starts = new AtomicInteger();

Flux<Integer> hot = Flux.range(1, 3)
    .doOnSubscribe(s -> starts.incrementAndGet())
    .publish()
    .autoConnect(2);

List<Integer> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();

hot.subscribe(a::add);                  // 只有 1 个,等待
hot.subscribe(b::add);                  // 第 2 个到达,连接
System.out.println("A=" + a + " B=" + b + " starts=" + starts.get());

实测输出:

A=[1, 2, 3] B=[1, 2, 3] starts=1

连接后即使订阅者全部取消,源也不会停。用 interval 可以观察到这一点:

AtomicInteger emitted = new AtomicInteger();

Flux<Long> hot = Flux.interval(Duration.ofMillis(50))
    .doOnNext(v -> emitted.incrementAndGet())
    .publish()
    .autoConnect(1);

Disposable d = hot.subscribe(v -> { });
Thread.sleep(220);
int before = emitted.get();
d.dispose();
Thread.sleep(300);
int after = emitted.get();

实测:

before dispose=4 after dispose=10

最后一个订阅者已经取消,interval 仍在继续发射,emitted 从 4 涨到 10。这就是 autoConnect 的资源泄漏风险:源会一直运行到进程结束或源自身终止。只有确定「源应该常驻」时才用它,例如进程级的共享事件总线。

replay():给后订阅者补历史

replay() 返回 ConnectableFlux,连接后把历史元素缓存下来给后订阅者。无参版本缓存全部历史:

ConnectableFlux<Integer> replay = source.replay();
replay.connect();

replay(int n) 只保留最近 n 个:

AtomicReference<FluxSink<Integer>> ref = new AtomicReference<>();
ConnectableFlux<Integer> replay = Flux.<Integer>create(ref::set).replay(2);
replay.connect();

ref.get().next(1);
ref.get().next(2);
ref.get().next(3);

List<Integer> late = new ArrayList<>();
replay.subscribe(late::add);
ref.get().next(4);
ref.get().complete();

System.out.println("late=" + late);

实测输出:

late=[2, 3, 4]

前 3 个元素发出后,replay(2) 只保留最近 2 个(2、3);后订阅者先收到这 2 个历史,再收到连接期间的 4。它不会把 1 补给它。

重载与语义:

重载保留策略
replay()全部历史,等价于 replay(Integer.MAX_VALUE)
replay(int n)最近 n 个
replay(Duration ttl)ttl 内的全部元素
replay(int n, Duration ttl)同时受数量与时间限制
replay(0)不保留历史,退化为 publish()

replay() 是有状态的:历史在内存里,和订阅者是否在线无关。对无限流使用 replay() 会让内存持续增长;replay(n) 给出数量上界,但不能限制速率,元素体积大时仍要按内存预算选 n。

replay().autoConnect() 与 replay().connect() 的区别只在连接时机:前者等第一个订阅者到达后连接,后者由你显式调用。两者都不会在订阅者归零时断开;需要自动停止时,用 replay().refCount(n)。

cache():冷结果只算一次

cache() 不是 ConnectableFlux,没有 connect()。它在第一个订阅者出现时订阅源,并把结果缓存下来:

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

List<Integer> a = new ArrayList<>();
full.subscribe(a::add);
List<Integer> b = new ArrayList<>();
full.subscribe(b::add);

List<Integer> c = new ArrayList<>();
lastTwo.subscribe(c::add);
List<Integer> d = new ArrayList<>();
lastTwo.subscribe(d::add);

System.out.println("cache(): A=" + a + " B=" + b);
System.out.println("cache(2): A=" + c + " B=" + d);

实测输出:

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(n) 限制的是给后订阅者的历史条数,正在消费的第一个订阅者仍然拿到全部元素;
  • 缓存独立于订阅者存在。所有订阅者都取消后,cache() 仍保留已缓存的数据,直到 cached 引用本身被回收。

这与 replay().autoConnect() 的差别在连接语义:cache() 由第一个订阅者触发,源完成后缓存永久保留;replay().autoConnect() 在连接后即使没人订阅也继续运行,并把新元素加入历史。

Mono.cache 与失效

Mono.cache() 缓存的是终止信号(值或错误),不是「当前值」。失败也会被缓存:

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

需要基于时间或条件失效时,Mono 提供了额外的重载:

Mono<Config> config = loadConfig()
    .cache(Duration.ofMinutes(10));          // TTL

Mono<User> user = fetchUser(id)
    .cacheInvalidateIf(u -> u.isExpired());  // 值满足条件就失效

Mono<Price> price = fetchPrice(sku)
    .cacheInvalidateWhen(p -> priceFeed.nextChange(sku)); // 外部信号触发失效

注意 cacheInvalidateIf / cacheInvalidateWhen 只在 Mono 上,Flux 没有对应方法;Flux 只有 cache()、cache(int)、cache(Duration)、cache(int, Duration) 及其带 Scheduler 的重载。

选择指南

需求选择原因
自动启停,省资源refCount(1)最后一个订阅者取消时断开
源应常驻,订阅者来去自由autoConnect(1)连接后不跟踪订阅者
后订阅者需要完整历史replay() / cache()但有内存上界问题
后订阅者只需要最近 n 个replay(n) / cache(n)内存有界,语义明确
冷结果只计算一次cache()第一个订阅者触发,后续复用
需要 TTL 或条件失效Mono.cache(ttl) / cacheInvalidate*Flux 没有等价的失效 API

决策树

需要把同一份数据共享给多个订阅者?
├── 否 → Cold Publisher
└── 是
    ├── 后订阅者需要历史?
    │   ├── 是 → 历史有界?
    │   │        ├── 是 → replay(n).autoConnect() / cache(n)
    │   │        └── 否 → replay().autoConnect() / cache()(评估内存)
    │   └── 否
    │       ├── 没有订阅者时要停? → refCount(n)
    │       └── 源应一直运行?     → autoConnect(n)
    └── 只需要复用一次计算结果? → cache()

小结

模式一句话
refCount(n)n 个订阅者连接,归零断开,源终止后新订阅会重新连接
autoConnect(n)n 个订阅者连接,之后永不因订阅者归零而断开
replay(n)保留最近 n 个历史,后订阅者补发
cache()第一个订阅者触发一次执行,结果长期保留
cache(n)只给后订阅者回放最近 n 个

选择之前先回答两个问题:后订阅者是否必须看到历史,以及没有订阅者时源是否应该继续跑。这两个答案基本决定了用哪一组操作符;内存只是随后要核算的代价。

阅读导航

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

下一篇:9.1 Sinks:程序化发射数据

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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