容量极限下的优雅表现
原文由 Nelson Elhage 于 发布,订阅该博客
假设我们有一个服务。暂时先不纠结细节,假定它接收来自外部世界的请求,并据此采取相应动作。这些请求可能是 HTTP 请求、RPC,也可能只是需要在网络层转发的入站数据包。细节可以后面再展开。
那么,它的性能表现如何呢?
目前我们只知道它会接收请求并作出处理。仅凭这一点,就能引出两个有用的衡量指标:
- 我们可以讨论请求速率,即单位时间内收到的请求量。我们通常用“每秒请求数”来衡量,但在某些场景下,数据总量比请求个数更重要,这时我们就会转而(或同时)讨论“每秒字节数”。
- 有些请求(最理想情况下是全部)会被成功处理。我们也可以用单位时间内成功完成的工作量来衡量这一速率,并称之为吞吐量。
现在正是引入一些具体例子的好时机,后文我们还会反复用到它们。我挑选了两个基于我熟悉的系统的例子,它们也是你很可能认得出的常见原型。
第一个例子是一个HTTP Web 应用。为了更具体,我们考虑一种相当常见的架构:用 nginx 作为反向代理,后面是一组 Python 工作进程,每个进程都包含应用代码并使用后端数据库。
对于这样的服务,我们将“请求速率”定义为到达 nginx 的 HTTP 请求速率,而“吞吐量”则类似于“成功请求的速率”,这里的成功指的是比如“收到了 HTTP 200 响应”。
第二个案例是一个TCP/IP 路由器或防火墙。它位于两个(或更多)网络之间,在任意接口上接收入站数据包,判断是否以及向何处转发,然后从另一个接口发送出去。
对于这个系统,我们同时关心以每秒接收数据包数和每秒字节数来衡量的“请求速率”:在“大量小包”与“少量大包”两种场景下,系统的表现可能截然不同。这里的吞吐量则是每秒成功转发的数据包数(或字节数)。
明确了这两个指标后,我们就可以从抽象层面来讨论性能,即二者之间的关系:吞吐量作为请求速率的函数。换句话说,随着流入流量不断增加,它如何影响我们实际完成的有效工作量?
在纯粹理想的世界里,每个请求都能被成功处理,请求速率与吞吐量之间将呈现 1:1 的线性关系。

然而在现实世界中,系统运行在有限的物理硬件之上,因此都存在有限的容量:即在给定系统设计和可用硬件条件下能够达到的最大吞吐量。于是,更现实的目标是在达到容量上限之前保持线性关系,之后进入饱和状态:在这一区间,额外的请求会失败,但不会拖累吞吐量。

值得牢记的是,每个系统都有极限。有时你很幸运,极限足够大,可以近似看作“无穷大”,但它始终存在。一个没有标明容量上限的系统并非没有上限,只是没有告诉你上限是多少而已。
然而现实还要更残酷。如果没有精心的设计和调优,大多数系统在达到容量后的表现会比上图糟糕得多;一旦满载,额外的请求会以某种方式过载系统,在无法产生有效吞吐的同时消耗宝贵资源,于是我们会看到如下曲线:

或者,更糟的情况:

当系统处于这种接收速率很高、但吞吐量却远低于容量的状态时,我们有几个术语来描述。具体用哪个词往往取决于故障细节,但我习惯用拥塞崩溃作为对这一区间的统称。我不确定这个词在网络以外的领域是否被广泛使用,但我发现它通常很容易让人理解。
要在具体系统中解决拥塞崩溃,很大程度上取决于细节和具体问题,不过其中确实有一些反复出现的通用模式。
竞争与准入控制
系统在重负载下表现不佳的最常见原因是竞争对共享资源。在此,我指的是多个进程试图使用同一资源,而它们之间的交互开销导致增加并发反而会降低该资源的总有效吞吐量。当系统因这类竞争而损失性能时,我们常称之为抖动。
许多资源都会出现竞争,以下是一些常见例子:
- 如果运行的进程数远多于物理 CPU 数量,我们会产生大量上下文切换和调度开销,吞吐量随之下降。
- 如果缓存(例如数据库的内存磁盘缓存)大到足以容纳
k个并发请求的相关数据,那么当我们尝试运行超过k个并发请求时,性能会急剧下降,因为缓存会在不同请求的数据之间来回颠簸,命中率骤降。 - 许多乐观并发系统——包括许多无锁算法——在重负载下会严重退化,最坏情况下会进入没有任何客户端能取得进展的模式,原因是事务相互冲突。我们把这种每个客户端都在做大量工作、却无人取得进展的最坏故障称为活锁。
在 nginx→python→database 这套架构中,有几个环节常常容易出现有害的竞争。
据我所知,“拥塞崩溃”一词最初是在网络领域提出的,因此网络路由器容易出现这类故障也就不足为奇。几个具体的机制包括:
解决竞争的基本策略是有意将并发限制在不会引发过度竞争的水平。概括来说,我们可以通过增加一个存放入站请求的队列,以及一个准入控制器来实现:只有当有可用容量时,控制器才允许请求离开队列并开始处理。“准入控制”1一词在某些数据库和通信系统中大致就是这个意思,但我倾向于将其泛化为符合这一通用模式的任何技术。

