跳转至

经典案例:高并发分布式限流器 (Rate Limiter)

限流器(Rate Limiter)是微服务架构与开放平台(如 Twitter/GitHub API、微信支付网关)中极其核心的系统自我保护设施。它通过对请求速率进行主动限制,防止恶意攻击、瞬时突发流量以及雪崩效应击垮脆弱的后端服务。


1. 为什么需要限流器?

graph LR
    Spike["瞬时突发流量 / 爬虫刷单 / 恶意 DoS"] --> Gate["API 网关限流器"]
    Gate -- "超出阈值 (拦截降级)" --> R429["返回 HTTP 429 Too Many Requests"]
    Gate -- "合规流量 (平滑放行)" --> Services["核心业务集群 & 数据库 (平稳运行)"]
  1. 防范拒绝服务(DoS)与爬虫滥用:防止自动化爬虫或恶意调用耗尽系统线程与网络连接;
  2. 保护脆弱依赖与下游资源:防止底层数据库或第三方外部接口(如短信发送、银行网关)因瞬间高并发请求超载而崩溃;
  3. 成本控制与付费阶梯:按调用量计费的 SaaS 服务(如 OpenAI、Stripe)根据用户付费等级提供不同的调用速率上限;
  4. 流量整形(Traffic Shaping):将不可控的脉冲式突发流量平滑为均匀请求流。

2. 五大主流限流算法深度剖析

2.1 固定窗口计数器 (Fixed Window Counter)

  • 原理:将时间划分为固定的时间窗口(例如每 1 分钟)。为每个窗口维护一个计数器,请求到达时计数器 \(+1\);若计数器超过阈值则拒绝请求;窗口结束时计数器清零。
  • 致命缺陷:临界窗口突发(Boundary Spike Problem):
       窗口 1 (00:00 - 01:00)           窗口 2 (01:00 - 02:00)
    +---------------------------+   +---------------------------+
    |                           |   |                           |
    |                最后 1 秒来 |   | 前 1 秒来                 |
    |                100 个请求 |   | 100 个请求                |
    +---------------------------+   +---------------------------+
                     [ 00:59 ]        [ 01:01 ]
                     <------- 2 秒内涌入 200 个请求! ------->

若限制为“每分钟最多 100 次”,攻击者可以在 00:59 发送 100 次,在 01:00 又发送 100 次。在短短 2 秒内系统承受了 200 次请求,实际速率达到额定值的 2 倍!


2.2 滑动窗口日志 (Sliding Window Log)

  • 原理:记录每个请求到达的绝对时间戳(通常使用 Redis Sorted Set ZSet 存储,分值为时间戳)。
  • 当新请求到达时,清理所有时间戳在 [now - window, now] 之外的过期记录(ZREMRANGEBYSCORE);
  • 统计当前窗口内剩余的元素总数(ZCARD);
  • 若数量小于限制阈值,将当前时间戳写入并放行,否则拒绝。
  • 优点:精度极高,彻底杜绝了窗口边界突发问题。
  • 缺点:内存开销巨大!如果一个接口限制为每小时 10 万次,每次请求哪怕只存一个 8 字节时间戳,高频调用下也会迅速耗尽内存。

2.3 滑动窗口计数器 (Sliding Window Counter)

固定窗口与滑动日志的折中改良方案:

     前一个窗口 (权重 30%)          当前窗口 (权重 70%)
   [====================]        [====================]
          100 次请求                    30 次请求
                          ^ 当前时间点 (当前窗口已走过 70%)
  • 计算公式:
\[ \text{Estimated Requests} = \text{Requests in Current Window} + \text{Requests in Previous Window} \times (1 - \text{Overlap Ratio}) \]

在上图中,当前窗口过去 \(70\%\),前一个窗口占 \(30\%\):

\[ \text{Estimated Requests} = 30 + 100 \times (1 - 0.7) = 30 + 30 = 60 \]
  • 优点:内存极度节省(只需存两个数字),计算平滑,有效压制边界突发。

2.4 令牌桶算法 (Token Bucket,工业界首选)

令牌桶算法是 Google Guava RateLimiter、Spring Cloud Gateway 与大多数云厂商 API 网关的核心基础。

          固定速率放入令牌 (Rate r = 10 个/秒)
                    |
                    v
             +--------------+
             |  O  O  O  O  |
             |   令牌桶     |  <-- 最大容量 Capacity = 20
             |  O  O  O  O  |
             +--------------+
                    | (请求到达时取走 1 个令牌)
                    v
             [ 处理业务请求 ]
  • 运行机制:
  • 系统以恒定速率 \(r\) 向容量为 \(C\) 的桶中投放令牌;
  • 桶满时多余的令牌溢出丢弃;
  • 每个请求到达时,必须从桶中成功获取 1 个令牌才能被处理;若桶空无可用令牌,则请求被拒绝或排队等待。
  • 核心优势:允许应对突发流量(Burst Traffic)! 若系统闲置了一段时间,桶中积攒了满额的 \(C\) 个令牌。此时突发涌入 \(C\) 个并发请求,能够瞬间全部放行,随后以恒定速率 \(r\) 平稳流转。

