寻求基于Redis+滚动窗口限流+退避策略的Java限流实现方案
基于Redis的Java滚动窗口令牌桶限流实现(支持退避时长)
核心思路
要实现和redis-token-bucket-ratelimiter一致的基于时间戳的滚动窗口限流,且返回退避时长,必须依赖Redis Lua脚本保证原子性(避免并发场景下的计数误差)。核心逻辑是:
- 用Redis有序集合(ZSet)存储滚动窗口内的令牌消耗记录,以时间戳为score、消耗令牌数为value
- 每次请求先清理窗口外的过期记录,计算窗口内已消耗的总令牌数
- 根据剩余令牌判断是否允许请求,若拒绝则计算需要等待的退避时长
实现代码
1. 定义返回结果类
public class RollingLimitResult { private int limit; private int remaining; private boolean rejected; private long retryDelta; private boolean forced; public RollingLimitResult(int limit, int remaining, boolean rejected, long retryDelta, boolean forced) { this.limit = limit; this.remaining = remaining; this.rejected = rejected; this.retryDelta = retryDelta; this.forced = forced; } // 按需生成getter方法 public int getLimit() { return limit; } public int getRemaining() { return remaining; } public boolean isRejected() { return rejected; } public long getRetryDelta() { return retryDelta; } public boolean isForced() { return forced; } }
2. Redis Lua脚本(核心原子逻辑)
-- KEYS[1] = 限流完整key -- ARGV[1] = 窗口间隔(毫秒) -- ARGV[2] = 窗口内令牌限额 -- ARGV[3] = 请求令牌数 -- ARGV[4] = 是否强制放行(1/0) -- ARGV[5] = 当前时间戳(毫秒) local key = KEYS[1] local interval = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) local requestAmount = tonumber(ARGV[3]) local force = tonumber(ARGV[4]) == 1 local now = tonumber(ARGV[5]) -- 清理窗口外的过期记录 local windowStart = now - interval redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart) -- 计算窗口内已消耗的总令牌数 local usedTokens = tonumber(redis.call('ZSUM', key) or 0) local remainingTokens = limit - usedTokens -- 判断是否允许请求 local allowed = remainingTokens >= requestAmount or force local newUsedTokens = usedTokens + requestAmount if allowed then -- 记录本次令牌消耗 redis.call('ZADD', key, now, requestAmount) -- 设置key过期时间,避免内存泄漏 redis.call('EXPIRE', key, math.ceil(interval / 1000) + 1) end -- 计算退避时长(仅拒绝时有效) local retryDelta = 0 if not allowed then -- 计算需要补充的令牌数 local needed = requestAmount - remainingTokens -- 按令牌恢复速率计算退避时长:恢复速率 = 总限额/窗口间隔(令牌/毫秒) retryDelta = math.ceil(needed * interval / limit) end -- 返回结果:[限额, 剩余令牌, 是否拒绝(1=是/0=否), 退避时长, 是否强制(1=是/0=否)] return { limit, remainingTokens - (allowed and requestAmount or 0), allowed and 0 or 1, retryDelta, force and 1 or 0 }
3. Java限流工具类封装
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import java.util.Collections; import java.util.List; public class RollingLimit { private final RedisTemplate<String, Object> redisTemplate; private final DefaultRedisScript<List<Long>> limitScript; private final int interval; private final int limit; private final String prefix; public RollingLimit(RedisTemplate<String, Object> redisTemplate, int interval, int limit, String prefix) { this.redisTemplate = redisTemplate; this.interval = interval; this.limit = limit; this.prefix = prefix; // 初始化Lua脚本(建议将脚本放在resources目录下读取,避免硬编码) this.limitScript = new DefaultRedisScript<>(); limitScript.setScriptText("上面的Lua脚本内容"); limitScript.setResultType(List.class); } public RollingLimitResult use(String reqKey, int reqAmount) { return use(reqKey, reqAmount, false); } public RollingLimitResult use(String reqKey, int reqAmount, boolean force) { String fullKey = prefix + ":" + reqKey; long now = System.currentTimeMillis(); List<String> keys = Collections.singletonList(fullKey); List<Object> args = List.of(interval, limit, reqAmount, force ? 1 : 0, now); // 执行Lua脚本 List<Long> result = redisTemplate.execute(limitScript, keys, args.toArray()); // 解析返回结果 int resultLimit = result.get(0).intValue(); int remaining = result.get(1).intValue(); boolean rejected = result.get(2) == 1; long retryDelta = result.get(3); boolean forced = result.get(4) == 1; return new RollingLimitResult(resultLimit, remaining, rejected, retryDelta, forced); } }
关键逻辑说明
- 滚动窗口实现:通过
ZREMRANGEBYSCORE清理窗口起始时间之前的所有记录,确保每次计算的都是当前窗口内的令牌消耗。 - 退避时长计算:当令牌不足时,根据令牌恢复速率(总限额/窗口间隔)计算等待时间,保证等待后窗口内有足够令牌处理请求。
- 原子性保障:所有计算和更新操作在Lua脚本中原子执行,避免并发请求导致的计数错误。
- 内存优化:给ZSet设置过期时间,避免无用key占用Redis内存。
使用示例
// 假设已初始化RedisTemplate RollingLimit limiter = new RollingLimit(redisTemplate, 60000, 100, "api:rate:limit"); try { RollingLimitResult result = limiter.use("user:123", 1); if (result.isRejected()) { System.out.println("请求被限流,需等待" + result.getRetryDelta() + "毫秒"); } else { System.out.println("请求允许,剩余令牌:" + result.getRemaining()); } } catch (Exception e) { // 处理Redis调用异常 e.printStackTrace(); }
内容的提问来源于stack exchange,提问作者Rajagopal M
相关产品推荐
相关产品推荐

