故事
万历年间,江南漕运改道,苏州府新凿了一条官河直通京师。河工们起初只顾掘深拓宽,把闸口开到最大,盼着粮船走得越快越好。头两月果然顺当:每日过船三百艘,漕米堆积如山,京师仓廒日日满仓。
到第三月,麻烦来了。京师卸货码头只有六个泊位,昼夜轮班也只能日卸八十艘。后续粮船仍按原速涌来,在通州湾里挤成一片。船碰船、锚缠锚,有三艘满载的漕船被挤翻,白米沉了千石。更糟的是,河道管理局的文书还在按"日发三百艘"的计划签发过闸牌票——上游根本不知道下游已经堵死。
户部派来的郎中姓沈,到通州一看便说:"这不是码头慢,是整条河没有'堰口'。"他下令在苏州、扬州、济宁三处各设一道水堰,堰板不是拦死,而是可升可降。下游码头报告"泊位余二",堰板就升高,过闸流速加快;码头喊"泊位已满,船在湾里排队",堰板就降下,上游来船自然减速。最妙的是,沈郎中不给各堰定死配额——三处堰板各自根据下游传来的"水位信号"独立调节,整条河无须一个中央调度。
老河工不解:"若三处堰各自为政,扬州堰升、济宁堰降,不是乱套?"沈郎中摇头:"济宁下游就是通州码头,它降是因为码头已满;扬州下游是济宁,若济宁还能吞,扬州就不必降。信号一级级传上去,像水波倒涌,哪处该慢、哪处该快,由末端的真实压力决定,不是由苏州府的文书拍脑袋。"
试行半年,通州再未翻船。更意外的是,总吞吐量反而上去了——以前三百艘挤在湾里互相碰撞,真正卸完的不足六十;现在堰板调节之下,日均过船降到一百八十艘,却艘艘都能卸完,实际入仓的漕米反比从前多。
有人建议把这套"堰板信号"写进永例,沈郎中却留了余地:"堰板只能缓一时之急。若连年丰收,码头终究要扩建;若连年歉收,河道也该缩窄。背压是救急的法子,不是偷懒的借口。"
概念解析
分布式系统里,速度不匹配是常态,不是异常。生产者比消费者快、聚合节点比存储节点快、突发流量比基线容量高几个数量级——这些情况每时每刻都在发生。朴素的做法是加队列:生产者只管往缓冲区塞,塞满就丢,或者撑爆内存。背压(Backpressure)是另一种思路:下游把"我撑不住了"的信号传回上游,让上游主动减速。
TCP 的滑动窗口是最早的背压实现。接收方在 ACK 包里附上"还能收多少"的窗口大小,发送方据此调节速率。窗口降到零,发送方就停;窗口重新打开,发送方再续。这不是礼貌请求,是协议层的硬性契约——接收方不授权,发送方不得擅动。
Reactive Streams 规范(2015)把这套机制推广到应用层:Publisher、Subscriber、Subscription 三个角色,Subscriber 通过 request(n) 显式索要 n 个元素,Publisher 没收到请求就不能发。这和 TCP 窗口的区别在于粒度——TCP 按字节流控,Reactive Streams 按消息条数流控,更适合业务语义。Akka Streams、RxJava、Project Reactor 都实现了这套规范。
背压和缓冲不是一回事。 缓冲是"下游慢就让它排队",队列是隐形的债务,迟早要还;背压是"下游慢就让上游知道",债务不累积,压力就地消解。Kafka 的消费者拉取(pull)模型天然带背压:消费者处理完一批才发下一轮 fetch 请求,broker 不会硬塞。相反,早期 RabbitMQ 的 push 模型缺了这层机制,消费者被洪水般的消息压垮是常见故障模式。
级联背压 是更精细的做法。沈郎中的三处水堰各自独立调节,但信号逐级上传——这和现代流处理系统的反压传播一样。Flink 的信用值流控(Credit-based Flow Control)让每个算子只向下游发送"我还有多少缓冲区额度"的信号,上游据此调节;信号沿 DAG 反向传播,直到数据源。没有中央调度,没有全局状态,每个节点只和直接邻居对话,整条管道却能协调一致。
背压的边界。 背压能防止系统被压垮,但不能创造不存在的处理能力。如果下游确实需要十秒才能处理一条消息,背压只能让上游也变成十秒一条,不能把它变成一秒一条。沈郎中说"堰板是救急,不是偷懒"——背压暴露瓶颈,但不解决瓶颈;它把"撑爆"变成"变慢",让系统活着,但活着不等于好用。真正的扩容、优化、架构调整,仍要另做。
有些场景背压也不适用。实时音视频流不能减速——画面卡顿比丢帧更糟,这时宁可丢包也要保时效。金融行情推送通常用固定频率,订阅者跟不上就采样或丢弃,而不是反压上游让交易所慢下来。背压是一种契约,契约需要双方都能履约;如果下游的"慢"不可接受,就得换别的韧性策略——熔断、限流、降级、分流。
现代系统如 gRPC 的流控、Kubernetes 的 CPU 节流、Netflix 的 Zuul 自适应限流,本质都在回答同一道题:当生产与消费的速度裂缝无法弥合,系统该用队列硬撑、用丢弃止损,还是用信号让压力倒涌回源头?背压选的是最后一种——它承认慢的事实,但不让慢变成灾难。