8.2 Hot Publisher:广播与共享

深入讲解 Hot Publisher 的语义:源流独立于订阅存在,late subscriber 只能收到订阅之后的元素。用 Flux.create 手工驱动 publish、connect,确定性演示后订阅者丢失历史;实测 autoConnect(n) 的连接阈值、refCount(1) 在订阅归零时断开并在下次订阅时重新连接,并纠正「Hot Publisher 无背压、会无限缓冲」的误解。示例基于 reactor-core 3.8.8。

Hot Publisher 的定义

Hot Publisher:数据流独立于订阅存在。元素一旦产生,立即广播给当前活跃的订阅者;后订阅的(late subscriber)只能收到订阅之后的元素。

判断 Cold 和 Hot,不看「是不是同一个对象」,而看数据是在订阅时生成,还是在订阅之外生成。Flux.range 每次订阅重新计数,是 Cold;publish() 连上源之后,源持续发射,新订阅者只能搭上后半程,是 Hot。

publish() + connect():先启动,后订阅

publish() 把 Cold 源转换成 ConnectableFlux。它不会在 subscribe() 时自动连接,需要显式 connect():

import reactor.core.publisher.ConnectableFlux;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;

public class PublishDemo {

    public static void main(String[] args) {
        AtomicReference<FluxSink<Integer>> ref = new AtomicReference<>();
        ConnectableFlux<Integer> hot = Flux.<Integer>create(ref::set).publish();

        hot.connect();                 // 连接源,但还没有订阅者

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

        ref.get().next(1);             // 此时没有订阅者,1 被丢弃
        hot.subscribe(a::add);
        ref.get().next(2);
        hot.subscribe(b::add);
        ref.get().next(3);
        ref.get().complete();

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

实测输出:

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

A 在 1 之后订阅,所以错过 1,拿到 2、3;B 在 2 之后订阅,只拿到 3。connect() 之前或两次订阅之间发出的元素不会为后来者保留——这就是 Hot 的「丢失历史」。

connect() 返回 Disposable,调用 dispose() 会断开与源的连接。重复 connect() 不会重复订阅源;ConnectableFlux 只维护一条上游连接。

autoConnect(n):凑够 n 个订阅者再连接

autoConnect(n) 省掉手动 connect(),但连接的触发条件是「累计 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);
System.out.println("first: A=" + a + " starts=" + starts.get());

hot.subscribe(b::add);
System.out.println("second: A=" + a + " B=" + b + " starts=" + starts.get());

实测输出:

first: A=[] starts=0
second: A=[1, 2, 3] B=[1, 2, 3] starts=1

第一个订阅者只是排队等待;第二个到达时触发连接,range 同步发射完 1、2、3,两个订阅者都收到完整序列,源只被订阅了一次。

autoConnect(n) 的 n 只决定何时连接,不决定何时断开。连接建立后,即使订阅者全部取消,源也会继续运行——对 interval 这类无限源就是持续占用调度器资源。需要「没有订阅者就停」时用 refCount。

refCount(n):按订阅数自动连接与断开

refCount(n) 在累计 n 个订阅者时连接,在订阅者数量归零时断开。这里的两个数字容易混淆:n 是连接阈值,断开阈值永远是 0。从 n 降到 n-1 不会断开。

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);      // 连接,range 发射完并完成
hot.subscribe(b::add);      // 源已经终止,refCount 重新连接

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

实测输出:

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

A 结束后连接已经终止;B 订阅时 refCount 重新订阅源,所以 B 又能拿到完整的 1、2、3,starts 变成 2。

对无限源,可以观察到「归零断开」:

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

B 重新从 0 开始,starts=2。interval 是 Cold 源,每次连接都重新计时;如果源本身是 Hot(例如消息队列的消费者),重新连接意味着重新注册消费者,是否能补上断开期间的数据取决于源,而不是 refCount。

share():refCount(1) 的简写

Flux<Long> hot = Flux.interval(Duration.ofMillis(100)).share();

share() 在 3.8.8 里的实现就是 new FluxRefCount<>(new FluxPublish<>(source, 256, ...), 1),等价于 publish().refCount(1)。需要自定义预取量时用 publish(n).refCount(1),share() 不接受参数。

背压:publish 会拖住源,而不是无限缓冲

一个常见的说法是「Hot Publisher 没有背压,慢消费者会导致内存无限增长」。在 Reactor 里这个说法不准确。

publish() 的 drain 循环在源线程上同步调用每个订阅者的 onNext。只要有一个订阅者在 onNext 里阻塞,整条广播就被拖住,源也无法继续发射:

Flux<Long> hot = Flux.interval(Duration.ofMillis(1))
    .publish()
    .autoConnect(1);

hot.subscribe(v -> {
    received.incrementAndGet();
    Thread.sleep(100);      // 慢消费者
});

实测 1.5 秒内 received=14 errors=0。没有队列暴涨,也没有 OOM,代价是所有订阅者一起被最慢的那个拖慢(head-of-line blocking)。元素在 publish 的预取队列里最多堆积 prefetch 个(默认 256),源随后就被反压住。

如果订阅者完全不请求,情况更直接:publish 只向上游请求预取量,drain 没有需求就不补请求,时间源自己会先失败。实测:

error: reactor.core.Exceptions$OverflowException: Could not emit tick 256 due to lack of requests
(interval doesn't support small downstream requests that replenish slower than the ticks)

要真正解耦慢消费者,必须让消费不再阻塞广播线程,并且接受「数据会被缓冲或丢弃」。publishOn 只能把阻塞点移到另一个线程:如果 slowConsumer 在 onNext 里继续阻塞,publishOn 的工作线程也会被占住,它自己的队列填满后仍然会反压回 publish。把 onBackpressureDrop 直接放在 publish() 之前也不能解决问题——publish 会向上游请求默认 256 个,Drop 看到的一直是有需求的,实测丢弃计数为 0。

真正有损的广播需要生产者主动丢弃,而不是让消费者反压。Sinks.many().multicast().directBestEffort() 会在订阅者跟不上时直接丢元素;Flux.create(sink, FluxSink.OverflowStrategy.DROP) 也能表达同样的策略。这些属于第 9 章的内容。这里先记住结论:publish() 的行为是「拖慢源」,不是「无限缓冲」;想要「不拖慢源、允许丢」,要换的是源与缓冲模型,而不是在链中间随手加一个 onBackpressureDrop。

Hot Publisher 与消息队列

特性Hot Publisher消息队列
持久化无有
消费者组广播给所有订阅者可分组、可竞争消费
重放replay(n) 在内存中有限重放可配置保留与重放
顺序单条连接内有序分区内有序
适用实时状态、进程内广播可靠传输、跨进程解耦

Hot Publisher 不解决「消息不能丢」的问题。需要可靠投递时,消息队列或持久化日志仍然是必要的;replay 只是进程内的短期缓冲,进程退出即消失。

小结

特性ColdHot
订阅前数据订阅后完整产生已产生的部分丢失
启动时机subscribeconnect / autoConnect(n) / refCount(n)
数据独立性每次订阅独立共享同一条源流
停止条件订阅结束或取消refCount 归零、显式 dispose 或源终止
内存按需产生取决于 replay 与慢订阅者队列

阅读导航

上一篇:8.1 Cold Publisher:每次订阅重放

下一篇:8.3 refCount、autoConnect 与 cache

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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