周末晚上七点,“辣得起”川菜馆进入了一天中最疯狂的时刻。后厨里三灶齐开,油花飞溅,一盘盘水煮鱼、辣子鸡像流水线一样被铲进盘子;传菜口那半米宽的不锈钢台面上,菜碟越摞越高,刚出锅的宫保鸡丁已经凉了,还有两盘差点被挤到地上——而服务员们正手忙脚乱,根本来不及端。如果你是大厨,你会怎么做?继续闷头猛炒?还是抬起头,看看传菜口的积压,顺着服务员们的节奏调整火候? 这个再平常不过的厨房难题,正是分布式系统世界里“背压机制(Backpressure)”要解决的终极命题:当上游生产数据的速度超过下游消费数据的速度,中间的缓冲区就像一个越堆越满的传菜台,迟早溢出、崩溃、丢数据。Reactive 响应式编程给出的答案是——让下游“点菜式”地告诉上游“我一次要几个”,上游按量产出,双方通过一张叫 Subscription 的“点菜单”达成契约。本文将从这家川菜馆出发,拆解背压的基础模型、响应式契约、请求量控制、传播链路、工程策略,以及它和限流、熔断这对“兄弟机制”的微妙区别。
背压是构筑高并发系统“自我保护”意识的最后一块拼图。理解它,你就能在流式架构、消息队列与 AI 推理管线中,让系统在洪峰下优雅降速而不是脆断崩溃——这是架构师从“能用”走向“可靠”的必修课。
目录
可视化演示
演示Reactive Streams的Subscription契约、请求量控制与背压传播链路,理解下游控制上游速率避免缓冲溢出的机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Subscription:请求驱动 request(n) 反向信号 request(n)→emit 按需拉取数据
背压基础概念:传菜台为什么不够用
七点的厨房里,大厨老王的手速是每秒 0.8 盘菜,而服务员小刘最多只能同时端三盘;传菜台总共只有 6 个位子——这就是活生生的“生产大于消费”。放到计算机世界,老王就是上游生产者(Publisher),小刘就是下游消费者(Subscriber),传菜台就是缓冲区(Buffer)。当生产速率超过消费速率,缓冲区会以肉眼可见的速度被填满,最终菜碟落地、汤汁四溅——术语叫缓冲区溢出,落在系统里就是内存飙升、消息丢失,甚至进程崩溃。
为了量化这种失衡,工程上引入“负荷率”指标:生产速率除以消费速率。
如何缓解失衡?传统“推模型”里,生产者闷头往外推数据,消费者只能被动接;而“拉模型”则反过来——消费者想要多少,生产者才给多少。背压机制正是建立在“拉模型”之上:它不是把缓冲区无限加大去“硬扛”,而是从源头调整生产节奏,让 ρ 天然趋近 1。就像老王不再闷头猛炒,而是看一眼传菜台的余量再动锅——背压的第一课是:别让缓冲区成为事故的接盘侠。
- λ生产(分子)lambda 代表单位时间内上游生产的数据量,相当于大厨老王每分钟炒出的盘数。
- μ消费(分母)mu 代表单位时间内下游最多能消化的数据量,相当于服务员小刘每分钟能端走的盘数。
- ρ 负荷率(比值)rho 是两者的商,代表整个“厨房”的忙闲程度:大于 1 说明超负荷运转,迟早出事故。
响应式契约:点菜单上的君子协定
光有“看一眼传菜台”的自觉还不行,人总有分神的时候。店长决定把节奏控制变成明文规定:每桌服务员必须先拿到一张点菜单,上面写清楚“我要几个菜”;后厨凭单炒菜,一份单最多出 N 道;菜上齐了菜单作废,出错了立刻叫停。这套规定,就是Reactive Streams 规范——一个跨语言的标准异步流处理协议,也是响应式编程(Reactive Programming)的地基。
规范定义了三个核心组件和四类信号:
- 发布者(Publisher):数据源,相当于后厨,负责按“点菜单”出菜;
- 订阅者(Subscriber):数据接收方,相当于服务员,负责消费并反馈需求;
- 订阅契约(Subscription):连接双方的“点菜单”,承载唯一的流量控制指令
request(n);
- 四类信号:
onSubscribe(递上菜单,建立联系)、onNext(端上一道菜)、onError(菜做砸了直接上报)、onComplete(菜上齐了,合作结束)。
有了这套契约,无论中间隔着多少环节,系统的真实吞吐都被下游的消费能力牢牢钳制:
值得强调的是,onNext 里还可以继续调用 request(n)——服务员端完一道菜,可以随时在菜单上再添几道。这确保契约不是僵化的“一次定终身”,而是动态可调的双向对话。一份优秀的点菜单协议,让“辣得起”的后厨再也没出现过传菜台爆满的狼狈,这就是规范的力量。
- λ上游上游(Publisher)的原始产出速率,即大厨不考虑传菜台限制时能炒出的最大盘数。
- μ下游下游(Subscriber)的实际消化能力,即服务员端菜的速度上限。
- min 取小函数取两者中较小者,就像餐厅的“上菜速度”永远由慢的那一方决定——大厨再快,服务员端不过来,菜也到不了客人桌上。
- T实际整个链路的有效吞吐。这个公式道出背压的本质:它不是提升吞吐的魔法,而是守住“系统真实能力”底线的纪律。
请求量控制:一次报五道,而不是两百道
有了点菜单,新问题来了:小刘每张单上报多少合适?如果她图省事,一次性报“给我 200 道”,后厨就会像脱缰野马般猛炒,传菜台瞬间淹没——这相当于亲手把背压机制关掉。反过来,一次只报 1 道,后厨刚热完锅就停火,效率又太低。请求量(request)的粒度,是背压工程的第一道调参题。
Reactive 社区常用动态窗口策略:消费者根据自己当前的“空闲槽位”和“在途数据”实时计算下一轮报数。比如小刘手里能同时端 3 盘、传菜台上还有 2 盘没拿,她就只报 1——刚好补齐空闲量,不贪多也不饿着。
除了动态窗口,批量拉取(batch fetch)也是常见手法:消费者攒够一批再统一 request,减少控制信号的通信次数,原理等同于小刘攒一批菜再统一上桌。请求量控制的核心心法只有一句:报得少会饿死吞吐,报得多会冲垮缓冲;跟着空闲槽位走,永远刚刚好。
- C空闲槽位消费者当前剩余的接收容量,相当于小刘空着的“手位”数量。
- W在途人数已经报出、但还没消费完的数据量,即后厨正在炒的那批菜。
- max(1, ·)防止在途人数为 0 时除零;至少按 1 计算,保证公式恒成立。
- ⌊ ⌋ 向下取整点单报数必须是整数道,不能报“0.7 道菜”,所以向下取整。
- k_t 本轮请求量第 t 轮实际报出的数字。它随系统状态动态变化,实现“按需分配、动态伸缩”。
传播链路:从传菜口到采购间的节奏共振
“辣得起”不止一层楼:传菜口上面是后厨,后厨后面是备菜间,备菜间连着仓库采购,层层叠叠,每一级都有自己的“小传菜台”。如果只有服务员对后厨喊停,而后厨还是拼命让备菜间切菜,备菜台的土豆丝照样堆成山。背压的威力,必须沿着整条链路逐级逆流而上——每一级算子都要把“我这边慢了”的信号传递给上一级,直到源头。
在 Reactive 编程中,一条链路通常由多个算子(Operator)串联而成:上游 → map 算子 → filter 算子 → 下游。每个算子既是消费者又是生产者:它先向下游申请额度,再向上游传递需求。下游慢了,它减少向上游的申请;上游快了,它用自带的线程调度器(Scheduler)与有界缓冲暂存。链路越长,节奏错位的风险越大,于是有了 Little 定律来量化积压:
全链路背压调节,本质上就是让每个环节的“传菜台余量”保持同步:服务员慢了,后厨慢炒;后厨慢了,备菜间少切;备菜间慢了,采购少进货——节奏在全链共振,而不是某一级独自硬扛。高吞吐的响应式系统,正是靠着这种层层传导的自律,才能在洪峰下保持优雅。
- L(积压量)系统中平均攒着的数据量,即所有传菜台上静止的、正在端送的菜的总数。
- λ(到达率)单位时间流入链路的数据量,即后厨的出菜速率。
- W(停留时间)每个数据从进入链路到被消费的平均等待时间,即一道菜从出锅到上桌的平均耗时。
- L = λ × W(乘积关系)三条变量互相锁死——要降低积压 L,要么降速 λ,要么缩短等待 W;扩大缓冲只会让 W 变长,L 不降反升。
策略与实现:传菜台满了,到底怎么办
即便有了点单契约和动态窗口,现实总有意外:隔壁公司团建包场,客流瞬间翻了三倍,传菜台还是爆了。这时候,店长有三条路可走——这对应着背压落地的三大策略。
第一条路:有界缓冲(Bounded Buffer)。传菜台从 6 位扩到 10 位,但绝不上不封顶。缓冲有上限,超出就触发背压或丢弃,系统内存可控,代价是极端洪峰下部分数据被拒。第二条路:溢出丢弃(Drop Policy)。传菜台满了,按规则倒掉最旧的菜(Drop Oldest)或最不急的菜(Drop Newest),保住最核心的客流——对应数据领域的“丢旧保新”“降精度采样”。第三条路:异步调度(Async Scheduler)。传菜台不够,就把部分菜委托给外包跑腿(独立线程池),用额外的执行资源换缓冲空间。
缓冲区随时间的变化,可以用一个积分式精确刻画:
实际框架中,RxJava 提供了 onBackpressureBuffer / onBackpressureDrop / onBackpressureLatest,Reactor 也有对应的自定义丢弃策略;当缓冲和丢弃都无法容忍时,还有终极手段——背压降级:把精确计算退化为近似值、把实时同步转为批量异步,以服务质量换系统存活。传菜台满不丢人,丢人的是明明可以提前规划,却在爆台时才手忙脚乱。
- B(0)(初始占用)初始时刻缓冲区的占用,通常为 0,即开张时传菜台是空的。
- λ进(s)(流入速率)s 时刻流入缓冲区的速率,即后厨往传菜台码菜的速度。
- μ出(s)(流出速率)s 时刻流出缓冲区的速率,即服务员端走菜的速度。
- ∫ 积分(累计净流入)把从 0 到 t 每一瞬间的“净流入”累加起来,得到累计积压增量。
实践与对比:限流、熔断与背压的三角关系
“辣得起”还有两家分店,一家在门口叫号限流,一家在厨师集体请假时直接挂出暂停营业牌。这三种经营策略,恰好对应高并发架构里的三件套:限流、熔断与背压。它们目标一致(保护系统),但发力点截然不同:
- 限流(Rate Limiting):在入口处主动拦截,超过阈值直接拒绝——门口叫号,进不来就是进不来;
- 熔断(Circuit Breaking):下游故障达到阈值时,上游直接短路、快速失败——厨房着火,直接停业保护;
- 背压(Backpressure):不拦截、不短路,而是让上游感知下游的消化速度,动态调整生产——服务员说“慢点炒”,大厨就放慢火候。
在真实系统中,三者往往组合使用。最典型的场景是流处理管道:假设一个实时风控系统,Kafka 里每秒涌入 10 万条交易事件,下游的规则引擎只能消化 2 万条。Flink 的背压机制会反向传导,Kafka 消费者自动降速,把积压留在消息队列里;同时入口的限流器把超过峰值的流量降级到离线批处理通道。而在 IO 密集场景(如数据库写入、第三方 API 调用)中,背压能防止“服务端响应慢 → 客户端疯狂重试 → 服务端更慢”的雪崩式恶性循环。定量的丢弃策略可以用一个简单的概率公式描述:
一次生产事故复盘很能说明问题:某金融公司晚间大促,下游清算服务响应时间从 30ms 飙到 3s,上游网关还在全速转发,最终引发内存溢出、整链崩溃。修复方案是三层防御:网关限流拦最外层、清算服务入口加熔断保护、消息管道启用背压让消费端按能力拉取——三者齐上,洪峰再来时系统只降级、不死机。背压不是银弹,而是纪律:它让系统诚实地面对自己的能力边界,并有尊严地降级。
- λ请求(到达速率)入口到达的请求速率,即门口排队的客流。
- μ服务上限(处理能力)系统能稳定处理的最大速率,即餐厅同时最多能招待的桌数。
- 分子差值(过剩流量)超出服务上限的那部分“过剩流量”。
- max(0, ·)(非负截断)请求速率没有超限时丢弃概率为 0;超限时才产生正数。
AI 应用场景
大模型推理服务的显存保护
阶段:推理部署与在线服务
解决的问题:大模型推理引擎(如 vLLM、TensorRT-LLM)的吞吐受 GPU 显存和算力双重约束,请求洪峰稍不注意就会触发显存 OOM、进程崩溃。传统做法是硬编码最大并发数,要么浪费 GPU,要么被击穿。用背压思路重构后:推理引擎作为“订阅者”,按当前可用 KV cache 槽位动态向上游“申请”请求量,额度耗尽则新请求排队或返回 503,GPU 永不超卖——就像服务员按手头空位向后厨报数,后厨按单出菜。
具体案例:某 LLM 推理平台在晚高峰遭遇 5 倍请求洪峰,启用基于剩余显存的动态 request 窗口后,GPU 利用率稳定在 92%,P99 时延仅从 1.2s 升到 1.8s,零 OOM、零崩溃。
实时风控特征流的降速传导
阶段:实时特征工程与流式在线预测
解决的问题:营销大促时 Kafka 流量暴增,下游特征计算 + 模型推理扛不住,Flink 状态后端(RocksDB)内存膨胀甚至堆外 OOM。开启 Flink 背压后,上游算子的输出缓冲一旦填满,反压信号逐级传导,Kafka 消费者自动降速,积压被“憋”在消息队列里,而不是堆积在内存中。
具体案例:某支付公司的实时反欺诈系统,大促峰值 20 万 QPS,规则引擎只能稳定处理 5 万。开启背压与有界缓冲后,Flink 自动放缓消费,队列积压控制在分钟级以内,模型精度不受影响,系统在 4 小时大促中零故障。
训练数据管线的速率匹配
阶段:模型训练
解决的问题:GPU 计算快而数据加载慢,会导致 GPU 饥饿;反过来数据加载无限快,又会让内存队列爆炸。PyTorch DataLoader 的 buffered queue + prefetch 本质上就是一种背压:按 GPU 的消费节奏提前拉取固定批次,队列有界,满载时数据管线暂停生产,等待 GPU 消化。
具体案例:某大模型训练任务将 DataLoader 的 num_workers 从 4 调到 8 并设置 prefetch_factor=2 后,GPU 利用率从 61% 提升到 89%,且内存占用稳定在 8GB 以内,不再频繁 OOM。
Python 演示
"""背压机制模拟: 消费者通过 request(n) 控制生产节奏"""class BackpressureChannel: """有界通道: 生产者推送数据前必须先获得消费额度""" def __init__(self, capacity=5): self.capacity = capacity self.credits = 0 # 消费者授予的额度 self.buffer = [] # 缓冲队列 self.dropped = 0 # 被拒收的个数 def request(self, n): """消费者申请 n 个额度(背压信号沿链路逆流而上)""" self.credits += n def push(self, item): """生产者尝试推送: 额度不足或缓冲已满则拒收""" if self.credits > 0 and len(self.buffer) < self.capacity: self.buffer.append(item) self.credits -= 1 return True self.dropped += 1 return False def consume(self): """消费者取走一个数据, 并立刻补充一个额度""" if self.buffer: item = self.buffer.pop(0) self.request(1) return item return None def simulate(produce_rate=1, consume_rate=3, total=15): """produce_rate / consume_rate 越小越快(每 N 帧生产/消费一次)""" ch = BackpressureChannel(capacity=5) ch.request(3) # 消费者先报数: 我要 3 个 produced = consumed = 0 for t in range(total): if t % produce_rate == 0: produced += 1 if not ch.push(produced): print("t=%2d 背压拦截: 数据 %d 未获额度被拒收" % (t, produced)) if t % consume_rate == 0: item = ch.consume() if item is not None: consumed += 1 print("t=%2d 缓冲=%d/%d 额度=%d" % (t, len(ch.buffer), ch.capacity, ch.credits)) print("统计: 生产 %d, 消费 %d, 拒收 %d" % (produced, consumed, ch.dropped)) return ch if __name__ == "__main__": simulate(produce_rate=1, consume_rate=3, total=15)
request(3),生产者每推送一个数据就消耗一个额度;额度耗尽后推送被拒收,模拟出上游被下游能力“反制”的全过程。ch.request(3)消费者开局“点单”:先向后厨要 3 个额度,相当于subscription.request(n)。def push(self, item):生产者推送的入口,每次推送先检查额度和缓冲容量。if self.credits > 0 and len(self.buffer) < self.capacity:背压的“阀门”:无额度或缓冲满,直接拒收。self.credits -= 1每接收一个数据,消耗一个消费额度,对应规范中 Publisher 在 onNext 后扣减计数。def consume(self):消费者取数据,同时调用self.request(1)回补额度,维持动态窗口。if not ch.push(produced):推送失败说明背压生效,数据被拦截,本次生产“白做”。simulate(produce_rate=1, consume_rate=3)生产者比消费者快 3 倍,逼出背压拦截场景。
常见误区
误区“限流就是在入口限速,背压也是让上游慢点,两者一回事吧?我在网关加个 RateLimiter 是不是就等于实现了背压?”
正解限流是“入口单方面设定一个阈值,超过就拒绝”,流量方向是单向的、静态的;背压是“下游根据自身实时消费能力,动态向上游申请额度”,是反馈闭环、自适应的。用餐厅比喻:限流是门口保安说“今晚只能进 100 桌”;背压是服务员告诉后厨“我这桌现在只要 3 道菜”。限流保护的是入口,背压保护的是全链路。
为何常见:两者目标都是“别让系统被打爆”,且常常组合部署在同一入口,容易让人误以为是同一机制。
误区“背压问题不就是缓冲溢出吗?我把缓冲队列从 1000 调到 100 万,不就不会溢出了?”
正解无界缓冲只是把“爆炸点”从内存搬到延迟上。数据积压会让消费延迟飙升、数据过期失去实时性,最终内存依旧被耗尽(OOM 同样会崩)。按 Little 定律 L = λW,缓冲越大,停留时间 W 越长,积压 L 不降反升。背压的正解是让生产速率匹配消费速率(让 ρ ≤ 1),而不是无限扩容传菜台。
为何常见:数据积压是不可见的,不像崩溃那么刺眼;“扛一扛就过去了”的侥幸心理让很多人优先选择加内存,而不是改架构。
误区“既然 request 是背压的开关,那我一次 request(100000),让生产者火力全开,吞吐不就能拉满吗?”
正解request(n) 的本质是“下游承诺自己能处理 n 个”。n 超过实际消费能力时,数据会大量滞留在缓冲里,延迟攀升、内存吃紧,背压名存实亡。n 应基于“空闲槽位/在途数据”动态计算——报得少饿死吞吐,报得多冲垮缓冲,刚刚好才是最优。
为何常见:人们习惯把“吞吐越大越好”当铁律,忽略了延迟、内存与吞吐之间的制约关系。
误区“我的服务是同步阻塞调用的,慢就慢呗,不需要什么背压机制。”
正解同步阻塞其实是“最原始的背压”——调用方没处理完就阻塞着不发起下一个请求,天然限制了上游速率。但它的代价是线程被白白占住,线程数/连接数耗尽会引发雪崩。异步响应式场景通过显式 request(n) 用极少的线程承载高并发,同时保留背压控制,这才是它被重视的原因。
为何常见:背压一词在响应式编程中太常见,容易让人以为只有响应式场景才需要;实际上同步模型的“限速”效果正是背压的朴素形式。
面试题
Q1. 在餐饮比喻中,服务员通过“点菜”控制后厨出菜速度,对应背压机制的哪个核心操作?
- A.生产者每满一点批量推送
- B.消费者主动申请 request(n)← 正确答案
- C.缓冲区快满时自动扩容
- D.消费者永久阻塞等待数据
答案:B。背压的核心是消费者通过 Subscription.request(n) 主动向上游申请额度,生产者只有在收到额度后才推送。就像服务员拿起点菜单对后厨说“先上三道”,后厨才会动这三道——申请动作本身就决定了出菜节奏,而不是生产者自顾自地批量推送。
Q2. Reactive Streams 中,连接发布者与订阅者、承载流量控制指令的对象是?
- A.Publisher 发布者对象
- B.Subscriber 订阅者对象
- C.Subscription 订阅契约← 正确答案
- D.Disposable 一次性资源
答案:C。Subscription 是连接 Publisher 与 Subscriber 的“点菜单”,唯一的流量控制接口 request(n) 就定义在它上面。Publisher 负责产生数据,Subscriber 负责消费数据,而“按多少给多少”的节奏控制由 Subscription 承载。
Q3. 当系统负荷率 ρ = λ/μ = 1.5 时,以下描述正确的是?
- A.生产速度低于消费速度
- B.缓冲区将会被逐渐排空
- C.生产速度高于消费速度← 正确答案
- D.系统处于完美均衡状态
答案:C。ρ = λ/μ 大于 1 表示生产速率高于消费速率,缓冲区会持续积压直至溢出,绝不可能是“排空”或“均衡”。就像传菜台上新菜的速度比服务员端走的速度快,台面只会越堆越高。
Q4. 以下哪些属于 Reactive Streams 规范定义的信号?
- A.onSubscribe 建立订阅契约← 正确答案
- B.onNext 传递一个新的数据← 正确答案
- C.onError 上报异常终止← 正确答案
- D.onRequest 归还消费额度
答案:ABC。规范定义了四类信号:onSubscribe(建立契约)、onNext(传递数据)、onError(异常终止)、onComplete(正常完成)。规范里没有 onRequest 信号——消费者的“需求”不是信号,而是通过 subscription.request(n) 方法调用来表达。
Q5. 完全依赖“无界缓冲”处理速率不匹配,最可能引发的问题是?
- A.延迟飙升与内存的耗尽← 正确答案
- B.生产者线程持续的阻塞
- C.消费者空转 CPU 飙升
- D.网络带宽被瞬间占满
答案:A。无界缓冲只是把爆炸推迟:数据排队时间越来越长(延迟飙升),内存占用持续上涨(最终 OOM)。Little 定律 L = λW 指明,缓冲越大不代表积压越小,反而让数据在系统中滞留更久。背压的正解是调速而非扩容。
Q6. 背压机制与限流机制的本质区别是?
- A.背压是双向反馈,限流是单向拦截← 正确答案
- B.背压只用在消息队列的场景
- C.限流更强调全链路的节流
- D.两者完全等价没有区别
答案:A。背压是“下游能力反馈上游”的双向闭环,限流是“入口阈值拦截”的单向规则。限流像门口保安规定“最多进 100 桌”;背压像服务员实时告诉后厨“我这桌只要 3 道”。两者常配合使用,但机制本质不同。
Q7. 一条算子链“上游 → A算子 → B算子 → 下游”中,下游变慢时正确的做法是?
- A.B 算子减少对 A 算子的请求量← 正确答案
- B.A 算子继续全速向源头拉数据
- C.B 算子用有界缓冲吸收尖峰← 正确答案
- D.下游变慢立刻抛出异常终止
答案:AC。下游变慢时,正确做法是逐级传导:B 算子减少向上游 A 的申请量,并用有界缓冲吸收短暂尖峰;A 继续全速拉取或直接抛异常都是错误选择,前者让积压向源头蔓延,后者粗暴中断整个数据流。
Q8. 消费者执行 subscription.request(5) 后,发布者后续最多能推送多少个数据?
- A.最多推送 5 个就须暂停← 正确答案
- B.之后可无限推送数据
- C.至少连续推送 5 个给下游
- D.完全由系统负载情况决定
答案:A。request(5) 的含义是“我最多能处理 5 个”,发布者最多推送 5 个后必须停下等待新的请求。这是背压最基础的语义——额度是上限承诺,不是保底义务。
Q9. 大模型推理服务用背压保护 GPU 显存,下列哪些是有效手段?
- A.按剩余显存动态发放请求额度← 正确答案
- B.无上限缓冲堆积超量的请求
- C.额度耗尽返回排队或 503← 正确答案
- D.强制所有请求归为同一批
答案:AC。推理服务保护显存的有效手段包括:按剩余 KV cache/显存量动态发放请求额度(有界并发)、额度耗尽时让请求排队或返回 503(显式背压)。无上限堆积请求会撑爆显存,强制同一批处理则违背按需调度原则。
Q10. 按照 Reactive Streams 规范,订阅者调用 request(0) 会触发什么结果?
- A.被当作无效的请求直接忽略
- B.发布者立即终止整条数据流
- C.以参数异常上报 onError← 正确答案
- D.自动按默认值取 1 个
答案:C。Reactive Streams 规范规定 request(n) 的 n 必须为正整数,n ≤ 0 视为非法参数,订阅者会收到携带 IllegalArgumentException 的 onError 信号并终止数据流。就像点菜单上不允许写“上 0 道菜”,协议的根本语义是“按需授予、数量为正”。