redis实现限流
Redis 实现限流:从滑动窗口到多方案对比
限流是保障系统稳定性的关键手段,通过控制单位时间内的请求量,防止服务因过载而崩溃。Redis 凭借其高性能和原子操作特性,成为实现分布式限流的常用工具。
滑动窗口限流
基于 Redis 的 ZSet 实现了滑动窗口限流,核心思想是精确统计任意时间窗口内的请求量,避免固定窗口的 “边界突发” 问题。
通过统计该窗口内的行为数量和限制的最大数量maxCount进行比较就可以得出当前的请求是否允许
public class RedisRateLimiter {
private StringRedisTemplate stringRedisTemplate;
public void test() {
for (int i = 0; i < 10; i++) {
System.out.println(isActionAllow("user/list", "127.0.0.1", 1, 5));
try {
Thread.sleep(150);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* 使用zset实现简单的滑动窗口限流
*
* @param uri
* @param ip
* @param period 时间窗口 单位秒
* @param maxCount 最大允许数量
* @return
*/
public boolean isActionAllow(String uri, String ip, int period, int maxCount) {
// key为 uri和ip
String key = String.format("hist:%s:%s", uri, ip);
long cur = System.currentTimeMillis();
List<Object> pipelined = stringRedisTemplate.executePipelined(
new RedisCallback<Long>() {
public Long doInRedis(RedisConnection connection) throws DataAccessException {
// 记录行为
connection.zAdd(key.getBytes(), cur, String.valueOf(cur).getBytes());
// 移除时间窗口之前的行为记录
connection.zRemRangeByScore(key.getBytes(), 0, cur - period * 1000);
// 获取窗口内的行为数量
Long count = connection.zCard(key.getBytes());
// 清理冷数据,防止冷数据持续占用内存
connection.expire(key.getBytes(), period + 1);
return count;
}
}
);
// System.out.println(pipelined);
Object o = pipelined.get(2);
// System.out.println(o);
return Long.parseLong(String.valueOf(o)) <= maxCount;
}
}
如果时间窗口内允许的数量较大,会消耗大量的内存。则不适合该方式
实现原理
- 存储结构:用
ZSet存储请求记录,score为请求时间戳(毫秒级),value为时间戳(确保唯一性)。 - 核心操作:
- 新增当前请求的时间戳到
ZSet。 - 移除时间窗口外的记录(如窗口为 1 秒,则移除
当前时间 - 1000ms之前的记录)。 - 统计窗口内的记录数,若小于等于
maxCount则允许请求,否则限流。 - 为
ZSet设置过期时间,避免冷数据占用内存。
- 新增当前请求的时间戳到
代码优化:原子性保证
上述代码使用了 pipeline 批量执行命令,但 pipeline 仅能减少网络往返,不能保证操作的原子性。高并发下,可能出现 “新增记录后,其他请求已修改了 ZSet,导致计数不准确” 的问题。
解决方案:用 Lua 脚本封装操作,确保添加、移除、计数三步原子执行:
-- 滑动窗口限流 Lua 脚本
-- KEYS[1]:限流键(如 "hist:user/list:127.0.0.1")
-- ARGV[1]:当前时间戳(毫秒)
-- ARGV[2]:窗口大小(毫秒,如 1000)
-- ARGV[3]:最大允许请求数
local key = KEYS[1]
local curTime = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local maxCount = tonumber(ARGV[3])
-- 1. 添加当前请求
redis.call('ZADD', key, curTime, tostring(curTime))
-- 2. 移除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, curTime - window)
-- 3. 设置过期时间(窗口+1秒,避免频繁删除)
redis.call('EXPIRE', key, window / 1000 + 1)
-- 4. 统计窗口内请求数
local count = redis.call('ZCARD', key)
-- 5. 判断是否允许
return count <= maxCount
Java 调用示例:
public boolean isActionAllowLua(String uri, String ip, int period, int maxCount) {
String key = String.format("hist:%s:%s", uri, ip);
long curTime = System.currentTimeMillis();
String luaScript = "local key = KEYS[1] ..."; // 上述Lua脚本
// 执行Lua脚本(原子操作)
Long result = stringRedisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(key),
String.valueOf(curTime),
String.valueOf(period * 1000), // 窗口毫秒数
String.valueOf(maxCount)
);
return result != null && result == 1;
}
优缺点分析
- 优点:流量控制精确,无固定窗口的边界问题(如 1 秒窗口,不会在 0.99 秒和 1.01 秒分别出现 maxCount 次请求)。
- 缺点:
- 当
maxCount很大(如 10 万),ZSet会存储大量数据,占用内存且ZCARD操作耗时增加(O (logN))。 - 不适合超高频场景(如每秒 10 万 + 请求)。
- 当
其他常见 Redis 限流方案
1. 固定窗口限流(简单高效)
原理:将时间划分为固定窗口(如 1 秒),用 Redis 计数器记录窗口内请求数,超过阈值则限流。
实现:
public boolean fixedWindowLimit(String uri, String ip, int windowSec, int maxCount) {
String key = String.format("fixed:%s:%s", uri, ip);
// 原子操作:INCR + 过期时间设置(首次设置时生效)
Long count = stringRedisTemplate.opsForValue().increment(key);
if (count != null && count == 1) {
stringRedisTemplate.expire(key, windowSec, TimeUnit.SECONDS); // 窗口大小即过期时间
}
return count != null && count <= maxCount;
}
优缺点:
- 优点:实现简单,
INCR操作 O (1),性能极佳。 - 缺点:窗口边界可能出现 “双倍流量”(如窗口 1 秒,前窗口最后 100ms 和新窗口前 100ms 各接受 maxCount 次请求)。
2. 漏桶算法(平滑流出)
原理:请求像水一样进入漏桶,漏桶以固定速率流出(处理请求),超过桶容量则限流。
实现:用 Redis 的 Hash 存储桶的 “当前水量” 和 “最后处理时间”,每次请求时计算应流出的水量,更新当前水量:
public boolean leakyBucketLimit(String uri, String ip, int rate, int capacity) {
String key = String.format("leaky:%s:%s", uri, ip);
long now = System.currentTimeMillis();
// 原子操作:获取并更新桶状态
List<Object> result = stringRedisTemplate.executePipelined(new SessionCallback<Object>() {
public Object execute(RedisOperations operations) throws DataAccessException {
operations.multi();
operations.opsForHash().entries(key);
return null;
}
});
// 解析结果,计算当前水量(省略细节)
// ...
return currentWater <= capacity;
}
优缺点:
- 优点:流量输出稳定,适合要求 “匀速处理” 的场景(如 API 调用限速)。
- 缺点:无法应对突发流量(即使桶空,也只能按固定速率处理)。
3. 令牌桶算法(允许突发)
原理:系统按固定速率向桶中添加令牌,请求需获取令牌才能通过,桶满后令牌溢出(允许一定突发)。
实现:用 Redis 存储 “令牌数” 和 “最后添加令牌时间”,每次请求前计算应添加的令牌数,再尝试获取令牌:
-- 令牌桶限流 Lua 脚本
-- KEYS[1]:限流键(如 "token:user/list:127.0.0.1")
-- ARGV[1]:当前时间戳(毫秒)
-- ARGV[2]:令牌生成速率(令牌/秒)
-- ARGV[3]:桶容量(最大令牌数)
-- ARGV[4]:本次请求需要的令牌数(默认为1)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local tokenRate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local required = tonumber(ARGV[4] or 1)
-- 初始化桶(hash结构存储两个字段:last_time和tokens)
local bucket = redis.call("HMGET", key, "last_time", "tokens")
local lastTime = bucket[1] and tonumber(bucket[1]) or now
local tokens = bucket[2] and tonumber(bucket[2]) or capacity -- 初始令牌数为容量
-- 计算从上次补充到现在应新增的令牌数
local elapsed = now - lastTime -- 毫秒差
local addTokens = math.floor((elapsed / 1000) * tokenRate) -- 转换为秒计算
-- 更新令牌数(不超过容量)
tokens = math.min(tokens + addTokens, capacity)
-- 尝试消耗令牌
local allowed = tokens >= required
if allowed then
tokens = tokens - required
end
-- 更新桶状态(最后补充时间和当前令牌数)
redis.call("HMSET", key, "last_time", now, "tokens", tokens)
-- 设置过期时间(防止冷数据占用内存,1小时无操作则过期)
redis.call("EXPIRE", key, 3600)
-- 返回是否允许(1=允许,0=拒绝)
return allowed and 1 or 0
/**
* 支持自定义令牌消耗数量的重载方法
*
* @param requiredTokens 本次请求需要的令牌数(如大文件上传可消耗多个令牌)
*/
public boolean tokenBucketLimit(String uri, String ip, int tokenRate, int capacity, int requiredTokens) {
String key = String.format("token:%s:%s", uri, ip);
long now = System.currentTimeMillis(); // 当前时间戳(毫秒)
// 执行Lua脚本
Long result = stringRedisTemplate.execute(
tokenBucketScript,
Collections.singletonList(key),
String.valueOf(now),
String.valueOf(tokenRate),
String.valueOf(capacity),
String.valueOf(requiredTokens)
);
return result != null && result == 1;
}
优缺点:
- 优点:兼顾流量平滑和突发需求(如秒杀场景允许短时间内的大量请求)。
- 缺点:实现较复杂,需精确计算令牌添加量。
方案选择指南
| 方案 | 精度 | 性能 | 适用场景 | 缺点 |
|---|---|---|---|---|
| 滑动窗口 | 高 | 中 | 流量精度要求高(如支付接口) | 内存占用高,大流量压力大 |
| 固定窗口 | 中 | 高 | 一般限流(如普通 API) | 窗口边界可能超流 |
| 漏桶算法 | 中 | 中 | 匀速处理(如消息推送) | 不支持突发流量 |
| 令牌桶算法 | 高 | 中 | 允许突发(如秒杀、搜索) | 实现复杂 |
分布式限流注意事项
- 原子性:所有限流操作必须原子化(用 Lua 脚本或 Redis 原子命令),避免分布式环境下的计数不准。
- 内存占用:滑动窗口和令牌桶可能存储大量中间数据,需合理设置过期时间(如窗口大小 + 1 秒)。
- Redis 性能:高并发下 Redis 可能成为瓶颈,建议:
- 分片存储限流键(避免单节点压力)。
- 对非核心接口降低限流精度(如用固定窗口替代滑动窗口)。