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(); } } } public boolean isActionAllow (String uri, String ip, int period, int maxCount) { 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; } } ); Object o = pipelined.get(2 ); return Long.parseLong(String.valueOf(o)) <= maxCount; } }
如果时间窗口内允许的数量较大,会消耗大量的内存。则不适合该方式
实现原理
存储结构 :用 ZSet 存储请求记录,score 为请求时间戳(毫秒级),value 为时间戳(确保唯一性)。
核心操作:
新增当前请求的时间戳到 ZSet。
移除时间窗口外的记录(如窗口为 1 秒,则移除 当前时间 - 1000ms 之前的记录)。
统计窗口内的记录数,若小于等于 maxCount 则允许请求,否则限流。
为 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 local key = KEYS[1 ]local curTime = tonumber (ARGV[1 ])local window = tonumber (ARGV[2 ])local maxCount = tonumber (ARGV[3 ])redis.call('ZADD' , key, curTime, tostring (curTime)) redis.call('ZREMRANGEBYSCORE' , key, 0 , curTime - window) redis.call('EXPIRE' , key, window / 1000 + 1 ) local count = redis.call('ZCARD' , key)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] ..." ; 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); 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 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 )local bucket = redis.call("HMGET" , key, "last_time" , "tokens" )local lastTime = bucket[1 ] and tonumber (bucket[1 ]) or nowlocal 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 >= requiredif allowed then tokens = tokens - required end redis.call("HMSET" , key, "last_time" , now, "tokens" , tokens) redis.call("EXPIRE" , key, 3600 ) 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 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(); 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 可能成为瓶颈,建议:
分片存储限流键(避免单节点压力)。
对非核心接口降低限流精度(如用固定窗口替代滑动窗口)。