从静态限流到智能负载管理:Uber 如何治理大规模数据库过载

从静态限流到智能负载管理:Uber 如何治理大规模数据库过载

本文是对 How Uber Conquered Database Overload: The Journey from Static Rate-Limiting to Intelligent Load Management 的整理与翻译。


内容结构概览

本文会围绕 Uber 如何从静态限流走向智能负载管理展开,主要包括:

  1. 为什么 Uber 的数据库过载治理是一个极难的问题
  2. Docstore 和 Schemaless 在 Uber 基础设施中的位置
  3. 查询层静态 quota 限流为什么失败
  4. 为什么过载管理必须尽量靠近存储节点
  5. QPS 为什么不是好的过载信号
  6. 并发数为什么更适合做负载指标
  7. 多租户系统为什么同时需要“韧性”和“公平性”
  8. 第一代统一 Load Manager:CoDel queue、Scorecard、Regulators
  9. CoDel 如何用排队时延判断是否丢弃请求
  10. 为什么 FIFO 在过载时会制造“陈旧请求堆积”
  11. Scorecard 如何限制 noisy neighbor
  12. Regulators 如何处理写入字节、热点分区、内存、goroutine 等特殊过载信号
  13. 第一代方案解决了什么,又留下了什么问题
  14. 为什么 Uber 用 Cinnamon 替代 CoDel
  15. Cinnamon 如何基于请求优先级做更智能的 shedding
  16. t0 到 t5 优先级模型如何保护核心用户流量
  17. Cinnamon 如何动态调整 queue timeout 和 inflight limit
  18. PID 控制器为什么能减少 thundering herd
  19. 为什么只看本地健康状态还不够
  20. commit index lag 这类远端信号为什么重要
  21. 最终统一 Load Shedding Engine 如何形成
  22. BYOS:Bring Your Own Signal 的设计思想
  23. 统一控制后的收益:吞吐、P99、goroutine、heap 使用变化
  24. Uber 从这个项目里总结出的七条经验
  25. 对普通后端工程师和存储系统设计的启发

一、为什么数据库过载治理这么难?

Uber 有成千上万个微服务,每月服务超过 1.7 亿活跃用户,包括乘客、Uber Eats 用户、司机和配送员。支撑这些业务的底层数据库系统,是 Uber 自研的 Docstore 和 Schemaless。

这两个系统都构建在 MySQL 之上,但服务的是 Uber 自己的大规模在线业务场景。它们跨越数千个集群,存储数十 PB 级的业务数据,每秒处理数千万请求,并读取或更新数十亿行数据。

这些数据库承载的不是边缘业务,而是 Uber 各条核心业务线:

  • 出行
  • 配送
  • 地图
  • 支付
  • 定价
  • 内部平台
  • 其他关键在线链路

在这种规模下,过载不是一个局部小问题。

一个局部 spike 可能迅速放大:
下游服务超时,调用方开始重试,重试继续堆积,数据库压力更大,最终从一个分区、一个租户、一个节点的问题,扩散成更大范围的故障。

这就是大规模分布式系统里最可怕的事情:

系统不是因为“请求太多”简单变慢,而是因为超时、重试、排队、资源耗尽互相强化,进入自我放大的失败循环。

更复杂的是,Docstore 和 Schemaless 是多租户系统。

也就是说,同一套数据库基础设施上有很多不同调用方、不同业务、不同优先级的流量。一个租户发出异常请求,不能拖垮其他租户;低优先级后台任务过载时,不能影响用户正在打车或下单。

所以 Uber 要解决的不是简单“超过 QPS 就拒绝”。

他们要解决的是:

什么时候说明系统真的过载? 该拒绝谁? 该先保护谁? 是全局 shedding,还是只限制某个 noisy tenant? 该在查询层限流,还是在存储层做 admission control? 静态阈值够不够? 如何避免重试风暴? 如何让系统自动适应不同负载?

这篇文章讲的就是 Uber 如何一步步从静态限流演进到智能负载管理。


二、Docstore 和 Schemaless 的架构背景

在讲 Load Manager 之前,原文先介绍了 Docstore 和 Schemaless 的基本架构。

Docstore 和 Schemaless 都是 Uber 内部的分布式数据库系统,但面向的业务形态不同。

Docstore 支持事务和完整 CRUD 操作。

Schemaless 则更偏向 append-only workloads,也就是追加型写入工作负载。

虽然两者用途不同,但底层架构有相似基础,主要包含三层:

Stateless Query Engine Stateful Storage Engine Control Plane

本文主要关注前两层。


三、Stateless Query Engine:无状态查询层

查询层是无状态的,负责:

  • query planning
  • request routing
  • sharding
  • schema management
  • authorization
  • request parsing
  • validation

简单说,Query Engine 是入口层和路由层。

客户端请求先到 Query Engine。
Query Engine 解析请求、校验权限、确定要访问哪个 shard 或 partition,然后把请求转交给存储层。

因为 Query Engine 是 stateless 的,它天然适合水平扩展。
你可以加更多 routing node 承接更多请求。

