Skip to content

分布式限流设计:从令牌桶算法到全局流量治理

引子:一次"流量洪峰"的教训

2025 年 618 零点,某电商平台的优惠券系统遭遇了"幸福的烦恼"。

事故现象

  • 优惠券接口 QPS 从 5000 飙升至 80000
  • 数据库连接池瞬间耗尽
  • 优惠券被超发 300%
  • 下游库存系统被拖垮

事故原因

活动开始前,团队做了充分的性能测试和容量规划。但有一个致命盲点:没有做限流

开发者的想法很朴素:"我们的系统能扛 10 万 QPS,活动峰值预计 5 万,留了一倍余量,应该没问题。"

但现实给了他们一记重锤:

  1. 实际峰值达到 8 万 QPS(接近极限)
  2. 某个慢查询导致数据库响应变慢
  3. 请求堆积,连接池耗尽
  4. 雪崩效应扩散到整个订单链路

事后复盘

关键洞察:限流不是"限制用户",而是"保护系统"。没有限流的系统,就像没有刹车的跑车——跑得越快,死得越惨。

更讽刺的是,团队其实"有限流"——在 Nginx 层配置了 limit_req。但问题是:Nginx 限流是单机维度的

用户请求 → Nginx 集群 (10 台) → 应用集群 (20 台) → 数据库

每台 Nginx 独立计数,每台应用独立限流。全局来看,限流阈值被放大了 10 倍

这就是分布式限流的核心挑战:如何在分布式环境下,实现全局一致的流量控制?

今天,我们就来深度拆解分布式限流的原理、算法和实战方案。


一、限流的本质:你在保护什么?

1.1 限流的三个目标

大多数开发者认为限流就是"防止系统被压垮"。但这只是最表层的目标。

第一层:保护系统资源

这是最直观的目标。当请求量超过系统处理能力时,主动拒绝部分请求,避免:

  • CPU 满载
  • 内存溢出
  • 连接池耗尽
  • 磁盘 IO 打满

第二层:保证服务质量

限流的更深层目标是公平性。想象一个场景:

系统总容量:1000 QPS
用户 A:每秒发起 800 个请求
用户 B:每秒发起 50 个请求
用户 C:每秒发起 50 个请求
...

如果不限流,用户 A 会独占 80% 的资源,其他用户的服务质量严重下降。

通过单用户限流(如每用户 100 QPS),可以保证每个用户都能获得基本的服务保障。

第三层:控制业务风险

这是最容易被忽视的层面。以优惠券场景为例:

  • 业务规则:每人限领 1 张
  • 技术实现:先扣减库存,再记录领取记录

如果没有限流,黑产可以用脚本瞬间发起 10 万请求。即使有数据库唯一索引保护,超发风险依然存在(并发扣减库存的竞态条件)。

关键洞察:限流是业务风控的第一道防线。它不能替代业务逻辑校验,但可以大幅增加攻击成本。

1.2 限流 vs 降级 vs 熔断

这三个概念经常被混淆。让我们厘清它们的本质区别:

机制触发条件目标恢复方式
限流请求量 > 阈值控制输入流量自动(请求减少即恢复)
降级系统过载保证核心功能手动/自动
熔断错误率 > 阈值防止故障扩散自动(半开探测)

形象类比

  • 限流:高速公路收费站,控制入口车流量
  • 降级:飞机迫降,放弃非核心功能保证安全着陆
  • 熔断:电路保险丝,电流过大时自动断开

最佳实践:三者应该配合使用,形成完整的防护体系。

请求 → 限流 (入口控制) → 业务逻辑 → 熔断 (依赖保护) → 降级 (兜底策略) → 响应

二、限流算法:四种经典方案的深度对比

2.1 固定窗口计数器

原理

将时间划分为固定窗口(如 1 秒),每个窗口内独立计数。

时间轴:|---1s---|---1s---|---1s---|
计数:  |  100   |   50   |   80   |
阈值:  |  100   |  100   |  100   |
结果:  |  通过  |  通过  |  通过  |

实现(伪代码):

key = "rate_limit:" + user_id
window = current_timestamp / window_size
count = redis.get(key + window)

if count < threshold:
    redis.incr(key + window)
    redis.expire(key + window, window_size * 2)
    return PASS
else:
    return REJECT

优点

  • 实现简单,性能好
  • 天然支持分布式(Redis 原子操作)

致命缺陷窗口边界问题

窗口 N:    |----------|
窗口 N+1:           |----------|

