艾体宝干货|API 限流那些事:五种算法、陷阱与常见问题

3 阅读8分钟

我们服务过很多团队只做一次限流设计,然后就再也没有人管,直到故障复盘,才发现限流器什么也没拦住。

概念理清

"限流"和"throttling"这两个词,在日常讨论里经常被混着用,但其实指的不是同一件事,搞混会影响设计客户端重试策略。

  • Rate Limiting(速率限制) 最直接的:在固定时间窗口内,超过请求次数上限,服务器直接返回 HTTP 429,拒之门外。限制维度可以是 IP、用户 ID、API Key,或者某个业务应用。
  • Throttling(流量整形) 是更宏观的流控机制,目标是在需求超出容量时决定"怎么办"。它不一定是直接拒绝,也可以是排队、延迟处理、降级响应。

两者往往需要配合使用:一个负责"拒绝滥用",一个负责"应对洪峰"。

引入这套机制,通常有三个核心原因:​防止恶意滥用和 DDoS 攻击​、​避免失控脚本或 Bug 客户端把后端资源耗尽​、以及​保证多租户场景下的公平性​。

五种限流算法

算法的选择直接影响内存开销、精度上限和突发流量容忍度。

1. 固定窗口计数器(Fixed Window Counter)

最简单的方案。每个时间窗口维护一个计数器,请求进来就 +1,窗口结束后重置。

优点显而易见:实现极简,Redis 一个 INCR 命令搞定,时间复杂度 O(1)。

但它有一个致命的边界问题:客户端可以在窗口快结束时打满配额,在下一个窗口开头再打满一次,两次请求之间的时间间隔可能只有几百毫秒,但实际通过的请求量已经是配额上限的两倍。

这个算法适合计费周期统计这类场景,对"离散计量窗口"有天然亲和性,但保护高成本接口时要慎用。

2. 滑动窗口日志(Sliding Window Log)

每次请求到达时,把时间戳记录在一个有序集合里。新请求进来,先删掉已过期的时间戳,再数剩余数量,超过上限就拒绝。

理论上最精确,彻底解决了固定窗口的边界问题,任意时刻截取一个完整窗口,计数都是准确的。

代价是内存。每个客户端身份在窗口期内的每条请求都要留一条记录,空间复杂度是 O(N),N 是窗口内的请求数量。高并发场景下内存压力会很大。

这个算法适合​安全敏感接口​,比如登录、短信验证码发送——这类接口宁可多消耗点内存,也不能容忍计数误差。

3. 滑动窗口计数器(Sliding Window Counter)

前两种算法的折衷方案,也是高流量 API 在生产中最常落地的选择。

核心思路:不存每条请求的时间戳,只维护"当前窗口"和"上一个窗口"两个计数器,用加权插值来近似一个滚动窗口:

暂时无法在飞书文档外展示此内容

内存开销小(两个计数器),实现也不需要 Lua 脚本,普通的 GET / SET / INCR 操作就能搞定,具备较好的分布式友好性。

唯一的缺点是存在轻微的精度误差,但对绝大多数业务场景来说,这点误差完全可以接受。

4. 令牌桶(Token Bucket)

每个客户端有一个"桶",桶有容量上限,以固定速率持续补充令牌。每次请求消耗一个(或多个)令牌,桶里没有令牌时拒绝请求。

核心特性是天然支持突发流量。 如果客户端一段时间内请求量较少,桶里会积累令牌,等真正的流量高峰来临时可以先消耗这些储备,不会被误杀。这对接口行为符合正常使用规律的场景非常友好。

实现上需要原子性的读-改-写操作(读取当前令牌数 → 计算补充 → 更新状态),单纯靠 INCR 是搞不定的,需要借助 Redis Lua 脚本来保证原子性。

5. 漏桶(Leaky Bucket)

请求进入一个队列,队列以固定速率消费——无论输入流量多么不规则,输出都是匀速的。队列满了,新来的请求直接丢弃。

跟令牌桶的区别在于​整形方向不同​:令牌桶控制的是"消费侧速率",允许积累额度,因而天然容忍突发;漏桶控制的是"输出侧速率",把不规则的输入流量磨平成平滑的输出流。