准入控制策略各不相同;最简单的,我们可以计算出一个静态的最大并发值,并用信号量来限制在途请求数;走向另一个极端,我们也可以构建一个精密的门控代理,使其同时掌握系统中瓶颈资源的模型以及每个入站请求所需的资源。
对于 Python HTTP 服务,最简单的准入控制形式或许就是将配置设为每台主机只运行固定数量 N 个工作进程。如果 N 选择得当,这样的限制在很大程度上就能解决 CPU 和内存抖动问题。
在更复杂的设计中,每个节点可以向负载均衡器汇报自身健康状况;如果这一判断基于本地可用资源,我们也可以将其视为一种准入控制。
无论哪种情况,我们通常不会在应用服务前看到一个显式的队列,但负载均衡器往往会在内部维护一个队列,而网络监听队列和套接字缓冲区也会作为待处理请求的隐式队列。
在网络系统中,我们最常把准入控制理解为对网络物理层的保护。传输介质无论是空气、铜线还是光纤,都有其最大容量,硬件的职责就是仅以介质能够承载的速率发送数据包。为了吸收超出此容量的突发流量,网卡和路由器上的软件都会维护数据包队列,并在容量可用时将其排空到网络上。
竞争现象至少有一个重要启示:如果系统在某个关键资源上已达到容量,增加并发很可能会损害而非提升吞吐量。最坏情况下,如果我们给非瓶颈资源扩容,反而可能降低性能,因为这会加剧有限资源上的竞争。具体来说,如果应用的瓶颈在数据库,增加并发进程数可能会把数据库进一步拖垮,导致整体吞吐量下降,而非好转。
队列非空即满
有了合适的准入控制,我们可以在任意请求速率下限制竞争、保持内部吞吐量高企。然而问题并未就此结束。如果入站请求速率持续高于我们的最大吞吐量,这些请求默认会无限制地在请求队列中堆积。

