我们服务过很多团队只做一次限流设计,然后就再也没有人管,直到故障复盘,才发现限流器什么也没拦住。
概念理清
"限流"和"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 响应头,客户端可以准确知道什么时候可以重试。
所有消费者共用一个全局限额
一个高频调用的客户端可以把整个平台的共享配额耗尽,其他所有人都会跟着遭殃。
限流策略应该做租户隔离,不同客户端、不同等级的用户拥有独立的配额,互不干扰。
小结
限流不是一个一蹴而就的设计,它是系统设计里的一个长期决策:
- 算法选错,限流器在边界条件下形同虚设
- 部署模式选错,限流器在分布式环境下完全失效
- 反模式没意识到,限流器本身可能把故障放大
理解这三层,比记住任何一个具体实现都更重要。