但这也意味着它不天然拥有每个 storage partition 的完整实时状态。

这点后面会变得非常关键。


四、Stateful Storage Engine:有状态存储层

存储层负责真正的数据读写和一致性,包括:

  • transaction management
  • connection pooling
  • consensus
  • replication

数据被分片到多个 partitions。
每个 partition 由一个 leader 和两个 followers 组成,并通过 Raft 协议协调,以保证强一致性。

每个 partition 底层由 MySQL 节点承载,节点使用本地 NVMe SSD,目标是支撑高吞吐、低延迟的在线负载。

这里要注意:
真正知道自己是否过载的,是 storage node。

它知道自己的连接池、内存、goroutine、写入字节、partition key 热点、replication lag、follower 状态等细节。

查询层虽然是入口,但它离真实资源更远。

这也是为什么 Uber 后来得出一个关键结论:

过载管理必须尽可能靠近存储节点,也就是靠近状态所在的位置。


五、第一次尝试:在 Query Engine 层做 quota-based rate limiting

Uber 最初探索的是一种基于 quota 的静态限流方案。

思路很直观:

  1. 给每个读请求和写请求计算一个 capacity unit cost
  2. 这个 cost 根据处理字节数等信息计算
  3. 给每个用户或租户设置固定 quota
  4. 请求消耗 quota
  5. 如果超过 quota,就返回 429
  6. 因为 Query Engine 是无状态的,所以 quota 使用量存到中心 Redis cache 里

这种方案乍看很合理。

很多系统都是这么做 API rate limiting 的。
给用户一个额度,超过就拒绝,简单、可解释、可配置。

但 Uber 发现,这套方案在生产里撑不住。


六、为什么 quota-based rate limiting 失败了?

1. 每个请求都要访问 Redis,增加复杂度和故障点

Query Engine 本来是无状态路由层。

现在为了统计 quota,每个请求都要访问一个中心 Redis cache。

这带来两个问题:

第一,每个请求多了一次网络 hop。
第二,Redis 自己变成新的故障点。

过载保护系统本来是为了让数据库更稳定,结果它自己引入了一个中心依赖。
这在高吞吐数据库入口层非常危险。


2. Query Engine 很难实时掌握所有 partition 的健康状态

如果想在 Query Engine 层准确为 storage partition 做 shedding,它就必须知道:

成千上万个 partition 的实时健康和负载信息

这意味着 Query Engine 要不断追踪大量 storage state。

但 Query Engine 的设计初衷是 stateless。

一旦让它维护大量实时状态,它的可扩展性就被削弱了。

更糟糕的是,状态传播总会有延迟。
当 Query Engine 发现某个 storage partition 过载时,真实情况可能已经变了。


3. cost model 不准确

这可能是最致命的问题。

Docstore 和 Schemaless 底层是 MySQL。
由于 MySQL 扫描和过滤的方式,一个查询即使最终只返回一行,也可能在底层扫描了大量数据。

但 quota cost 如果只看返回字节数或结果大小,就会把两类完全不同的请求当成一样:

请求 A:只读取一行,底层也只访问一行 请求 B:做 full table scan,但最后只返回一行

如果两者都返回一行,它们在静态 cost model 下可能被认为成本相同。

这显然不对。

一个可能把存储层打爆的重查询,在 quota 系统里看起来像轻量请求。
这会让限流决策完全失真。


4. 静态 quota 在多租户环境里很难维护

最后,quota 本身是静态定义的。

这带来的运维问题是:

业务增长了,来申请加 quota。
某个 job 临时变大,来申请加 quota。
某个团队认为自己被限制太严,来申请加 quota。
有的 quota 设置太高,保护不了系统。
有的 quota 设置太低,误伤正常业务。

结果就是大量人工调参和 stakeholder 协调。

这种系统看起来可控,但实际会变成运维负担。


七、第一次失败带来的关键启发

虽然 quota-based rate limiting 失败了,但它给 Uber 一个重要结论:

过载管理必须尽可能靠近存储节点。

因为只有存储层最了解真实资源状态。

Query Engine 层可以做身份、路由、解析、鉴权,但真正判断“这个 partition 是否扛不住了”“这个节点是否内存紧张了”“这个热点 key 是否正在拖垮集群”,最好发生在 storage layer。

所以后续设计从 Query Engine 转向 Stateful Storage Engine。


八、过载信号应该选什么?为什么 QPS 不够?

设计 Load Manager 的核心问题是:

用什么信号判断系统过载?

最简单的信号是 QPS。

比如:

超过 10000 QPS 就开始限流

但 QPS 太粗糙。

同样是 1000 QPS,不同 workload 差距巨大:

1000 QPS point lookup 1000 QPS large scan 1000 QPS large write payload 1000 QPS hot partition key writes 1000 QPS background deletes

这些对系统的压力完全不同。

所以单纯按 QPS 限流,可能太早,也可能太晚。

太早:轻量请求被误杀。
太晚:重请求已经把系统拖垮。

