限流(Rate Limiting) 是高可用系统最基础的保护手段:当下游扛不住、上游突然爆发流量、或外部恶意攻击时,通过限制单位时间内的请求量,保证核心服务不被打垮。
本文从「为什么需要限流」出发,依次讲透四大经典算法、单机与分布式实现、限流的位置(网关/应用/接口)、以及与熔断/降级的协同。配合「短信发送设计」一文中的限流案例(60s/日 10 条)食用更佳。
本文脉络: 一 为什么需要限流 二 限流的四个核心问题(用什么限、限在哪、限多少、超限怎么办) 三 四大经典算法(计数器/滑动窗口/漏桶/令牌桶)原理与对比 四 分布式限流:从单机到 Redis 五 限流的位置与分层(网关 / 应用层 / 接口层) 六 常见限流框架(Guava / Sentinel / Nginx) 七 限流后的处理策略(拒绝 / 排队 / 降级) 八 与熔断、降级、重试的协同(稳定性三板斧) 九 常见问题
|
一、为什么需要限流
系统吞吐量曲线(Load / Throughput):
吞吐量 │ 可用区 熔断/限流挡在这 │ ┌──────────┐ ↓ │ / \ / │ / \_____ │ / ↑ │ / 系统最佳负载点 │ ────/ │ / ↑ │ / 实际流量可能瞬间冲到这里 └──────────────────────────────────────► 流量
|
核心矛盾:系统能稳定处理的最大负载(Sweet Spot)远小于实际可能出现的峰值流量。
三类必须限流的场景
| 场景 |
触发原因 |
不限流的后果 |
| 下游保护 |
上游 QPS 突增,超过下游能力 |
下游被打挂,级联雪崩 |
| 资源保护 |
DB 连接、线程池、外部 API 配额耗尽 |
资源耗尽、服务全停 |
| 安全防御 |
恶意刷接口、爬虫、暴力破解 |
数据被爬、成本飙升、正常用户受影响 |
不限流的典型事故
大促/营销活动 → 瞬间 10 倍流量涌入 → 应用线程池打满 → 请求排队 → 响应超时 → 上游重试 → 流量放大 2~3 倍 → 雪崩 → 数据库连接池耗尽 → 全站不可用
短信/验证码接口被刷: → 黑产批量调发送验证码 → 通道被限流(第三方拒绝) → 正常用户收不到验证码 → 流失
|
限流的本质:用「主动拒绝部分请求」换取「核心服务的存活」——两害相权取其轻。
二、限流的四个核心问题
设计一个限流方案,先回答四个问题:
1. 用什么维度限(限流对象)
常见限流维度(粒度从粗到细):
全局总量: 所有请求合计不超过 1000 QPS 按接口: /api/login ≤ 100 QPS,/api/search ≤ 500 QPS 按用户: 单个 userId 每分钟 ≤ 60 次 按 IP: 单个 IP 每秒 ≤ 10 次(防爬虫/撞库) 按设备指纹: 单设备每小时 ≤ 5 次(防黑产) 按业务键: 短信中按 phoneNo 限(60s/日 10 条) 组合维度: userId + IP(同一用户换 IP 也限、同一 IP 多用户也限)
|
选型经验:防外部攻击用 IP/设备;防用户滥用用 userId;保护下游用接口级总量。
2. 限在哪里(限流位置)
┌──────────────────────────────────────────────────────┐ │ ① 边缘网关(CDN/WAF) ← 最外层,挡恶意流量、爬虫 │ │ ↓ │ │ ② API 网关(Nginx/APISIX/Kong) ← 接口级总量限流 │ │ ↓ │ │ ③ 应用层(Sentinel/Resilience4j) ← 业务维度限流 │ │ ↓ │ │ ④ 资源层(DB 连接池/Redis) ← 保护最脆弱的资源 │ └──────────────────────────────────────────────────────┘
原则:越靠外挡住越好(早拒绝省资源),但业务限流只能在应用层做。
|
3. 限多少(阈值)
阈值不是拍脑袋,要有依据:
压测得出系统最大吞吐(Sweet Spot)→ 阈值 = 最大吞吐 × 0.8(留 buffer)
例:压测得出 /api/search 单实例稳定 500 QPS → 单实例阈值 400 QPS → 10 个实例总阈值 4000 QPS → 网关层按总量限 4000,应用层按单实例限 400(双层保护)
|
4. 超限了怎么办(拒绝策略)
| 策略 |
行为 |
适用 |
| 直接拒绝 |
返回 429 / 错误码 |
大多数场景默认 |
| 排队等待 |
请求进队列,匀速处理 |
削峰填谷、消息场景 |
| 降级 |
返回缓存/默认值/热门推荐 |
读多写少、可容忍旧数据 |
| 阻塞等待 |
请求挂起,等令牌/配额 |
内部同步调用 |
三、四大经典算法
这是限流的核心,面试必问。四种算法各有适用场景,没有绝对的优劣。
3.1 固定窗口计数器(Fixed Window)
原理:把时间切成固定窗口(如每 1 秒一个窗口),窗口内计数,超阈值则拒绝,窗口结束时计数清零。
阈值 = 100 req/s,固定窗口 1 秒
时间轴(每格 1 秒): |---窗口1---|---窗口2---|---窗口3---| count=80 count=120 count=50 (放行) (拒绝20个) (放行)
窗口边界:count 归零
|
致命缺陷——临界突发(Boundary Burst)
窗口边界前后流量集中:
窗口1 结束 窗口2 开始 ↓ ↓ ─────────│────────────────│────────── count=0 │ count 一下子冲 │ │ 到 100 │ ↑ ↑ 0.9s 时来 100 个 1.1s 时又来 100 个
实际:1 秒内(0.9s~1.9s)通过了 200 个请求!是阈值的 2 倍。
|
临界问题:窗口切换瞬间,前后两个窗口各用满配额,实际通过量可达阈值的 2 倍。
| 优点 |
缺点 |
| 实现最简单(一个计数器) |
临界突发,限制不精确 |
| 内存占用极小 |
无法应对窗口边界的流量尖峰 |
适用:对精度要求不高、阈值宽裕的场景(如粗粒度的全局总量保护)。
3.2 滑动窗口计数器(Sliding Window)
原理:把固定窗口再切成更细的小格子(子窗口),窗口随时间滑动,统计「当前时刻前 N 秒」内的总量。窗口越细,越接近真实曲线。
把 1 秒窗口切成 4 个 250ms 子窗口:
时间 → ┌──┬──┬──┬──┐┌──┬──┬──┬──┐┌──┬──┬──┬──┐ │A0│A1│A2│A3││B0│B1│B2│B3││C0│C1│C2│C3│ └──┴──┴──┴──┘└──┴──┴──┴──┘└──┴──┴──┴──┘ ←─ 滑动窗口(统计 A2 A3 B0 B1) ─→
当前时刻在 B1 结尾: 统计窗口 = A2 + A3 + B0 + B1(最近 1 秒) 超阈值则拒绝,不会出现固定窗口的边界双倍问题
|
为什么能解决临界问题:统计的是「过去 N 秒」的连续总量,与窗口边界无关。固定窗口的临界问题本质是「窗口边界两侧各自计数」,滑动窗口把边界两侧一起算进总量。
格子数量 vs 精度 vs 内存
格子越多 → 越精确 → 内存越大
格子数 4: 精度误差 ≤ 25% 格子数 10: 精度误差 ≤ 10% 格子数 100: 精度误差 ≤ 1%(接近真实曲线)
Sentinel 默认:窗口 1s,格子 2 个(精度 50%,偏简单) 生产实践:窗口 1s,格子 10~20 个(精度 5%~10%)
|
| 优点 |
缺点 |
| 解决了固定窗口的临界突发 |
实现比固定窗口复杂 |
| 精度可调(格子数) |
内存随格子数增长 |
| 仍是计数器,性能好 |
仍是「请求计数」,不控制速率平滑性 |
适用:大多数接口级限流(Sentinel、网关限流的默认实现)。
3.3 漏桶算法(Leaky Bucket)
原理:请求像水一样倒进桶里,桶以恒定速率漏水(处理)。桶满则溢出(拒绝)。输出永远是匀速的。
请求(不均匀,可能突发) ↓↓↓↓↓↓↓↓↓↓ ┌─────────────┐ │ │ ← 桶容量(bucket capacity,缓冲突发) │ 漏 桶 │ │ ~~~~~~~~ │ │ │ └──────┬──────┘ │ 漏水速率恒定(rate,如 10 req/s) ↓ 输出(永远匀速 10 req/s)
|
关键特性:平滑输出。无论输入怎么波动,输出永远是固定速率。
输入: 0 0 0 100 0 0 50 0 0 0 (突发) 桶缓冲: 100 进桶,按速率漏出 输出: 10 10 10 10 10 10 10 10 10 10 (匀速)
|
漏桶的两种用法
① 作为限流器(请求进桶,匀速处理): 适合保护下游:下游只看到匀速流量,绝不会被突发打挂。 常见:消息队列消费、调用第三方 API(如短信通道)。
② 作为流量整形(Traffic Shaping): 不是"拒绝",而是"排队"——突发流量进桶排队,匀速放行。 桶满才拒绝。
|
| 优点 |
缺点 |
| 输出绝对匀速,保护下游最优 |
无法应对合理突发(即使系统空闲,也只能匀速) |
| 简单可靠 |
排队会增加延迟 |
| 天然削峰 |
对突发流量的容忍度低 |
适用:保护脆弱下游(如外部 API、短信通道)、流量整形(MQ 消费匀速化)。
对比「短信发送设计」中的 Worker 消费速率控制——那就是漏桶思想:通道允许 1000 QPS,Worker 按 1000 QPS 匀速拉取,不会因为积压就猛冲。
3.4 令牌桶算法(Token Bucket)
原理:以恒定速率往桶里放令牌(token),桶有容量上限。请求来时取一个令牌,取到才放行,取不到则拒绝或等待。允许一定程度的突发。
令牌生成(恒定速率 rate,如 10 token/s) ↓ ↓ ↓ ↓ ↓ ┌─────────────┐ │ ◉ ◉ ◉ ◉ ◉ ◉ │ ← 桶容量(burst,允许的最大突发量) │ 满了丢弃 │ └──────┬──────┘ │ 取令牌 ↓ 请求来了拿令牌:有 → 放行;无 → 拒绝/等待
|
关键特性:允许突发。桶里攒的令牌可以瞬间被消耗,应对短时尖峰。
系统空闲时: 令牌持续生成,桶攒满(如 100 个令牌)
突发流量来(瞬间 150 个请求): 前 100 个:消耗桶里 100 个令牌,瞬间放行 ✅ 后 50 个:桶空了,按生成速率(10/s)放行,每秒放 10 个 → 应对突发的同时,长期平均速率仍是 10/s
|
令牌桶 vs 漏桶(核心区别)
漏桶: 输出恒定速率,输入突发被桶"削平" → 严格匀速,不允许任何突发
令牌桶: 长期平均速率恒定,但允许短时突发(用攒的令牌) → 兼顾平均速率和突发响应
实例: 限流 10 req/s 漏桶: 无论何时,永远 10 req/s 输出,来 100 个请求要 10 秒处理完 令牌桶: 桶攒了 10 令牌时,瞬间 20 个请求可放行 10 个(突发),后续匀速
|
| 优点 |
缺点 |
| 允许合理突发,资源利用率高 |
实现相对复杂(需定时/懒生成令牌) |
| 兼顾平均速率和响应速度 |
突发量受桶容量限制 |
| 业界主流(Guava/Sentinel 默认) |
不能保证输出绝对匀速 |
适用:绝大多数接口限流(既要限制总量,又要容忍合理突发)。生产中最常用。
单机令牌桶的经典实现:Guava 的 RateLimiter(下文详述)。
3.5 四大算法对比总览
| 算法 |
核心思想 |
突发流量 |
输出平滑性 |
实现复杂度 |
典型场景 |
| 固定窗口 |
时间片内计数 |
❌ 临界双倍 |
差 |
⭐ |
粗粒度总量保护 |
| 滑动窗口 |
滑动子窗口计数 |
✅ 可控 |
中 |
⭐⭐ |
接口限流主流 |
| 漏桶 |
恒速漏出 |
❌ 削平 |
最优(匀速) |
⭐⭐ |
保护下游、流量整形 |
| 令牌桶 |
恒速发令牌 |
✅ 容忍突发 |
长期匀速 |
⭐⭐⭐ |
接口限流最主流 |
选型口诀:
- 要保护下游、要匀速 → 漏桶
- 要限流但要容忍突发 → 令牌桶
- 简单计数够用 → 固定/滑动窗口
四、分布式限流:从单机到 Redis
4.1 单机限流的局限
集群部署 3 个实例,每个实例限流 100 QPS:
实例A(100 QPS) ─┐ 实例B(100 QPS) ─┼→ 理论总量 300 QPS 实例C(100 QPS) ─┘
问题:负载均衡不均匀时 实例A 拿到 250 QPS → 超限拒绝 150 个 实例B 拿到 30 QPS → 没用满 实例C 拿到 20 QPS → 没用满 实际只处理了 150 QPS,远低于系统的 300 能力
更糟:要保护下游只能承受 200 QPS 3 个实例各自 100,总量 300 > 200 → 下游被打挂 单机限流无法做「集群总量」限制
|
结论:需要保护集群总配额、或下游总量时,必须用分布式限流(集中计数)。
4.2 Redis 分布式限流(生产主流)
思路:用 Redis 集中存储计数,所有实例共享同一个计数器。
┌─────────┐ ┌─────────┐ ┌─────────┐ │ 实例 A │ │ 实例 B │ │ 实例 C │ └────┬────┘ └────┬────┘ └────┬────┘ │ │ │ └─────────────┼─────────────┘ ↓ ┌─────────────┐ │ Redis │ ← 集中计数器 │ INCR + EX │ └─────────────┘
|
实现一:Redis + INCR(固定窗口)
INCR rate_limit:{userId}:{window}
EXPIRE rate_limit:{userId}:{window} 60
|
问题:INCR 和 EXPIRE 是两条命令,中间可能中断。且无法实现复杂算法(滑动窗口、令牌桶)。
实现二:Redis + Lua 脚本(原子 + 复杂算法)
local key = KEYS[1] local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local threshold = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key) if count >= threshold then return 0 end
redis.call('ZADD', key, now, now .. math.random()) redis.call('EXPIRE', key, window / 1000) return 1
|
用 Sorted Set(ZSET) 存储每个请求的时间戳,ZREMRANGEBYSCORE 清理过期记录,ZCARD 统计当前窗口数量——这就是滑动窗口的 Redis 实现。
Redis 分布式限流的优缺点
| 优点 |
缺点 |
| 集群统一计数,精确 |
Redis 成为单点(需集群+哨兵) |
| 支持复杂算法(Lua) |
每次请求都查 Redis,有网络开销 |
| 成熟稳定 |
Redis 故障时限流失效(需降级) |
详见「短信发送设计」方案三,那里用 Redis Lua 实现了 60s/日 10 条的原子限流,是典型的分布式限流落地。
4.3 其他分布式限流方案
| 方案 |
原理 |
适用 |
| Redis + Lua |
集中计数,原子操作 |
主流,生产首选 |
| 令牌桶服务化 |
专门服务发令牌,各实例取 |
Netflix 内部做法,复杂 |
| 分布式协调(ZK) |
ZK 选主 + 分配配额 |
少见,过度设计 |
| 本地估算 + 中心校准 |
本地令牌桶 + 定期同步全局配额 |
高性能场景(如阿里 Sentinel 集群限流) |
五、限流的位置与分层
5.1 多层限流(Defense in Depth)
┌──────────────────────────────────────────────────────────┐ │ L1:CDN / WAF(边缘) │ │ 限 IP 频次,挡爬虫/CC 攻击 │ │ 例:单 IP 每秒 ≤ 20 次 │ ├──────────────────────────────────────────────────────────┤ │ L2:API 网关(Nginx / APISIX / Kong) │ │ 限接口总量,保护整个集群 │ │ 例:/api/login 全集群 ≤ 500 QPS │ ├──────────────────────────────────────────────────────────┤ │ L3:应用层(Sentinel / Resilience4j) │ │ 限业务维度(用户、订单、商户) │ │ 例:单 userId 每分钟 ≤ 60 次 │ ├──────────────────────────────────────────────────────────┤ │ L4:资源层(连接池 / 线程池 / Redis) │ │ 保护最脆弱的资源 │ │ 例:DB 连接池最大 50 │ └──────────────────────────────────────────────────────────┘
原则:层层兜底,外层粗、内层细。 外层挡大流量,内层做精确保护。
|
5.2 网关限流 vs 应用限流
| 维度 |
网关限流 |
应用限流 |
| 位置 |
流量入口,应用前 |
应用内部 |
| 维度 |
IP、接口、总量 |
业务键(用户、订单) |
| 实现 |
Nginx lua / APISIX 插件 |
Sentinel / Guava |
| 优点 |
早拒绝、省应用资源 |
业务感知、精确 |
| 缺点 |
无法做业务限流 |
太靠后,资源已消耗 |
最佳实践:网关做总量保护(粗),应用做业务限流(细),双层配合。
六、常见限流框架
6.1 Guava RateLimiter(单机令牌桶)
import com.google.common.util.concurrent.RateLimiter;
RateLimiter limiter = RateLimiter.create(10);
public void handleRequest() { if (!limiter.tryAcquire()) { throw new RateLimitException("请求过于频繁"); } doBusiness(); }
|
特点:纯单机、令牌桶、基于「预消费」思想(下次请求补偿上次欠的时间)。轻量,适合单实例接口限流。
6.2 Sentinel(阿里,分布式限流主流)
FlowRule rule = new FlowRule(); rule.setResource("queryOrder"); rule.setCount(100); rule.setGrade(RuleConstant.FLOW_GRADE_QPS); rule.setLimitApp("default");
FlowRuleManager.loadRules(Collections.singletonList(rule));
try (Entry entry = SphU.entry("queryOrder")) { queryOrder(); } catch (BlockException e) { return fallback(); }
|
特点:
- 支持滑动窗口、令牌桶、匀速排队(漏桶思想)
- 集群限流(Token Server 集中分配)
- 与熔断、降级、系统自适应保护集成
- 带控制台 Dashboard,可动态调整规则、实时监控
- 国内 Java 生态最主流
6.3 Nginx 限流(网关层)
limit_req_zone $binary_remote_addr zone=ip_limit:10m rate=10r/s;
server { location /api/ { limit_req zone=ip_limit burst=20 nodelay; proxy_pass http://backend; } }
|
特点:基于漏桶(rate 严格匀速)+ burst 容忍突发。网关层第一道防线。
七、限流后的处理策略
限流后不只是「返回错误」,要根据场景选合适的策略:
| 策略 |
实现 |
适用场景 |
| 直接拒绝 |
返回 429 / 业务错误码 |
默认策略,大多数场景 |
| 排队等待 |
请求挂起,等令牌/配额 |
内部调用、可容忍延迟 |
| 降级 |
返回缓存/默认值 |
读多写少、有兜底数据 |
| 重试引导 |
返回 Retry-After 头 |
客户端可重试的场景 |
| 转入异步 |
写 MQ,异步处理 |
写操作、可容忍最终一致 |
public Result query(String userId) { if (!rateLimiter.tryAcquire(userId)) { return cacheService.getOrDefault(userId); } return realQuery(userId); }
|
关键原则:被限流的请求要给用户可接受的兜底,而不是空白或报错。
八、与熔断、降级、重试的协同(稳定性三板斧)
限流不是孤立存在的,它和熔断、降级、重试共同构成稳定性保障。
8.1 四者的职责分工
┌──────────────────────────────────────────────────────┐ │ 限流(Rate Limiting) │ │ 主动控制「进来多少」——保护自己/下游不被流量打垮 │ │ 触发条件:流量超阈值 │ ├──────────────────────────────────────────────────────┤ │ 熔断(Circuit Breaker) │ │ 被动响应「下游挂没挂」——下游故障时快速失败,不再调用 │ │ 触发条件:下游错误率/超时率超阈值 │ ├──────────────────────────────────────────────────────┤ │ 降级(Degradation) │ │ 「挂了/限流了之后给什么」——返回兜底数据 │ │ 触发条件:限流、熔断、异常时 │ ├──────────────────────────────────────────────────────┤ │ 重试(Retry) │ │ 「失败了再试一次」——应对瞬时故障 │ │ 触发条件:超时、网络抖动 │ └──────────────────────────────────────────────────────┘
|
8.2 协同关系
正常调用:请求 → 限流 → 调下游 → 返回 │ 下游故障? ↓ 熔断打开 → 快速失败 → 降级(返回兜底)
限流触发:请求 → 限流拒绝 → 降级(返回兜底/排队)
重试与限流的冲突: 上游重试 → 放大流量 → 触发限流 → 上游又重试 → 雪崩 解法:重试要有退避(backoff)+ 上限,不能无限重试
|
8.3 限流 vs 熔断(最易混淆)
| 维度 |
限流 |
熔断 |
| 主动/被动 |
主动(我决定放多少进来) |
被动(下游状态决定调不调) |
| 保护对象 |
保护自己/下游的容量 |
保护下游不再被请求压垮 |
| 触发依据 |
流量/QPS |
错误率/超时率/失败次数 |
| 触发后 |
部分请求拒绝 |
全部请求快速失败(一段时间) |
| 状态 |
无状态(每个请求独立判断) |
有状态(开/半开/关) |
一句话区分:限流是「我主动控制流量」,熔断是「下游病了我不再打扰它」。
九、常见问题
| 问题 |
速答要点 |
| 限流有哪些算法? |
固定窗口、滑动窗口、漏桶、令牌桶。前两个是计数,后两个是速率控制。 |
| 固定窗口有什么问题? |
临界突发:窗口边界前后各用满配额,实际通过量可达阈值 2 倍。 |
| 漏桶和令牌桶区别? |
漏桶输出恒定匀速(不允许突发);令牌桶允许短时突发(用攒的令牌)。保护下游用漏桶,接口限流用令牌桶。 |
| 生产中用哪个最多? |
接口限流:令牌桶(Guava RateLimiter、Sentinel);网关:漏桶(Nginx limit_req)。 |
| 单机限流有什么问题? |
集群部署时各实例独立计数,无法做总量限制,负载不均时实际处理量低于系统能力。 |
| 分布式限流怎么做? |
Redis 集中计数 + Lua 脚本保证原子。滑动窗口用 ZSET,令牌桶用计数+时间戳。 |
| 限流和熔断的区别? |
限流是主动控制进来的流量(看 QPS);熔断是被动响应下游故障(看错误率),熔断后全部快速失败。 |
| 限流维度怎么选? |
防外部攻击用 IP/设备;防用户滥用用 userId;保护下游用接口级总量。 |