2.5 漏桶算法 (Leaky Bucket)

  • 原理:请求像水流一样注入一个固定容量的漏桶中;桶底开有一个孔,以绝对恒定的恒定速率漏出水滴(处理请求)。若注入速度过快导致水溢出,多余的请求被直接丢弃。
  • 与令牌桶的关键差异:
  • 令牌桶:允许突发流量瞬间放行,后续平滑;
  • 漏桶:强制平滑输出速率,绝不允许任何突发请求击中后端,输出速率永远恒定。

2.6 五大算法选型全景对照

算法 突发流量支持 内存消耗 实现复杂度 精度 典型适用场景
固定窗口 差(存在 2 倍突发漏洞) 极低(单一计数器) 极简单 低 粗粒度防护、低频统计(如每月短信上限)
滑动日志 极佳(无任何边界突发) 极高(存储所有请求时间戳) 中等 极高 严格精度、高安全风控、低并发场景
滑动计数器 良好(平滑过渡) 极低(仅存前后两窗口值) 中等 高 高并发 API 网关通用限流
令牌桶 (Token Bucket) 极佳(支持可控的瞬时爆发) 极低(记录当前令牌数与上次时间) 中等 高 绝大多数互联网高并发业务(首选)
漏桶 (Leaky Bucket) 差(完全禁止任何突发) 低(固定队列容量) 较高(需队列维持) 高 保护极脆弱的第三方系统(如恒定速率发邮件)

3. 分布式限流实现:Redis + Lua 脚本原子执行

在分布式集群环境中,单机内存限流(如 Guava RateLimiter)无法跨多台 Web 服务器统计全局配额,必须依赖中心化存储(Redis 集群)。

3.1 为什么必须使用 Lua 脚本?

限流检查涉及多个连续操作: 1. 读取当前剩余令牌数; 2. 根据时间差计算新补充的令牌; 3. 判断令牌是否充足并执行扣减; 4. 写回最新状态。

若使用普通 Redis 命令,并发下存在严重的竞态条件(Race Conditions)。利用 Redis 单线程执行 Lua 脚本的特性,能够保证上述逻辑全局原子性(Atomic)执行,且网络往返仅需 1 次 RTT!

3.2 生产级 Redis Lua 令牌桶脚本

-- KEYS[1]: 限流器 Key (例如 "rate_limit:user_10086")
-- ARGV[1]: 桶最大容量 Capacity
-- ARGV[2]: 令牌补充速率 Rate (每秒产生多少令牌)
-- ARGV[3]: 当前时间戳 (单位: 秒)
-- ARGV[4]: 本次请求消耗的令牌数 (通常为 1)

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

-- 获取当前桶中令牌数及上次刷新时间
local info = redis.call("HMGET", key, "tokens", "last_updated")
local tokens = tonumber(info[1])
local last_updated = tonumber(info[2])

if tokens == nil then
    -- 首次访问,初始化满桶
    tokens = capacity
    last_updated = now
else
    -- 计算两次请求的时间差,补充新令牌
    local delta = math.max(0, now - last_updated)
    local generated = delta * rate
    tokens = math.min(capacity, tokens + generated)
    last_updated = now
end

-- 判断令牌是否充足
if tokens >= requested then
    tokens = tokens - requested
    redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated)
    -- 设置 Key 过期时间,防止冷数据永久霸占内存
    redis.call("EXPIRE", key, math.ceil(capacity / rate) * 2)
    return 1 -- 放行
else
    -- 令牌不足,拒绝
    return 0
end

4. HTTP 协议标准与系统集成

当限流器决定拒绝请求时,应该如何与客户端优雅交互?

4.1 标准 HTTP 响应格式

  • HTTP 状态码:429 Too Many Requests
  • 标准 HTTP 响应头(Headers):
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 5
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1672531200

{
    "code": 429,
    "message": "API rate limit exceeded. Please retry in 5 seconds."
}
  • X-RateLimit-Limit:当前周期内允许的最大请求总数;
  • X-RateLimit-Remaining:当前周期内剩余可用配额;
  • X-RateLimit-Reset:当前限流周期重置的 Unix 时间戳;
  • Retry-After:客户端应该等待多少秒后才允许发起重试。

4.2 容灾设计:Fail-Open vs Fail-Closed

当 Redis 集群故障或限流中间件自身不可用时,系统该如何决策? - Fail-Closed(默认拒绝):全部阻断请求。适合高安全性金融系统; - Fail-Open(默认放行,推荐):记录告警日志并自动放行所有请求,降级为直通模式。在绝大多数互联网业务中,宁可冒着后端短暂受压的风险,也不能让限流器自身故障导致全站瘫痪。