Uber 认为更可靠的信号是并发数,也就是当前 in-flight operations 数量。

这背后可以用 Little’s Law 理解:

Concurrency = Throughput × Latency

如果请求吞吐不变,但延迟上升,in-flight 数量就会上升。
如果请求变多,in-flight 也会上升。

在有状态系统里,in-flight concurrency 往往更接近真实资源占用。

因为它反映的是:

当前有多少工作正在系统里占用资源

而不是单纯看单位时间来了多少请求。


九、多租户系统的双重目标:韧性和公平性

过载管理不只是保护系统不崩。

它还有一个目标:公平。

Uber 面临两类情况。

第一类:系统整体压力大

比如整个数据库节点都很忙。
这时要保护核心业务,优先丢弃低优先级请求。

例如:

后台 pipeline 可以先丢 内部聚合任务可以先丢 垃圾回收类任务可以先丢 用户实时请求要尽量保住

这叫 resilience,也就是系统韧性。

第二类:单个租户或调用方异常

比如某个租户不断打热点 key,或者突然发大量大 payload 写入。
系统整体可能还没到全局过载,但这个租户已经在抢占资源。

这时要限制 noisy neighbor,避免它影响其他租户。

这叫 fairness,也就是公平性。

所以 Uber 需要的不是一个单一限流器,而是一套组合机制:

全局过载时,按优先级 shedding 局部 noisy actor 时,按租户或调用方隔离 特殊资源压力时,引入专门 regulator

十、第一代统一 Load Manager:CoDel + Scorecard + Regulators

在转向存储层之后,Uber 建立了第一代 Load Manager,包含三个核心组件:

CoDel queues Scorecard engine Regulators

这三者解决不同问题。

CoDel 负责队列层面的 overload shedding。

Scorecard 负责多租户公平性,限制单个 tenant 并发。

Regulators 负责一些不容易被并发数捕捉的特殊压力信号。


十一、CoDel:用排队时延判断是否应该丢请求

CoDel,全称 Controlled Delay,最初来自网络领域,用来解决 bufferbloat 问题。

普通队列容易犯一个错误:
只看队列长度。

队列长度短,不代表没问题。
如果请求在队列里等得太久,说明系统已经开始积压。

CoDel 的核心思想是:

不看队列里有多少请求,而看请求在队列里等了多久。

如果请求排队时间太长,就说明它已经很可能过期、被客户端放弃,或者即将触发超时重试。

这时候继续处理它,可能是浪费。

所以 CoDel 更关注 responsiveness,也就是响应性,而不是单纯追求吞吐。

Uber 为不同操作类型实现了不同 CoDel queue:

Read queue:point lookup 和轻量查询 Write queue:insert、update、upsert Slow queue:scan、delete、replication 等长耗时或后台操作

这样可以隔离不同 workload。

读写慢操作互相分开,避免长任务拖垮短任务。


十二、为什么 FIFO 在过载时会变糟?

普通队列通常是 FIFO:

先来先服务

正常负载下,这很好。

但过载时,FIFO 会制造一个陷阱:

  1. 老请求堆在队列前面
  2. 它们已经等待很久
  3. 客户端可能已经超时或即将超时
  4. 系统继续处理这些老请求
  5. 新请求排在后面等待
  6. 新请求本来还有机会成功,却被旧请求堵住
  7. 客户端重试开始堆积
  8. 系统做了大量无效工作

这就是过载时 FIFO 的问题:

它公平地处理所有请求,但不聪明地浪费系统资源。

CoDel 在压力下会切换到 adaptive LIFO。

正常时,队列按 FIFO 工作。
压力大时,它更偏向处理新请求,让陈旧请求快速失败。

这听起来不够公平,但对系统恢复非常重要。

因为新请求更可能仍然有效。
老请求很可能已经失去意义,继续处理它只是在浪费 CPU、内存和连接。

所以 CoDel 的逻辑是:

正常时公平排队,过载时优先处理仍有成功希望的新请求,快速丢弃陈旧工作。


十三、Scorecard:多租户公平和 noisy neighbor 隔离

CoDel 解决系统过载,但不能解决所有公平性问题。

如果一个租户不断发请求,占用大量并发,但系统整体还没达到过载,CoDel 不一定触发。

这时需要 Scorecard。

Scorecard 是一个 rule-based admission control 组件,也可以理解成轻量 quota 系统。

它用于在多租户环境中强制执行 per-tenant concurrency limit。

它的目标不是全局 shedding,而是:

不让单个 tenant 独占共享基础设施

Scorecard 的配置简单、确定性强。

它的核心价值在 incident containment。

当发生 outage 或流量 spike 时,Scorecard 可以快速定位和限制问题来源:

  • 哪个 tenant 占用太多并发?
  • 哪个调用方正在制造冲击?
  • 能不能限制它,而不影响其他正常用户?
  • 能不能降低 blast radius?

Scorecard 带来的好处是:

即使系统整体还没过载,也能隔离 misbehaving tenant。

这对多租户数据库极其关键。


