Redis中分布式限流的三种实现方案详解
一、概述
在分布式系统中,限流是保障服务稳定性的重要手段。本文详细对比三种基于 Redis 的分布式限流方案:固定窗口、滑动窗口、令牌桶,帮助你在不同场景下做出合理的技术选型。
二、方案一:固定窗口(Fixed Window)
2.1 原理
将时间划分为固定长度的时间窗口(如 1 秒),每个窗口独立计数。窗口内计数器累加,超过阈值则拦截。
时间轴: |--- 第1秒 ---|--- 第2秒 ---|--- 第3秒 ---|
① ② ③ ④ ⑤ ⑥ ① ② ③ ① ② ③ ④ ⑤
↓ ↓ ↓
计数=6 计数=3 计数=5
阈值=5 阈值=5 阈值=5
❌ 拦截2个 ✅ 全部放行 ✅ 全部放行
2.2 Redis 实现(Lua 脚本)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
-- 原子性自增
local current = redis.call('INCR', key)
-- 首次访问设置过期时间
if current == 1 then
redis.call('EXPIRE', key, ttl)
end
return current2.3 优缺点
| 维度 | 评价 |
|---|---|
| 实现复杂度 | ⭐ 极低,代码简洁 |
| 性能 | ⭐⭐⭐ 最高,单次 Redis 调用 |
| 内存占用 | ⭐⭐⭐ 最低,每个窗口只存一个计数器 |
| 流量均匀性 | ⭐⭐ 存在边界突发问题 |
2.4 边界突发问题
时间线: |---- 第1秒 (阈值10) ----|---- 第2秒 (阈值10) ----|
请求: 第9、10个在 999ms 到达
第1、2个在 1001ms 到达
结果:在 2ms 内通过了 12 个请求(超了 10 的限制)
2.5 适用场景
| 场景 | 是否适用 | 说明 |
|---|---|---|
| API 防刷 | ✅ 推荐 | 正常用户不会卡时间边界,边界问题可接受 |
| 登录限流 | ✅ 推荐 | 防暴力 破解,简单高效 |
| IP 限流 | ✅ 推荐 | 最常见的防刷场景 |
| 严格流量整形 | ❌ 不推荐 | 边界突发不符合要求 |
| 秒杀/抢购 | ⚠️ 慎用 | 边界突发可能导致不公平 |
2.6 代码示例
@Component
public class FixedWindowRateLimiter {
private static final String LUA_SCRIPT =
"local current = redis.call('INCR', KEYS[1])\n" +
"if current == 1 then\n" +
" redis.call('EXPIRE', KEYS[1], ARGV[2])\n" +
"end\n" +
"return current";
public boolean allow(String key, int limit, int windowSeconds) {
Long count = redisTemplate.execute(
new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
Collections.singletonList(key),
String.valueOf(limit),
String.valueOf(windowSeconds)
);
return count != null && count <= limit;
}
}三、方案二:滑动窗口(Sliding Window)
3.1 原理
使用 Redis 的 Sorted Set(ZSET) 存储每个请求的时间戳,通过移除窗口外的旧数据,精确统计窗口内的请求数。
时间轴: |---- 过去1秒 ----|现在
① ② ③ ④ ⑤ ⑥ ⑦ ⑧ ⑨
↓ ↓
ZSET 存储所有请求时间戳
ZREMRANGEBYSCORE 移除窗口外数据
ZCARD 统计窗口内数量
3.2 Redis 实现(Lua 脚本)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) -- 窗口大小(毫秒)
local limit = tonumber(ARGV[3])
-- 移除窗口外的旧数据
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 获取当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count >= limit then
return count -- 拦截
end
-- 添加当前请求(使用毫秒时间戳 + 随机数作为 member,避免重复)
redis.call('ZADD', key, now, now .. ':' .. math.random())
-- 设置过期时间(窗口 + 1 秒)
redis.call('PEXPIRE', key, window + 1000)
return 0 -- 放行3.3 优缺点
| 维度 | 评价 |
|---|---|
| 实现复杂度 | ⭐⭐ 中等,需要理解 ZSET 操作 |
| 性能 | ⭐⭐ 较高,ZREMRANGEBYSCORE + ZCARD + ZADD |
| 内存占用 | ⭐⭐ 较高,每个请求存一条记录 |
| 流量均匀性 | ⭐⭐⭐ 最精确,无边界问题 |
3.4 滑动窗口精度示意
固定窗口(边界突发):
第1秒 第2秒
|██████████| |██████████|
9 10 1 2 ← 2ms 内通过 12 个
滑动窗口(精确控制):
|◄─── 1秒 ───►|◄─── 1秒 ───►|
请求均匀分布,任意 1 秒窗口内 ≤ 阈值
3.5 适用场景
| 场景 | 是否适用 | 说明 |
|---|---|---|
| 严格 QPS 控制 | ✅ 推荐 | 需要精确控制每秒请求数 |
| 金融交易限流 | ✅ 推荐 | 对流量均匀性要求高 |
| API 网关精确限流 | ✅ 推荐 | 用户感知要求高 |
| 防刷场景 | ⚠️ 可用 | 但固定窗口更简单,性价比更高 |
| 高并发场景 | ⚠️ 慎用 | 内存占用随请求量线性增长 |
3.6 代码示例
@Component
public class SlidingWindowRateLimiter {
private static final String LUA_SCRIPT =
"local window = tonumber(ARGV[2])\n" +
"local now = tonumber(ARGV[1])\n" +
"local limit = tonumber(ARGV[3])\n" +
"redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)\n" +
"local count = redis.call('ZCARD', KEYS[1])\n" +
"if count >= limit then return count end\n" +
"redis.call('ZADD', KEYS[1], now, now .. ':' .. math.random())\n" +
"redis.call('PEXPIRE', KEYS[1], window + 1000)\n" +
"return 0";
public boolean allow(String key, int limit, int windowMs) {
long now = System.currentTimeMillis();
Long count = redisTemplate.execute(
new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
Collections.singletonList(key),
String.valueOf(now),
String.valueOf(windowMs),
String.valueOf(limit)
);
return count != null && count == 0;
}
}四、方案三:令牌桶(Token Bucket)
4.1 原理
系统以固定速率向桶中放入令牌,每个请求需要消耗一个令牌。桶有容量上限,允许一定程度的突发流量。
┌─────────────────────┐
│ 令牌桶 (容量 20) │
│ 🪙🪙🪙🪙🪙🪙🪙🪙 │
│ 🪙🪙🪙🪙🪙🪙🪙🪙 │
│ 🪙🪙🪙🪙 │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ 固定速率填充 │
│ 10 令牌/秒 │
└─────────────────────┘
4.2 Redis 实现(Lua 脚本)
local key = KEYS[1]
local limit = tonumber(ARGV[1]) -- 桶容量
local rate = tonumber(ARGV[2]) -- 填充速率(令牌/秒)
local now = tonumber(ARGV[3])
-- 获取桶状态
local state = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(state[1]) or limit
local lastTime = tonumber(state[2]) or now
-- 计算应该补充的令牌数
local delta = math.max(0, now - lastTime)
local filledTokens = math.min(limit, tokens + (delta * rate / 1000))
-- 判断是否有足够令牌
if filledTokens >= 1 then
-- 消耗 1 个令牌
local newTokens = filledTokens - 1
redis.call('HMSET', key, 'tokens', newTokens, 'last_time', now)
redis.call('EXPIRE', key, 10)
return 1 -- 放行
else
-- 更新状态(不消耗)
redis.call('HMSET', key, 'tokens', filledTokens, 'last_time', now)
redis.call('EXPIRE', key, 10)
return 0 -- 拦截
end4.3 优缺点
| 维度 | 评价 |
|---|---|
| 实现复杂度 | ⭐⭐⭐ 最高,需要维护桶状态 |
| 性能 | ⭐⭐ 较高,HMGET + HMSET 多次操作 |
| 内存占用 | ⭐⭐⭐ 低,仅存两个字段 |
| 流量均匀性 | ⭐⭐⭐ 最平滑,允许可控突发 |
4.4 突发流量处理对比
固定窗口:
请求数
▲
20│ ████████ (瞬间突发被拦截)
10│ ████████
└─────────────────► 时间
令牌桶:
请求数
▲
20│ ████████ (突发被平滑)
10│ ████████
└─────────────────► 时间
4.5 适用场景
| 场景 | 是否适用 | 说明 |
|---|---|---|
| 秒杀/抢购 | ✅ 推荐 | 允许初期突发,平滑后续流量 |
| 消息队列消费 | ✅ 推荐 | 控制消费速率,平滑处理 |
| 第三方 API 调用 | ✅ 推荐 | 严格遵守对方限流规则 |
| 网关流量整形 | ⚠️ 可用 | 功能强大但实现复杂,收益不高 |
| 简单防刷 | ❌ 过度设计 | 固定窗口足够,没必要上令牌桶 |
4.6 代码示例
@Component
public class TokenBucketRateLimiter {
private static final String LUA_SCRIPT =
"local limit = tonumber(ARGV[1])\n" +
"local rate = tonumber(ARGV[2])\n" +
"local now = tonumber(ARGV[3])\n" +
"local state = redis.call('HMGET', KEYS[1], 'tokens', 'last_time')\n" +
"local tokens = tonumber(state[1]) or limit\n" +
"local lastTime = tonumber(state[2]) or now\n" +
"local delta = math.max(0, now - lastTime)\n" +
"local filled = math.min(limit, tokens + (delta * rate / 1000))\n" +
"if filled >= 1 then\n" +
" redis.call('HMSET', KEYS[1], 'tokens', filled - 1, 'last_time', now)\n" +
" redis.call('EXPIRE', KEYS[1], 10)\n" +
" return 1\n" +
"end\n" +
"redis.call('HMSET', KEYS[1], 'tokens', filled, 'last_time', now)\n" +
"return 0";
public boolean allow(String key, int capacity, int ratePerSecond) {
long now = System.currentTimeMillis();
Long result = redisTemplate.execute(
new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
Collections.singletonList(key),
String.valueOf(capacity),
String.valueOf(ratePerSecond),
String.valueOf(now)
);
return result != null && result == 1;
}
}五、三种方案全景对比
| 维度 | 固定窗口 | 滑动窗口 | 令牌桶 |
|---|---|---|---|
| 实现复杂度 | ⭐ 低 | ⭐⭐ 中 | ⭐⭐⭐ 高 |
| 性能 (Redis调用) | 1 次 | 3 次 | 2 次 |
| 内存占用 | ⭐⭐⭐ 低 (1个Key) | ⭐⭐ 中 (N个成员) | ⭐⭐⭐ 低 (2个字段) |
| 流量均匀性 | ⭐⭐ 边界突发 | ⭐⭐⭐ 最精确 | ⭐⭐⭐ 最平滑 |
| 允许突发 | ❌ 突发即拦截 | ❌ 突发即拦截 | ✅ 可控突发 |
| 时间精度 | 秒级 | 毫秒级 | 毫秒级 |
| Key 过期处理 | 自动过期 | 自动过期 | 需设置 TTL |
| 适用场景 | 防刷、限流 | 精确控制 | 流量整形 |
性能基准测试(参考值)
| 方案 | 单次请求耗时 | 每秒处理能力 |
|---|---|---|
| 固定窗口 | ~0.5ms | 20000+ |
| 滑动窗口 | ~1.2ms | 8000+ |
| 令牌桶 | ~1.0ms | 10000+ |
六、选型决策树
开始
│
▼
是否需要严格均匀的流量分布?
│
├── 是 ──► 是否允许突发流量?
│ │
│ ├── 是 ──► 令牌桶
│ │
│ └── 否 ──► 滑动窗口
│
└── 否 ──► 是否需要毫秒级精度?
│
├── 是 ──► 滑动窗口
│
└── 否 ──► 固定窗口 ✅ 最推荐
七、场景化推荐
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| API 防刷 | 固定窗口 | 简单、高效、够用 |
| IP 限流 | 固定窗口 | 最常见场景,性价比最高 |
| 登录暴力 破解防护 | 固定窗口 | 3-5次/秒,固定窗口足够 |
| 秒杀/抢购 | 令牌桶 | 允许初期突发,平滑后续 |
| 消息队列消费 | 令牌桶 | 控制消费速率 |
| 第三方 API 调用 | 令牌桶 | 遵守对方限流规则 |
| 金融交易限流 | 滑动窗口 | 精确控制,无边界问题 |
| 严格 QPS 保证 | 滑动窗口 | 任意时刻都不超限 |
| 网关通用限流 | 固定窗口 | 性能最优,运维简单 |
八、总结
一句话选型
大部分场景选固定窗口,需要精确控制选滑动窗口,需要流量整形选令牌桶。
最终建议
| 优先级 | 方案 | 说明 |
|---|---|---|
| 首选 | 固定窗口 | 覆盖 80% 场景,简单可靠 |
| 按需 | 滑动窗口 | 对精度有严格要求时使用 |
| 慎用 | 令牌桶 | 功能强大但实现复杂,非必要不用 |
记住:过度设计是最大的敌人。先用最简单的方案解决问题,等真正遇到瓶颈再升级。
到此这篇关于Redis中分布式限流的三种实现方案详解的文章就介绍到这了,更多相关Redis分布式限流内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!


最新评论