Uber 的面向服务的架构每秒处理数亿个 RPC(远程过程调用)请求,跨越数千个服务。保持此系统的可靠性需要强大的过载保护,确保没有单个调用者、客户端或服务可以压倒另一个。
为了实现这一点,我们设出设计一个限速服务的目标,使服务能够轻松地为每个调用者或每个过程配置限制,而无需进行代码更改。这个服务,后来被称为 GRL(全局限速器),直接集成到服务网格中,服务网格负责转发大多数 Uber 服务的 RPC 请求。
随着时间的推移,这个基础演变成一个完全自动化的控制系统, 由 RLC(限速配置器)提供支持,根据历史世界流量模式保持限制的更新和准确性。
这些系统共同确保 Uber 的平台即使在每秒处理数亿个请求和数千个微服务的情况下仍然保持弹性。
在分布式系统中,限速是一种维持可靠性和公平性的基本机制。它保护共享服务免受过载,防止级联故障,并确保没有单个调用者可以消耗不成比例的容量。
最常见的分布式设计使用分布式计数器存储,通常使用 Redis 实现。每个请求都会增加代表调用者或端点的键,当计数器超过配置的阈值时,系统会拒绝请求。对于许多应用程序,这种方法提供了简单有效的平衡,权衡了准确性和成本。
在 Uber 微服务架构的早期,没有单一的方法来保护服务免受流量过载。团队实施了自己的节流逻辑,有时在业务逻辑中,有时通过自定义中间件或基于 Redis 的计数器。每种方法都为其所有者提供了服务,但造成了系统性的低效:
不一致的配置。不同的实现以不同的方式执行限制,导致即使配额相同也会有不同的行为。
操作开销。基于 Redis 的限速器增加了延迟,并需要舰队管理。
操作摩擦。更新限速通常需要重新部署服务器,从而延迟了对不断变化的流量模式的响应。
维护风险。分散的、不太文档化的限速器在发生事件期间创建了操作风险,因为它们难以找到或修改。此外,依赖外部依赖项(如 Redis)引入了隐藏的复杂性和意外行为变化的潜在风险,例如由于库升级而导致的变化。
不均匀的保护。许多较小的服务根本没有限速器。
有限的可观察性。由于每个限速器以不同的方式报告指标和错误,因此没有统一的方法来确定故障是由限速还是其他问题引起的。
使用像 Redis 这样的分布式键值存储作为限速的集中计数器可能看起来是可行的。然而,这种模型无法扩展到 Uber 的舰队,舰队中有数十万台主机,每秒处理数亿个请求,跨越多个地区。每个请求都需要远程增加和读取计数器,从而引入了不可接受的延迟和跨区域一致性挑战。即使使用分片和复制,也需要数百个 Redis 集群来实时维护准确的全局状态,从而增加了操作复杂性和新的故障模式。
我们还排除了定期同步计数器的可能性,因为这会减少网络开销,但会产生过时的数据,并且对突然的流量激增做出反应较慢。
团队最终得出结论,一个完全分布式的架构是实现低延迟和全局可扩展性的唯一方法,在这种架构中,局部代理使用聚合负载而不是中央计数器做出执行决策。
解决方案是将限速集成到 Uber 的服务网格 中,服务网格是服务间 RPC 流量的基础设施层。
将限速器嵌入到这一层允许每个请求在到达其目的地之前被检查和评估,无论调用者的语言或框架如何。
目标是雄心勃勃的:提供一个统一的限速服务,使任何团队都可以轻松配置每个调用者或每个过程的配额,而无需进行代码更改。
设计还需要扩展到每秒数亿个请求、数万个服务对和不同地理区域的主机群,以最小的延迟增加。
GRL 的核心引入了一个三层反馈循环。
限速客户端(在服务网格数据平面中)。根据从聚合器接收的指令在本地执行每个请求的决策。每秒向区域级聚合器报告每个主机的请求计数。
聚合器(每个区域)。收集同一区域内所有客户端的指标,并计算区域级别的使用情况,然后将其发送到控制器。
控制器(每个区域,全球)。聚合区域数据以确定全局利用率,并将更新的丢弃比率指令推送到聚合器和客户端。
这种分层聚合确保了热路径中的低延迟(决策是本地的),同时在几秒钟内保持全局协调。如果控制平面变得不可用,客户端会失败打开,允许流量继续而不是冒着自我造成中断的风险。
图 1:GRL(全局限速器)的三层架构,显示客户端、区域级聚合器和区域/全球控制器。
当 GRL 项目开始时,团队最初在网络数据平面中的每个代理上实现了令牌桶算法进行执行。每个代理在本地跟踪请求计数,并随着时间的推移补充令牌,允许或拒绝请求,基于可用的令牌数量。
令牌速率是通过比较代理的本地负载和全局限制来得出的。代理计算其观察到的流量与全局负载目标之间的比率,然后根据该比率(比率 × 限制 RPS)补充令牌。
如果令牌可用,请求被允许并且会消耗一个令牌。如果没有,请求被标记为限速。为了处理突发或不均匀的流量,客户端在循环缓冲区中存储未使用的令牌,允许它们在短暂的激增期间被花费。
默认情况下,令牌可以保留长达 10 秒,服务的突发性更高的服务可以配置为最长 20 秒。
虽然这种方法在早期测试中有效,但它在生产中暴露了可扩展性和公平性问题。一个问题是调用者分布不公平。当调用者数量超过配置的限制时,令牌桶无法公平地在它们之间分配容量。另一个问题是实例级别的激增。具有突发性流量的单个主机可能会过早地开始丢弃请求,即使整体负载仍然低于全局限制。
为了减轻这些限制,团队引入了按比例丢弃,当聚合的全局负载超过配置的限制时,会调整行为。当这种情况发生时,客户端开始概率性地在过载的调用者的所有实例中丢弃一定百分比的请求。
例如,如果调用者的聚合 RPS 是其限制的 1.5 倍,则其所有实例都将开始丢弃大约 33% 的请求,计算方法如下:
丢弃比率 = (实际 RPS - 限制 RPS) / 实际 RPS
这种全局丢弃信号由控制平面每隔几秒更新一次,确保过量流量在所有调用者实例中均匀地被限制,而不是仅依赖于每个主机的令牌状态。
按比例丢弃在大型网关式服务中特别有效,这些服务有数百或数千个调用者实例,在这些服务中,每个主机的令牌核算无法捕获全局负载分布。
随着 GRL 成熟,团队完全废弃了令牌桶机制,转而使用控制平面驱动的概率丢弃模型,其中所有执行决策都基于聚合负载而不是本地计数器。
同时维护两个算法增加了配置复杂性和网络开销,因为每个客户端需要与控制平面交换定期的负载报告,以保持令牌速率对齐。
通过在单一模型上进行整合,GRL 简化了配置,减少了控制平面带宽,并将所有限速决策统一在一个全局一致的机制下。权衡是限速决策现在依赖于每秒更新的全局聚合数据,这意味着执行可能会滞后于实时流量条件 2 到 3 秒。
在实践中,这个延迟被证明是微不足道的,几乎所有工作负载都不会影响限速准确性,除非调用者生成非常短、非常高的流量激增。与之相比,这个轻微的延迟是可以接受的,相比之下,系统仍然在容量范围内,过早地丢弃有效流量。
在当前的 GRL 模型中,执行发生在网络数据平面中的客户端层。每个代理从控制平面接收限速配置和丢弃比率指令。
当请求到达时:
客户端将请求与配置的存储桶匹配,存储桶由调用者、过程或两者定义。
如果请求匹配一个具有当前丢弃比率指令的存储桶,则客户端根据该比率概率性地丢弃一定百分比的请求。
如果存储桶没有活动的丢弃指令,则请求正常转发。
这种方法使热路径极其轻量——没有本地令牌核算或每个请求与控制平面的通信。所有决策都是在进程中使用简单的概率采样进行的。
聚合器和控制器执行更复杂的计算,外部于转发平面:它们聚合请求计数,比较它们与配置的阈值,并每秒计算新的丢弃比率。
这些更新的比率被推送到所有连接的客户端,实现了全局协调,延迟最小且无中央瓶颈。
这种设计使 GRL 能够扩展到每秒数亿个请求,同时保持在几秒钟延迟内的一致全局执行准确性。
图 2:最终的 GRL 设计,采用控制平面导向的概率丢弃模型。
服务所有者在配置文件中定义限速存储桶。每个存储桶可以指定:
作用域:全局、区域或区域。
匹配规则:调用者名称、过程或两者。
行为:拒绝(执行)或允许(用于测试的阴影模式)。
GRL 在不需要目的服务进行代码更改的情况下透明地应用这些限制。
GRL 的引入在可靠性和性能方面对 Uber 的服务网格产生了可衡量的改进。
在 GRL 之前,许多服务依赖于 Redis 支持的限速器,需要为每个请求进行网络往返。通过将限速直接移到服务网格数据平面,限速可以在本地进行评估,从而消除了额外的跳转并显著降低了延迟。
延迟改进在多个服务中都有观察到。图 3 显示了请求如何在移除其 Redis 支持的限速器并迁移到 GRL 之后转移到较低延迟的存储桶中,对于 Uber 的一个大型关键服务。
| 请求延迟 | 之前(RPS) | 之后(RPS) | 更改 |
|---|---|---|---|
| 2-3 毫秒 | 2.5K | 30 K | +1100% |
| 3-4 毫秒 | 50K | 170 K | +240% |
| 4-5 毫秒 | 120K | 120 K | 0% |
| 5-6 毫秒 | 80K | 34 K | -57.5% |
| > 6 毫秒 | 100K | 20 K | -80% |
图 3:在将关键服务迁移到 GRL 后,请求分布转移到较低延迟。
在关键 API 端点中,延迟改进在每个百分位都是一致的。中位数(P50)延迟减少了大约 1 毫秒,而 90th 百分位(P90)延迟减少了几十毫秒。在尾部(P99.5),之前需要几百毫秒的请求减少到几十毫秒,代表着最慢的响应时间最高可达 90% 的改进。
图 4:在迁移到 GRL 后,关键 API 端点的延迟改进。
这些收益凸显了从外部依赖(如 Redis)中删除限速并在数据平面中本地处理限速的显著改进,改进了中位数和尾部延迟性能。
将限速集中在服务网格数据平面中也简化了基础设施。服务不再需要维护单独的数据存储或缓存层来执行配额,从而减少了操作开销并提高了一致性。迁移带来了显著的计算效率增益,释放了大量以前专门用于限速工作负载的 Redis 实例。
自部署以来,GRL 一再阻止了在激增、故障转移和重试风暴期间的过载。
通过在服务网格中概率性地丢弃过量流量,服务现在即使在突然的流量激增期间也能保持一致的响应时间。
图 5:一个关键服务 在没有降级的情况下幸存了 15 倍的流量激增(22 K → 367 K RPS)。
图 6:DDoS 攻击在到达内部系统之前被吸收。
除了自动保护外,GRL 还是 Uber 操作手册中值得信赖的一部分。在事件缓解期间,生产工程团队经常使用 GRL 在高流量调用者或过程上应用有针对性的限速。由于控制平面每秒更新一次,GRL 可以在几秒钟内对过载条件做出反应,使团队能够快速安全地遏制流量激增,而无需重新部署。
这种快速安全地在不需要服务重新部署的情况下限制特定流量模式的能力使 GRL 成为生产中最可靠的工具之一,用于遏制过载条件。
在完全扩展的情况下,GRL 现在每秒处理大约 8000 万个请求,跨越 1100 多个服务,动态执行配额,同时保持端到端延迟低。这种延迟降低、资源效率和可预测的全局执行的组合使 GRL 成为 Uber 可靠性平台的基石,并为下一阶段奠定了基础:在大规模上自动化限速配置。
虽然 GRL 统一了执行,但配置限速仍需要手动努力。服务所有者定义了 YAML 文件,描述了每个调用者和每个过程的配额,并且必须在流量模式发生变化时调整它们。
在 Uber 的规模下,数百个微服务不断演变,这些静态配置很快就过时了。
过于严格:服务即使在健康的流量峰值期间也被限制。
过于宽松:限制设置得远远高于实际容量,提供的保护很少。
过于手动:更改依赖于人类分析仪表盘并手动调整数字。
为了跟上不断变化的步伐,限速管理本身需要自动化。
RLC(限速配置器)被构建来自动保持 GRL 配置的准确性和更新。它定期分析实时流量指标,并根据观察到的需求重新编写限速设置。
在固定时间表或配置更改时,RLC 执行以下周期:
从 Uber 的可观察性平台中收集过去几周的指标。
使用历史峰值和缓冲头room计算每个调用者或过程的安全限制。
将更新的配置写入共享配置存储库。
通过现有的控制平面将新限制推送到全局限速器。
这种闭环过程确保限制随着实际流量而演变,尽量减少人工干预,同时保持过载保护。
RLC 从一开始就被设计为支持多种限速计算策略。虽然默认策略依赖于历史 RPS 数据,但系统的架构允许新的策略类型被无缝添加,因为 Uber 的流量模型和服务需求会随着时间的推移而演变。
例如,一些服务(如提供映射和位置数据的服务)使用预测模型,该模型结合了流量预测和预先规划的容量来计算限速。这些模型预测未来负载,而不是仅仅对历史趋势做出反应。
在其他情况下,分配给每个调用者或服务的固定配额会发生较少的变化,并受预先确定的合同或运营协议的管理,提供可预测的长期限速。
通过支持多种策略,RLC 可以根据不同的服务域定制限速计算,无论服务是否依赖于近实时的流量模式、预测或静态配额。
为了确保安全,团队可以将限速配置为阴影模式,在这种模式下,限速会被生成和监控,但不会被执行。这允许服务所有者在生产环境中观察到限速的行为,然后再激活它。专用仪表盘和警报可视化观察到的流量和假设的丢弃,提供了在推出之前的信心。
自动限速配置带来了立即的好处:
操作简单性。现在,数千个限速规则会自动更新,而无需手动编辑。
一致性。策略是从相同的公式和数据源生成的,适用于所有服务。
灵活性。不同的策略类型允许团队为其工作负载选择或扩展最合适的计算逻辑。
安全性。阴影模式确保在执行之前的正确性。
限速配置器将 GRL 从静态安全层转变为自适应、可扩展、自我维护的系统。
虽然限速配置器保持 Uber 的限速准确和最新,但旅程还在继续。近年来,团队扩展了缓冲区调整,引入了区域限速以减少配置更改的影响范围,并增加了更新频率,使系统对实时流量更加响应。这些工作得到了 Uber 的节流器层的补充,节流器层提供了额外的过载保护,距离应用程序更近。
今天,GRL 是 Uber 多层可靠性堆栈的关键组件,确保即使在极端负载下,Uber 的平台也保持稳定和公平。
Uber 的限速系统展示了基础设施投资如何从分散和手动操作演变为大规模的统一自动化。GRL 提供了统一保护的基础,而限速配置器使这种保护变得自我调整和维护。
如今,Uber 的限速系统在大规模运行,自主运行,保持流量安全、延迟低,并让工程团队专注于构建功能,而不是管理配置。