9.3 Sinks 重放:大小与时间限制

讲解 Sinks.Many.replay() 的五种策略:all() 无限历史、limit(n) 保留最近 n 个、limit(Duration) 按 TTL 淘汰、limit(n, Duration) 同时受限、latest() 只留最新值。实测 late subscriber 在 limit(3) 下只收到 [2,3,4]、只 request(1) 时只收到 1 个元素,证明重放仍受下游背压约束;并给出 latestOrDefault、过期后无新元素、完成后订阅的真实输出。示例基于 reactor-core 3.8.8。

ReplaySpec:先选策略,再建 sink

Sinks.many().replay() 返回的是 MulticastReplaySpec,不是 sink 本身。必须先选一种历史保留策略:

// 全部历史,无界
Sinks.Many<Integer> all = Sinks.many().replay().all();

// 最近 n 个
Sinks.Many<Integer> last3 = Sinks.many().replay().limit(3);

// 最近 ttl 内
Sinks.Many<Integer> recent = Sinks.many().replay().limit(Duration.ofMinutes(5));

// 同时受数量与时间限制
Sinks.Many<Integer> bounded = Sinks.many().replay().limit(3, Duration.ofMinutes(5));

// 只保留最新一个
Sinks.Many<Integer> latest = Sinks.many().replay().latest();

没有无参的 replay()。这个设计是刻意的:历史保留一定伴随内存代价,API 不提供「默认无界」这种容易误用的入口。

limit(n):只给后订阅者最近 n 个

Sinks.Many<Integer> sink = Sinks.many().replay().limit(3);

sink.tryEmitNext(1);
sink.tryEmitNext(2);
sink.tryEmitNext(3);
sink.tryEmitNext(4);        // 1 被挤出历史

List<Integer> late = new ArrayList<>();
sink.asFlux().subscribe(late::add);
System.out.println("late=" + late);

实测输出:

late=[2, 3, 4]

后订阅者拿到的是最近 3 个,不是「从订阅点开始」:4 是订阅之前发射的,但它仍在历史里,所以也会补给后订阅者。这与 publish() 的 Hot 语义不同——replay 的历史对后订阅者是可见的。

历史在 sink 完成之后仍然保留:

sink.tryEmitComplete();

List<Integer> afterComplete = new ArrayList<>();
boolean[] completed = {false};
sink.asFlux().subscribe(afterComplete::add, e -> {}, () -> completed[0] = true);

实测:

late=[2, 3, 4] completed=true
emit after complete=FAIL_TERMINATED

完成后订阅仍能拿到历史,并立即收到 onComplete;再发射返回 FAIL_TERMINATED。

重放仍受下游 request 约束

一个常见误解是「重放是预加载,不需要 request」。实际上历史元素和实时元素一样,按下游的请求额度逐个发送:

Sinks.Many<Integer> sink = Sinks.many().replay().limit(3);
sink.tryEmitNext(1);
sink.tryEmitNext(2);
sink.tryEmitNext(3);

List<Integer> one = new ArrayList<>();
sink.asFlux().subscribe(new BaseSubscriber<Integer>() {
    @Override
    protected void hookOnSubscribe(Subscription s) {
        request(1);                 // 只要 1 个
    }

    @Override
    protected void hookOnNext(Integer v) {
        one.add(v);                 // 不再 request
    }
});

Thread.sleep(100);
System.out.println("one=" + one);

实测输出:

one=[1]

即使历史里有 3 个元素,下游只请求 1 个,就只收到 1 个;剩下 2 个留在重放缓冲里,等下一次 request。所以 replay 不会绕过 Reactive Streams 的背压规则,它只是把「历史」变成了可被请求的数据源。

这也解释了为什么 replay().limit(100) 不能替代 onBackpressureBuffer:前者控制的是保留多少历史,后者控制的是当前在途元素能积压多少。两者解决的不是同一个问题。

limit(Duration):按时间淘汰

传 Duration 时,过期元素不再补给后订阅者。实测 limit(Duration.ofMillis(100)):

Sinks.Many<Integer> sink = Sinks.many().replay().limit(Duration.ofMillis(100));
sink.tryEmitNext(1);
sink.tryEmitNext(2);
Thread.sleep(150);              // 超过 TTL

