Files
cs-note/hzh/REDIS/分布式限流.md
T

15 KiB
Raw Blame History

tags, create time
tags create time
redis
rate-limiting
distributed-system
lua-script
sliding-window
token-bucket
2026-05-30 14:30

分布式限流(基于 Redis)

概述

假设你写了一个用户注册接口,设置了每秒最多处理 100 个请求。在单机环境下这没问题——一个计数器就够了。

但当你把服务扩展到 10 台机器后,问题来了:每台机器各自计数,实际吞吐量变成了 1000 QPS,远超数据库的承载能力。

[!question]- 💡 思考:你怎么解决?

现在有 10 个服务实例,每个都在自己的内存里维护计数器。用户的一个请求打到任意一台机器上。

想一想——如果让你设计一个方案,让所有机器看到同一个数字,你会怎么做?

答案是:把所有计数器的状态集中到一个共享存储。Redis 天然适合这个场景——原子操作保证一致性、低延迟支撑高频判断、集群模式可以横向扩展。

flowchart LR
    subgraph "多实例应用"
        I1["📦 实例 A"]
        I2["📦 实例 B"]
        I3["📦 实例 C"]
    end

    GW["🌐 API Gateway"] --> I1
    GW --> I2
    GW --> I3

    I1 -->|共享 Key| R(("🔴 Redis<br/>统一限流状态"))
    I2 -->|共享 Key| R
    I3 -->|共享 Key| R

    style R fill:#ffebee,stroke:#ef5350,stroke-width:3px,color:#000

接下来我们从最简单的方案开始,一步步看到边界和问题,再引入更复杂的算法来解决它们。每一节的"缺陷"都是下一节存在的理由。


一、从固定窗口开始

1.1 最简单的方法:固定窗口计数器

核心思想就两句话:

  1. 把时间切成一个个等长的窗口(比如每 1 秒一个窗口)
  2. 每个窗口内用一个计数器记录请求数,到达阈值就拒绝
flowchart TD
    Req["请求到达"] --> TS["取当前时间戳<br/>映射到当前窗口起始点"]
    TS --> Key["构造 Key:<br/>rate:limit:{id}:{windowStart}"]
    Key --> INCR["INCR 自增"]
    INCR --> First{"首次?"}
    First -- 是 --> Expire["EXPIRE 设过期时间"]
    First -- 否 --> SkipExpire["跳过 EXPIRE"]
    Expire --> Check{"count > max?"}
    SkipExpire --> Check
    Check -- 是 --> Reject["❌ 拒绝 (429)"]
    Check -- 否 --> Allow["✅ 放行"]

对应的 Go + Lua 实现:

const fixedWindowLua = `
local key      = KEYS[1]
local maxCount = tonumber(ARGV[1])
local window   = tonumber(ARGV[2])

local count = redis.call('INCR', key)
if count == 1 then
    redis.call('EXPIRE', key, window)
end
return count
`

调用时这样构造 Key:

// 将时间戳对齐到窗口起点,确保同一窗口共用一个 Key
windowStart := time.Now().Unix() / int64(windowSeconds) * int64(windowSeconds)
key := fmt.Sprintf("rate:limit:%s:%d", userId, windowStart)

这段代码短小精悍,看起来已经够用了。但它有一个隐蔽的边界问题。

1.2 边界突发:窗口的"双杀"效应

[!warning]- ⚠️ 先看一个具体例子

限制 100 请求/秒,窗口大小 1 秒。考虑两个相邻窗口的交界处:

时刻 所在窗口 行为
t = 0.9s 第 0 秒窗口 计数 +1,累计 100
t = 1.1s 第 1 秒窗口 计数归零 +1,累计 100

在 0.2 秒内通过了 200 个请求,突增量达到了限制的两倍。

为什么会这样?因为前后两个窗口各自独立计数,互不关心。窗口切换的那一刻,新旧两个窗口可以同时满负荷运行。

这个问题的根源在于「窗口」太粗了。我们换一个思路:不要只盯着当前窗口,而是看最近一段时间内的全部请求。

这就是下一节要讲的滑动窗口。


二、更精确:滑动窗口

2.1 滑动窗口计数器

