如何在Redis中构建2小时时间桶键并实现Java滑动窗口限流?
Java + Redis 实现用户级限流(2小时窗口/200次阈值)
核心逻辑说明
针对每个用户维护一个2小时时间窗口,窗口内请求累计达200次则触发限流。窗口规则为:从首次请求时间起算2小时,超时后重置计数器;若窗口内请求数未达阈值,每次请求递增计数。
由于并发场景下存在竞态风险(如多个请求同时判断窗口状态导致计数不准),所有Redis操作需通过Lua脚本原子执行,保证逻辑一致性。
Redis数据结构选择
使用Redis哈希表(Hash)存储每个用户的限流信息,键格式为rate_limit:user:{USERID},哈希表包含两个字段:
count:当前窗口内的请求计数expire_ts:窗口过期时间戳(毫秒级)
具体实现
1. 原子性Lua脚本
Lua脚本负责完成以下原子操作:
- 检查用户对应的哈希键是否存在
- 若不存在:初始化计数为1,设置窗口过期时间为当前时间+2小时,返回1(放行)
- 若存在:
- 获取当前窗口的过期时间和计数
- 判断当前时间是否超过窗口过期时间:
- 是:重置计数为1,更新过期时间,返回1(放行)
- 否:递增计数,若计数≥200返回0(限流),否则返回当前计数(放行)
-- 限流Lua脚本 local key = KEYS[1] local threshold = tonumber(ARGV[1]) local window_ms = tonumber(ARGV[2]) local current_ts = tonumber(ARGV[3]) -- 获取哈希表中的计数和过期时间 local count = tonumber(redis.call('HGET', key, 'count')) or 0 local expire_ts = tonumber(redis.call('HGET', key, 'expire_ts')) or 0 if current_ts > expire_ts then -- 窗口已过期,重置 redis.call('HSET', key, 'count', 1) redis.call('HSET', key, 'expire_ts', current_ts + window_ms) -- 设置键的过期时间,避免内存泄漏(窗口过期后额外保留1分钟) redis.call('EXPIRE', key, window_ms / 1000 + 60) return 1 else if count >= threshold then -- 达到阈值,触发限流 return 0 else -- 递增计数 local new_count = count + 1 redis.call('HSET', key, 'count', new_count) return new_count end end
2. Java代码集成(Spring Data Redis示例)
依赖配置
确保项目引入Spring Data Redis依赖(Maven示例):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
限流工具类实现
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; import java.util.Collections; import java.util.concurrent.TimeUnit; @Component public class UserRateLimiter { // 限流阈值:200次 private static final int THRESHOLD = 200; // 窗口时长:2小时(转毫秒) private static final long WINDOW_MS = TimeUnit.HOURS.toMillis(2); // Redis键前缀 private static final String KEY_PREFIX = "rate_limit:user:"; private final RedisTemplate<String, Object> redisTemplate; private final DefaultRedisScript<Long> rateLimitScript; public UserRateLimiter(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; // 初始化Lua脚本 rateLimitScript = new DefaultRedisScript<>(); rateLimitScript.setScriptText(getLuaScriptContent()); rateLimitScript.setResultType(Long.class); } /** * 判断用户请求是否被限流 * @param userId 用户ID * @return true=被限流,false=放行 */ public boolean isLimited(String userId) { String key = KEY_PREFIX + userId; long currentTs = System.currentTimeMillis(); // 执行Lua脚本 Long result = redisTemplate.execute( rateLimitScript, Collections.singletonList(key), THRESHOLD, WINDOW_MS, currentTs ); // 结果为0表示触发限流,其他值为当前计数(放行) return result != null && result == 0; } /** * 读取Lua脚本内容 */ private String getLuaScriptContent() { return "-- 限流Lua脚本\n" + "local key = KEYS[1]\n" + "local threshold = tonumber(ARGV[1])\n" + "local window_ms = tonumber(ARGV[2])\n" + "local current_ts = tonumber(ARGV[3])\n" + "\n" + "local count = tonumber(redis.call('HGET', key, 'count')) or 0\n" + "local expire_ts = tonumber(redis.call('HGET', key, 'expire_ts')) or 0\n" + "\n" + "if current_ts > expire_ts then\n" + " redis.call('HSET', key, 'count', 1)\n" + " redis.call('HSET', key, 'expire_ts', current_ts + window_ms)\n" + " redis.call('EXPIRE', key, window_ms / 1000 + 60)\n" + " return 1\n" + "else\n" + " if count >= threshold then\n" + " return 0\n" + " else\n" + " local new_count = count + 1\n" + " redis.call('HSET', key, 'count', new_count)\n" + " return new_count\n" + " end\n" + "end"; } }
3. 业务代码中使用
@RestController public class DemoController { private final UserRateLimiter userRateLimiter; public DemoController(UserRateLimiter userRateLimiter) { this.userRateLimiter = userRateLimiter; } @GetMapping("/api/resource") public String accessResource(@RequestParam String userId) { if (userRateLimiter.isLimited(userId)) { return "请求过于频繁,请2小时后再试"; } // 处理业务逻辑 return "请求成功"; } }
补充:严格滑动窗口实现(可选)
如果需要计算当前时间往前2小时内的所有请求数(而非固定窗口),可使用Redis有序集合(ZSet)实现:
- 每个请求到来时,向ZSet添加元素(value为唯一标识,score为当前时间戳)
- 删除ZSet中score小于(当前时间-2小时)的元素
- 统计ZSet元素数量,若≥200则触发限流
实现逻辑类似,核心是用ZSet维护请求时间戳并定时清理过期元素。
内容的提问来源于stack exchange,提问作者Yathish Manjunath
相关产品推荐
相关产品推荐

