先说一个我们线上真实发生过的事:某个核心查询接口突然接二连三超时,监控面板上调用方A的QPS明明只有 1200,离它申请的 2000 额度还差得远,可数据库连接池却被打满了。查到最后发现,是另一个调用方B用同一批出口IP在疯狂刷量——因为我们当时只做了“按调用方维度”的限流,而它的配额是独立统计的,两个调用方加起来把下游击穿了。
那次事故之后,我把限流方案从头拆了一遍,最终落成的就是这套“分层API限流:节点、调用方、用户与接口配额”的组合模型。它的核心思路不复杂:不再指望某一条限流规则包打天下,而是按照请求经过路径拆成多个独立维度,每层各管一段,层层设卡。今天这篇文章就把这套设计从思路到落地讲透,包括每一层为什么存在、参数怎么定、代码怎么写、以及我踩过的那些坑。
如果你是后端开发、网关维护者,或者正在做开放平台API网关的设计,这套方案可以直接拿去做蓝本。就算你只是需要给内部系统加一层保护,里面关于Redis + Lua实现限流的细节也能省不少时间。
1. 限流为什么要“分层”讲:先理解四个维度各管什么
1.1 单层限流的三个典型死法,你都踩过哪个
很多团队最开始做的限流就是“全局接口限流”:给每个接口设一个总QPS阈值,比如 /user/info 一秒最多放行 5000 次。这种方式简单,但活着撑过线上真实流量后,几乎绕不开三个坑。
第一个坑是误伤。假设 /user/info 总阈值是 5000,正常用户流量占了 4500,此时调用方C发起 800 的突发请求,触发的直接结果是你没法只拦C——所有用户都被限了,正常业务也断了。第二个坑是共享出口IP下的不可控。现在很多B端客户、小程序端都走同一个NAT出口,按IP限流基本等于失效,你要限制的是某个调用方整体,而不是某一个IP。第三个坑是慢接口和快接口被一刀切。同样一次调用,一个查询缓存 5ms 就返回,另一个要查多张表耗时 300ms,它们对系统资源的消耗天差地别,用同一个接口级QPS去限制必然不合理。
分层限流解决的就是这个问题:把“限谁”“限多狠”“在哪个环节限”拆开,让每一层只做自己职责内的事情,互相不抢逻辑,互相又能兜底。
1.2 把限流看成一棵树:从根节点到叶子节点逐级设卡
我习惯把整个限流模型想象成一棵树,这一点也和很多网关社区里讲的“根节点/叶子节点”一致。
最上面是根节点,也就是整个网关集群或者API入口,它负责的是“全局总量保护”。往下是第一层分支,按调用方划分——通常对应一个企业客户、一个应用App,用 appId 或者 client_id 来标识。再往下一层是按用户划分,也就是跟在请求里的 userId、设备ID等身份标识。最底部是叶子节点,按具体接口 endpoint(也就是 URL 路径 + 请求方法)划分。
请求从根进来,一路经过调用方、用户,最后落到具体接口。每一层都可以设置独立的配额,也可以设置跨层的组合配额。比如“调用方A对 /order/create 这个接口的配额是 100 QPS”,“普通用户对 /user/info 的配额是 5 QPS”。当请求打到任意一层超出了配额,就在该层直接拒绝,不会让它继续往下撞数据库。
这个设计的第一原则是:每一层都是独立且可叠加的,命中最严格的规则生效。
1.3 四个维度的职责边界:谁负责公平,谁负责保命
用一张表格把这一节的重点讲清楚,对照着理解会清晰得多:
| 维度 | 标识方式 | 核心职责 | 典型失败后果 |
|---|---|---|---|
| 节点层 | 网关实例IP / 实例ID | 保护单机资源、防止CPU和内存被打满 | 单节点宕机,集群雪崩 |
| 调用方层 | appId / client_id / API Key | 保障租户间公平,防止某个调用方挤占全部容量 | 某个客户拖垮全局业务 |
| 用户层 | userId / deviceId / session | 防止单个用户刷接口、占资源 | 大量“单人多请求”打爆缓存和DB |
| 接口配额层 | endpoint + method | 精确到具体业务接口的成本控制 | 慢接口拖垮整体,快接口被慢接口连累 |
这四个维度之间不是替代关系,而是叠加上限的关系。同一时刻,一个请求可能同时被“用户层限流”和“调用方层限流”拦截,谁先命中了它的最小剩余额度,谁就生效。换句话说,最终起到保护作用的永远是“最紧缺的那个配额”。
这也是为什么分层限流在部署时一定要先在纸上画清楚树结构:根节点下面挂哪些调用方,调用方下面挂哪些用户池,用户池挂哪些接口,每一层之间是共享桶还是独立桶。只有职责清楚了,后面写代码才不容易乱。
2. 四个维度逐一拆解:怎么定阈值,为什么这么定
2.1 节点层:单机容量是最后一道物理防线
节点层的限流对象是整个集群里的每一个服务实例,也就是网关进程本身。它的含义是:这台机器最多扛多少 QPS,超过就直接拒绝,不管请求来自哪个调用方、哪个用户。
为什么需要这一层?因为无论你在上游做了多精确的调用方配额控制,流量总归要落到物理机器上。假如集群有 10 个实例,每个实例最多承受 1000 QPS,而调用方配额只设了“总集群 8000 QPS”,一旦负载均衡不均,其中一台机器可能分到 2000 QPS,它的CPU先飚到 90%,GC频繁,接着其他请求跟着超时。
节点层的阈值怎么定?我总结的经验公式是:单节点安全QPS ≈ 1000ms / 平均响应时间(ms) × 可用并发线程数 × 0.7。举个例子,某个网关接口平均响应时间是 20ms,这个实例上分配给Web容器的线程数是 200,算下来就是 1000 / 20 × 200 = 10000 QPS,再乘 0.7 的冗余系数,得到 7000 QPS。这样子留了 30% 的余量应对偶发拖延。
实操时要注意,节点层限流通常配合“本地令牌桶”实现,不需要每次都访问Redis,因为它是针对单个进程的,用本地内存的效率最高。我见过有人在节点层也走Redis,结果限流本身成了性能瓶颈,这是没有必要的。
2.2 调用方层:租户公平性的第一道闸门
调用方层是解耦“单一全局限流误伤所有人”的关键设计。在开放平台场景中,每个调用方都有唯一的 appId,它通常绑定了一个企业客户或者一个应用。调用方层要保护的,就是不让某一个appId打满整个网关的总容量。
调用方层阈值怎么定?从业务合同上就能拿数据。比如SaaS平台和客户签的SLA是“峰值 500 QPS”,那这个调用方的限制就是 500 QPS。如果是内部系统没有合同,可以根据这个调用方依赖的下游数据库和第三方接口容量来算,公式是:调用方可分配QPS = 下游资源安全水位QPS ÷ 该调用方预估流量占比。
需要注意的一个细节是:调用方层最好同时设置一个“总配额网络”的概念。我在实际项目中会把调用方配额再拆成两个子桶:一个是总QPS桶,一个是最大并发桶。总QPS负责平均流量控制,最大并发负责防止瞬间创建大量连接。比如某调用方配额 500 QPS,但如果它总是用 100 个并发连接来发起这 500 个请求,对网关连接池的压力是很大的。限制最大并发数为 50 后,它的实际吞吐就会被压得更平滑。
2.3 用户层:uid=0 这种匿名流量最容易被漏掉
用户层是针对具体终端用户的限流。登录用户用 userId,游客用设备ID或会话ID。这里有一个必须注意的坑:很多接口设计里,匿名请求的 userId 是 0,如果程序只对非零用户做限流,那么所有匿名请求都会绕过用户层,直接打到调用方层。
我经历过一次故障:某个老系统把所有未登录用户的请求统一标记为uid=0,我们上线用户层限流时忘记排除这个特殊值,结果一个批量脚本用了一组随机设备ID并发请求,每次请求落到 Redis 的 key 都是不同的,限流形同虚设。后来我们在用户层加了一条硬规则:uid=0 的用户不做单用户限流,但要纳入匿名用户池统一限额,相当于把匿名流量单独切了一个小水管。
用户层的阈值设定应区分“读接口”和“写接口”。读接口可以稍微宽松,比如 10 QPS/用户,因为读操作对缓存依赖高,短突发还能接受。写接口必须严格,比如 1~2 QPS/用户,因为写入直接落库,而且往往伴随事务和锁,突发写流量特别容易把数据库拖死。还有一种业务场景是“用户维度限流要按接口加权”,比如普通浏览 20 QPS,但导出接口只允许 1 QPS,这个我放在下一小节接口配额层一起讲。
2.4 接口配额层:慢接口和快接口要分开管理
接口配额层是限流的“叶子节点”,精确到某个具体endpoint。它与前面几层最大的不同在于:它必须感知接口的成本。同样是/query结尾,/query/cache可能只读Redis,/query/db却要扫描几张大表。把它们放在同一个配额池里,会让慢接口占掉快接口的资源。
所以接口配额层设计的第一步,是给接口做成本分级。我把接口分为S级、A级、B级、C级:S级是写库且涉及分布式事务的接口,配额必须很低,比如 5 QPS;A级是查询多张表或者调用多个外部服务的接口,域名级配额 50 QPS;B级是查主库但逻辑简单的接口,100 QPS;C级是走缓存的读接口,可以 500 QPS以上。分级之后,再用同一套限流框架,为每个接口分配不同等级的桶就行。
接口配额还涉及一个选择:令牌桶、漏桶还是滑动窗口。我的建议是网关入口层用滑动窗口,因为它能精确控制“每秒并发”的上限,不会因为令牌桶的突发特性出现瞬间尖峰。业务层对单个接口的单个用户限流,用固定窗口就够了,因为业务侧可接受“某一秒内最多N次”的粗粒度控制。如果是金融或交易类接口,才需要引入漏桶做流量整形。
3. 实操落地:从配置模型到核心代码一次跑通
3.1 请求进来先查哪一层:判断顺序的讲究
分层限流落地的时候,判断顺序比想象中更重要。顺序不对,要么响应慢,要么该拦截的没拦住。
我的推荐顺序是:节点层 → 调用方层 → 用户层 → 接口配额层。为什么是这个顺序?一句话解释:先做容量保护,再做公平性保护,最后做精确控制。
节点层必须最先检查,因为它是物理极限,如果节点已经快扛不住了,后面检查再多层都没有意义。其次是调用方层,因为调用方是一个相对粗糙的聚合维度,计算量小,而且能最快速地拦截“某个调用方整体超速”的异常流量。等通过了这两个相对“粗”的判断,再对用户和具体接口做精细化判断,计算成本会合理很多。
这里有一个容易被忽视的点:如果某个请求在用户层被限了,它还需要继续判断接口配额层吗?不需要。只要任意一层拦截,就直接返回429,后面的层不再执行。这样不仅减少Redis操作次数,也保证错误响应是确定性可预测的,客户端不用猜测自己到底触发了哪一层。
3.2 配置模型:一份YAML管住所有开放平台配额
配置模型我会直接给出一份能用的YAML示例,你可以按照自己的业务字段做调整:
rate_limit: global_defaults: algorithm: sliding_window redis_prefix: "ratelimit:" deny_status: 429 message: "请求过于频繁,请稍后再试" nodes: - node_id: gateway-01 max_qps: 7000 max_concurrency: 300 callers: - app_id: app-company-a max_qps: 500 concurrency: 50 # 针对用户层的默认覆盖 user_default_qps: 10 - app_id: app-internal-b max_qps: 2000 concurrency: 200 users: # 规则则为:caller + user 维度组合限制 - app_id: app-company-a user_id: "*" qps: 10 write_qps: 1 endpoints: - endpoint: GET:/api/v1/user/info qps: 2000 cost_level: C - endpoint: POST:/api/v1/order/create qps: 100 cost_level: S # 单用户维度接口覆盖 user_qps: 2这套配置里每层的数据都独立存储,但引用关系明确:endpoint 层可以覆盖用户层的默认设置,用户层可以覆盖调用方层的默认设置。多层之间是“最严格生效”还是“逐级独立限流”,取决于产品策略,这里我采用的更常见策略是逐级独立限流——每一层都要通过才算放行。
注意:千万不要把配置写死在代码里。实际落地时这套YAML要放到配置中心(Nacos/Etcd/Consul)中,支持热更新,否则每次调整配额都要发版,效率极低而且容易出错。
3.3 核心实现:Redis + Lua 保证原子性
分层限流落地的技术选型,我推荐用 Redis + Lua 脚本,原因只有一点:限流判断必须原子。如果先读计数再判断再写入,在高并发下会出现明显的竞态条件,两个请求同时读到剩余配额为 1,双双放行,配额就失效了。
下面是一段基于 Redis + Lua 的滑动窗口限流核心伪代码,按你的实际语言稍微封装就能用。这里我以 Java 客户端为例:
public class RateLimiter { private final StringRedisTemplate redisTemplate; // 判断是否允许请求通过 // keyPrefix 形如 "ratelimit:caller:app-company-a" // qps 是这一层的配额 public boolean allow(String keyPrefix, int qps, long windowMs) { long now = System.currentTimeMillis(); long windowStart = now - windowMs; String key = keyPrefix + ":" + now; String script = "local current = redis.call('INCR', KEYS[1]) " + "if current == 1 then " + " redis.call('EXPIRE', KEYS[1], ARGV[2]) " + "end " + "redis.call('ZREMRANGEBYSCORE', KEYS[2], 0, ARGV[1]) " + "redis.call('ZADD', KEYS[2], tonumber(ARGV[3]), KEYS[1]) " + "redis.call('ZREMRANGEBYSCORE', KEYS[2], 0, tonumber(ARGV[3]) - tonumber(ARGV[4])) " + "local count = redis.call('ZCARD', KEYS[2]) " + "redis.call('EXPIRE', KEYS[2], ARGV[2]) " + "if count > tonumber(ARGV[4]) then return 0 else return 1 end"; List<String> keys = Arrays.asList( key, // KEYS[1]: 当前秒的计数器 keyPrefix + ":sliding:window" // KEYS[2]: 滑动窗口的有序集合 ); List<String> args = Arrays.asList( String.valueOf(windowStart), // ARGV[1]: 窗口起点 String.valueOf(windowMs / 1000 + 5), // ARGV[2]: key的过期秒数 String.valueOf(now), // ARGV[3]: 当前时间戳 String.valueOf(qps) // ARGV[4]: 配额 ); Long result = redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), keys, args.toArray() ); return result != null && result == 1L; } }这段脚本的思路是:用 ZSet 保存每个请求的时间戳,每次请求先清理掉窗口外的旧记录,再加入当前请求,然后统计窗口内的请求总数。如果大于配额就返回 0,不允许通过。
实际项目中,你需要在网关拦截器里依次调用:
// 伪代码,表示拦截器中的限流判断流程 if (!limiter.allow("ratelimit:node:" + nodeId, nodeQps, 1000)) { throw new RateLimitException("node limited"); } if (!limiter.allow("ratelimit:caller:" + appId, callerQps, 1000)) { throw new RateLimitException("caller limited"); } if (!limiter.allow("ratelimit:user:" + appId + ":" + userId, userQps, 1000)) { throw new RateLimitException("user limited"); } if (!limiter.allow("ratelimit:endpoint:" + method + ":" + path, endpointQps, 1000)) { throw new RateLimitException("endpoint limited"); }有几件事我必须额外提醒:
- Redis的key一定要设置合理的过期时间,否则无限增长会把内存打爆。滑动窗口的窗口时长我按 1 秒窗口演示,如果你要精确限制 10 秒窗口,把 windowMs 改为 10000 即可,过期时间也要相应延长。
- Lua脚本在 Redis Cluster 环境下要确保同一个 key 的多个字段落到同一个 slot。这里 KEYS[1] 和 KEYS[2] 都基于同一个 prefix,只要前缀部分包含统一的hash tag,就能通过
{key}方式强制归属同一个 slot。我们在生产环境把key设计成ratelimit:{caller:app-company-a}:window就是这个原因。 - 尽量批量获取配置,不要每次请求都去查一次数据库或配置中心。我自己常用的做法是网关启动时把配置全量加载到本地缓存里,然后每 30 秒拉一次增量变更。
3.4 动态调整与降级:配额告急时让谁先让路
限流方案的健壮性不只是“能限”,还要“能调”。线上流量是活的,某天一个热门活动上线,某个接口的瞬间流量翻了几倍。如果配额是固定的,客户投诉电话会直接打爆。
所以我建了一层“动态配额调整”机制。具体做法是:对每个调用方和接口设定两档阈值,一档是硬上限,一档是软上限。正常情况下,使用软上限控制流量,比如调用方A软上限 500 QPS。当监控发现总集群还有富余容量,且调用方A的请求被限率超过 10% 时,会临时把A的软上限往上调,直至触及硬上限。这种自动“借额度”的机制能最大化利用资源,又不会让某个调用方把整个集群拖垮。
降级策略也要提前设计好。当Redis本身出现故障或者响应变慢时,限流组件绝不能拖垮主业务流程。我的做法是:Redis操作设置 5ms 超时,超时后走本地进程内限流(用本地令牌桶顶上),同时打印预警日志。等Redis恢复后,再平滑切换回去。简单来说就是“限流是保护业务的,不是制造故障的”。
4. 压测验证与问题排查:100个用户并发跑出真相
4.1 用 JMeter 模拟真实场景做一次完整验证
分层限流做完不能直接上生产,必须先压测。我常用的工具是 JMeter,开 100 个线程模拟 100 个用户并发访问,这在这个场景下基本够用了。
具体配置方法是:测试计划里建一个 Thread Group,线程数设为 100,Ramp-Up Period 设为 1 秒,循环次数设为 20。这样相当于 1 秒内 100 个用户同时涌入,每个用户连续发 20 个请求,总请求数 2000。然后分别针对“User Info 接口”和“Order Create 接口”建立 HTTP 请求取样器,并在HTTP头管理器里带上不同的 userId 或 appId。
压测时要重点看几个指标:聚合报告里的Error %、Throughput、Response Time。如果配置是“每个用户 10 QPS”,100 个用户同时发 20 个请求分布在 1 秒内,必然有部分请求返回 429。你需要确认的是:返回 429 的请求是否精确等于“超出配额”的那部分,而不是所有请求都被误杀。
我跑过一次印象很深的压测:某个接口本应该每用户接收 10 QPS,但结果所有用户都被限了。排查发现Redis中的key是ratelimit:user:0:GET:/api/v1/user/info,全部落在uid=0上——因为压测脚本忘了设置用户ID。这就是前面说的匿名用户坑,压测时一定要覆盖uid=0和正常用户两种场景。
4.2 线上典型问题速查表
下面这份速查表是我多次踩坑后的总结,可以直接贴在运维手册里:
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 所有请求全部 429,连健康检查也失败 | 节点层配得太低,把正常流量全拦了 | 查看节点层拒绝日志,确认是否按实例IP统计 | 调高节点层qps,或改为按集群总量限流 |
| 某个调用方始终被限,其他调用方没事 | 调用方层配额未动态调整,软上限低 | 对比该调用方实际流量与配额数据 | 临时上调软上限,并复盘配额测算模型 |
| 用户层误杀正常用户,频率不高也 429 | 用户层按固定窗口限流,窗口边界聚簇 | 看拒绝时间分布是否集中在窗口边界 | 改为滑动窗口,或增加 20% 的窗口冗余 |
| Redis主从切换瞬间大量限流异常 | 限流依赖Redis,主从切换期间不可用 | 检查缓存组件日志,是否有超时 | 增加本地降级限流,超时熔断 |
| 慢接口占用快接口配额 | 接口成本分级没做,统一配额 | 查看各endpoint平均耗时 | 拆分成本等级,分别配额 |
4.3 日志与响应头:让客户端一眼看懂被哪层拦住
限流的排查效率很大程度取决于日志细节。我强烈建议在每一个被拦截的请求响应头里带上业务上下文,比如:
HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 10 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1699999999 X-RateLimit-Scope: user X-RateLimit-Key: userId:0:GET:/api/v1/user/infoX-RateLimit-Scope告诉客户端是node、caller、user还是endpoint层拦截了你,X-RateLimit-Key能具体到限流粒度。这样一来,客户端反馈问题的时候,你不需要再去翻日志,看响应头就能快速定位。服务端日志至少需要记录:时间戳、节点ID、appId、userId、endpoint、被限层、当前配额值、实际请求次数。有了这些,排查“为什么刚好在下午两点整那段流量被限了”这类问题会轻松很多。
最后再分享一个我在实战中养成的习惯:分层限流上线后的前两周,一定要每天拉一次“各层拦截分布”报表,看看每个维度分别拦了多少请求。如果发现某个调用方的请求被用户层大量拦截,说明它的客户端SDK可能在循环重试;如果发现接口层配额被频繁打满,说明业务确实需要扩容而不是单纯限流。限流不只是拒绝请求,它更是一面镜子,映照出系统容量和调用行为之间真实的关系。这个观察习惯,比任何限流算法都值钱。