ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从单层限流到四层配额:API网关分层限流实战

从单层限流到四层配额:API网关分层限流实战 去年三季度末我们在 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 脚本作为核心每次请求只调一次 EVALSHAlocal 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。这些日志直接同步到日志分析系统可以做三件事按调用方排行找出“流量大户”、按接口排行定位“热点接口”、按用户排行揪出“异常刷单”。这些数据在限流之后依然是宝贵的流量治理依据而不是限流事件发生完就丢掉。我个人一直认为限流的核心不是“拒绝”而是“让系统在异常面前仍然可控同时留下足够的数据让你知道发生了什么”。这套分层配额体系就是沿着这个思路一点点搭起来的希望对你也有用。
返回列表