将限流组件从ZSET滑动窗口 (依赖ZREMRANGEBYSCORE清理过期数据)改为1秒十个小分桶 (即10个100ms的固定窗口组合),本质是从高精度滑动窗口 降级为低精度分段窗口 。这个改动主要在内存效率 、精度 和实现复杂度之间做了取舍。
ZSET方案(原方案):精准但耗内存
ZSET方案是实现滑动窗口限流的经典方法。每次请求来时,会将当前时间戳作为score存入ZSET,然后通过ZREMRANGEBYSCORE移除窗口外的旧数据,最后用ZCARD统计窗口内请求数。
优势在于:
-
精度高 :它是真正的滑动窗口,能精确统计过去
N秒内任意时刻的请求量,没有"临界突变"问题。 -
实时清理:每次操作都会清理过期数据,理论上内存占用可控。
劣势也很明显:
-
内存开销大 :每个请求都需要在ZSET中存储一个唯一的
value(通常用UUID或时间戳+随机数)和score(8字节的双精度浮点数)。在高并发下(例如1秒内百万级请求),这个数据量是巨大的,内存占用会非常可观。 -
操作成本高 :每次请求都伴随着
ZADD、ZREMRANGEBYSCORE和ZCARD多个Redis操作,虽然可以用pipeline优化,但整体CPU和网络开销仍较高。
十小分桶方案(新方案):内存友好,精度妥协
将1秒分成10个100ms的小桶,实际上是用分段固定窗口 来模拟 滑动窗口。每个桶只存一个计数值(INCR操作),而不是存储每个请求的明细。
优势在于:
-
极致的内存效率:1秒10个桶,意味着每个限流Key最多只需要存储10个计数器,内存占用几乎可以忽略不计。这对于高并发、长周期限流(如每分钟、每小时)非常友好。
-
操作简单高效 :每次请求只需根据当前时间计算桶索引,然后执行一次
INCR操作,并设置好过期时间。Redis操作从多次变为1-2次,性能大幅提升。
劣势也很明显:
-
精度损失:它仍然有固定窗口算法的边界问题。10个桶只是把统计精度从1秒粗糙到了100ms,并不能完全消除"窗口切换瞬间流量突刺"的问题。例如,在第1个桶的末尾和第2个桶的开头集中涌入大量请求,统计上可能会超出阈值。
-
实现复杂度增加 :你需要自己维护桶的索引计算和轮转逻辑(例如用
hincrby操作不同field),比直接用ZSET的ZREMRANGEBYSCORE要更复杂一些。
总结对比
| 维度 | ZSET 滑动窗口 | 1秒十个小分桶 |
|---|---|---|
| 统计精度 | 高,真正的滑动窗口 | 中等,精度为100ms |
| 内存占用 | 高,每个请求存一个元素 | 极低,固定10个计数器 |
| 性能开销 | 较高,涉及多个Redis命令 | 很低,主要是原子自增操作 |
| 实现复杂度 | 简单,代码量少 | 略复杂,需维护桶逻辑 |
| 适用场景 | 低频、对精度要求严格的场景 | 高频、对内存敏感、允许轻微边界波动的场景 |
选择建议
如果你的系统QPS很高(例如上万),且对内存非常敏感,那么1秒十个小分桶是更优的选择。它能以微小的精度牺牲,换来巨大的内存和性能收益。
如果业务逻辑对限流精度要求极其苛刻,必须保证任意时刻的请求数都不能超过阈值(例如金融交易),那么原生的ZSET滑动窗口方案虽然"重",但却是最安全的。
特别提醒 :无论采用哪种方案,都别忘了给Key设置合理的过期时间(EXPIRE),防止冷用户的数据持续占用Redis内存。
方案一:Hash 分桶(推荐,最优雅)
每个限流Key是一个Hash,里面有10个field(0 ~ 9),每个field的值是该100ms内的请求计数。
1. 核心计算逻辑
java
public boolean allow(String userId) {
// 1. 获取当前时间戳(毫秒)
long now = System.currentTimeMillis();
// 2. 计算当前属于哪一秒(用于生成Redis Key)
String secondKey = "rate_limit:" + userId + ":" + (now / 1000);
// 3. 计算当前属于该秒内的第几个100ms桶 (0-9)
int bucketIndex = (int) ((now % 1000) / 100);
String bucketField = String.valueOf(bucketIndex);
// 4. 执行Lua脚本(原子操作)
// 参数:KEYS[1]=secondKey, ARGV[1]=bucketField, ARGV[2]=过期时间(2秒), ARGV[3]=阈值
String luaScript =
"local current = redis.call('HINCRBY', KEYS[1], ARGV[1], 1) " +
"redis.call('EXPIRE', KEYS[1], ARGV[2]) " +
"local total = 0 " +
"for i=0,9 do " +
" total = total + tonumber(redis.call('HGET', KEYS[1], tostring(i)) or 0) " +
"end " +
"return total";
Long total = (Long) redisTemplate.execute(luaScript,
Collections.singletonList(secondKey),
bucketField, "2", String.valueOf(limitThreshold));
return total <= limitThreshold;
}
对比 ZSET 实现的代码差异
如果是 ZSET,代码逻辑是:
java
// ZSET实现
long now = System.currentTimeMillis();
String key = "rate_limit:" + userId;
// 清理1秒前的数据
redisTemplate.opsForZSet().removeRangeByScore(key, 0, now - 1000);
// 添加当前请求
redisTemplate.opsForZSet().add(key, UUID.randomUUID().toString(), now);
// 统计总数
Long count = redisTemplate.opsForZSet().zCard(key);
优化1:HVALS改成HGETALL(小优化,提升5%~10%)
当前你用的是HVALS获取所有值再求和,但HVALS返回的是值列表,不包含field名。HGETALL返回field+value的完整列表,虽然数据量翻倍,但Redis内部是单次遍历,比HVALS多一次hash查找。
实际上,更优的是用HGETALL替代HVALS,因为field列表是固定的10个,Redis在底层可以连续内存读取。