Jay Phelps 2019-02-01
背压(Backpressure,也称“反压”)是几乎每位软件工程师迟早都会遇到的问题,对某些人来说甚至是个高频难题。但“背压”这个术语本身的含义却远不如它的重要性那样广为人知。
在本文中,我将详细解释什么是背压、它通常出现在哪些场景,以及我们可以采用哪些策略来缓解它。
定义
在软件领域,“背压”一词借用了流体力学中的类比,比如汽车排气系统或家庭管道中的压力现象。
维基百科的定义如下:
流体在管道中流动时所受到的阻力或反向作用力。
在软件上下文中,我们可以稍作调整,使其适用于数据流动:
数据在软件中流动时所受到的阻力或反向作用力。
软件的核心目标是将输入数据转化为期望的输出数据。这些输出可能是 API 返回的 JSON、网页的 HTML,或是显示器上呈现的像素。
背压指的是在将输入转化为输出的过程中,某种形式的阻力阻碍了这一流程。在大多数情况下,这种阻力源于计算速度不足——即无法以与输入相同的速度处理并生成输出。因此,从计算速度的角度理解背压是最直观的方式。但背压也可能由其他原因引起,例如软件需要等待用户执行某个操作。
顺便提一句,你可能会听到有人用“背压”这个词来表示控制、处理或避免数据流动受阻的能力。
这主要是因为一些工程师对“背压”的理解与我在本文中的定义不同。他们不是将该词用于描述不希望出现的阻力本身,而是指管理这种阻力的行为。例如,如果有人说:“我开发了一个内置背压支持的新库……”
我个人认为这种用法不够准确,因为在流体力学中,“背压”通常不是一种理想状态。当然,由于“背压”并非正式定义的术语,我也不能说这种理解是绝对错误的。
即便如此,在掌握上述定义后,“背压”具体指什么可能仍有些模糊。我发现很多人是在听到一些具体例子后才恍然大悟的。
背压示例
《我爱露西》:巧克力工厂
我们先从一个类比开始。在 1950 年代的电视剧《我爱露西》(I Love Lucy)中,有一集讲的是露西在一家糖果包装厂工作。她的任务是从传送带上取下糖果并用纸包好。起初这很简单,但很快她发现传送带的速度远远超过了她的处理能力,于是闹出了各种笑话。
这是一个完美的背压案例。她实际上尝试了两种应对方式:把一些糖果先放到一边稍后再处理(缓冲),最后甚至开始把糖果塞进嘴里和帽子(丢弃)。然而在巧克力工厂的场景中,这两种策略都不可行。真正需要的是让传送带减速——换句话说,她需要能够控制生产者的速度。稍后我们会详细讨论这些策略!
文件读写
接下来我们看一些与软件相关的背压场景。最常见的例子就是文件系统操作。
写入文件通常比读取文件慢。假设一块硬盘的有效读取速度为 150 MB/s,而写入速度只有 100 MB/s。如果你一边尽可能快地从文件读取数据到内存,同时又尽可能快地将数据写回磁盘,那么每秒就会产生 50 MB 的缓冲区增长。这是一种不断扩大的“赤字”!只有在输入文件完全读取完毕后,你才能开始“偿还”这部分积压的数据。
现在想象一下你在处理一个 6 GB 的文件:
- 6 GB ÷ 150 MB/s = 40 秒
- 每秒赤字:150 MB - 100 MB = 50 MB
- 总缓冲量:50 MB × 40 = 2 GB!
这会占用大量内存。在某些系统上,甚至可能超出可用内存容量。再想象一下,如果这是一个 Web 服务器,同时为多个请求执行此类操作,后果不堪设想。显然,这种做法在很多情况下是不可行的。
不过别担心,解决方案其实很简单:只以你能写入的速度去读取。几乎所有 I/O 库都提供了自动处理这种问题的抽象机制,通常通过“流”(streams)和“管道”(pipes)实现。Node.js 就是一个很好的例子。
服务器通信
下一个例子是服务器之间的通信。如今,微服务架构非常普遍,系统的职责被拆分到多个独立的服务中。
在这种架构下,当一个服务器向另一个服务器发送请求的速度超过后者处理能力时,就很容易出现背压。
例如,服务器 A 每秒向服务器 B 发送 100 个请求(rps),但服务器 B 每秒只能处理 75 个,这就产生了每秒 25 个请求的积压。服务器 B 可能因为自身需要大量计算,或者还需调用下游服务而变慢。
无论如何,服务器 B 必须以某种方式应对背压。缓冲是一种选择,但如果这种请求速率差持续存在,内存很快就会耗尽,导致服务崩溃。丢弃请求是另一种选择,但在很多场景中,丢弃请求是不可接受的。
最理想的方式是让服务器 B 控制服务器 A 的请求发送速率。但这并不总是可行——如果服务器 A 是代表用户发起请求,你很难控制用户的行为。不过有时也可以做到!此外,通常更合理的做法是让请求方(服务器 A)进行缓冲,这样可以将内存压力集中在下游(即真正承受压力的地方),而不影响其他请求者。
举个例子:假设有三种服务(A、B、C)都向同一个下游服务 Z 发起请求。如果其中服务 A 突然负载激增,服务 Z 可以有效地告诉 A:“请慢一点”(即控制生产者),迫使 A 缓冲自己的请求。虽然 A 最终可能因缓冲过多而内存耗尽,但服务 B 和 C 仍能正常运行,服务 Z 也不会因为一个“行为异常”的服务而拒绝其他合法请求。这种情况下,虽然故障可能无法完全避免,但我们限制了故障范围,防止了级联式的拒绝服务(DoS)。这就是“控制生产者 → 生产者缓冲(因其无法控制自己的上游——用户)”的典型场景。总得有人缓冲,关键是谁来承担。
我在 Netflix 工作时,这类背压问题非常常见。我在一次演讲中也提到过。最终采用哪种背压策略,取决于具体用例。有时我们能通过 RSocket 或 gRPC 等协议直接控制生产者。
UI 渲染
最后一个背压的例子是 UI 应用的渲染。当你的渲染速度跟不上所需更新频率时,就会发生背压。这可能简单到渲染一个超长列表、对快速连续的键盘事件进行防抖,也可能复杂到显示一个每秒发出 20,000 条消息的 WebSocket 数据流。
由于 UI 背压往往涉及“千刀万剐”式的性能问题,我们聚焦于 WebSocket 这个更清晰的例子。
如果一个 WebSocket 每秒发出 2 万、10 万甚至 20 万条以上消息,你根本不可能逐条实时渲染——完全没机会。因此必须采用某种背压策略。如果无法从服务端控制消息速率,客户端就只能选择缓冲或丢弃。
- 缓冲方案:你可以将消息暂存到数组中,然后在每个
requestAnimationFrame回调中批量渲染;或者按时间窗口(如每秒)刷新一次。 - 如果你要将这些消息追加到一个表格中,很可能还需要使用表格虚拟化技术,因为一次性渲染 10 万行本身就是巨大的性能瓶颈——这也是一种背压!
根据实际消息量,上述方法可能有效。否则,唯一的选择就是丢弃:只采样一部分消息(例如每秒 10%),其余全部过滤掉。
背压应对策略
除了横向扩展计算资源外,处理背压的策略基本可以归纳为以下三种:
- 控制生产者(由消费者决定生产速度)
- 缓冲(临时累积突发的数据)
- 丢弃(对输入数据进行采样,丢弃部分)
严格来说还有第四种选择——忽略背压。说实话,如果背压并未引发严重问题,忽略它也未尝不可。毕竟引入更复杂的机制本身也有代价。
控制生产者是最理想的策略。只要可行,它几乎没有明显缺点(除了控制机制本身的开销)。你既不需要额外内存来缓冲,也不会因丢弃数据而造成信息损失。
可惜的是,控制生产者并不总是可行。最典型的例子就是用户输入——无论你怎么努力,都很难让用户“慢一点”!
缓冲是大多数人首先想到的方案。但务必记住:无界缓冲(unbounded buffer)非常危险——即没有设置大小或时间上限的缓冲区。无界缓冲是服务器内存崩溃的常见原因。
使用缓冲时,请始终自问:缓冲区的增长速度是否可能长期超过其消费速度? 上文“服务器通信”例子就展示了这种情况。
事实上,有些人主张永远不要使用无界缓冲。我虽不至于如此严格,但我认为:与其让系统彻底崩溃(内存耗尽),不如主动丢弃部分数据。
丢弃通常是最后的选择,但也常与缓冲结合使用。最常见的做法是基于时间的采样,例如“每秒只保留 10% 的数据”。
如何选择策略?
在决定采用哪种策略时,用户体验(UX)往往是关键指引。即使技术上能做到每秒更新表格 10 万次,这对用户真的好吗?可能并非如此。用户是否更希望看到每秒一次的采样更新?或者是否可以重新设计架构,将数据流写入数据库,让用户按需缓慢查询?
只有你的具体场景能给出答案,但请记住:UX 可以指引技术决策!
开发者常常花费大量时间优化性能,结果却得到了糟糕的用户体验——而如果一开始就从 UX 出发,可能根本不会遇到这些性能问题。
背压相关的代码模式
我尽量让本文不依赖特定编程语言或平台,因为背压是所有人都会面对的问题。但不同生态中确实存在一些通用模式,尤其考虑到我的许多读者是 Web 开发者。
需要注意的是,“流”(stream)一词本身含义模糊。详细展开各种流类型需要另写一篇文章,但这里简要总结两类主流模式:
拉模式(Pull)
在拉模式流中,消费者控制生产者。通常是 1:1 的请求 → 响应模型,但也支持类似 request(n) 的批量请求模式(如 RxJava 中的 Flowable)。其他拉模式流包括:
- Node.js Streams
- Web Streams
- Async Iterators(JS 开发者可关注 IxJS 库,其他语言也有类似实现)
有人认为,如果响应是异步“推送”给消费者的,这些流属于“推拉混合”模式。也有人将同步迭代器视为传统“拉”流,而对异步/同步不做区分,统称为“拉”。
推模式(Push)
在推模式流中,生产者主导,在数据可用时主动推送给消费者。这类流常用于处理用户输入,因为用户行为确实无法被控制。最流行的推模式流实现是 Reactive Extensions(Rx) 系列库,例如:
- RxJS
- RxJava
- 以及几乎所有主流语言的 Rx 实现(RxYourFavoriteLanguage)
总结
当我第一次听到“背压”这个词时,坦白说我感到有些畏惧。它听起来像是一种故作高深的行话——不幸的是,有时候确实如此。但事实上,背压是一个真实存在的问题。了解它并掌握应对方法,能让你更有信心地解决更大规模的挑战,无论是处理快速移动的鼠标事件,还是管理成千上万台服务器,背压无处不在。