先别急着看代码,想想你直觉上会怎么改进固定窗口:

与其让两个相邻窗口各算各的,不如用多个小窗口加权平均来估算当前窗口的总量?

比如把 1 秒分成 N=10 个子窗口,每个子窗口 100ms。当请求在第 8 个窗口时,前 7 个已经过期的窗口按剩余时间比例折算贡献度:

flowchart LR
    subgraph "N=3 个子窗口示意图"
        W1["⬛ 旧窗口<br/>已过期的部分 × 占比"]
        W2["🟨 当前完整窗口<br/>100% 计入"]
        W3["🟩 新窗口<br/>刚开始,从 0 累积"]
    end

    Current["当前请求时刻"] --> Calc["weightedCount = W1.weight × W1.count + W2.count"]
    Calc --> Compare{"total > max?"}
    Compare -- 是 --> Reject["拒绝"]
    Compare -- 否 --> Allow["放行"]

效果对比:

维度 固定窗口 滑动窗口 (N=10)
精度 低(可突破 2 倍) 中(突限量缩小到 ~1/N)
空间 O(1) per window O(N) 子窗口
复杂度 极低 中等

[!tip] 折中选择 如果你的业务对限流精度要求不高(比如日配额限流),N=10 的滑动窗口计数器已经足够,且内存开销很小。

如果需要逐条精确统计,可以用 Sorted Set 实现"日志级"滑动窗口(见 2.2)。

2.2 日志级滑动窗口(Sorted Set)

这是最精确的方案:每条请求的时间戳作为 Sorted Set 的成员(score),通过范围查询和清理来实现窗口统计。

流程:

sequenceDiagram
    participant App as 应用实例
    participant R as Redis

    App->>R: ZREMRANGEBYSCORE key -(now-T] now<br/>清理过期记录
    R-->>App: 返回清理数量
    App->>R: ZCARD key<br/>统计当前窗口内请求数
    R-->>App: currentCount
    alt currentCount >= max
        App->>App: ❌ 拒绝 (429)
    else currentCount < max
        App->>R: ZADD key now member<br/>EXPIRE key TTL
        App->>App: ✅ 放行
    end

关键是把三个步骤(清理 → 计数 → 插入)放在一个 Lua 脚本里,确保原子性:

const slidingWindowLua = `
local key     = KEYS[1]
local window  = tonumber(ARGV[1])  -- 窗口大小 (秒)
local maxLen  = tonumber(ARGV[2])  -- 最大请求数
local now     = tonumber(ARGV[3])  -- 毫秒级时间戳
local member  = ARGV[4]            -- 唯一 ID (uuid / requestId)

-- 清理窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000)

-- 统计当前窗口内的数量
local count = redis.call('ZCARD', key)

if count >= maxLen then
    return count  -- 超限
end

-- 添加新记录
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window)

return count + 1
`

[!warning]- 代价与取舍

维度 滑动窗口计数器 滑动窗口日志
精度 近似 精确到每一条
空间 O(N) 子窗口 O(maxQPS × window)
何时不用 — 高并发下 sorted set 太大

以 maxQPS=1000、窗口 60s 为例,需要存储 6 万条 Sorted Set 记录。对于高频 API 网关,这可能成为瓶颈——这时应该切换到令牌桶或漏桶方案。


三、换个角度:令牌桶 & 漏桶

前面的算法都在回答一个问题:"过去一段时间内有多少请求?"但还有一种不同的思考方式——不管输入节奏,规定系统能处理的速率。

3.1 为什么需要"桶"模型?

回想一下固定窗口的问题:它在窗口边界处会出现突发。令牌桶的思路完全不一样:

想象一个桶,你以恒定速度往里灌水(补充令牌),下面的排水孔以恒定速度漏水(处理请求)。桶有上限,水满了就溢掉;桶空了,新来的水流不进。

关键在于:桶在空闲时可以攒下多余的令牌。这意味着短时间内的突发流量也能被容忍——只要之前积累够了就行。这对用户体验友好得多。