十四、Regulators:处理并发数看不出来的过载

Scorecard 基于并发数限制租户。

但并发数不能覆盖所有过载类型。

原文举了两个例子。

第一,一个低 QPS 调用方,可能每次都发送很大的写 payload。
QPS 不高,并发也不一定高,但写入字节量巨大,可能打爆 I/O。

第二,流量可能集中到某个 partition key。
整体 QPS 看起来正常,但某一个 partition 或 cluster 已经热点严重。

因此 Uber 引入了 plug-in regulators。

Regulators 是 node-local overload detectors。
它们用于保护一些系统不能违反的 invariants。

这些 regulator 在健康情况下很少触发。
这正是设计目标。

只有当用户误操作、热点出现、大写入涌入、内存紧张等异常发生时,regulator 才介入,防止系统级联失败。

原文列出了几类 regulator:

Write bytes regulator Partition key regulator Memory regulator Goroutines regulator

逐个解释。

Write bytes regulator

限制并发写入的数据量,防止 I/O 饱和。

它关注的不是请求数,而是写入字节。

一个请求如果写入很大,成本就应该更高。

Partition key regulator

限制打到热点 partition key 的流量。

这用于防止某个 key 或某个分区变成热点,拖垮局部集群。

Memory regulator

监控进程剩余内存。

当内存不足时,开始 throttle,避免 OOM。

Goroutines regulator

监控 goroutine 总数。

当 goroutine 数超过阈值时,说明系统可能在堆积请求、阻塞或重试,此时也需要 shedding。


十五、第一代方案的收益

第一代 Load Manager 不是失败品。

它确实带来了明显收益。

CoDel queues 通过丢弃超额请求,防止资源无限耗尽,让已经接受的请求更有可能成功。
在过载时,它帮助核心功能保持可用。

Scorecard 成功隔离 noisy tenants。
当某个租户行为异常时,它可以强制 per-tenant concurrency limit,不让它拖垮其他租户。

Regulators 则覆盖了一些更细粒度的资源风险。

所以第一代方案打下了基础:

系统不会轻易被打爆 多租户之间有边界 特殊资源压力有专门保护

但它还不够好。


十六、第一代方案的问题

1. CoDel 不知道请求优先级

CoDel 是 priority-agnostic。

也就是说,它不知道哪个请求更重要。

过载时,它可能丢低优先级请求,也可能丢用户正在等待的高优先级请求。

这会造成不好的用户体验,也会增加 on-call 负担。

对于 Uber 来说,这一点尤其重要。

后台异步 pipeline 和实时打车请求,不能被同等对待。
如果必须丢,当然先丢后台任务。


2. 固定 queue timeout 和静态 inflight limit 不适合动态系统

CoDel 依赖固定 queue timeout 和静态 inflight concurrency limit。

但真实系统是动态的。

不同时间段、不同服务、不同 workload、不同地域,系统能承受的等待时间和并发上限都不一样。

静态阈值会带来两种问题:

阈值太低:过早 shedding,误杀正常请求 阈值太高:shedding 太晚,系统已经过载

结果就是频繁人工调参。

这和一开始 quota 的问题类似:静态配置在动态系统里很容易变成 operational toil。


3. 固定等待时间会引发 thundering herd

固定 wait time 还会造成 thundering herd。

过载时,请求在队列里等待固定时间。
一旦等待超时,大量请求同时被拒绝。
客户端同时重试。
新的重试又形成下一波压力。
系统再次过载,再次批量拒绝。

这会形成周期性过载和拒绝循环。

更糟的是,因为没有流量优先级,高优先级请求也会被一起丢掉。

所以第一代方案的问题可以总结为:

它能防止系统崩溃,但不够精细,不够动态,也不够保护用户关键路径。

Uber 需要 priority-aware、dynamic、self-tuning 的负载管理。


十七、架构演进:Cinnamon 替代 CoDel

Uber 观察到,很多数据库过载来自低优先级异步任务:

  • pipelines
  • aggregators
  • internal garbage collection flows
  • 后台扫描
  • 批处理
  • 内部维护任务

这些任务不应该和实时用户请求有同等 survivability。

于是 Uber 用 Cinnamon 替代 CoDel。

Cinnamon 是 Uber Delivery 团队开发的 priority-aware load shedder。

它做 shedding 时会考虑:

  • request rank
  • dynamic system state
  • workload relative importance

也就是说,它不再只问:

请求等了多久?

而是会问:

这个请求有多重要? 当前系统状态如何? 这个 workload 相对于其他 workload 应该优先保吗?

十八、请求优先级模型:t0 到 t5

Cinnamon 使用 tiering model 定义请求优先级。

优先级从 t0 到 t5:

t0:最关键流量 t1:最重要的用户面在线流量 t2-t4:中间层级 t5:最低优先级流量

t0 只保留给少数 critical infrastructure services。

t1 则代表最重要的 user-facing online traffic,也就是 Uber 在过载时最想保护的核心流量。