请求在 0.9s 和 1.1s 各发起 100 个请求
窗口 N 计数:100(未超限)
窗口 N+1 计数:100(未超限)
但实际在 0.2s 时间窗口内,请求量 = 200

适用场景:对精度要求不高的场景,如 API 调用频率限制。

2.2 滑动窗口计数器

原理

将固定窗口细分为多个小格子,每个格子独立计数。滑动时,只保留最近的格子。

固定窗口:|--------1s--------|
滑动窗口:|_1_|_2_|_3_|_4_|_5_|  (每个格子 200ms)

当前计数 = 最近 5 个格子的总和

优点

  • 解决了窗口边界问题
  • 精度可配置(格子越多越精确)

缺点

  • 内存占用增加(需要存储多个格子)
  • 实现复杂度提高

适用场景:需要较高精度,但能接受一定内存开销的场景。

2.3 令牌桶算法(Token Bucket)

原理

系统以恒定速率向桶中添加令牌,请求需要消耗令牌才能通过。

令牌生成:每 10ms 添加 1 个令牌
桶容量:100 个令牌
请求:每个请求消耗 1 个令牌

桶满时,新令牌丢弃
桶空时,请求被拒绝或等待

关键特性

1. 允许突发流量

桶中有存量令牌时,可以瞬间处理大量请求。这对于"平时流量低,偶尔有峰值"的场景非常友好。

2. 平滑限流

令牌生成速率是恒定的,因此长期平均速率被严格控制。

实现(Redis + Lua):

lua
-- KEYS[1]: 桶 key
-- ARGV[1]: 桶容量
-- ARGV[2]: 令牌生成速率 (个/秒)
-- ARGV[3]: 当前时间戳 (毫秒)
-- ARGV[4]: 请求消耗令牌数

local bucket = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

-- 获取当前桶状态
local data = redis.call('HMGET', bucket, 'tokens', 'last_time')
local tokens = tonumber(data[1]) or capacity
local last_time = tonumber(data[2]) or now

-- 计算新增令牌
local elapsed = (now - last_time) / 1000
local new_tokens = elapsed * rate
tokens = math.min(tokens + new_tokens, capacity)

-- 判断是否足够
if tokens >= requested then
    tokens = tokens - requested
    redis.call('HMSET', bucket, 'tokens', tokens, 'last_time', now)
    redis.call('EXPIRE', bucket, 3600)
    return 1  -- 通过
else
    return 0  -- 拒绝
end

优点

  • 允许合理突发
  • 长期速率严格控制
  • 实现成熟,广泛使用

缺点

  • 无法限制"瞬时并发"(桶中有令牌时,100 个请求可以同时通过)

适用场景:绝大多数 API 限流场景,尤其是需要允许突发的场景。

2.4 漏桶算法(Leaky Bucket)

原理

请求像水一样注入桶中,桶以恒定速率漏水(处理请求)。桶满时,新请求被拒绝。

注入:任意速率(可能突发)
漏出:恒定速率(如 100 QPS)
桶满:新请求溢出(拒绝)

与令牌桶的关键区别

特性令牌桶漏桶
突发处理允许(消耗存量令牌)不允许(强制平滑)
输出速率可能突发严格恒定
适用场景API 限流流量整形

形象类比

  • 令牌桶:水库放水——平时蓄水,需要时可以开闸泄洪
  • 漏桶:水龙头滴水——无论进水多快,出水速率恒定

适用场景:需要严格平滑输出的场景,如数据库写入限流、消息队列消费限流。


三、分布式限流:从单机到全局的演进

3.1 单机限流的局限

在单体应用时代,限流很简单:

java
// Guava RateLimiter (单机)
RateLimiter limiter = RateLimiter.create(100.0); // 100 QPS

public void handleRequest() {
    limiter.acquire(); // 获取令牌,可能阻塞
    // 处理请求
}

但进入微服务时代后,问题出现了:

用户 → Nginx(10 台) → 应用 (50 台) → 数据库

单机限流:每台应用 100 QPS
全局限流:10 × 50 × 100 = 50,000 QPS(远超预期)

解决方案:需要全局统一计数

3.2 Redis 集中式限流

架构

应用集群 → Redis 集群(共享计数)→ 限流决策

核心思路

将限流状态(令牌桶计数、窗口计数等)存储在 Redis 中,所有应用实例共享同一份数据。

优势

  • 实现简单
  • 全局一致
  • Redis 性能足够(单实例 10 万+ QPS)

挑战

1. Redis 成为瓶颈

限流是高频操作(每个请求都要访问 Redis)。如果限流阈值是 10 万 QPS,Redis 本身可能成为瓶颈。