如果你的后端服务对请求到达节奏非常敏感(比如某些数据库操作或下游 RPC 调用),漏桶是保护它的好手段。

绕不过的问题:原子性

选定算法之后,会发现实现复杂度的分水岭在于:这个算法的核心操作能不能原子执行。

固定窗口只需要一个 INCR,Redis 本身就是原子的,没什么问题。

但令牌桶、漏桶这类算法需要"先读再写",如果两个并发请求同时读到相同的令牌数,都判断"有令牌可用",就会产生超发。解决办法是把读-改-写逻辑打包成 Lua 脚本,在 Redis 服务端原子执行,不会有并发插入的问题。

分布式部署的三种模式

算法解决了"怎么计",部署模式决定了"在哪计"。

集中式共享存储

所有服务节点的限流判断都指向同一个共享计数器(比如同一个 Redis 实例或集群)。

优点​:全局视图一致,限流精度高,不会出现"单节点没超限但整体超了"的问题。

缺点​:每次请求多一次网络 round-trip,对 P99 延迟有影响;共享存储本身也成了单点风险。

本地计数 + 最终一致

各节点维护本地计数器,周期性地向全局做同步,节点之间可能存在短暂的数据不一致。

优点​:延迟极低,无外部依赖。

缺点​:在同步间隔内,客户端可以把请求分散打到多个节点,绕过单节点的限流判断,实际通过量会超过配额。对于安全性要求高的场景,这个漏洞不可接受。

本地 + 全局混合

本地计数做快速预判(大多数请求在本地就拦掉了),全局计数做最终仲裁(超出全局阈值时兜底拒绝)。

复杂度最高,但如果你的业务对延迟和精度同时有要求,这是最值得考虑的架构。

反模式的问题

微服务各自为战

每个服务独立维护限流逻辑,互相之间没有协调。结果是:被服务 A 拒掉的客户端,转头去请求服务 B 照样通过。

多实例部署用本地计数器

这是生产环境最常见、最隐蔽的翻车方式。

本地计数器的实际效果是:​有效限额 = 单机配置限额 × 实例数量​。

如果你配了每秒 100 QPS,部署了 10 个实例,那客户端只要把请求均匀打到这 10 个实例,实际上可以用 1000 QPS 而不触发任何限流。如果你在后台看到客户端的实际通过量远超配置值,而且你是多实例部署的,大概率就是这个问题。

被限流之后无脑重试

客户端收到 429,马上以全速重试——这不是在"自动恢复",这是在"主动加剧雪崩"。

正确的做法是​**指数退避 + 随机抖动(jitter)**​。指数退避防止客户端频繁重试,随机抖动防止多个客户端在同一时刻同步重试,把一次流量洪峰变成多次叠加冲击。

只看请求数量,不看请求成本

把所有请求等同对待,用请求次数作为唯一限流维度。但一个复杂报表查询和一个简单 ping 接口,对后端资源的消耗可能差了几十倍。

客户端完全可以在次数限制内,专挑高成本操作打,把你的后端打穿。考虑引入请求权重按资源消耗计费的限流策略。

出错时返回 500 而不是 429

客户端无法区分"被限流了"和"服务挂了"。看到 500,客户端会当作基础设施故障,立刻重试,本来是一次可控的限流,变成了让故障扩大的催化。

429 是一个语义明确的信号:"你的请求被拒绝了,等一下再来。" 配合 Retry-After 响应头,客户端可以准确知道什么时候可以重试。

所有消费者共用一个全局限额

一个高频调用的客户端可以把整个平台的共享配额耗尽,其他所有人都会跟着遭殃。

限流策略应该做​租户隔离​,不同客户端、不同等级的用户拥有独立的配额,互不干扰。

小结

限流不是一个一蹴而就的设计,它是系统设计里的一个长期决策:

  • 算法选错​,限流器在边界条件下形同虚设
  • 部署模式选错​,限流器在分布式环境下完全失效
  • 反模式没意识到​,限流器本身可能把故障放大

理解这三层,比记住任何一个具体实现都更重要。