Airbnb 亿级流量的限流架构
写在前面
如果你做过任何线上服务的限流,大概会写过这种代码:
ifredis.incr(f"qps:{caller}")>LIMIT:return429按 caller 配 QPS 上限,超了直接 429。简单、有效,很多业务都是这么干的。但 Airbnb 在 Mussel(Airbnb的核心 KV 存储)上跑了几年这套方案之后,发现这在"阻止系统挂掉"这件事上是合格的,但不能让系统在压力下表现最好
这篇文章我们来讲一下,Airbnb 是怎么把 Mussel 的静态 QPS 限流重构成自适应流量管理系统,目标从防止崩溃变成压力下保留尽可能多的好流量。
为什么静态 QPS 限流不够用?
先看看 Airbnb 的两个真实问题:
问题 1:请求之间的成本差异巨大。同样是一个请求,可能是读单条 row,也可能是扫 10 万条 row。它们在 QPS 计数器里都算一次。
结果是什么?低成本调用方一直被限流,高成本调用方反而过得很好——因为前者很容易把 QPS 打满,后者一秒钟就发几个但是把后端打爆。
问题 2:流量分布偏斜。某个热点 key 突然被狂打,比如一个被分享出去的房源,这时候哪怕 caller 的总 QPS 没超,单个 shard 已经被打爆了。
从这两个问题我们可以发现QPS 其实跟系统真实压力并没有太紧密。QPS更多是在假设每个请求成本一样、每个 key 压力均匀,但真实的场景并不会每个请求都一样,所以静态限流没法满足真实系统需要在边界附近精细调度。
RARC 按真实成本算请求
Airbnb 引入了一个叫RARC(Resource-Aware Rate Control)的机制,核心是用request unit(RU)代替 QPS 当限流单位,公式如下:
每种操作有个基础 RU,读 1、写 6,写更贵,加上 bytes 和延迟等等。权重是通过离线 load test 校准出来的,往集群里灌不同 size 的请求,观察后端的真实 CPU/IO/磁盘表现来反推权重。
每个调用方有自己的 RU 配额,按 token bucket 形式补给。一个请求执行完才知道它真实消耗了多少 RU,然后从 bucket 里扣。很类似现在一些大模型的token计费。
这套系统每个 dispatcher pod本地维护自己的 token bucket,不做跨 pod 同步。牺牲严格全局精确,省掉了一个分布式协调系统的麻烦。Airbnb 觉得客户端流量自然在多 pod 间负载均衡,统计意义上的 RU 消耗会均匀,本地 token bucket 已经够用。这样就能解决上面说的两个问题:
- 一个 100k rows 的扫描会被记上几百 RU,正常的 small read 只记 1 RU;
- 调用方再也不能用"我 QPS 没超"这种话术绕过限流。
load shedding 基于实时信号的负载抛弃
光有 RU 还不够,RU 只是个配额,但当系统真的被打到压力极限的时候,我们需要主动丢掉一部分请求来保住核心。这就是load shedding(负载抛弃)。Airbnb 的做法是把 load shedding 拆成三个实时信号配合:
信号 1:criticality tier(关键度分层)
系统会提供一个明确的优先级骨架,每个调用方在配置里被打成 T0~T3:
shedding 的时候先丢 T3,再丢 T2,T1 和 T0 留到最后。
信号 2:latency ratio
Airbnb 会给 shard 的健康度算一个延迟指标:
ratio=p95_latency_short_window/p95_latency_long_window比如 10s 短窗口的 p95 除以 5min 长窗口的 p95:
- ratio ≈ 1:当前延迟跟平时差不多,正常。
- ratio < 0.3 或 > 3:系统在剧烈变化,特别是 ratio 超过某阈值代表当前比平时慢很多,这时候就
需要提高 RU 单价,来限制流量。
⚠️ 注意:这里用比率而不是绝对值是为了自适应不同 shard,有的 shard 平时本来就慢一点,比如读多冷数据的,绝对延迟阈值不通用,比率归一化掉了baseline的不同。
信号 3:CoDel 队列控制
CoDel(Controlled Delay)是一个经典的网络队列管理算法,思路是
看请求在队列里排队多久(sojourn time),超过阈值就主动丢请求。
Airbnb 把 CoDel 接进 Mussel 的请求队列:当队列里堆积的请求 sojourn time 超过阈值,直接丢最老的。这个跟 latency ratio 配合,能在毫秒级别检测到压力异常。
这里我给你最直接、最简洁、最不绕弯子、最一针见血的一句话总结:
三个信号一起配合:tier 决定丢谁、ratio 决定涨多少价来限流、CoDel 决定什么时候必须丢。
hot-key defence 单点风暴防御
最后一类问题是 hot key,某个 key 突然被狂打,把它所在的 shard 单独打爆。Airbnb 用了三步防线:
Step 1:Space-Saving 算法做 top-k 计数。
Space-Saving 是一种 streaming 算法,O(k) 内存就能近似估算key的频率。每个 dispatcher 自己跑一份,不需要全局聚合,单 pod 的视角就能识别出对自己来说的热点。
Step 2:超阈值识别为 hot。
某个 key 的频率超过预设阈值,标记为 hot key,进入下一阶段处理。
Step 3:进程内缓存 + request coalescing。
热点 key 进进程内 LRU,TTL 大概 3 秒。同一个 key 的并发请求合并成一个真实后端请求,同时来 1000 个查同一个 key 的请求,后端只看到 1 个,其他 999 个拿同一个 result,就是 singleflight 机制。
⚠️注意:3 秒 TTL 是非常细的取舍。短了 cache hit 不够,挡不住热点风暴,长了,数据 staleness 增加,影响一致性。Airbnb 选 3 秒是因为 Mussel 的写入主流向已经走 Kafka 异步,3 秒级别的滞后对调用方影响小。这个数字不能盲抄——你的系统的可接受 staleness 决定了你自己的 TTL。
Airbnb模拟约 1M QPS 打到单个 key 上,经过这三步后,后端实际看到的流量被压到了几乎察觉不到。
最后
我们把整套自适应流量管理系统从上往下捋一遍:
- 请求进来 →RARC估算 RU 成本 → 扣 token bucket。
- 同时 →hot-key 识别 + 进程内缓存合并。
- 同时 →CoDel 监控队列 sojourn time + latency ratio 监控 shard 健康。
- 系统压力上来 → 按criticality tier主动丢 T3/T2 流量,保住 T0/T1。
整体效果:
- p99 在压力下更稳。
- 真实 hot key 风暴下后端无感。
- 不需要 oncall 半夜爬起来调 QPS。
最后一条才是最重要的,自适应的本质就是把 oncall 从循环里踢出去。
参考:https://medium.com/airbnb-engineering/from-static-rate-limiting-to-adaptive-traffic-management-in-airbnbs-key-value-store-29362764e5c2