解决方案

  • Redis 集群分片
  • 本地缓存 + 异步同步(牺牲一定精度)
  • 分层限流(Nginx 层粗粒度 + 应用层细粒度)

2. 网络延迟影响

每次限流决策都需要一次 Redis 网络调用(~1ms)。对于低延迟要求的系统,这是不可接受的。

解决方案

  • 本地预取令牌(批量获取,本地消耗)
  • 异步限流(先放行,后审计,超标再惩罚)

3. Redis 故障

Redis 挂了怎么办?

最佳实践

  • Fail-open:Redis 不可用时,暂时放通(避免影响业务)
  • 本地降级:切换到单机限流模式(阈值降低)
  • 多活部署:Redis 集群跨机房部署

3.3 生产级方案:分层限流架构

经过多个项目的实践,我总结了一套分层限流架构:

┌─────────────────────────────────────────────────┐
│  L1: Nginx 层 (粗粒度,保护基础设施)              │
│  - IP 维度限流:单 IP 1000 QPS                    │
│  - 全局限流:100 万 QPS                          │
│  - 实现:Nginx limit_req + Lua                   │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│  L2: 网关层 (中粒度,业务入口控制)                │
│  - 用户维度限流:单用户 100 QPS                  │
│  - API 维度限流:单接口 10000 QPS                │
│  - 实现:Redis + Lua (令牌桶)                    │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│  L3: 应用层 (细粒度,核心资源保护)                │
│  - 本地限流:单机 500 QPS                        │
│  - 关键操作限流:下单 10 QPS/用户                │
│  - 实现:Guava RateLimiter + Redis 兜底          │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│  L4: 资源层 (最后防线)                           │
│  - 数据库连接池限流                              │
│  - 线程池限流                                    │
│  - 实现:框架内置 (HikariCP, ThreadPoolExecutor) │
└─────────────────────────────────────────────────┘

设计原则

  1. 越上层越粗糙:Nginx 层只做 IP 和全局限流,不处理复杂业务逻辑
  2. 越下层越精细:应用层可以做用户级、操作级的细粒度控制
  3. 快速失败:上层限流应该快速拒绝,避免请求穿透到下层
  4. 冗余保护:每层都应该有独立的限流逻辑,不依赖其他层

四、实战经验:生产环境踩过的坑

4.1 坑一:Redis Lua 脚本的原子性陷阱

问题

最初实现的令牌桶算法:

lua
-- 错误示范:非原子操作
local tokens = redis.call('GET', bucket)
if tokens >= 1 then
    redis.call('DECR', bucket)
    return 1
else
    return 0
end

在高并发场景下,出现了超卖问题(令牌被多扣)。

原因

GETDECR 是两个独立命令,中间存在时间窗口。两个请求可能同时读到 tokens=1,然后都执行 DECR,导致令牌变成负数。

解决

使用 Redis 的原子操作Lua 脚本(Lua 脚本在 Redis 中是原子执行的):

lua
-- 正确示范:使用 HMGET + HMSET 原子更新
local data = redis.call('HMGET', bucket, 'tokens', 'last_time')
-- ... 计算 ...
redis.call('HMSET', bucket, 'tokens', tokens, 'last_time', now)

教训

分布式系统中,任何"读取 - 判断 - 写入"的操作序列,都必须保证原子性。要么用 Lua 脚本,要么用分布式锁。

4.2 坑二:限流阈值的"拍脑袋"设置

问题

新系统上线,限流阈值怎么设?

  • 开发说:"压测能扛 5000 QPS"
  • 运维说:"那就设 5000"
  • 上线后:实际流量 3000 QPS 就崩了

原因

压测环境和生产环境的差异:

  • 数据量不同(压测 1 万条 vs 生产 1 亿条)
  • 网络拓扑不同(压测内网 vs 生产跨机房)
  • 并发模式不同(压测恒定 vs 生产脉冲)

解决

渐进式限流策略:

第 1 周:阈值 = 压测值 × 20%  (观察系统表现)
第 2 周:阈值 = 压测值 × 40%  (逐步放量)
第 3 周:阈值 = 压测值 × 60%  (持续观察)
...
第 N 周:阈值 = 压测值 × 100% (稳定运行)

同时配合自适应限流

监控指标:CPU 使用率、P99 延迟、错误率

if CPU > 80% or P99 > 1s:
    阈值 = 阈值 × 0.8  (自动降级)
elif CPU < 50% and P99 < 200ms:
    阈值 = 阈值 × 1.1  (缓慢提升)

教训

限流阈值不是"设完就忘"的配置,而是需要持续观察和动态调整的"活参数"。