List<Integer> late = new ArrayList<>();
sink.asFlux().subscribe(late::add);
System.out.println("late=" + late);

输出:

late=[]

即使期间没有新元素触发清理,订阅时也不会重放已过期的 1、2。再看「过期后又有新元素」:

Sinks.Many<Integer> sink = Sinks.many().replay().limit(Duration.ofMillis(100));
sink.tryEmitNext(1);
sink.tryEmitNext(2);
Thread.sleep(150);
sink.tryEmitNext(3);            // 新元素,1、2 已过期

sink.asFlux().subscribe(v -> System.out.println(v));

输出:

3

limit(int, Duration) 同时施加两个上界,先到者生效。实测 limit(2, Duration.ofMillis(200)) 连续发射 1、2、3 后立即订阅:

late=[2, 3]

这里数量上限先起作用。

TTL 的单位是「元素进入历史的时刻」,不是「元素被消费的时刻」。下游订阅得越晚,能补到的历史越少,这一点和 limit(n) 不同。

latest() 与 latestOrDefault()

latest() 只保留最后一个元素:

Sinks.Many<Integer> sink = Sinks.many().replay().latest();
sink.tryEmitNext(1);
sink.tryEmitNext(2);

List<Integer> late = new ArrayList<>();
sink.asFlux().subscribe(late::add);
sink.tryEmitNext(3);

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

实测输出:

latest=[2, 3]

后订阅者先拿到订阅前的最新值 2,再收到订阅后的 3;1 不会出现。它适合「状态快照」类数据:新订阅者只关心当前值,不需要完整变更历史。

如果订阅时还没有任何元素,latest() 不补任何东西。需要默认值时用 latestOrDefault(T):

Sinks.Many<Integer> sink = Sinks.many().replay().latestOrDefault(0);
List<Integer> late = new ArrayList<>();
sink.asFlux().subscribe(late::add);
System.out.println("no emission late=" + late);

输出:

no emission late=[0]

0 是后订阅者在无历史时收到的初始值,不会进入后续的历史淘汰。

与 Flux.cache() 的区别

维度Sinks...replay()Flux.cache()
发射端外部显式 tryEmitNext由上游 Publisher 驱动
启动时机创建 sink 后即可发射第一个订阅者出现时订阅源
无订阅者时按策略缓存历史源不执行
后订阅者按 limit / latest 补历史默认补全部;cache(n) 补最近 n 个
终止信号缓存并重放完成/错误缓存并重放完成/错误
多订阅者原生多播原生支持,但共享同一份缓存

Flux.cache(n) 的 n 只限制后订阅者看到的历史长度,第一个订阅者仍会收到完整序列;Sinks...replay().limit(n) 则从一开始就只保留 n 个。这个差别在排查「为什么第二个订阅者少了数据」时很关键。

选择指南

场景选择注意
新订阅者只需要当前状态latest() / latestOrDefault(v)无历史的订阅者会立即拿到默认值
聊天室最近 100 条limit(100)内存按元素数封顶
配置在 5 分钟内有效limit(Duration.ofMinutes(5))过期后订阅者可能什么都拿不到
既要数量又要时间上界limit(n, ttl)先到者生效
完整审计历史all()无界,必须评估内存与保留时长

replay 的历史是进程内内存,进程退出即消失。需要跨进程、跨重启的重放,应该用消息队列或持久化日志,而不是把 replay() 当成可靠存储。

小结

策略保留规则后订阅者看到
all()全部历史完整历史 + 新元素
limit(n)最近 n 个最近 n 个 + 新元素
limit(Duration)TTL 内未过期历史 + 新元素
limit(n, Duration)数量与时间双重上界两者都满足的历史 + 新元素
latest()最新 1 个最新值 + 新元素
latestOrDefault(v)最新 1 个,初始为 vv 或最新值 + 新元素

两点必须记住:重放仍受下游 request 约束;limit(0) 会抛 IllegalArgumentException: historySize must be > 0,不存在「退化为 publish」的写法。

阅读导航

上一篇:9.2 Sinks 多播:背压与重放

下一篇:10.1 RxJava Subject:与 Sinks 的对照

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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