一、问题背景
在实际业务场景中,限流是保护系统的重要手段:在一段时间(period)内,限定某个行为(action)的最大次数(max_count)。本文介绍如何基于 Redis 实现多种限流方案。
二、限流类型总览
| 限流类型 | 核心思想 | 优点 | 缺点 |
|---|---|---|---|
| 固定窗口限流 | 时间窗口固定,到期自动清零 | 实现简单 | 存在窗口边界突击流量问题 |
| 滑动窗口限流 | 窗口随时间滑动,统计窗口内请求数 | 精确解决边界问题 | 实现稍复杂 |
| 漏斗限流 | 容量固定,速率固定 | 精确控制容量和速率 | 需要 Redis 模块支持 |
| 令牌桶限流 | 令牌以固定速率放入桶中 | 支持突发流量 | 实现复杂 |
三、固定窗口限流
3.1 什么是固定窗口限流
将时间划分为固定的窗口,例如每 5 分钟为一个窗口:
|---5min---|---5min---|---5min---|---5min---|
20:00 20:05 20:10 20:15
在每个窗口内独立计数,窗口到期后计数清零。
3.2 Redis 实现
lua
-- 固定窗口限流实现
local key = "***" .. user_id .. ":" .. action
local limit = 10 -- 最大次数
local period = 10 -- 时间窗口(秒)
-- 方式1:INCR + EXPIRE(存在问题)
redis.call('INCR', key)
redis.call('EXPIRE', key, period)
-- 方式2:SET + INCR(正确实现,解决竞态条件)
-- 使用 SET + EXPIRE 原子操作,避免窗口切换时丢失数据
redis.call('SET', key, 0, 'EX', period, 'NX')
local count = redis.call('INCR', key)
return count <= limit
关键点:使用 SET + EXPIRE 代替单独 EXPIRE,避免 INCR 和 EXPIRE 之间进程崩溃导致数据丢失。
可通过 Pipeline 保证两个命令同时发送:
python
# Python 示例
pipe = redis.pipeline()
pipe.set(key, 0, ex=period, nx=True)
pipe.incr(key)
res = pipe.execute()
return res[1] <= limit
3.3 固定窗口的局限性
假设 5 分钟内限定 10 次请求:
20:04-20:05 发生 9 次请求
20:05-20:06 发生 9 次请求
在 20:04-20:06 这 2 分钟内,实际发生了 18 次请求,远超每 5 分钟 10 次的限制。
问题根源:固定窗口的边界不连续,在边界处可能发生突发流量。
四、滑动窗口限流
4.1 核心思想
滑动窗口的核心是窗口随时间连续滑动,而非固定边界:
传统固定窗口: |-----5min-----|-----5min-----|
20:00 20:05 20:10
滑动窗口:
现在时刻的窗口持续向前滑动
|----5min-----|----5min-----|
20:01 20:06
4.2 Redis 实现(ZSET)
lua
local function is_action_allowed(red, user_id, action, period, max_count)
local key = "***" .. user_id .. ":" .. action
local now = redis.call('TIME') -- 获取当前时间戳(毫秒)
-- 1. 记录当前行为(score 和 member 都用时间戳)
red:zadd(key, now, now)
-- 2. 移除窗口之前的行为记录
red:zremrangebyscore(key, 0, now - period * 1000)
-- 3. 获取窗口内的行为数量
local count = red:zcard(key)
-- 4. 设置过期时间,避免冷用户持续占用内存
red:expire(key, period + 1)
return count <= max_count
end
流程图:
时间轴:[--窗口period--|---未来---]
↑now
ZSET 存储:score=时间戳, member=时间戳
ZREMRANGEBYSCORE:删除 score < now-period 的旧记录
ZCARD:统计剩余元素数量,即窗口内请求数
4.3 为什么用 ZSET 而非 LIST
| 数据结构 | 适用场景 |
|---|---|
| ZSET | 支持按时间范围删除,适合滑动窗口 |
| LIST | 只能按索引删除,无法按时间范围清理 |
五、漏斗限流(Redis-Cell)
5.1 什么是漏斗限流
漏斗限流的核心是容量固定 + 速率固定,能精确控制元素的容量和速率:
漏斗模型:
[入口] -> (容量固定) -> [出口]
↓
速率恒定
- 漏斗容量:最多能容纳多少请求
- 漏斗速率:单位时间内能处理多少请求
5.2 Redis-Cell 模块安装
Redis-Cell 是 Redis 的第三方模块,采用 Rust 编写,需要单独安装:
bash
# 下载并编译
git clone https://github.com/brandur/redis-cell
cd redis-cell
cargo build --release
cp target/release/libredis_cell.so /path/to/modules/
# 启动 Redis 加载模块
redis-server --loadmodule /path/to/modules/libredis_cell.so
5.3 CL.THROTTLE 命令详解
bash
CL.THROTTLE key capacity operations seconds [quota]
参数说明:
| 参数 | 含义 | 示例 |
|---|---|---|
| key | 漏斗容器名称 | user:123:login |
| capacity | 漏斗容量(最大容纳请求数) | 10 |
| operations | 单位时间内的操作次数 | 5 |
| seconds | 单位时间(秒) | 60 |
| quota | 单次行为消耗的令牌数(可选,默认1) | 1 |
示例:每 60 秒最多 5 次请求,漏斗容量 10
bash
CL.THROTTLE user:123:login 10 5 60
返回结果:
1) (integer) 0 # 是否被限流(0=允许,1=拒绝)
2) (integer) 7 # 漏斗剩余容量
3) (integer) 7 # 如果被拒绝,还需要等多久(秒)
4) (integer) -1 # 预留字段
5) (integer) 60 # 下次请求的间隔时间
5.4 流速计算
流速 = operations / seconds = 5 / 60 ≈ 0.083 请求/秒
这意味着每秒只能处理约 0.083 个请求,即约 12 秒处理 1 个请求。
六、令牌桶限流
6.1 核心思想
令牌桶的核心是令牌以固定速率放入桶中:
令牌桶:
-> [桶容量] -> 请求消耗令牌 -> 通过
↑
固定速率放入令牌
- 桶容量:最大令牌数
- 令牌添加速率:每秒添加多少令牌
- 请求消耗:每个请求消耗 1 个令牌
6.2 特点
| 特点 | 说明 |
|---|---|
| 支持突发流量 | 桶满时可一次性处理多个请求 |
| 令牌非即时补充 | 需要等待令牌生成 |
6.3 与漏斗限流的区别
| 对比维度 | 漏斗限流 | 令牌桶限流 |
|---|---|---|
| 速率 | 匀速 | 匀速(令牌补充) |
| 突发能力 | 不支持 | 支持(桶满时) |
| 实现难度 | 较简单 | 较复杂 |
七、四种限流方案对比
| 维度 | 固定窗口 | 滑动窗口 | 漏斗限流 | 令牌桶 |
|---|---|---|---|---|
| 实现复杂度 | 低 | 中 | 低 | 高 |
| 边界突击 | 有 | 无 | 无 | 无 |
| 突发流量支持 | 不支持 | 不支持 | 不支持 | 支持 |
| 精度控制 | 低 | 中 | 高 | 高 |
| 额外依赖 | 无 | 无 | Redis-Cell | 无 |
八、面试追问 FAQ
| 问题 | 回答要点 |
|---|---|
| Q: 为什么固定窗口需要 SET + INCR 组合? | 单独 INCR + EXPIRE 在进程崩溃时可能丢失数据,SET+EXPIRE 原子操作保证一致性 |
| Q: 滑动窗口为什么要设置过期时间为 period+1? | 避免窗口边界附近过期导致数据丢失,确保跨窗口的请求仍被统计 |
| Q: 漏斗限流和令牌桶限流各适用于什么场景? | 漏斗:需要精确控制速率的 API 限流;令牌桶:允许突发流量的场景(如秒杀) |
| Q: Redis-Cell 是原子操作吗? | 是,CL.THROTTLE 整个命令是原子的,无需担心并发问题 |
| Q: 滑动窗口的 ZSET 会不会无限增长? | 不会,每次请求都会清理窗口外的旧数据,且有 expire 保证清理 |
九、相关题目
| 题目 | 考察点 |
|---|---|
| Redis 固定窗口限流如何保证原子性? | SET + INCR + Pipeline |
| 滑动窗口限流为什么用 ZSET 而不是 LIST? | 按时间范围删除的能力 |
| 漏斗限流如何计算流速? | operations / seconds |
| 令牌桶和漏斗限流的本质区别? | 突发流量支持 |
十、总结
| 限流方案 | 实现难度 | 精度 | 突发流量 | 推荐场景 |
|---|---|---|---|---|
| 固定窗口 | 低 | 低 | 不支持 | 简单场景 |
| 滑动窗口 | 中 | 中 | 不支持 | 需要精确控制 |
| 漏斗限流 | 低 | 高 | 不支持 | API 限流 |
| 令牌桶 | 高 | 高 | 支持 | 秒杀/抢购 |
核心结论: 根据业务场景选择合适的限流方案,简单场景用固定窗口,精确控制用滑动窗口或漏斗限流,需要突发能力用令牌桶。
根据零声教育教学写作https://github.com/0voice