0%

redis实现限流

Redis 实现限流:从滑动窗口到多方案对比

限流是保障系统稳定性的关键手段,通过控制单位时间内的请求量,防止服务因过载而崩溃。Redis 凭借其高性能和原子操作特性,成为实现分布式限流的常用工具。

滑动窗口限流

基于 Redis 的 ZSet 实现了滑动窗口限流,核心思想是精确统计任意时间窗口内的请求量,避免固定窗口的 “边界突发” 问题。

通过统计该窗口内的行为数量和限制的最大数量maxCount进行比较就可以得出当前的请求是否允许

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class RedisRateLimiter {
@Autowired
private StringRedisTemplate stringRedisTemplate;

@Test
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>() {

@Override
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 为时间戳(确保唯一性)。
  • 核心操作:
    1. 新增当前请求的时间戳到 ZSet
    2. 移除时间窗口外的记录(如窗口为 1 秒,则移除 当前时间 - 1000ms 之前的记录)。
    3. 统计窗口内的记录数,若小于等于 maxCount 则允许请求,否则限流。
    4. ZSet 设置过期时间,避免冷数据占用内存。

代码优化:原子性保证

上述代码使用了 pipeline 批量执行命令,但 pipeline 仅能减少网络往返,不能保证操作的原子性。高并发下,可能出现 “新增记录后,其他请求已修改了 ZSet,导致计数不准确” 的问题。

解决方案:用 Lua 脚本封装操作,确保添加、移除、计数三步原子执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 滑动窗口限流 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 调用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 计数器记录窗口内请求数,超过阈值则限流。

实现

1
2
3
4
5
6
7
8
9
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 存储桶的 “当前水量” 和 “最后处理时间”,每次请求时计算应流出的水量,更新当前水量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
operations.multi();
operations.opsForHash().entries(key);
return null;
}
});
// 解析结果,计算当前水量(省略细节)
// ...
return currentWater <= capacity;
}

优缺点

  • 优点:流量输出稳定,适合要求 “匀速处理” 的场景(如 API 调用限速)。
  • 缺点:无法应对突发流量(即使桶空,也只能按固定速率处理)。

3. 令牌桶算法(允许突发)

原理:系统按固定速率向桶中添加令牌,请求需获取令牌才能通过,桶满后令牌溢出(允许一定突发)。

实现:用 Redis 存储 “令牌数” 和 “最后添加令牌时间”,每次请求前计算应添加的令牌数,再尝试获取令牌:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- 令牌桶限流 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 支持自定义令牌消耗数量的重载方法
*
* @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) 窗口边界可能超流
漏桶算法 匀速处理(如消息推送) 不支持突发流量
令牌桶算法 允许突发(如秒杀、搜索) 实现复杂

分布式限流注意事项

  1. 原子性:所有限流操作必须原子化(用 Lua 脚本或 Redis 原子命令),避免分布式环境下的计数不准。
  2. 内存占用:滑动窗口和令牌桶可能存储大量中间数据,需合理设置过期时间(如窗口大小 + 1 秒)。
  3. Redis 性能:高并发下 Redis 可能成为瓶颈,建议:
    • 分片存储限流键(避免单节点压力)。
    • 对非核心接口降低限流精度(如用固定窗口替代滑动窗口)。

欢迎关注我的其它发布渠道