限流的本质:从令牌桶到分布式流控的架构思考
为什么你的系统需要限流
先看两个真实事故。
事故一:短信轰炸。 电商大促,运营要向 200 万用户推送促销短信。开发对接了短信服务商 API,写了批量发送任务就上线。活动当天,200 万条请求几乎同时涌向服务商。服务商 API 上限是 400 QPS。没有任何限流措施,前几秒就把接口打崩,后续请求全部超时或静默丢弃。几个小时后才发现,超过一半的短信根本没送达。
事故二:风控反噬。 某大型互联网公司风控系统,平时运行稳定。双十一流量瞬间飙到日常 10 倍,风控依赖的下游评分服务没做流量保护,直接崩溃。连锁反应:所有经过风控的交易请求因调用超时被拦截——包括完全正常的用户交易。最终损失不是来自欺诈,而是自己的系统把正常用户挡在了门外。
两个事故揭示同一个本质:限流不是为了"限制",而是为了"保护"。
在高并发系统设计中,缓存、降级和限流被称为"三大利器":
| 手段 | 解决的问题 | 核心机制 | 局限性 |
|---|---|---|---|
| 缓存 | 提速 | 将高频数据放入更快的存储层 | 对写操作无能为力 |
| 降级 | 止损 | 放弃非核心功能保核心链路 | 前提是有东西可降,秒杀场景无法降级 |
| 限流 | 控流 | 主动丢弃/延迟超量请求 | 需要准确的容量评估,否则误杀或漏放 |
三者各有分工,但限流的不可替代性在于:当稀缺资源被争抢、写操作高并发、昂贵查询集中调用时,缓存和降级都帮不了你。
四种限流算法:原理、适用场景与工程取舍
漏桶算法(Leaky Bucket)
核心原理
漏桶的逻辑可以用一句话概括:无论流入多快,流出永远恒定。
请求流入 → [ 桶(有容量上限) ] → 恒定速率流出 → 下游处理
↓
桶满则丢弃
- 请求以任意速率流入桶中
- 桶底以固定速率流出(处理请求)
- 桶有容量上限,溢出的请求被直接丢弃
核心参数
| 参数 | 含义 | 设计考量 |
|---|---|---|
| 流出速率 | 下游能承受的恒定处理能力 | 取决于下游系统的稳态吞吐上限 |
| 桶容量 | 允许暂存的最大请求数 | 过大导致延迟积累,过小导致突发流量全被丢弃 |
适用场景
- 对接物理设备或硬件接口(严格不允许任何突发)
- 需要绝对平滑的输出流量(如音视频流的恒定码率传输)
- 流量整形(traffic shaping)场景
不适用场景
- 互联网业务的 API 限流(真实流量天然是突发的,漏桶的死板会浪费系统空闲容量)
- 需要快速响应突发请求的场景
工程实践:Nginx 的 limit_req 就是漏桶实现
# 定义限流区域:10MB 共享内存,每个 IP 每秒 10 个请求
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
# burst=20:桶容量为 20,超出的排队
# nodelay:排队请求不延迟,立即处理(占用 burst 配额)
limit_req zone=api burst=20 nodelay;
# 超限返回 429 而非默认的 503
limit_req_status 429;
}
}
这里有个常见误区:burst=20 nodelay 不是"允许突发 20 个请求"那么简单。nodelay 的含义是突发请求立即转发(不排队等待),但每个突发请求会"占用"一个 burst 槽位,槽位按 rate 的速率恢复。实际效果是:瞬间可以通过 30 个请求(rate + burst),但之后必须等槽位恢复。
令牌桶算法(Token Bucket)
核心原理
令牌桶的理念与漏桶相反:在空闲时积蓄能力,在繁忙时释放能力。
令牌生成器 ──恒定速率──→ [ 令牌桶(有容量上限) ]
↓
请求到达 → 取令牌 → 有令牌则通过
→ 无令牌则拒绝/等待
- 系统以恒定速率向桶中放入令牌
- 每个请求消耗一个(或多个)令牌
- 令牌充足时请求立即通过
- 令牌耗尽时请求被拒绝或阻塞等待
- 桶有容量上限,多余令牌溢出
核心参数
| 参数 | 含义 | 设计考量 |
|---|---|---|
| 令牌生成速率 | 系统的持续处理能力 | 对应系统稳态吞吐上限 |
| 桶容量 | 允许的最大突发量 | 编码了对突发流量的容忍度 |
适用场景
- 互联网 API 限流(绝大多数场景的首选)
- 允许合理突发的业务场景(秒杀、热点事件引发的流量脉冲)
- 需要区分长期速率和瞬时峰值的场景
工程实践:Guava RateLimiter 的两种模式
Guava 提供了两种令牌桶实现,对应两种不同的业务需求:
// 模式一:SmoothBursty —— 允许突发
// 以每秒 100 个令牌的速率生成,桶容量等于 1 秒的产量(100)
RateLimiter limiter = RateLimiter.create(100.0);
// 场景:API 网关限流
// 特点:空闲期积累的令牌可以一次性消费,应对突发
if (limiter.tryAcquire()) {
processRequest();
} else {
return Response.status(429).build();
}
// 模式二:SmoothWarmingUp —— 冷启动预热
// 速率 100/s,预热期 3 秒
RateLimiter limiter = RateLimiter.create(100.0, 3, TimeUnit.SECONDS);
// 场景:数据库连接池、缓存冷启动
// 特点:系统刚启动时不会全速放量,给下游一个"热身"时间
// 预热期内速率从低到高线性增长,避免冷系统被瞬时流量打垮
SmoothBursty vs SmoothWarmingUp 的选择
| 维度 | SmoothBursty | SmoothWarmingUp |
|---|---|---|
| 突发处理 | 允许消费积累的令牌,支持突发 | 冷启动期间限制突发 |
| 典型场景 | API 限流、消息推送 | 数据库预热、缓存预热 |
| 核心关注 | 流量的峰谷平衡 | 系统的冷热状态转换 |
关键注意:Guava RateLimiter 是单机限流。它只能控制当前 JVM 进程的流量,在分布式环境下需要配合 Redis 方案使用。
固定窗口计数器(Fixed Window Counter)
核心原理
在一个固定时间窗口内维护计数器,超过阈值就拒绝,窗口结束时归零。
|← 窗口1 (0-1s) →|← 窗口2 (1-2s) →|
count=0→100 count=0→...
阈值=100 阈值=100
经典问题:窗口边界的 2 倍峰值
|← 窗口1 →|← 窗口2 →|
↑
最后100ms涌入100个 最前100ms涌入100个
→ 200ms 内实际通过了 200 个请求(2 倍于阈值)
适用场景
- 精度要求不高的简单限流(大部分业务场景)
- 需要快速实现的场景
- 阈值本身留有足够余量(2 倍偶发峰值可承受)
工程判断:在很多场景中,固定窗口的精度已经足够。边界处偶尔的 2 倍峰值,对于留有余量的系统来说不是问题。不要为理论上的完美过度工程化。
滑动窗口计数器(Sliding Window)
核心原理
将时间窗口划分为更细的子窗口(slot),统计时基于当前时间点向前滑动统计。
子窗口: |s1|s2|s3|s4|s5|s6|s7|s8|s9|s10|
当前统计范围: |←————————————→|
与固定窗口的对比
| 维度 | 固定窗口 | 滑动窗口 |
|---|---|---|
| 精度 | 存在边界 2 倍峰值 | 消除边界效应 |
| 实现复杂度 | 一个计数器 | N 个子窗口计数器 |
| 存储开销 | O(1) | O(N),N 为子窗口数 |
| 适用场景 | 精度要求低、快速实现 | 精度要求高、阈值接近系统极限 |
工程实践:Sentinel 的滑动窗口实现
阿里巴巴的 Sentinel 框架使用 LeapArray 数据结构实现滑动窗口:
- 将 1 秒划分为若干个
WindowWrap(默认 2 个,即 500ms 一个子窗口) - 每个子窗口维护独立的 pass/block/exception 等计数器
- 通过环形数组 + 时间戳判断实现窗口滑动,避免频繁创建销毁对象
四种算法对比总结
| 算法 | 核心特征 | 突发处理 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|
| 漏桶 | 恒定输出 | 不允许突发 | 低 | 流量整形、硬件接口 |
| 令牌桶 | 弹性输出 | 允许有限突发 | 中 | API 限流(首选) |
| 固定窗口 | 简单计数 | 边界可能 2 倍峰值 | 最低 | 快速实现、精度要求低 |
| 滑动窗口 | 精确计数 | 平滑 | 高 | 精度要求高、阈值紧 |
选择策略:如果没有特殊需求,令牌桶是互联网业务的默认选择。如果需要极致简单,用固定窗口。如果下游绝对不能承受波动,用漏桶。如果阈值非常接近系统极限,用滑动窗口。
从单机到分布式:最关键的认知跃迁
单机限流为什么在集群中失效
一个团队用 Guava RateLimiter 限制短信 API 调用为 400 QPS,本地测试完美。代码部署到 4 个节点后,4 个节点各自以 400 QPS 发送,服务商实际承受 1600 QPS,接口再次崩溃。
根因:单机限流只能控制单个进程的流量,对其他节点一无所知。
直觉的修复是均分配额:4 个节点各分 100 QPS。但这引入新问题:
理想中:
节点A: 100 QPS → 25%
节点B: 100 QPS → 25%
节点C: 100 QPS → 25%
节点D: 100 QPS → 25%
现实中(负载不均):
节点A: 240 QPS → 只放行 100,拒绝 140 ✗
节点B: 120 QPS → 只放行 100,拒绝 20 ✗
节点C: 30 QPS → 只用了 30,浪费 70
节点D: 10 QPS → 只用了 10,浪费 90
总放行:240 QPS(理论可放 400,实际只放了 240)
→ 系统实际吞吐远低于理论上限
动态调整配额(根据节点负载实时重新分配)?复杂度爆炸——你需要协调机制感知节点上下线、收集实时负载、计算下发配额,这本身就是一个分布式系统问题。
标准答案:将限流状态提升到共享的集中存储中。
分布式限流的核心原则
限流的粒度决定了它的准确性。
| 保护对象 | 限流粒度 | 方案 |
|---|---|---|
| 本机 CPU/内存 | 进程级 | Guava RateLimiter、Sentinel |
| 外部 API 配额 | 系统级(全集群) | Redis 分布式计数器 |
| 业务规则(如用户发送频率) | 用户级 | Redis + 用户维度 key |
Redis 分布式限流:为什么是标准答案
Redis 之所以成为分布式限流的事实标准,是因为它的特性精确匹配了限流的每一个核心需求:
| 限流需求 | Redis 特性 | 为什么匹配 |
|---|---|---|
| 原子性:"读取-判断-递增"必须原子 | INCR 原子命令 + Lua 脚本 | 单线程模型,天然无并发冲突 |
| 极致性能:每个请求都要过限流 | 内存操作,亚毫秒级延迟 | 不成为业务瓶颈 |
| 共享状态:所有节点看到同一个计数器 | 独立服务,集群可访问 | 分布式协调问题消失 |
| 自动过期:时间窗口结束后计数器清零 | Key 级别 TTL | 无需额外清理逻辑 |
工程实践:基于 Redis + Lua 的固定窗口限流
为什么必须用 Lua 脚本?
不用 Lua 的伪代码:
count = redis.GET(key) -- 步骤1:读取
if count < threshold: -- 步骤2:判断
redis.INCR(key) -- 步骤3:递增
return ALLOW
else:
return REJECT
并发问题:两个节点同时读到 count=399(阈值 400),都判断"未超限",都执行 INCR。最终 count=401,但两个请求都通过了。高并发下,这种竞态条件被急剧放大,限流形同虚设。
Lua 脚本实现(原子操作)
-- KEYS[1]: 限流 key,如 "rate_limit:sms_api:1609459200"
-- ARGV[1]: 阈值
-- ARGV[2]: 窗口过期时间(秒)
local key = KEYS[1]
local threshold = tonumber(ARGV[1])
local expire_time = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or "0")
if current + 1 > threshold then
return 0 -- 拒绝
else
redis.call('INCR', key)
if current == 0 then
redis.call('EXPIRE', key, expire_time)
end
return 1 -- 放行
end
Key 设计规范
格式:rate_limit:{业务标识}:{维度}:{时间窗口}
示例:
rate_limit:sms_api:global:1609459200 -- 全局短信 API 限流
rate_limit:login:user:12345:1609459200 -- 用户维度登录限流
rate_limit:order:tenant:abc:1609459200 -- 租户维度下单限流
工程实践:基于 Redis 的滑动窗口限流
当固定窗口的边界问题不可接受时,可以用 Redis Sorted Set 实现滑动窗口:
-- KEYS[1]: 限流 key
-- ARGV[1]: 阈值
-- ARGV[2]: 窗口大小(毫秒)
-- ARGV[3]: 当前时间戳(毫秒)
-- ARGV[4]: 唯一请求ID
local key = KEYS[1]
local threshold = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local request_id = ARGV[4]
-- 移除窗口外的过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < threshold then
-- 添加当前请求,score 为时间戳
redis.call('ZADD', key, now, request_id)
redis.call('PEXPIRE', key, window)
return 1 -- 放行
else
return 0 -- 拒绝
end
两种 Redis 方案的对比
| 维度 | 固定窗口(String + INCR) | 滑动窗口(Sorted Set) |
|---|---|---|
| 存储开销 | O(1),一个 key 一个计数器 | O(N),N 为窗口内请求数 |
| 时间复杂度 | O(1) | O(log N) |
| 精度 | 边界可能 2 倍峰值 | 精确 |
| 适用 | 大部分场景 | 阈值紧、精度要求高 |
工程建议:优先用固定窗口方案。只有当阈值非常接近系统极限(余量 < 20%)时,才需要滑动窗口的精度。
关于时钟同步
分布式系统中,各节点用本地时间计算 Redis key 中的时间窗口标识,时钟偏移可能导致不同节点在不同窗口中计数。严格做法是用 Redis 服务端时间 redis.call('TIME')。但现代服务器通过 NTP 同步后的时钟偏差通常在毫秒级,对秒级窗口几乎无影响。
工程判断:对于秒级窗口,使用本地时间戳即可。对于百毫秒级窗口或对精度有极端要求的场景,使用 Redis 服务端时间。
多层限流:纵深防御架构
一个常见误区是试图在某一层解决所有限流问题。良好的限流架构应该是分层的——每一层保护不同的东西,承担不同的职责。
请求流入
↓
┌──────────────────────────────────────────┐
│ 第一层:接入层(Nginx / CDN) │ ← 挡住恶意流量和 DDoS
│ 基于 IP 的连接数和请求速率限制 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 第二层:API 网关(Gateway) │ ← 业务感知型限流
│ 基于用户/租户/API 维度的差异化限流 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 第三层:业务层 │ ← 业务规则型限流
│ 业务语义的频率控制(发帖/下单/发短信) │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 第四层:数据层 │ ← 最后一道防线
│ 连接池 / 线程池隔离 / 熔断器 │
└──────────────────────────────────────────┘
各层详细对比
| 层级 | 保护对象 | 限流维度 | 典型工具 | 算法 |
|---|---|---|---|---|
| 接入层 | 基础设施 | IP、连接数 | Nginx limit_req/limit_conn |
漏桶 |
| API 网关 | 服务处理能力 | 用户 ID、API Key、租户 | Redis + Lua、Sentinel | 令牌桶/滑动窗口 |
| 业务层 | 业务规则 | 业务实体(用户行为频率) | Redis + 业务代码 | 固定窗口 |
| 数据层 | 存储和依赖 | 并发连接数 | 连接池、Hystrix、Resilience4j | 信号量/熔断 |
各层工程实践
接入层:Nginx 配置示例
http {
# IP 维度的请求速率限制
limit_req_zone $binary_remote_addr zone=ip_rate:10m rate=100r/s;
# IP 维度的并发连接数限制
limit_conn_zone $binary_remote_addr zone=ip_conn:10m;
server {
# API 接口:每 IP 100r/s,突发 50
location /api/ {
limit_req zone=ip_rate burst=50 nodelay;
limit_conn ip_conn 50;
limit_req_status 429;
}
# 登录接口:更严格的限制
location /api/login {
limit_req zone=ip_rate burst=5;
limit_req_status 429;
}
}
}
API 网关层:差异化限流
// 不同级别用户的限流配置
public class RateLimitConfig {
// 免费用户:60 次/分钟
// 付费用户:600 次/分钟
// 企业用户:6000 次/分钟
public int getThreshold(User user) {
return switch (user.getTier()) {
case FREE -> 60;
case PREMIUM -> 600;
case ENTERPRISE -> 6000;
};
}
// 不同 API 端点的限流配置
// 重查询接口:50 QPS
// 轻量读接口:5000 QPS
// 写操作接口:200 QPS
public int getThreshold(String endpoint) {
return switch (endpoint) {
case "/api/report/generate" -> 50; // 计算密集
case "/api/user/info" -> 5000; // 轻量读
case "/api/order/create" -> 200; // 写操作
default -> 1000;
};
}
}
业务层:业务规则型限流
// 业务限流的阈值来自产品需求,不是压测
public class BusinessRateLimiter {
// 防骚扰:每用户每分钟最多 5 条短信
public boolean allowSendSms(long userId) {
String key = "biz:sms:" + userId + ":" + currentMinute();
return redisRateLimiter.tryAcquire(key, 5, 60);
}
// 反垃圾:新账号 24 小时内最多发 10 条帖子
public boolean allowPost(long userId, boolean isNewAccount) {
if (!isNewAccount) return true;
String key = "biz:post:new:" + userId + ":" + today();
return redisRateLimiter.tryAcquire(key, 10, 86400);
}
// 运营策略:商家每天最多创建 100 个促销活动
public boolean allowCreatePromotion(long merchantId) {
String key = "biz:promo:" + merchantId + ":" + today();
return redisRateLimiter.tryAcquire(key, 100, 86400);
}
}
数据层:隐式限流
数据层的"限流"通常不以限流的名义出现,但本质上发挥着同样的作用:
- 连接池:连接池满时新请求排队等待 → 并发度上限
- 线程池隔离:为每个下游依赖分配独立线程池 → 故障隔离
- 熔断器:错误率超阈值时直接停止调用 → 自适应限流
每一层保护不同的东西。 接入层保护基础设施不被滥用流量冲垮;API 网关保护服务处理能力不被超载;业务层保护业务规则不被绕过;数据层保护最脆弱的存储和依赖。
限流之后:被拒绝的请求去哪了
大多数限流讨论都集中在"如何拒绝",很少有人思考"拒绝之后怎么办"。而在真实业务中,后者往往更重要。
| 策略 | 做法 | 适用场景 | 风险 |
|---|---|---|---|
| 直接拒绝 | 返回 429 + Retry-After | 开放 API、程序化调用方 | 用户体验差 |
| 排队等待 | 写入 MQ,消费者限速消费 | 异步操作(短信、邮件、报表) | 队列积压导致延迟不可控 |
| 降级响应 | 返回缓存/兜底数据 | 推荐、搜索、详情页非核心模块 | 数据时效性降低 |
| 引流分担 | 导向备用路径(CDN/只读副本) | 读多写少的场景 | 需要备用链路的维护成本 |
关键原则:限流策略和拒绝策略必须配套设计。
回到短信发送事故:被限流的短信不能直接丢弃,必须进入重试队列。秒杀请求被限流?直接告知"已售罄"比让用户苦等体验更好。商品详情页被限流?返回缓存数据即可,用户感知的是"数据没那么新"而不是"服务挂了"。
只设计了限流而没考虑拒绝后的处理,就像只安装了闸门却没修泄洪渠——水是拦住了,但迟早会溃坝。
阈值从哪来:限流的度量方法论
所有限流工程中最难的问题不是技术实现,而是:阈值应该设多少?
四步确定阈值
| 步骤 | 方法 | 产出 |
|---|---|---|
| 1. 压测基线 | 逐步加压,观察 P99 延迟和错误率的拐点 | 系统实际容量边界 |
| 2. 安全系数 | 阈值 = 容量边界 × 70%~80% | 留出余量应对突发波动 |
| 3. 持续监控 | 监控 P99、错误率、CPU、内存 | 发现容量变化及时调整 |
| 4. 渐进调整 | 从保守值开始,观察线上表现后逐步放宽 | 避免上线即翻车 |
自适应限流
更高级的形态是基于实时指标的自动限流。以 Sentinel 为例:
// 基于系统负载的自适应限流
SystemRule rule = new SystemRule();
rule.setHighestCpuUsage(0.8); // CPU > 80% 时触发限流
rule.setHighestSystemLoad(2.5); // System Load > 2.5 时触发限流
rule.setAvgRt(200); // 平均 RT > 200ms 时触发限流
// 优点:省去人为猜测阈值
// 风险:正常流量波动可能触发误限,需仔细调试灵敏度
阈值是业务决策
限流阈值不是纯技术参数,而是一个业务决策。
它编码的是"我们愿意承受多大负载,以及拒绝超额流量的业务成本是什么"。
- 面向消费者的核心交易链路:拒绝一个请求 = 损失一笔订单 → 阈值宜宽
- 内部数据分析任务:晚执行几分钟无损失 → 阈值可严
- 计算密集的报表接口:单个请求消耗大量资源 → 阈值必须严
阈值设定必须综合技术容量和业务容忍度,需要工程团队和产品团队协同决策。
总结:限流是一种系统思维
限流从表面看是算法选择题,但真正落地到生产环境时,它是一个系统设计问题:
| 维度 | 核心问题 |
|---|---|
| 容量 | 系统到底能承受多少?需要压测和监控,不是拍脑袋 |
| 优先级 | 必须拒绝时,拒绝谁?VIP vs 普通、核心 vs 边缘、写 vs 读 |
| 失败模式 | 限流触发后怎么办?报错、排队、降级还是引流 |
| 权衡 | 平滑性 vs 响应性、精确性 vs 性能、简单性 vs 灵活性 |
最好的限流系统是你感觉不到它存在的系统。流量平稳时安静旁观,突增时默默吸收合理突发,真正超限时优雅拒绝——确保已接受的请求仍能正常处理。它不是一堵墙,而是一个阀门:精确控制流量进出,让系统在极端压力下保持可控、可预测、可依赖。
限流的本质,是对系统能力边界的敬畏,以及在边界之内追求最大价值的工程智慧。