去年三季度末,我们在 API 网关遇到过一次典型的“多租户风暴”:一个调用方在投放活动期间流量突然暴涨,把整个节点的 API 限流配额全部打满,结果同节点下其他调用方、其他用户的正常请求全被 429 拦下来。事故复盘时我意识到,传统单层限流只看“总量超没超”,它根本不关心超限的流量来自哪个调用方、哪个用户、打的是哪个接口,更谈不上配额隔离。之后我把整套限流体系重做成了按节点、调用方、用户、接口四个维度分层的配额管理方案。这篇文章就把这套分层 API 限流的设计思路、核算规则、判定顺序和工程落地完整复盘一遍,给同样在做网关治理、接口配额控制的同学一个可直接参考的样板。
1. 一次线上事故:单层限流为什么挡不住“多租户风暴”
1.1 事故复盘:17点03分的429雪崩
当时我们的网关节点配置了全局限流,峰值上限是每分钟 10 万次请求,超过就返回 429。出事那天晚高峰整体 QPS 并没有击穿阈值,最高只到了 8.2 万,按单层限流的逻辑,系统不应该触发限流。但真实情况是,某个调用方因为渠道投放引流,其自身流量在 10 分钟内翻了 5 倍,把节点内的共享资源几乎全部吃掉了。
由于限流器只认“全局总量”,它不会区分请求来自谁。结果就是:这个调用方的海量请求和所有正常请求一起竞争同一个配额池,池子被快速消耗,其他调用方的请求到达率直线下降。我们监控里看到的表象是——节点总 QPS 平稳,但各业务线成功率从 99.95% 掉到 60% 多,用户侧大量请求超时重试。等到我们手动介入把那个调用方限流掉,已经过去了 40 分钟。
这类事故最有迷惑性的地方在于:整体指标是健康的,局部的恶化被全局聚合值掩盖了。单层限流关注的维度只有一个“总数”,总数安全不等于每个调用方、每个用户、每个接口都安全。就像一个大楼只有一个总水表,某一层水管爆了拼命漏水,总水表读数还没到上限,但其他楼层已经开始水流变小甚至停水。
1.2 单层限流的三个死穴
复盘之后,我把单层限流的问题总结成三个死穴:
- 公平性缺失:所有请求共用一个配额池,没有按调用方、用户做份额隔离,大户永远可以挤死小户。只要某个调用方流量异常,全节点跟着陪葬。
- 无法定位热点:限流触发后只能看到“总 QPS 超限”,但说不清是谁造成的、打的是哪个接口、哪部分用户受影响最大。排障只能靠日志去猜。
- 阈值僵化:单层限流的阈值是一个确定的数字,没法表达“每个调用方最多 5000 次”“每个用户最多 100 次”这类复杂策略;也没法做到某个接口单独保护。
我当时判断,光靠调阈值、加机器解决不了根因。问题的本质是:多个调用方共享同一个资源池,必须在池子内部再做细分,让每一层、每一个维度都有自己独立的配额核算,互不挤占。于是就有了这套分层限流方案:节点配额、调用方配额、用户配额、接口配额,四级联动。
2. 四层配额模型:节点、调用方、用户、接口到底管什么
2.1 从“树”说起:从根节点到叶子节点的四条路径
分层限流的第一件事,是把配额的管理对象从一个“平面”变成一个“树”。整棵树的根节点代表网关的整体资源池,也就是集群共享的入口能力。从根节点往下,先分出调用方(caller),代表接入方应用,比如一个外部合作方、一个内部业务系统;调用方下面再分出用户(user),代表终端用户或者发起请求的业务账号;再往下的叶子节点,就是具体接口(API),例如POST /order/create、GET /user/info。
一次请求到达网关时,会带上一组身份元数据:请求所属的调用方标识、用户标识、请求路径。系统需要把这些信息映射到树的对应路径上,然后沿着这条路径从根节点往下逐级做配额核算。也就是说,每一层配额不是独立存在的,而是通过请求的维度组合串联起来的。
用日常例子类比:节点配额是整栋楼的供水总量,调用方配额是每个单元的用水份额,用户配额是每户人家的水表,接口配额是某个特定水龙头的限流阀。任何一个环节超了,水流都会被截断,但截断的位置不同,影响的范围也不同。
2.2 每一层的配额语义与典型阈值
分层限流里,每一层的职责差异很大,我把各层的语义整理成了下表,方便对照设计:
| 层级 | 作用对象 | 典型配额语义 | 常用算法 | 超限表现 |
|---|---|---|---|---|
| 节点 | 整个网关/集群共享资源 | 每分钟 10 万次 | 滑动窗口 | 整个节点拒绝新请求 |
| 调用方 | 第三方应用/合作方系统 | 每分钟 5000 次/调用方 | 令牌桶 | 仅该调用方被拦截 |
| 用户 | 单个终端用户/业务账号 | 每分钟 100 次/用户 | 令牌桶 | 仅该用户被拦截 |
| 接口 | 具体 API 路径 | 每分钟 600 次/接口 | 固定窗口 | 该接口降级或拒绝 |
注意这里每一层的算法选择不是随意的。节点层是全局咽喉,用滑动窗口可以精确控制瞬时流量,避免固定窗口在临界点出现“两倍突发”;调用方层面对的是合作方承诺的突发流量,令牌桶允许一定程度的突发,不至于把正常的运营活动误伤;用户层是体验最敏感的一层,单用户偶尔多点几下不该被秒杀,令牌桶可以让单用户有很小的突发余量;而接口层大部分都是热点集中的写接口或慢查询接口,用固定窗口最简单、行为最可预期,配合窗口错峰就能规避临界问题。
这套模型跑起来之后,我收到最多的反馈是“原来只能看到节点超限,现在一眼就能看出是哪个接口被打爆了”,这就是分层的价值——每一层都是一道独立的观察和治理边界。
2.3 缺省继承:为什么不能强制每一层都显式配置
很多团队做分层限流会犯一个错误:要求所有调用方、所有用户、所有接口都配置明细配额。这在初期没什么,但一旦调用方数量上千、接口数量过万,配置工作根本维护不过来。我们最后采用的是缺省继承策略:配额配置不必每一层都写满,缺省时自动向上或向默认值继承。
具体规则是:新接入的调用方没有专属配额,就走caller.default的默认配额;用户维度没有单独配置就走user.default;接口维度只在需要重点保护的场景才显式配置覆盖值。配置中心只需要维护一份嵌套的配额文件,接入方扩容时不需要人肉去加配额。
继承策略还带来一个附加收益:新接入的小流量调用方天然被默认配额保护着,不会因为配置遗漏就拥有无限额度。反过来,如果强行要求全量配置,很容易配错漏配,漏配的后果往往就是某条链路不受限流保护,埋下比限流本身更严重的隐患。
3. 配额核算规则:预占、扣减、透传怎么配合
3.1 预占与回滚:严格扣减为什么不可行
分层限流最核心的工程问题,不是怎么定义配额,而是多个层同时核算时,配额到底怎么扣。一开始我照搬了购物车“先锁定库存,后续失败再释放”的思路:请求进来,先把节点、调用方、用户、接口四层配额都预占掉,后面某一层判定拒绝,就把前面已经预占的配额全部回滚。
这个方案听起来严谨,实际上线后很快就崩了。原因很简单:严格回滚需要记录所有已预占的层,失败时逐个做反向扣减,在高并发下会放大竞争冲突。比如两个请求同时预占节点层配额,一个成功一个失败,失败的还要去回滚节点预占,节点层的计数 Redis key 会被反复修改,热点冲突比预想严重得多。实测中严格回滚方案把网关的 P99 延迟从 8ms 拉到了 40ms,而且配额统计在流量抖动时还会出现负值。
后来我换成了“宽松扣减”:每层配额核算通过后,预占就视为扣减完成;后续层如果拒绝,不回滚已通过的预占记录。这样做的误差是可控的——被拒绝的请求确实浪费了少量前置层配额,但它本身也是真实到达的请求,对应真实流量消耗,统计上的误差通常在 1% 以内。用这个方案换来的收益是:完全不需要回滚锁,节点层、调用方层的 Redis 操作次数直接减半,P99 延迟稳定回到 12ms 左右。
3.2 穿透逻辑:不同层拒绝时的对外表现
分层限流里最容易被忽略的,是“某一层拒绝之后,其他层和调用链路上的表现应该是什么”。这里我采用的穿透逻辑是:
- 节点层拒绝:这是最严重的场景,说明整个集群资源耗尽,直接对全部请求返回 429,并附带
Retry-After,让客户端退避重试。 - 调用方层拒绝:只对该调用方的请求返回 429,其他调用方完全不受影响。用户看到的现象是“这个 App 暂时不可用,但另一个 App 正常”。
- 用户层拒绝:只拒绝该用户,同一调用方的其他用户不受影响。
- 接口层拒绝:只对该接口的请求做限流,其他接口正常放行。
这套逻辑的核心价值是控制爆炸半径。单层限流中一次超限就是整体事故,而分层限流中绝大多数超限都是局部事件:某个用户刷接口、某个调用方突增、某个接口出现热点,都会被限制在最小范围内。线上大量限流事件最终只影响了个别用户或个别接口,这对我们来说是质变。
3.3 窗口类型与偏移:同一个时间源,但别让所有窗口一起刷新
分层限流里各层算法不同,必然涉及窗口对齐问题。我们的 Redis 里存的是窗口计数,窗口 ID 统一由 Redis 时间计算生成,所有实例共享同一个时间源。这一点非常重要——多个业务实例如果各自取本地时间生成窗口 ID,实例间哪怕只有几十毫秒的时钟偏差,也会造成“有的实例认为在这个窗口,有的认为在下一个窗口”,限流判定错乱,用户会体验到“明明没到上限却被限”。
另外,固定窗口层(节点层我们用滑动窗口,调用方和接口层有固定窗口)容易出现整点刷新时的毛刺。所有限流器的窗口如果都在 0 秒刷新,会产生周期性的配额空档。我给每个固定窗口加了一个偏移量,偏移值通过对层级名+作用域取哈希得到,比如hash("caller:app_A") % 60秒。这样不同调用方、不同接口的窗口刷新时间错开,整体流量曲线平滑很多。
4. 命中判定顺序:当多层配额同时生效,谁先拒绝
4.1 从“AND关系”理解多级限流的结果
分层限流涉及四个层同时判断,经常会有人问:到底哪一层说了算?答案很直白——四层是 AND 关系,任何一层不通过就拒绝,全部通过才放行。不存在“节点层还有余量所以用户层可以超一点”这种说法,每一层都是独立的硬边界。
这个原则决定了限流行为是可预期的:节点层是全局兜底,调用方层是应用隔离,用户层是单用户保护,接口层是热点抑制。你不需要关心“最终哪一层拒绝了我”,只需要知道只要有一个边界被击穿,请求就会被拦下来。
4.2 判定顺序:便宜的、影响范围大的先执行
虽然最终结果是 AND 关系,但多级判定在实际执行时要讲究顺序。我的顺序是:节点层 → 调用方层 → 用户层 → 接口层。这个顺序考虑两点:
- 影响范围大的先查。节点层一旦拒绝,整个节点都进不来,没必要继续往下算调用方、用户、接口。先做节点层判定,可以最快拦截最严重的异常。
- 成本低的先做。节点层和调用方层的配额 key 是热点 key,通常会走本地缓存命中;用户层和接口层 key 维度更多,Redis 访问成本更高。把低成本判定放在前面,可以尽早短路,避免每个请求都做完整四层查询。
有一个特例可以单独配置:接口层紧急保护。比如支付接口上了紧急开关,希望一旦接口超限就立即拒绝,不关心用户层是否还有配额。这种情况下可以给接口层配priority: true,让接口层判定提前到用户层之前。这种配置不能滥用,一般只留给资金、登录等核心接口。
4.3 一个具体的多级判定案例拆解
我举一个真实场景帮助理解。假设节点配额 1000 QPS,调用方 A 配额 600 QPS,用户 u1 配额 100 QPS,支付接口配额 60 QPS。现在 u1 用脚本并发打支付接口:
- 当总 QPS 超过 1000 时,节点层先拒绝,所有请求都进不来,u1 的脚本被整体拦截。
- 当节点层没超但调用方 A 的 QPS 超过 600 时,调用方层拒绝 A 的请求,其他调用方不受影响。
- 当调用方 A 没超但 u1 单个用户 QPS 超过 100 时,用户层拒绝 u1,其他用户不受影响。
- 当这些都正常但支付接口总 QPS 超过 60 时,接口层拒绝该接口的流量,哪怕其他接口还有配额。
这个案例也说明了另一件事:“哪一层先拒绝”取决于当前最稀缺的资源。限流器本身不需要做复杂的优先级仲裁,只需要严格按 AND 关系逐层执行即可。如果某个层长期处于“总是最先拒绝”的状态,那它对应的业务维度就是真正的瓶颈。
5. 存储与热key治理:Redis计数方案的工程实现
5.1 Key设计、Lua脚本与原子性
配额计数的存储选型,我直接用的是 Redis,没有用 MySQL。原因很朴素:限流是高频、低价值、可容忍极小误差的数据,MySQL 的高可靠特性在这里完全没有必要,反而会因为频繁写放大拖垮数据库。Redis 的 INCR/EXPIRE 足够简单可靠。
Key 的设计遵循“从树根到树叶”的拼接关系:
ratelimit:{node}:{window_id} ratelimit:{node}:{caller}:{window_id} ratelimit:{node}:{caller}:{user}:{window_id} ratelimit:{node}:{caller}:{user}:{api}:{window_id}这里有个关键点是:窗口 ID 是由固定时间长度计算出来的,比如分钟级窗口就是时间戳 / 60,而不是直接存一个完整的 RFC3339 时间字符串。用窗口 ID 的好处是 key 短、可预测,而且大促时容易实现不同窗口大小的切换(比如限流粒度从 1 分钟切到 5 分钟)。
配额判断和计数必须原子执行。我写了一个简化版的 Lua 脚本作为核心,每次请求只调一次 EVALSHA:
local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local current = redis.call('GET', key) if current and tonumber(current) >= limit then return {tonumber(current or 0), limit, 0} end local new_value = redis.call('INCR', key) if new_value == 1 then redis.call('EXPIRE', key, window) end return {new_value, limit, 1}用 Lua 的原因很简单:如果不加锁、不做原子操作,两个并发请求同时读到当前计数小于 limit,然后同时 INCR,最终计数会超出配额,这就是经典的 check-then-act 竞态条件。Lua 脚本在 Redis 中是串行执行的,天然避免了这个问题。
5.2 本地一级缓存降热:实测P99从25ms到1.2ms
分层限流跑起来之后,第二个工程问题是热 key。节点层的计数 key 是全局唯一的,调用方层的 key 也就那么几十上百个,大促时一个热 key 每秒要被访问几十万次。Redis 单节点虽然能扛,但 P99 延迟会被长尾请求拖到 25ms 以上,而且有把 Redis CPU 打满的风险。
我给限流器加了一层本地缓存做一级防护,用的方案很简单但有效:
- 允许通过的请求,在一秒内直接放行,不访问 Redis。
- 拒绝的请求也做本地缓存,避免反复打到 Redis 上做无意义的拒绝判断。
- 每秒本地缓存过期后,回源 Redis 重新获取真实计数。
这个方案的代价是:本地缓存过期的瞬间,可能允许一个极短时间的小突发,但这个突发被下一轮 Redis 计数兜底限掉,实际影响非常小。上线后,Redis 的 P99 延迟从 25ms 降到了 1.2ms,热点 key 的 CPU 占用也溃了。记住一个原则:限流器本身不能成为新的瓶颈,本地缓存就是它的保险丝。
5.3 时钟同步与窗口对齐:一个容易翻车的暗坑
这部分是分层限流最容易被忽视的暗坑。我们最初在多个业务实例上分别计算窗口 ID,用的是各实例本地时间time.Now().Unix()/60。上线后出现了一种诡异现象:限流阈值明明是 100,但用户反馈“我发了 80 个请求就被限了”,而且每个实例的表现不一样。
排查后发现,问题出在实例间的本地时钟偏差。实例 A 的时钟比标准时间快了 500ms,实例 B 慢了 300ms,两个实例对“当前窗口”的判定产生了分歧,导致同一时刻不同实例看到的计数器 key 不同,全局限流失效。
修复方案是:所有实例的窗口 ID 统一用 Redis 时间生成,不再依赖本地时间。具体做法是在限流器初始化时向 Redis 发送一次 TIME 命令获取标准时间,之后在一个短时间内基于该时间计算窗口 ID,并定期重新校准。这样所有实例基于同一个时间源生成窗口,限流判定就稳定了。这个细节在做分布式限流时非常关键,但很多文章都不会提到。
6. 落地配置与真实排障记录
6.1 配置示例:嵌套配额文件的组织方式
分层限流的配置我采用嵌套 YAML,由配置中心统一下发,网关启动时加载,支持热更新。一个最简化的配置看起来是这样:
limit: node: total: 100000 algorithm: sliding_window caller: default: 5000 overrides: high_priority_caller: 20000 scraping_bot: 500 user: default: 100 api: default: 600 overrides: "/order/pay": 60 "/user/info": 200 priority: "/order/pay": true重点说一下两个容易误解的字段:overrides是用来给特定调用方/接口覆盖默认值的,通常只在大促前临时调整;priority用于把接口层的判定顺序提前。配置下发时我会加一个校验器,提前检查层级名和路径是否存在,避免因为配置写错导致限流失效。
6.2 线上出现的三个典型问题
这套系统上线后,我陆续处理过不少问题,挑三个有代表性的记录在这里:
问题一:多个限流层同时生效,Retry-After 该返回哪个?方案是取各层中最保守(也就是最大)的值。比如节点层要求 30 秒,调用方层要求 5 秒,则返回 30 秒。这样设计是为了避免客户端一小时后重试又被限,造成重试风暴。客户端如果遵守 Retry-After,这个值就是整个链路的“安全退避时间”。
问题二:配额刷新瞬间的毛刺波动。固定窗口在窗口切换的瞬间会释放全部配额,导致请求在一瞬间集中涌入。除了之前提到的窗口偏移策略,我还在监控系统里针对窗口切换后的第一个 5 秒设置了单独的指标,专门观察毛刺。如果毛刺过大就放大窗口粒度,从 1 分钟改为 5 分钟。
问题三:监控报表里用户配额和接口配额重复计数。一个请求如果同时触发用户层和接口层判定,统计时很容易被计两次,导致报表数据虚高。我给每条限流日志增加了final_hit_layer字段,只记录最终先拒绝的那一层,报表统计时用这个字段做去重。各层命中数相加,就能接近真实超限请求总量。
6.3 分层限流在灰度、容灾与告警上的扩展
分层限流跑稳之后,它的价值远不止拦截异常流量,还能变成容量管理和发布灰度的一部分。
我把监控告警也做成了分层:节点层超限是红色告警,意味着整个集群的健康度存在问题,需要立刻介入;调用方层和接口层超限是黄色告警,说明局部业务有热点,可能是营销活动或接口性能劣化;用户层超限通常直接忽略,偶尔刷接口的单用户行为不值得半夜叫醒人。这个分级让值班同学的负担大幅下降。
在大促前,我会给核心调用方临时提高配额,给非核心调用方降低配额,让有限的节点资源优先流向核心业务。这个过程不需要改代码,只需要调整配置中心里对应调用方的overrides配置,热更新后 10 秒内生效。这比过去“大促前改全局限流阈值”的粗暴做法精细得多。
最后再分享一个小技巧:给每一层配额拒绝日志打上结构化的字段——hit_layer、caller_id、user_id、api_path、limit_value、current_value。这些日志直接同步到日志分析系统,可以做三件事:按调用方排行找出“流量大户”、按接口排行定位“热点接口”、按用户排行揪出“异常刷单”。这些数据在限流之后依然是宝贵的流量治理依据,而不是限流事件发生完就丢掉。我个人一直认为,限流的核心不是“拒绝”,而是“让系统在异常面前仍然可控,同时留下足够的数据让你知道发生了什么”。这套分层配额体系就是沿着这个思路一点点搭起来的,希望对你也有用。