限流器设计指南:从算法原理到 Redis+Lua 分布式实现
假设你负责的一个核心接口日常 QPS 不到 100,某天因为一次活动推广或者外部调用方配置错误,流量突然冲到 3000。数据库连接池瞬间被打满,慢 SQL 拖垮了整个服务,连带着其他业务一起雪崩。事后复盘,你会发现大多数情况下解决办法不是“再扩两台机器”,而是应该在接口前面加一道闸门,这道闸门就是限流器(Rate Limiter)。
但如果你去搜“设计一个限流器”,很容易看到两种情况:一种是一上来就抛令牌桶、漏桶算法,然后贴一段 Guava 代码;另一种是把面试题目背得滚瓜烂熟,但真放到生产环境,发现多节点下各个实例各限各的,Redis 偶尔超时反而把服务拖慢,阈值拍脑袋设了一个数字,结果误杀了正常用户。
这篇文章不打算只讲算法。我会从限流器要解决的真实问题出发,把固定窗口、滑动窗口、漏桶、令牌桶这四种经典算法讲清楚,给出可运行的单机 Java 实现和 Redis + Lua 分布式实现,最后说明线上配置、验证、排错的完整思路。读完你可以动手写一个能用的限流器,也能在面试时把“设计限流器”这个经典题目讲得更有层次。
1. 限流器真正要解决的问题
先给一个判断:限流器的目标是保护系统的可用性,而不是简单地把请求“变少”。它解决的是一种失控风险:当请求速率超过系统能承受的极限时,系统不是优雅地拒绝多余流量,而是直接崩溃、雪崩,最终连正常用户也无法访问。
Rate Limiter 的技术定义不复杂:在单位时间内,限制某个维度(用户、IP、接口、设备等)的请求数量不超过预设阈值。超过阈值的请求,可以选择直接拒绝、排队等待,或者返回降级响应。
它主要解决三类问题:
- 保护下游依赖。数据库、第三方 API、消息队列等下游资源往往比应用服务更脆弱。如果上游无节制地请求,数据库连接池、外部接口配额很快会被打满。
- 保障核心 API 的 SLA。同一个服务里,可能某个高优接口和某个低优接口共用线程池。如果不做限流,低优接口的突发流量可能占满线程池,导致高优接口响应变慢。
- 防刷与成本控制。短信验证码、开放平台调用配额、爬虫防护都依赖限流。这类场景下,限流不只是技术手段,也是成本控制手段。
很多人会问:为什么不直接扩容?扩容有两个问题:第一是滞后性,流量是突发的,扩容有机器采购、服务启动、流量调度的时间成本;第二是成本,如果只是为了几秒钟的峰值把整条链路扩容一倍,经济上不划算。限流的价值在于,在系统容量已知的前提下,把流量控制在一个安全的范围内,给扩容和降级争取时间。
读到这里,你应该能判断自己是否需要关注限流器了。只要你的系统存在“多个调用方共用一个服务”“依赖第三方接口”“对外的接口会被爬虫访问”这三个特征中的任何一个,限流就应该是系统设计的一部分,而不是出现问题后才补的窟窿。
2. 核心概念:限流规则与四种经典算法
2.1 一条限流规则长什么样
在写代码之前,先明确限流规则由什么组成。一条最简单的规则包含三个参数:key、limit、window。
key 表示按什么维度限流,比如用户 ID、客户端 IP、接口路径或者它们的组合;limit 表示窗口内允许的最大请求数;window 表示时间窗口长度,单位秒。扩展配置还可能包括 burst 容量、拒绝策略(拒绝返回还是排队等待)等。
这里容易混淆的是 limit 和容量。很多算法里有“桶容量”的概念,它和“每秒速率”是两个独立参数。限流规则里的窗口和阈值,通常描述的是“长期平均速率”,而桶容量描述的是“瞬时突发上限”。这两个参数不区分清楚,后面配置时很容易踩坑。
2.2 固定窗口算法(Fixed Window)
固定窗口是最好理解的算法:把时间划分为固定长度的时间片,每个时间片维护一个计数器,计数器达到阈值后,这个窗口内剩余时间直接拒绝请求,下一个窗口开始后计数器清零。
伪代码如下:
它的优点是实现极其简单,内存占用小,一个 key 只需要维护窗口开始时间和当前计数。但它有一个著名的问题:临界突变。比如限制“每分钟 100 次”,第 59 秒请求了 100 次,第 61 秒又可以请求 100 次,这两个窗口之间只隔了 1 秒多,却放行了 200 个请求。对于后端数据库来说,这种短时间内的两倍流量可能就足够触发告警了。
2.3 滑动窗口算法(Sliding Window)
滑动窗口的出现就是为了解决固定窗口的临界问题。它的核心思想是:不按固定时间片统计,而是统计“当前时刻往前推一个窗口长度”这段时间内的请求数。
常用的实现有两种。第一种是滑动日志(Sliding Log):为每个请求记录时间戳,每次请求时把窗口之外的时间戳删掉,然后统计剩余时间戳数量。这种方法精确,但是每个请求都要保存一条记录,内存开销大,而且统计不是 O(1) 操作。第二种是滑动窗口计数器(Sliding Window Counter):把窗口拆成多个小桶,时间每推进一个小桶就丢弃最旧的桶,统计时只需要把窗口内所有小桶的计数相加。它是一种近似算法,误差取决于小桶的大小,但内存占用远小于滑动日志。
如果要在代码里演示,滑动日志反而最好写,因为和它的名字一样直观:
这段代码每次请求时先清理窗口外的时间戳,再判断当前窗口内请求数是否达到阈值。注意这里用了 synchronized,因为并发场景下,先检查后插入不是一个原子操作。
2.4 漏桶算法(Leaky Bucket)
漏桶的直观类比是:一个底部有洞的水桶,水以固定速率从洞中流出,请求就是往桶里倒水。如果倒水的速度超过流出速度,桶里的水会积攒,桶满后新倒进来的水直接溢出丢弃。
漏桶的核心特征是严格的速率整形(rate shaping)。它保证请求被处理的速率恒定不变,无论上游流量是平稳还是突发。这种特性非常适合“下游完全无法接受突发流量”的场景,比如对接一个硬性限速的第三方 API,或者保护带宽有限的网络设备。
漏桶的问题也很明显:不能应对突发流量。即使下游明明有处理能力,漏桶也只会按固定速率放过请求,突发的请求要么排队等待,要么直接被丢弃。对于互联网场景下的秒杀、热点事件,漏桶的“一刀切”会让大量本来可以被处理的请求被拒之门外,用户体验很差。
2.5 令牌桶算法(Token Bucket)
令牌桶和漏桶看着像,但思路刚好相反。令牌桶里装的不是请求,而是“令牌”。系统以固定速率往桶里放令牌,桶有最大容量。每个请求必须先拿到一个令牌才能通过,令牌被拿走后就从桶里移除。
这个设计有两大优点:第一,长期来看,请求速率被“放令牌的速率”限制住,不会无限超过系统容量;第二,短时间内容许突发,因为桶里可以积攒最多 capacity 个令牌,突然来一波请求时,只要桶里令牌够,就能全部放行。
实现令牌桶不需要真的开一个定时任务放令牌,而是采用懒计算方式:记录上次补充令牌的时间,在每次请求到达时,用当前时间减去上次时间,乘上补充速率,算出这段时间应该补充多少令牌,然后取容量上限。
这段代码的关键逻辑在于补充令牌的计算。假设 capacity 为 10,每秒补充 10 个令牌,如果请求在第一个 100 毫秒内集中到达,最多可以放行 10 个请求,因为桶里初始有 10 个令牌。之后每秒还能再放行约 10 个。这样就实现了“短期突发 + 长期限速”的效果。
2.6 四种算法对比
| 算法 | 实现复杂度 | 内存占用 | 是否允许突发 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 低 | 低 | 有边界突发 | 简单场景、网关基础限流 |
| 滑动日志 | 中 | 高 | 按窗口精确计数 | 需要精确统计的场景 |
| 滑动窗口计数器 | 中 | 中 | 近似,受桶大小影响 | 对精度有要求且内存有限的场景 |
| 漏桶 | 低 | 低 | 不允许 | 下游硬限速、带宽控制 |
| 令牌桶 | 低 | 低 | 允许一定突发 | 大多数业务接口的默认选择 |
3. 算法选型:为什么令牌桶是主流
很多文章会把令牌桶说成“最好的限流算法”,这其实是不严谨的。令牌桶只是“大多数业务场景下更合适”,不是“所有场景下最好”。
令牌桶最大的优势是允许突发。互联网系统的流量天然带有突发性,比如活动开始瞬间、热点内容发布后的几分钟。如果系统有足够的空闲容量,令牌桶允许请求在积攒的令牌范围内集中通过,这对用户体验是友好的。漏桶则做不到这一点,它会强行把突发流量拉平,可能因为排队过长导致接口超时。
固定窗口的问题在临界点,两个窗口交接时可能瞬间放行两倍流量,这在限流场景下是很危险的。滑动日志最精确,但内存开销和计算开销高,在高 QPS 接口上容易成为瓶颈。滑动窗口计数器是精度和成本的折中,但实现比令牌桶复杂,容易在桶边界处理时出 bug。
这也是为什么很多现成限流组件默认采用令牌桶思想。比如 Google Guava 的 RateLimiter、Resilience4j 的 RateLimiter,本质上都是基于令牌桶变体实现的,同时加入了预热、预支等能力来适应不同场景。实际项目中如果不想自己造轮子,优先使用成熟库是更稳妥的选择。
但选型的前提永远是“理解自己的下游”。如果你的下游是一个只能接受恒定速率的第三方服务,令牌桶的突发特性反而可能造成问题。这种情况下,漏桶严格控速的价值才真正体现出来。
4. 单机限流器:从原理到可运行的 Java 实现
4.1 固定窗口计数器实现
先写一个最简单的单机固定窗口限流器。这类实现适合放在 Filter 或者 Interceptor 里,对单个应用实例的某个接口做粗粒度保护。
synchronized 在单机场景下能保证 count 的自增和窗口切换是线程安全的。这里不需要用 AtomicInteger 做 CAS,因为算法本身包含“检查当前窗口是否过期”和“计数自增”两步,两步合在一起才是一个原子业务操作。
4.2 滑动窗口计数器实现
固定窗口的问题在于窗口切换的一瞬间会重置计数。滑动窗口计数器则把窗口切分成多个小桶,时间推进时整体滚动。下面是一个简化实现,用 ArrayDeque 保存窗口内的小桶。
这个实现比固定窗口复杂,但原理并不难:每个小桶只记录一个时间段内的请求数,窗口滚动时只把最旧的小桶移出统计范围。误差来自窗口边界处的小桶不能完全精确地只包含窗口内的时间点,但桶越小越接近精确。
4.3 令牌桶实现
令牌桶的完整代码在 2.5 节已经给出,这里补充两个使用注意点。
第一,capacity 和 refillRatePerSecond 的含义要搞清楚。capacity 控制最大突发量,refillRatePerSecond 控制长期平均速率。如果接口能承受的长期 QPS 是 100,那么 refillRatePerSecond 应该设为 100,capacity 可以设为 100 或略小于 100,避免突发流量直接打满线程池。
第二,tryAcquire 方法可以增加一个等待版本,比如支持“等待一段时间拿令牌,拿不到才失败”。但等待模式下,线程会被阻塞,对高并发接口来说,阻塞线程本身也是一种资源消耗。生产环境通常还是建议 fail fast,直接拒绝并让客户端重试或降级。
5. 分布式限流:Redis + Lua 的正确打开方式
5.1 为什么单机限流不够
单机限流器只能保护单个进程。如果服务有 3 个实例,每个实例的本地令牌桶都允许 100 QPS,那么整个服务对外实际可能吃下 300 QPS,远超后端数据库或第三方调用的承受能力。
分布式限流需要一个所有实例共享的中央计数器,Redis 是实现这种计数器最常用的基础设施。但使用 Redis 有一个关键问题:判断计数是否超限、计数自增这两个操作必须原子完成。如果用先 GET 再 INCR 的非原子方式,并发请求下会出现“同时读到同一个 count,然后各自 INCR”的问题,最终放行的请求数超过阈值。
Lua 脚本在 Redis 中天然具备原子性,是解决这个问题的标准方案。
5.2 Redis + Lua 实现固定窗口
下面是一段固定窗口限流的 Lua 脚本。
脚本逻辑是:先读取当前计数,如果已经达到 limit 就返回 0;否则对 key 执行 INCR,第一次 INCR 时设置过期时间。因为在 Lua 脚本里 GET、INCR、EXPIRE 是整体原子执行的,所以不会有并发竞态问题。
注意:不要用“先 INCR 再 EXPIRE”的非 Lua 两段式实现,因为如果 INCR 执行后、EXPIRE 执行前进程崩溃,Redis 里会留下一个没有过期时间的 key,结果就是限流器过几分钟后永久失效。这种 bug 在线上比较隐蔽。
5.3 Redis + Lua 实现滑动窗口
固定窗口在分布式下同样存在临界突变问题。如果业务对精度有要求,可以用 Redis 的 ZSET 保存每个请求的时间戳,统计窗口内的请求数量。
这段脚本每次请求先清理窗口外的时间戳,再统计窗口内数量,未超限就加入当前请求的时间戳,并刷新 key 的过期时间。
这里有一个新手经常踩的坑:ZADD 的 member 必须保证唯一。如果直接用时间戳作为 member,同一毫秒内多个请求会对应同一个 member,ZADD 会把旧请求覆盖掉,导致统计出来的请求数偏少。正确做法是把 member 设为“时间戳 + 随机后缀”,比如 1690000000000-abc123。
5.4 Java 侧调用 Lua 脚本
在 Spring Boot 项目中,可以用 Spring Data Redis 的 DefaultRedisScript 来执行 Lua 脚本。
注意 DefaultRedisScript 的 KEYS 和 ARGV 传入方式:第一个参数是 key 列表,后面的可变参数对应 ARGV。如果传错顺序,脚本读到的值就不对,限流规则会完全失效。
6. 完整示例:用并发调用验证限流效果
6.1 验证单机令牌桶
写一个并发测试入口,模拟 100 个同时到达的请求,观察限流器放行数量。
运行结果可能有波动,但放行数量会稳定在 10 到 20 之间。原因在于:桶的初始容量是 10,100 个请求同时放行的一瞬间最多有 10 个能拿到令牌;随后测试线程执行期间,令牌会按每秒 10 个的速度持续补充,所以总放行数略高于 10。这个结果正好体现了令牌桶“允许突发 + 长期限速”的特性。
如果换成固定窗口限流器,同样配置 10 QPS 和 100 个并发请求,放行数量会更接近 10。
6.2 验证 Redis 分布式限流
首先启动一个 Redis 实例。如果本地没有 Redis,可以用 Docker 快速启动一个测试实例:
然后使用 redis-cli 直接执行之前写的 Lua 脚本文件,验证滑动窗口限流脚本。注意 redis-cli 的传参格式,--eval 之后的参数,第一个参数是 key 列表,然后用逗号分隔,逗号后面的参数是 ARGV。
连续执行 10 次后,第 11 次会返回 0。执行期间可以观察 Redis 中的 ZSET 成员数量:
如果返回 10,说明滑动窗口内部已经记录了 10 个窗口内的请求。验证完后清理测试 key:
6.3 如何判断限流是否成功
判断标准有三个:第一,被拒绝的请求数量不为 0,说明阈值真的在起作用;第二,放行的请求数量不超过“容量 + 测试执行耗时对应的补充令牌数”,说明长期速率没有被突破;第三,在分布式验证中,多个客户端并发请求时,Redis 中统计的通过总数不超过阈值,而不是每个客户端各自放行阈值数量的请求。
如果出现放行数量远超预期,先检查限流 key 是不是同一个。比如本地用 user:1,Redis 端却用 user:1:api:order,两者肯定对不上。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 限流完全不生效 | 限流 key 的生成规则与请求维度不一致,或配置未加载 | 打印或查看 Redis 中的 key 是否按预期生成 | 统一 key 生成规则,增加日志确认脚本命中 |
| 正常用户被误杀 | 阈值小于真实峰值,或令牌桶 capacity 设置过小 | 查看接口真实 QPS 和 P99 延迟,对比阈值 | 根据压测数据重新设定 limit 和 capacity |
| Redis 超时导致接口失败 | Redis 网络抖动、单点故障或连接池耗尽 | 监控 Redis 耗时,查看异常堆栈 | 限流失败时降级为放行或本地限流兜底 |
| 多个节点总量超过阈值 | 使用了本地限流而没有接入统一 Redis | 检查部署架构和限流组件配置 | 改为 Redis + Lua 或本地 + 中央两级限流 |
| Redis key 永不过期 | INCR 后没有执行 EXPIRE,或 Lua 脚本未包含 EXPIRE | 查看 key 的 TTL | 在 Lua 脚本内原子设置过期时间 |
| ZSET 滑动窗口统计偏少 | ZADD 的 member 重复导致请求被覆盖 | 打印 member 值 | member 追加随机后缀 |
| 客户端出现重试风暴 | 收到 429 后立即重试 | 查看网关和应用日志中同一个请求的时间戳 | 返回 Retry-After,要求客户端指数退避重试 |
8. 最佳实践与工程建议
8.1 阈值从压测来,不要拍脑袋
限流阈值的准确性取决于对系统容量的认知。上线前应该对核心接口做压测,摸清单机可以承受的 QPS 和对应的 P99 延迟,再结合线上机器数量估算集群容量。建议把阈值设为压测容量的 70% 到 80%,留出冗余应对流量波动。单纯拍脑袋设一个“感觉差不多”的数字,要么限不住流量,要么把正常用户挡在门外。
8.2 合理设计限流返回
被限流的请求应该返回明确的错误信息。HTTP 语义下推荐返回 429 Too Many Requests,并带上 Retry-After 响应头,告诉客户端多少秒后再试。如果是内部接口,也应该有统一的错误码,方便调用方识别和处理。不要让客户端收到一个 500,然后猜测到底是服务端崩溃还是被限流。
8.3 按调用方分级限流
不同的调用方应该有不同的配额。匿名用户、普通登录用户、付费用户、内部服务,这四类调用的重要性和成本完全不同。统一用同一个阈值限流,可能因为某个调用方的异常流量把其他正常用户的额度全部挤占。比较好的实践是按用户维度、接口维度组合限流,并对核心用户单独放行。
8.4 本地限流与 Redis 限流结合
纯 Redis 限流的缺点是每次请求都多一次网络调用,在 Redis 抖动时会放大约接口延迟。纯本地限流的缺点是无法全局精确控制。生产环境常见做法是两级限流:每个实例先做一层本地限流,挡掉大多数流量;再通过 Redis 做全局精确限流。本地阈值可以设置为全局阈值的 1/实例数 的 80% 左右,确保即使某台实例流量不均,也不会突破全局配额。
8.5 监控与告警必须配套
限流器的正确性不能只靠代码 review,还要靠监控。至少应该埋点统计三个指标:通过数、拒绝数、拒绝率。拒绝率突然升高可能有两种原因:一是流量真的超过了阈值,说明需要扩容;二是限流配置有误导致误杀。如果系统里有几个不同的限流维度,建议把维度也放进监控标签,方便定位是哪个接口、哪类用户被限流。
8.6 与熔断、降级配合使用
限流关注的是请求速率,熔断关注的是调用失败率,降级关注的是返回内容。三者是三个独立维度,不能互相替代。流控防止流量超过容量,熔断防止故障扩散,降级保证核心功能在异常时仍可使用。设计稳定性方案时,应该先把这三者放在一起考虑,而不是只做了