如果请求显式携带 priority,Cinnamon 使用这个 priority。
如果没有显式 priority,它会根据调用服务设置默认值。

这个模型让系统可以做到:

过载时,先丢低优先级流量 尽量保护用户可见、关键在线请求

这比 CoDel 的无差别丢弃更符合业务目标。


十九、队列结构也被简化

引入 Cinnamon 后,Uber 简化了队列结构。

之前 CoDel 有:

Read queue Write queue Slow queue

其中 Slow queue 用于长耗时、后台操作。

有了 priority 后,不再需要单独 Slow queue。

长耗时和后台操作可以被标成低优先级。
所以队列简化成:

Read queue Write queue

区分读写仍然有价值,因为读和写对系统资源的影响不同。
但慢操作不再靠单独队列隔离,而是靠 priority 控制 survivability。

这是一种更统一的设计:

不再用队列类型表达业务重要性,而是用请求优先级表达业务重要性。


二十、Cinnamon 如何做 priority-aware shedding?

在 CoDel 时代,过载时 shedding 是 priority-agnostic 的。
请求一旦满足丢弃条件,就可能被丢。

在 Cinnamon 时代,shedding 变成 priority-aware。

过载时,Cinnamon 按优先级顺序 shedding:

先丢 t5 再丢 t4 再丢 t3 ... 尽量保 t1 / t0

这样可以保证低优先级后台工作为核心在线流量让路。

这对用户体验非常重要。

一个后台 pipeline 慢一点,通常可以接受。
一个用户打车请求失败、支付请求失败、实时定价失败,就会直接影响体验。

Cinnamon 让系统把“业务重要性”带入 admission control。


二十一、Cinnamon 的动态 queue timeout

Cinnamon 不再依赖固定 queue timeout。

它会使用 P90 latency metrics 来动态调整 queue timeout threshold。

这意味着系统不会永远用一个固定 5ms 或 10ms 作为排队等待阈值,而是根据当前实际延迟状态调整。

如果系统健康,可能允许适度排队。
如果系统变慢,就更早 shedding。
如果系统恢复,又可以放宽。

这样可以避免 CoDel 的静态 wait time 带来的误杀和手工调参。


二十二、Auto Tuner:动态调整 inflight limits

Cinnamon 还引入 Auto Tuner,用于动态调整 inflight limits。

原文描述中,图里的蓝色 box 表示 available slots。

这些 slots 可以理解成当前允许进入系统的并发容量。

Auto Tuner 会持续监控实时 latency 和 error rate signals,并据此调整 inflight limits,以最大化吞吐,同时保持系统稳定。

这比静态并发上限强很多。

静态上限的问题是:

低峰时可能太保守 高峰或系统退化时可能太激进 不同 workload 下不适配

Auto Tuner 则让系统在不同状态下自动寻找更合适的 limit。


二十三、PID-based control:为什么比简单 reactive shedding 更稳?

Cinnamon 使用 PID-based control。

PID 是控制系统里经典的调节方法,包含:

P:Proportional,当前误差 I:Integral,历史累计误差 D:Derivative,变化趋势

简单 reactive shedding 只看当前状态。
例如:

当前错误率高,就大幅 shedding 当前错误率低,就放开

这种方式容易过度反应。

它的问题是:

  • 反应太晚
  • 一旦反应又太重
  • 系统在过载和恢复之间震荡
  • 请求被批量拒绝
  • 重试形成新波峰
  • 造成 thundering herd

PID 控制器则会考虑历史和趋势。

它不像锤子一样猛砸,而更像调光器,平滑调整 shedding 强度。

原文用一个很形象的比喻:

没有 PID regulation,shedding 像锤子 有 PID regulation,shedding 像 dimmer switch

这能带来:

  • 更平滑恢复
  • 更少 429
  • 更稳定可用性
  • 更少不必要拒绝
  • 更少重试风暴
  • 更低尾延迟

二十四、Cinnamon 后仍然存在的问题

虽然 Cinnamon 相比 CoDel 有明显提升,但系统还没到最终形态。

主要问题是:

Cinnamon 最初主要基于本地 server health 做决策。

它看的是本地信号:

  • inflight concurrency
  • write bytes
  • memory usage
  • 本地 queue state
  • 本地 latency/error

但在分布式有状态系统里,过载不一定是本地的。

一个 leader node 自己可能很健康,但 follower nodes 复制落后。
这时候 leader 继续接收写入,可能会加剧 follower lag。

原文把这个问题称为:

commit index lag

也就是 follower commit index 落后。

这类情况说明:

一个节点是否应该 shedding,不能只看它自己健康不健康,还要看与它相关的远端状态。

过去这类 remote shedding 决策常由外部组件使用 token-bucket-based rate limiter 处理。

但 token bucket 在规模化场景下也有问题:

  • 容易构建
  • 但在大规模下不够有效
  • 可能产生 split-brain behavior
  • shedding 决策全局不最优
  • 与本地 load manager 割裂
  • 造成多个控制系统互相打架

所以 Uber 需要把本地和远端 overload signals 统一到一个决策系统里。