flowchart TD
    subgraph "🪣 令牌桶内部"
        TANK["令牌桶<br/>容量 = maxBurst"]
        FILL["⚡ 固定速率 r/s 持续添加令牌<br/>最多填满到 maxBurst"]
    end

    REQ["请求到来"] --> CHECK{桶中有令牌?}
    CHECK -- 是 --> CONSUME["消耗 N 个令牌<br/>✅ 放行"]
    CHECK -- 否 --> REJECT["❌ 拒绝 / 等待"]

    FILL -.-> TANK
    CONSUME -.->|"桶 - N"| TANK

3.2 令牌桶 vs 漏桶:到底有什么区别?

这两个概念经常混淆,核心区别只有四个字:允许/不允许突发。

维度 令牌桶 🪣 漏桶 🕳
突发 ✅ 允许(空闲时令牌会累积) ❌ 不允许(多余直接拒绝)
输出形态 跟随输入节奏 始终匀速
隐含队列 无(拒绝即拒) 有(先入桶排队再处理)
保护对象 对客户端更友好 对下游更安全
典型场景 API 网关、第三方配额 数据库连接池、消息队列消费者
flowchart TD
    Choice["突发流量来了怎么办?"] --> Q{"是否需要允许合理突发?"}

    Q -- "是,给客户端好体验" --> TB["🪣 选令牌桶<br/>• REST API 限流<br/>• 用户多次点击不都失败"]

    Q -- "否,稳定压住下游" --> LB["🕳 选漏桶<br/>• DB 连接池限速<br/>• 防止后端被打满"]

3.3 Redis + Lua 实现令牌桶

为什么要用 Lua? 因为令牌桶的操作涉及三步:读当前令牌数 → 计算补充量 → 写回新值。这三个步骤如果不原子化,多实例并发时就会超发令牌。Redis 的单线程模型 + Lua 脚本天然解决这个问题。

flowchart TD
    Req["请求到达"] --> Read["读取 tokens + last_refill"]
    Read --> Calc["计算补充:<br/>newTokens = min(capacity,<br/>tokens + elapsed × rate)"]
    Calc --> Check{tokens ≥ needed?}
    Check -- 是 --> Consume["tokens -= needed<br/>✅ 放行"]
    Check -- 否 --> Decline["❌ 拒绝<br/>返还需等待时长"]
    Consume --> Writeback["写回 tokens + 更新时间<br/>设置 TTL 防僵尸 Key"]
    Decline --> Writeback

完整的 Lua 脚本:

const tokenBucketLua = `
local tokensKey  = KEYS[1]
local capacity   = tonumber(ARGV[1])
local rate       = tonumber(ARGV[2])  -- 每秒补充的令牌数
local requested  = tonumber(ARGV[3])  -- 请求需要的令牌数
local now        = tonumber(ARGV[4])  -- 毫秒时间戳

-- 读取当前状态
local bucket = redis.call('HMGET', tokensKey, 'tokens', 'last_refill')
local tokens   = tonumber(bucket[1])
local lastFill = tonumber(bucket[2])

if tokens == nil then
    tokens = capacity   -- 首次使用:初始化为满桶
    lastFill = now
end

-- 根据经过的时间补充令牌
local elapsed = (now - lastFill) / 1000.0
tokens = math.min(capacity, tokens + elapsed * rate)

-- 尝试消费
local allowed = 0
local remaining = tokens
if tokens >= requested then
    tokens = tokens - requested
    allowed = 1
    remaining = tokens
end

-- 写回
redis.call('HMSET', tokensKey, 'tokens', remaining, 'last_refill', now)
redis.call('EXPIRE', tokensKey, math.ceil(capacity / rate) * 2)

return {allowed, math.floor(remaining * 1000 / rate)}
`

对应 Go 封装:

func TokenBucketAllow(ctx context.Context, c *redis.Client, id string, capacity float64, rate float64) (bool, int64, error) {
    key := fmt.Sprintf("rate:tokenbucket:%s", id)
    now := time.Now().UnixMilli()

    result, err := c.Eval(ctx, tokenBucketLua, []string{key}, capacity, rate, 1, now).IntSlice()
    if err != nil {
        return false, 0, err
    }
    // result[0]: 1=放行 0=拒绝, result[1]: 下次可用毫秒数
    return result[0] == 1, result[1], nil
}