对此我们可以做出几点观察:
- 这里的吞吐量与队列长度无关。无论队列是短是长(只要从未被排空),我们从队列取消息、处理消息的速率都是一样的。这一点很重要:队列无法提升峰值容量。
- 随着队列增长,整体延迟也随之增加;队列中有 N 条消息,就需要 N/Y 秒才能处理完,因此在 FIFO 的前提下,请求从队列左侧走到右侧也需要 N/Y 秒。
- 随着队列不断增长,最终会出现以下一种或多种情况:
- 队列达到配置的最大长度,被迫丢弃消息。
- 队列没有配置最大长度,但会耗尽服务器上可用的存储空间(通常是磁盘或内存)。
- 随着延迟升高,上游客户端最终会超时,并将请求视为失败,即便它们在系统内部最终可能会成功。
在许多系统中,过载时最先观察到的症状——即实际出现错误的第一个地方——往往是因队列达到配置上限而丢弃请求。此时,很容易想通过增大队列来“修复”眼前的问题;但如果问题本质上确实是容量问题——即总吞吐量小于请求速率——这样做并不能恢复健康:队列无法提升峰值容量。
如果遇到的是第三种情况——因高延迟导致的超时——我们可能会意识到,任何在系统中停留时间已超过相关超时的请求都“已经失败”了,再花时间处理它并不值得。于是我们可以在请求队列内部,或在出队时增加显式检查,避免在这些请求上继续消耗资源。我们可能会写出如下逻辑:
request = queue.pop()
if time.time() - request.arrival_time >= REQUEST_LATENCY_BUDGET:
return_error(request, RequestTimedOut()
else:
process(request)如果实现得当,这样的检查可以在一定程度上改善状况:它会使队列的出队速率在平均意义上与入队速率持平,队列不再继续增长。
然而,这样的检查也无法把队列收缩到某个临界点以下:一旦队列长度达到让请求需要 REQUEST_LATENCY_BUDGET 才能走完全程的大小,我们就会停止排空它。如果请求速率依然居高不下,队列就会恰好徘徊在这一规模附近,给每一个被处理的请求都额外增加延迟。最好的情况下,我们获得了不错的吞吐量,但代价是每个请求都多出 REQUEST_LATENCY_BUDGET 的额外延迟;最坏情况下,我们预留的余量不够,请求依然会超时。
我们称这样的系统存在常驻队列。在健康的系统中,队列用于吸收短暂的突发流量并很快排空;而在这里,队列在稳态下依然保持相当的规模,导致不必要的延迟。
在我们典型的 HTTP Web 服务架构中,重负载下最先“察觉”问题(即发出信号或记录错误)的组件,往往是前端的 nginx,它对允许的并发在途请求数有着严格限制。
然而如前所述,仅仅调大这个数值很少能真正解决问题,而只是把问题推到别处。一般来说,如果我在 nginx 日志中看到“worker_connections are not enough”,我的第一反应总是怀疑 nginx 背后的容量瓶颈,而不是真的去调大该配置。
nginx 内部以及各种网络缓冲区中的请求队列往往非常隐蔽,作为运维人员很难直接观察到。我喜欢的一种做法是让 nginx 为请求添加一个包含当前时间戳的请求头;当请求到达应用代码时,用当前时间减去 nginx 记录的时间,就能得到队列延迟的度量。
在网络语境下,这种常驻队列问题正是臭名昭著的“缓冲区膨胀”问题。为了避免丢包,加之内存成本下降速度快于网络吞吐量的提升,网络硬件不断加大用于存储入站数据包、再按网络容量转发出去的队列。然而,如果网络持续过载,这些队列并不能让物理链路真的变得更快或更粗,在缺乏精细管理的情况下,很快就会变成增加所有排队数据包延迟的常驻队列!
本节的两个要点,也是我最希望你带走的结论:
- 队列可以帮助在时间上平滑突发负载,但无法提升总体的峰值吞吐量。
- 如果在负载下积累起持久的常驻队列,我们的队列就是在毫无收益地增加延迟;而且延迟可能是灾难性的。
什么才是有效的办法
归根结底,如果我们持续超出容量,又无法或不愿增加容量,唯一的办法就是:想办法减少需要完成的工作量。
我通常会考虑两种相关的策略:
- 我们可以设法让客户端减少请求,从而降低入站负载。
- 我们可以选择不处理一部分请求,尽可能廉价地丢弃它们,从而腾出资源来成功处理剩余请求。
我们把这两种策略中的任意一种或两者统称为背压;无论哪种情况,我们都是在某种意义上对入站工作“施加反压”,让客户端感知到我们的容量极限。前者是明确要求它们放慢速度,后者则是让它们收到错误或丢弃,从而不得不自行处理。
作为关心可用性和用户体验的服务设计者与维护者,以这种方式把问题推回给用户可能有违直觉;这感觉像是承认失败或推卸责任。没有人喜欢主动丢弃一个合法请求。然而,具备韧性的系统不可避免地需要某种形式的背压,原因有二:
- 如前所述,每个系统都有极限;当我们触及极限时,更希望对接下来发生什么做出有意决策,尽可能审慎、优雅地行事。
- 更重要的是,背压形成了一个闭环系统。如果一个原本健康的系统过载,其直接原因就是有人向我们发送了过多流量。通过把问题推回给发送方,我们让问题与问题的源头靠得更近,从而为真正的解决创造可能。
流量控制
如果我们能够协同设计系统、客户端协议和/或与之交互的客户端,有时可以在流量控制机制的形式中构建背压,让接收方直接告知发送方何种流量速率是安全或可支持的。
大多数底层通信原语(例如 TCP 套接字或 UNIX 管道)都通过限制流中已发送但尚未处理的数据量来实现某种形式的流量控制。因此,如果我们的协议主要通过单个或少量这样的流进行通信,有时就能从底层机制中“继承”一种基本的流量控制。
显式的流量控制机制在 HTTP Web 服务中较少见,但我能想到几种至少在精神上符合的机制:
- “429 Too Many Requests” HTTP 状态码于2012 年左右标准化,允许服务器明确告知请求因负载或限流被拒绝,并允许通过
Retry-After头告诉客户端多久后重试。 - 一些 HTTP API 会在每个响应中提供限流状态以及请求者距离被限流还有多远的头部信息。
负载丢弃
如果我们没有流量控制机制可用,或者客户端不遵守它,那么剩下的选择就是负载丢弃:我们挑选一部分请求,并尽早丢弃(这可以指字面意义上的遗忘,或在代价足够低的情况下返回错误)。通过这样做,我们释放资源、排空队列,使剩余请求得以成功处理。
负载丢弃与限流相关,后者会限制每个用户的入站请求数,并在用户超出某个阈值时返回错误。一般来说,我所理解的区别在于,“限流”指始终生效、无论系统整体容量如何都会强制执行的每用户限制,而“负载丢弃”则特指当系统整体达到或接近容量上限时才触发的机制。在实践中,许多场景下导致超载的常见原因正是某个失控的单一用户,因此二者密切相关,常常解决重叠的问题。
我们可以采用多种策略来挑选要丢弃哪些请求以削减负载,常见的做法包括:
- 随机丢弃
- 这种方式简单且实现成本低:可能完全无需解析入站请求,也无需在多个前端组件之间协调。视具体场景而定,它也可以被认为是“公平的”
- 客户端分级
- 如果你有免费层级和付费层级,可以优先丢弃免费请求。这种做法可以泛化为多种形式,甚至包括请求中包含容量竞价的现货市场。当系统中存在明确的SLA,部分用户有合同保障的服务水平而其他用户没有时,分级策略尤其有效。
- 请求分级
- 你也可以基于请求类型来区分优先级,优先丢弃那些不太可能对时间敏感、或更容易稍后重试的请求。例如在 Stripe,创建收费的请求被认为远比单纯查询或罗列历史支付记录的请求关键;如果 API 服务负载过高,我们会优先保障前者,而有意丢弃[部分]后者。
- 公平分配
- 均匀随机丢弃在“每个请求被丢弃概率相等”的意义上是公平的。但它也会激励发送方刷请求:如果某个用户占了全部入站请求的 80%,他就会获得 80% 的吞吐量。因此,有时按照更细致的“公平”定义来分配容量更有意义——例如按客户端平均分配可用容量,而不是按请求。
- 在实践中,要实现这种公平分配需要额外的协调,实现起来颇具挑战,而在前端先施加针对单用户的限流往往就能达到类似效果,因此常常足以替代。
在 HTTP Web 服务中,我们会看到各种各样的负载丢弃与限流技术。
对于“Python 加数据库”这类应用,一个常见模式是请求本身处理成本较高,需要运行大量应用代码或执行多次、昂贵的数据库查询;这种高成本往往促使我们采用多层次的负载丢弃体系:
- 我们通常会在服务前端引入一个高性能限流器,或许使用nginx、CDN 或云服务,在流量抵达笨重的应用代码之前就拦截掉最极端的突发流量(甚至包括显式的 DDoS 攻击)。
- 接着,一旦流量抵达应用,我们还可以利用应用的认证和/或路由逻辑对请求进行分类,实现更细粒度的限制。如果无需访问数据库就能完成,这一层仍会比完整处理请求廉价得多。
- 或者,我们可以在应用前使用“API 网关”,它在应用代码之外独立实现路由、认证以及请求感知的限流等组合功能。
我的前同事 Paul Tarjan 在 Stripe 撰写了一篇精彩的文章,详细介绍了 Stripe 在采用类似的前端/应用代码/数据库架构时所做出的限流与负载丢弃决策和技术。
TCP 接收方清楚自身容量,可以“直接告诉”发送方放慢速度,但管理和估算网络容量要困难得多,因为它涉及共享同一链路的所有不同流和节点之间的相互作用。
传统上,TCP 依靠检测丢包来发现拥塞。系统假定丢包意味着网络链路过载,发送方需要减速。这一设计本质上是将一种负载丢弃机制(网络丢包)重新用作一种流量控制形式。
历史上,网络设备在负载下使用简单的“尾丢弃”算法来丢包。这种选择对管理路由器自身的负载很有效,但对整个网络的 TCP 流量控制行为而言,其涌现特性却非常糟糕。因此,现代路由器实现了“主动队列管理”,对丢弃哪些数据包做出策略性决策,从而决定通知哪些流减速。现代的 CoDel 算法在缓解乃至基本解决缓冲区膨胀问题上成效显著。
此外,TCP 的显式拥塞通知扩展允许路由器在发生拥塞时设置一个标志,而无需丢弃数据。如果网络流上的所有参与方都支持 ECN,进行主动队列管理的路由器就可以选择设置“发生拥塞”标志来通知流减速,而无需真正丢弃数据包。
主动队列管理要求路由器有足够的剩余容量来对数据包做出审慎决策。如果入站数据包速率高到有可能压垮路由器自身的处理能力,我们可能还需要在路由器流水线的更早阶段就丢弃数据包,甚至在网卡硬件层面就进行丢弃。
作战短接
要想正常工作,负载丢弃或限流必须在系统依然健康、正常运行时就开始介入,远早于系统陷入严重过载之前。然而,如果系统仍然健康,它通常还能再处理少量额外的边际请求,因此负载丢弃器不可避免地会拒绝或丢弃一些本可以成功的请求。
这可能是个难以接受的现实:没有人喜欢主动丢弃请求。但这通常是正确选择。通常而言,以可控方式在刻意选择的时机丢弃请求,要好于让系统进入过载状态。
然而,在某些关键系统中,有时可能会判定故障代价过高,以至于在某些情况下宁愿有意关闭保护机制、放手一搏,也不愿主动丢弃请求或禁用子系统。进入这种模式的开关有时被称为“作战短接(battleshort)”,得名于某些军事装备在作战中用实心铜排旁路或“短接”保险丝的做法。其逻辑在于,在激烈交战中,失去机动能力或还击能力被判定为比车辆电气子系统过热或受损的风险更高。
增加容量
本文的主要目标是探讨理解和管理过载系统——即持续处于或超出某种容量极限的系统——的技术与框架。实际上,如果一个系统经常过载,我们往往更希望通过增加容量来解决问题,而非仅仅(或额外)采取措施让过载状态更平缓。在结束前,我想就提升系统容量补充几点说明。
首先,“增加容量”与“优雅地处理过载”往往并非“二选一”的抉择;真实系统通常两者兼需。例如,我们可能会实现自动扩缩容来增加容量,同时使用负载丢弃器来确保在等待新容量上线期间仍能优雅运行。又或者,我们可能会为“正常”负载预留容量,但仍需背压或负载丢弃技术来应对偶尔向我们发送 10 倍于基线负载的、存在缺陷或设计不佳的进程。
其次,我想强调的是,在增加容量时,识别瓶颈资源并对其进行扩容至关重要。如果我们对错误的资源进行扩容(例如,为数据库瓶颈的服务增加更多应用 CPU),有时反而会让问题更糟!而这背后的原因,与本文讨论的竞争和常驻队列等主题密切相关:系统是由更小的子系统分形组合而成,各个子系统可能以不同且微妙的方式容易出现这些故障模式。因此,即便我们的目标是将容量扩充到始终绰绰有余,理解系统在超载时行为的这些教训往往仍大有裨益。
结论
“系统在达到或超出容量时会如何表现?”这个问题在入门级软件工程资料和初期系统设计中常常被一带而过,但在我看来,它几乎定义了在规模化复杂系统上工作的全部体验。
对于任何具体系统,系统的细节和容量极限都至关重要;但我也发现,其中存在大量共通的趋势与主题,如果你能在更高几层抽象上审视这些细节,就会看到许多高层次的相似性。而如果你真正将这些模式内化并能加以识别,它们就能成为判断哪些细节重要、并将其置于恰当语境中的宝贵指南。
本文试图总结我脑海中围绕这一问题的主题与概念图谱,并以可分享的方式阐明其中一些模式与趋势。我希望它能帮助那些在新应用中首次遇到这类问题的工程师更好地理解上下文,并找到有价值的先例或概念指引。欢迎告诉我它是否对你有所启发。
维基百科上有许多关于该术语在网络和互连领域用法的引用;我最初是在数据库语境中接触到它的,但找不到同样好的引用;你可以在这篇论文第 14 页看到一个此用法的例子。 ↩︎
随机一篇博客
评论
登录后参与讨论