二十五、最终演进:Unified Load Shedding Engine

最终,Uber 把 Cinnamon 从一个 concurrency-only shedder,演进成 general-purpose overload control engine。

核心变化是:

把所有 overload signals 统一进一个 modular decision-making loop。

它不再只处理本地并发。
它可以接入外部信号,比如 follower commit lag。

这样系统就能在同一个 admission control path 中做:

  • 本地过载判断
  • 远端过载判断
  • priority-aware shedding
  • caller-specific shedding
  • system-wide shedding
  • noisy actor 限制

这就是 Unified Load Shedding Engine。


二十六、BYOS:Bring Your Own Signal

统一 Load Manager 的关键设计理念是 BYOS:

Bring Your Own Signal

也就是“带上你自己的信号”。

不同过载原因需要不同信号:

  • inflight concurrency
  • latency
  • error rate
  • write bytes
  • memory
  • goroutine count
  • hot partition key
  • follower commit lag
  • partition-level pressure
  • tenant-specific pressure

一个固定的 load shedder 不可能提前内置所有未来信号。

所以 Uber 做成 pluggable framework。

新信号可以被嵌入 Load Manager,并路由到合适控制路径。

如果压力是系统性的,就按优先级做 broad shedding。
如果压力来自特定 actor,就按 caller 做精确限制。
如果压力来自某个 partition key,就针对热点 key throttle。
如果是 follower lag,就让 leader 在合适路径上 shedding。

这就是 BYOS 的价值:

不是为每个问题造一个新限流器,而是让新信号接入同一个决策大脑。


二十七、统一控制带来的收益

原文给出了非常明确的性能和资源收益。

把 token bucket limiter 替换为基于 Cinnamon 的统一控制后,Uber 看到:

过载下吞吐提升 80% P99 latency 降低约 70% goroutine 数量减少约 93% heap 使用降低约 60%

具体数字是:

吞吐:平均 QPS 5,400 vs 3,000 P99 latency:upsert 平均 1.0s vs 3.1s goroutines:峰值 10,000 vs 150,000 heap:最大 1GB vs 5-6GB spikes

这些数据很有说服力。

它说明统一 Load Manager 不只是“更优雅”,而是实实在在改善了系统运行状态。


二十八、为什么 goroutine 和 heap 会大幅下降?

这点非常值得展开。

旧的 token bucket 或静态限流方式,往往会让请求进入系统后再等待。

请求等待时会占用:

  • goroutine
  • request object
  • context
  • buffer
  • connection resources
  • heap memory
  • downstream state

如果系统过载但仍然把请求留在内存里排队,它们会堆积。

堆积越多,goroutine 越多,heap 越大,GC 压力越大,延迟越高,系统更慢。
系统更慢,又导致更多请求堆积。

这是典型恶性循环。

Cinnamon 的做法是 fail fast。

当系统判断请求不该进入,就尽早拒绝,而不是让它在内存中等待到过期。

这就是为什么它能减少 goroutine 和 heap:

被拒绝的请求不再长期占用进程资源。

从这个角度看,load shedding 不是单纯为了“少处理请求”,而是为了避免系统把资源浪费在注定失败的请求上。


二十九、commit lag 的稳定性提升

原文还比较了 token bucket limiter 和 Cinnamon PID-based controller 在 commit lag 下的 shedding 行为。

没有 PID regulation 时,shedding pattern 很尖锐。
它像一个锤子:反应式、突然、激烈。

这会导致 commit lag 曲线不稳定,shedding 也忽高忽低。

有 Cinnamon PID controller 后,shedding 更平滑,commit lag 更容易稳定。

这说明统一 Load Manager 不只是降低平均资源使用,也改善了控制系统本身的稳定性。

在分布式数据库里,稳定控制非常重要。

如果控制系统自己震荡,业务层看到的就是:

  • 一会儿大量成功
  • 一会儿大量 429
  • 一会儿延迟飙升
  • 一会儿恢复
  • 然后重试再次打爆

PID 的作用就是减少这种控制震荡。


三十、Uber 总结的七条经验

原文最后给出了 Lessons Learned,非常值得单独整理。

1. Prioritization is paramount

优先级最重要。

有效的 load shedding,第一步不是写算法,而是决定什么最重要。

必须优先保护关键用户流量。
其他一切都是次要的。

如果系统不知道请求重要性,就只能无差别丢请求。
无差别 shedding 在大规模业务系统里很危险。


2. Fail fast, don’t block

快速失败,不要阻塞。

在过载时,早点拒绝通常比把请求留在内存里等到过期更好。

早拒绝的好处是:

  • 减少无效工作
  • 保持延迟可预测
  • 防止 OOM
  • 减少 goroutine 堆积
  • 降低 GC 压力
  • 让系统更容易恢复

过载时最糟糕的不是拒绝请求,而是系统假装还能接,最后让所有请求一起慢死。


3. PID regulation for stable shedding

简单 reactive shedding 容易不稳定。