4.3 坑三:限流后的用户体验

问题

系统限流后,直接返回 HTTP 429(Too Many Requests)。

用户反馈:"页面显示'请求太频繁',但没有任何引导,不知道该怎么办。"

解决

友好的限流响应

json
{
  "code": 429,
  "message": "请求过于频繁,请稍后再试",
  "retry_after": 5,  // 建议重试时间(秒)
  "limit": 100,      // 限流阈值
  "remaining": 0,    // 剩余配额
  "reset": 1711584000 // 配额重置时间戳
}

前端配合

  • 显示友好的提示信息(而非技术错误码)
  • 自动重试(带退避策略)
  • 降级展示(如"数据加载中..."而非空白页)

教训

限流不是"拒绝用户",而是"引导用户合理使用"。好的限流体验,用户甚至感知不到限流的存在。

4.4 坑四:分布式限流的一致性难题

问题

Redis 集群场景下,主从切换导致限流计数丢失。

主节点:tokens = 50
从节点:tokens = 50 (异步复制,延迟 100ms)

主节点故障,从节点提升为主
新请求到达:读到 tokens = 50(实际应该更少)
结果:令牌被多发,限流失效

解决

方案一:Redis 持久化 + 强一致性

  • 开启 AOF 持久化(每条命令都落盘)
  • 配置 min-slaves-to-write(至少 N 个从节点确认才写入)
  • 代价:性能下降 20-30%

方案二:容忍一定不精确

  • 接受主从切换时的计数偏差
  • 通过"阈值预留"来缓冲(如实际阈值 100,配置 80)
  • 代价:限流精度下降

方案三:本地限流兜底

  • Redis 不可用时,自动切换到本地限流
  • 本地阈值更保守(如全局 1000 QPS,单机 50 QPS)
  • 代价:分布式一致性丧失

我的选择

对于大多数业务场景,方案二 + 方案三组合是最佳平衡:

  • 正常情况:Redis 全局限流(精确)
  • 异常情况:本地限流兜底(保守但安全)
  • 阈值预留:配置值 = 实际阈值 × 80%(缓冲空间)

五、总结与思考

5.1 限流设计的核心原则

回顾全文,我总结了分布式限流设计的五个核心原则:

1. 保护优先

限流的首要目标是保护系统,而不是限制用户。当限流触发时,系统应该:

  • 快速失败(避免请求堆积)
  • 友好提示(引导用户合理重试)
  • 记录日志(便于后续分析和优化)

2. 分层防御

不要依赖单一层级的限流。从 Nginx 到网关到应用到资源层,每一层都应该有独立的限流逻辑。这样即使某一层失效,其他层仍能提供保护。

3. 动态调整

限流阈值不是一成不变的。应该根据系统负载、业务周期、历史数据等因素动态调整。自适应限流是未来的方向。

4. 精度与性能的平衡

分布式限流本质上是在"一致性"和"性能"之间做权衡:

  • 强一致性:Redis 集中计数,但性能受限
  • 高性能:本地限流,但精度下降

没有银弹,只有适合场景的选择。

5. 可观测性

限流系统本身必须可观测:

  • 限流触发次数
  • 限流维度分布(哪些用户/接口被限)
  • 限流后的系统表现(是否达到保护效果)

没有监控的限流,就像没有仪表盘的飞机——你不知道什么时候会坠毁。

5.2 未来的方向

分布式限流技术仍在演进,以下几个方向值得关注:

1. AI 驱动的自适应限流

利用机器学习模型,根据历史流量模式、系统负载、业务周期等因素,自动预测和调整限流阈值。

2. 服务网格集成

Istio 等服务网格已经内置了限流能力。未来,限流可能成为服务网格的标准功能,应用层无需关心。

3. 全局流量调度

限流不仅是"拒绝",更是"调度"。将超限流量引导到备用集群、边缘节点或其他可用区,实现全局负载均衡。


结语

限流是分布式系统设计的必修课。它看似简单(不就是计数吗?),实则蕴含着深刻的系统设计思想。

希望这篇文章能帮助你:

  • 理解限流的本质(保护而非限制)
  • 掌握经典算法(令牌桶、漏桶、滑动窗口)
  • 避开生产陷阱(原子性、阈值设置、用户体验)

最后,送给大家一句话:

好的限流系统,用户感知不到它的存在;但当它失效时,所有人都能感受到。


参考资料

  • Google SRE Handbook: Handling Overload
  • Netflix: Hystrix Dashboard
  • Redis: Lua Scripting Documentation
  • 《分布式系统原理与范型》