四、生产实践:组合与优化

单一种算法通常不够用。现实中的限流体系像洋葱一样层层叠加——每层保护不同的目标,用不同的算法。

4.1 多级限流架构

flowchart TD
    Req["请求进来"] --> L1
    L1["🔴 L1: IP 维度<br/>防刷 / 防攻击"] -- pass --> L2
    L2["🟡 L2: 用户维度<br/>控制每日/每月配额"] -- pass --> L3
    L3["🟢 L3: 接口维度<br/>保护服务峰值"] -- pass --> Biz["执行业务逻辑"]

    L1 -- fail --> R["返回 429"]
    L2 -- fail --> R
    L3 -- fail --> R

    style R fill:#ffebee,color:#000

每层的典型配置:

层级 限流目标 推荐算法 典型参数
L1 IP 层 防暴力扫描、恶意攻击 短窗口固定计数 100 req/s/IP
L2 用户层 控制付费/免费配额 天级滑动窗口日志 1000 req/day
L3 接口层 保护实例不被打满 令牌桶 5000 req/s/endpoint

4.2 优雅降级:被限流时该说什么

被限流的请求不应静默丢弃,而应给出明确信号:

HTTP/1.1 429 Too Many Requests
Retry-After: 2                    # 建议客户端等待的秒数
X-RateLimit-Limit: 100            # 限流阈值
X-RateLimit-Remaining: 0          # 当前窗口剩余配额
X-RateLimit-Reset: 1690000060     # 窗口重置时间戳

{
    "error": {
        "code": "RATE_LIMITED",
        "message": "请求过于频繁,请稍后再试",
        "retry_after_seconds": 2
    }
}

[!tip]- 客户端收到 429 后该怎么做?

  1. 遵守 Retry-After 头部进行等待重试
  2. 使用指数退避 + jitter:wait = min(base × 2^attempt + random_jitter, Retry-After)
  3. 不要立即无脑重发——这会造成 "惊群效应"(thundering herd),再次打垮恢复中的服务

4.3 性能优化清单

flowchart LR
    Pipelines["Pipeline 减少 RTT<br/>MULTI/EXEC 合并命令"]
    EvalSHA["EVALSHA 预加载 Lua<br/>避免重复传输脚本"]
    HashKey["Hash 压缩 Key 数<br/>HSET 替代多个独立 Key"]
    SCAN["SCAN 替代 KEYS<br/>避免阻塞其他客户端"]

    Pipelines --> Perf["⚡ 整体性能"]
    EvalSHA --> Perf
    HashKey --> Perf
    SCAN --> Perf
技巧 收益 注意
Pipeline 降低网络往返,QPS 翻倍 注意批量命令不能拆成单独事务
EVALSHA 节省带宽,Lua 调用提速 需先用 SCRIPT LOAD 预加载脚本
Hash 聚合 Key 减少内存碎片和过期管理开销 设计时就要规划好聚合粒度
SCAN 替代 KEYS 避免大库扫按时阻塞服务端 KEYS 在数据量大时影响严重

五、排错指南

[!example]- 出了问题先从这些方向排查

症状 可能原因 排查方法
超出限制大量通过 时钟不同步导致 Lua 中 now 异常 date 比对 Redis 和应用服务器
Redis CPU 飙高 滑动窗口日志 Sorted Set 规模过大 MEMORY USAGE key 查大 Key;换令牌桶
偶发性限流不一致 Pipeline 非原子性拆分了判断逻辑 确认核心判断走单个 Lua 脚本
内存持续增长 Key 的 EXPIRE 未生效 TTL key 检查是否已过期
Cluster 下限流失效 Key 跨 slot,Lua 无法多 Key 操作 使用 Hash Tag {user123}.daily 锁定 slot

[!warning]- Redis Cluster 的关键陷阱 Lua 脚本中涉及多个 Key 时,它们必须在同一个 hash slot。默认的 Key 格式可能导致不同实例落在不同 slot 上。

解决方法: 用 {} 包裹路由键,强制 Hash Tag 一致:

rate:limit:{user123}:daily    ← {} 保证路由到同一 slot
rate:limit:{user123}:hourly

关联笔记