只看当前错误率或当前延迟,经常会:

  • 过晚反应
  • 反应过猛
  • 造成震荡
  • 引发重试风暴

PID-based regulation 通过考虑历史和趋势,让 shedding 更平衡、更持续、更稳定。


4. Place control close to the source of truth

控制逻辑要靠近真实状态。

对有状态系统来说,最好的 shedding 决策通常发生在存储层。

因为 storage layer 拥有最完整上下文:

  • 当前资源
  • partition 状态
  • replication 状态
  • 本地内存
  • goroutine 数
  • write bytes
  • key hotspot
  • follower lag

入口层如果离状态太远,就要维护大量远端信息,既复杂又不准确。


5. Embrace dynamism

避免静态配置。

系统应该能根据上下文自适应不同场景。

固定 quota、固定 queue timeout、固定 inflight limit 都会在动态系统里制造问题。

更好的系统应该基于实时信号调整。


6. Invest in visibility and monitoring

可观测性是调优和信任的基础。

你必须知道:

  • 什么请求被 shed
  • 为什么被 shed
  • 哪个组件判断过载
  • 哪个 signal 触发
  • 哪个 tenant 被限制
  • shedding 后系统是否恢复
  • 429 是否减少或增加
  • latency 和资源是否改善

没有 visibility,Load Manager 会变成黑盒。
黑盒限流器最容易被业务方不信任。


7. Simplicity over complexity

最后一条是元原则:

简单优于复杂

这不是说系统不能复杂。
Uber 最终系统当然不简单。

但每个组件都应该有清晰职责。
不要为了某个局部问题引入一个完全独立的限流器,最后形成多套控制系统互相打架。

统一 Load Manager 的价值就在于:

多个信号 统一决策 统一入口 统一可观测性 统一控制路径

这是复杂系统中的简单性。


三十一、这篇文章真正讲的不是“限流”

很多人看到标题,可能以为这是限流算法文章。

但它其实讲的是更大的问题:
大规模有状态分布式系统如何做 overload management。

传统限流经常是:

每秒最多多少请求 超过就 429

但 Uber 这篇文章里,限流已经演进成一套智能 admission control 系统。

它要回答的问题包括:

请求重要吗? 系统真的过载吗? 是本地过载还是远端过载? 是全局过载还是某个租户异常? 是写入字节太大,还是热点 key? 是 memory 紧张,还是 goroutine 堆积? 是 follower commit lag,还是 leader 本地压力? 该全局丢,还是按 caller 丢? 该立刻丢,还是排队? 该调低 inflight limit,还是缩短 timeout?

这已经不是简单 rate limiting,而是 load management。


三十二、从普通后端系统角度理解

即使你的公司没有 Uber 这么大,这篇文章也很有启发。

很多服务都会经历类似演进。

第一阶段:静态 QPS 限流

最开始通常是:

每个用户每秒最多 N 个请求

简单好用,但粗糙。

第二阶段:按接口、租户、资源类型限流

开始区分:

读请求 写请求 重接口 轻接口 VIP 用户 普通用户 后台任务

第三阶段:基于并发和延迟做 admission control

不再只看 QPS,而看:

inflight latency error rate queue wait time

第四阶段:引入优先级

开始承认请求不是平等的。

用户实时请求 > 后台任务 支付/下单 > 数据修复 核心业务 > 统计报表

第五阶段:引入自适应控制

从静态阈值转向动态调节:

根据 P90/P99 latency 调整 queue timeout 根据 error rate 调整 inflight 用 PID 避免震荡

第六阶段:统一 Load Manager

把不同信号汇总到一个决策系统:

本地资源 远端状态 租户行为 热点 key 内存 goroutine replication lag 业务优先级

Uber 的文章,就是从第一阶段一路演进到第六阶段。


三十三、对 Go / 微服务 / 存储系统开发者的启发

如果你做后端或存储系统,可以重点借鉴这些点。

1. 不要迷信 QPS

QPS 是最容易拿到的指标,但不是最好的 overload signal。

尤其在数据库和存储系统里,请求成本差异非常大。
一个 full scan 和一个 point lookup 不能只因为返回结果一样就被认为成本一样。

2. 并发数比 QPS 更接近系统压力

in-flight concurrency 结合 latency,能更真实地反映系统是否积压。

3. 多租户必须考虑 fairness

没有 fairness,一个 noisy tenant 会拖垮所有人。

Scorecard 这类 per-tenant concurrency limit 很重要。

4. 过载保护要靠近资源所在层

如果状态在 storage layer,就不要把所有过载判断都放在无状态 gateway。

入口层看起来统一,但未必有真实状态。

5. 静态阈值会带来长期运维负担

固定 quota、固定 timeout、固定并发上限,在动态系统里迟早要人工调参。

6. 优先级不是锦上添花

当系统真的过载时,最重要的问题不是“丢不丢”,而是“先丢谁”。

没有优先级,就无法保护真正关键流量。

7. Fail fast 是保护系统,不是放弃用户

过载时早拒绝,比让请求等到超时更负责任。

