限流详解

限流(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} # 原子自增
# 返回 1 说明是新窗口
EXPIRE rate_limit:{userId}:{window} 60 # 设置 60s 过期
# 判断返回值 > 阈值则拒绝

问题INCREXPIRE 是两条命令,中间可能中断。且无法实现复杂算法(滑动窗口、令牌桶)。

实现二:Redis + Lua 脚本(原子 + 复杂算法)

-- 滑动窗口限流的 Lua 实现(简化)
-- KEYS[1] = 限流 key
-- ARGV[1] = 当前时间戳
-- ARGV[2] = 窗口大小
-- ARGV[3] = 阈值

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;

// 创建令牌桶:每秒生成 10 个令牌(10 QPS)
RateLimiter limiter = RateLimiter.create(10);

public void handleRequest() {
// tryAcquire:尝试获取令牌,获取不到立即返回 false(不阻塞)
if (!limiter.tryAcquire()) {
throw new RateLimitException("请求过于频繁");
}
// 获取到令牌,处理请求
doBusiness();
}

特点:纯单机、令牌桶、基于「预消费」思想(下次请求补偿上次欠的时间)。轻量,适合单实例接口限流。

6.2 Sentinel(阿里,分布式限流主流)

// 定义限流规则
FlowRule rule = new FlowRule();
rule.setResource("queryOrder");
rule.setCount(100); // 阈值 100
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setLimitApp("default"); // 来源

// 滑动窗口(默认)+ 丰富的拒绝策略
FlowRuleManager.loadRules(Collections.singletonList(rule));

// 业务代码用 try-with-resources 包裹
try (Entry entry = SphU.entry("queryOrder")) {
// 受限流保护的业务逻辑
queryOrder();
} catch (BlockException e) {
// 被限流/降级
return fallback();
}

特点

  • 支持滑动窗口、令牌桶、匀速排队(漏桶思想)
  • 集群限流(Token Server 集中分配)
  • 与熔断、降级、系统自适应保护集成
  • 带控制台 Dashboard,可动态调整规则、实时监控
  • 国内 Java 生态最主流

6.3 Nginx 限流(网关层)

# 按 IP 限流:每秒 10 个请求
limit_req_zone $binary_remote_addr zone=ip_limit:10m rate=10r/s;

server {
location /api/ {
limit_req zone=ip_limit burst=20 nodelay;
# burst=20:允许突发 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;保护下游用接口级总量。