因为晚失败会消耗更多资源,还会放大重试风暴。

8. 控制系统要避免震荡

简单 threshold-based shedding 容易一刀切。
PID 这类控制思想可以让系统平滑调整,减少过度修正。

9. 新信号会不断出现

今天是 commit lag,明天可能是磁盘队列、compaction backlog、replication delay、cache miss storm。

所以 Load Manager 应该可插拔,而不是每来一个问题造一个限流器。


三十四、我的理解:这是数据库版“智能熔断器”

如果用一句话概括 Uber 的最终系统,我会说:

它是一个数据库存储层里的智能熔断器,但比普通熔断器更细、更动态、更懂业务优先级。

普通熔断器通常是:

错误率高了就打开 过一会儿半开 恢复后关闭

Uber 的 Load Manager 更像:

我知道每个请求的优先级 我知道本地资源状态 我知道租户并发 我知道热点 key 我知道写入字节 我知道内存和 goroutine 我还能接收 follower lag 这种远端信号 我会根据 PID 控制平滑调整 shedding 我可以全局丢低优先级,也可以精确限制某个 caller

这已经是一个面向存储平台的智能负载控制系统。

它的目标不是让所有请求都成功,而是在不可能全部成功时,做最正确的失败选择。

这句话非常关键:

过载管理的本质,不是避免失败,而是控制失败的方式。

好的系统不是永远不拒绝请求。
好的系统是在压力下:

  • 拒绝得早
  • 拒绝得准
  • 先拒绝不重要的
  • 不让无效工作占满内存
  • 不让一个租户影响所有人
  • 不让控制系统自己震荡
  • 尽快恢复核心功能

Uber 这篇文章展示的,就是这种工程哲学。


三十五、总结

Uber 这篇文章讲述了他们从静态限流走向智能负载管理的完整过程。

最初,Uber 尝试在无状态 Query Engine 层做 quota-based rate limiting。
每个请求根据处理字节数计算 capacity cost,租户超过 quota 就返回 429,quota 使用量存到 Redis。

但这个方案在生产中失败了:

每个请求都要访问 Redis,增加故障点和网络开销 无状态查询层很难实时掌握数千 partition 的健康状态 cost model 不准确,full scan 返回一行和真正读取一行可能被算成同等成本 静态 quota 需要频繁人工调整,不适合多租户动态系统

失败之后,Uber 得到关键启发:

过载管理必须尽量靠近存储节点

随后,他们在有状态存储层构建第一代 Load Manager,包括:

CoDel queues:根据排队时延做 shedding Scorecard engine:限制 per-tenant concurrency,隔离 noisy neighbor Regulators:处理写入字节、热点 partition key、内存、goroutine 等特殊过载信号

这套方案改善了稳定性和公平性,但仍有问题:
CoDel 不知道请求优先级,固定 queue timeout 和静态 inflight limit 需要人工调参,还容易制造 thundering herd。

于是 Uber 用 Cinnamon 替代 CoDel。

Cinnamon 是 priority-aware load shedder。
它基于 t0 到 t5 的优先级模型,优先保护 t0/t1 关键流量,在过载时先丢低优先级后台任务。它还通过 P90 latency 动态调整 queue timeout,通过 Auto Tuner 动态调整 inflight limits,并使用 PID-based control 平滑 shedding,减少不必要拒绝、重试风暴和系统震荡。

后来 Uber 又发现,仅靠本地健康信号还不够。
在分布式数据库里,一个 leader 可能本地健康,但 followers 已经因为 commit index lag 落后。传统外部 token bucket limiter 处理这类远端 shedding 不够有效,还会造成 split-brain 和全局不优决策。

最终,Uber 将 Cinnamon 演进成 Unified Load Shedding Engine。

它采用 BYOS,Bring Your Own Signal 模型,让不同 overload signals 插入同一个统一决策系统。无论是本地并发、写入字节、内存、goroutine、热点 key,还是远端 follower commit lag,都可以进入统一控制回路。系统再根据压力类型,选择按优先级广泛 shedding,或者按 caller 精确限制。

最终收益非常明显:

过载下吞吐提升 80%,平均 QPS 从 3,000 到 5,400 P99 latency 降低约 70%,upsert 平均从 3.1s 降到 1.0s goroutine 峰值减少约 93%,从 150,000 降到 10,000 heap 使用降低约 60%,从 5-6GB spike 降到最大约 1GB

这篇文章最大的启发是:

大规模有状态系统不能只靠静态 QPS 限流。
真正可靠的负载管理,需要靠近状态所在层,理解请求优先级,结合本地和远端信号,动态调整 shedding,并且在系统压力下快速、精准、平滑地失败。

对后端工程师来说,这篇文章值得反复读。
它讲的不是一个限流算法,而是一套从“防止系统崩溃”到“保护关键流量、隔离 noisy neighbor、统一 overload signals、降低运维负担”的完整工程演进。


参考资料

  • How Uber Conquered Database Overload: The Journey from Static Rate-Limiting to Intelligent Load Management