
1. Redis ZSetLua脚本SpringBoot实战滑动窗口限流方案从原理到落地在分布式系统开发中接口限流是每个后端工程师必须掌握的保命技能。记得去年双十一大促我们系统就因为没有做好限流被突发的流量高峰直接打挂导致核心服务不可用近半小时。这次惨痛教训让我深入研究了各种限流方案最终发现基于Redis ZSet和Lua脚本实现的滑动窗口限流在精度和性能上达到了最佳平衡。1.1 为什么选择滑动窗口限流1.1.1 固定窗口的致命缺陷传统固定窗口限流就像地铁站的闸机每分钟只放行固定数量的人。但想象一下在59秒时突然涌入大量乘客紧接着下一分钟刚开始又有一批人到达——这两批人实际上是在极短时间内连续进入的但系统却认为它们属于两个不同的时间窗口。这就是著名的边界漏洞问题。1.1.2 滑动窗口的精准控制滑动窗口则像高速公路上的区间测速不是看你在某个固定点的瞬时速度而是计算你在某段连续距离内的平均速度。对应到限流场景它会动态统计最近N秒内的请求量无论流量高峰出现在哪个时间点都能准确捕捉。2. Redis ZSet的核心优势2.1 有序集合的天然特性Redis的ZSet有序集合有两个关键特性特别适合实现滑动窗口每个元素都有唯一的score值我们用时间戳作为score可以高效地按score范围查询和删除元素// 示例ZSet中存储的请求记录 // key: user:123:api:login // value: [ [1612345678.123, req1], [1612345678.456, req2], ... ]2.2 内存友好的数据结构相比用List存储所有请求记录ZSet通过以下方式优化内存自动清理过期数据通过ZREMRANGEBYSCORE压缩列表ziplist在小数据量时的内存优化跳跃表skiplist在大数据量时仍保持O(logN)的操作复杂度3. Lua脚本的原子性保障3.1 竞态条件的风险考虑以下场景客户端A查询当前请求数100个未超限客户端B同时查询也是100个两者都判断未超限都允许通过 结果实际通过了101个请求突破了限制。3.2 Lua的原子执行Redis单线程模型Lua脚本可以完美解决这个问题。整个限流逻辑查询→判断→记录在一个原子操作中完成-- 限流核心逻辑伪代码 local current_time tonumber(ARGV[1]) local window_size tonumber(ARGV[2]) local limit tonumber(ARGV[3]) -- 移除过期记录 redis.call(ZREMRANGEBYSCORE, KEYS[1], 0, current_time - window_size) -- 获取当前请求数 local count redis.call(ZCARD, KEYS[1]) if count limit then -- 记录新请求 redis.call(ZADD, KEYS[1], current_time, current_time) -- 设置过期时间避免内存泄漏 redis.call(EXPIRE, KEYS[1], window_size) return 1 -- 允许通过 else return 0 -- 拒绝请求 end4. SpringBoot工程化实现4.1 项目结构设计src/ ├── main/ │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ ├── config/ │ │ │ └── RedisConfig.java │ │ ├── annotation/ │ │ │ └── RateLimit.java │ │ ├── aspect/ │ │ │ └── RateLimitAspect.java │ │ └── service/ │ │ └── RateLimitService.java │ └── resources/ │ └── application.yml4.2 核心代码实现RateLimit注解定义Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface RateLimit { String key() default ; // 限流key int limit() default 10; // 限制次数 int window() default 60; // 时间窗口(秒) String message() default 请求过于频繁请稍后再试; }Lua脚本加载Configuration public class RedisConfig { Bean public DefaultRedisScriptLong rateLimitScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptSource(new ResourceScriptSource( new ClassPathResource(META-INF/scripts/rate_limiter.lua))); script.setResultType(Long.class); return script; } }切面逻辑实现Aspect Component public class RateLimitAspect { Autowired private StringRedisTemplate redisTemplate; Autowired private DefaultRedisScriptLong rateLimitScript; Around(annotation(rateLimit)) public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String key rateLimit.key(); if (StringUtils.isEmpty(key)) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); key signature.getMethod().toString(); } ListString keys Collections.singletonList(key); long now System.currentTimeMillis() / 1000; Long result redisTemplate.execute( rateLimitScript, keys, String.valueOf(now), String.valueOf(rateLimit.window()), String.valueOf(rateLimit.limit()) ); if (result null || result 0) { throw new RuntimeException(rateLimit.message()); } return joinPoint.proceed(); } }5. 性能优化与生产实践5.1 内存优化技巧合理设置时间窗口不是越大越好通常API限流设置1-60秒足够使用短KEY如rl:u123:a:login替代rate_limit:user_123:api:login定期清理即使有EXPIRE也可以增加定时任务二次保障5.2 集群环境注意事项在Redis Cluster中所有Lua脚本操作的KEY必须在同一个slot解决方案使用hash tag确保相同用户的路由到同一节点{user123}:api:login或采用本地限流全局限流的分层设计5.3 监控与调优// 在切面中添加监控埋点 Around(annotation(rateLimit)) public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { long start System.currentTimeMillis(); try { // ...原有逻辑... Metrics.counter(rate_limit.pass, key, key).increment(); return joinPoint.proceed(); } catch (Exception e) { Metrics.counter(rate_limit.block, key, key).increment(); throw e; } finally { Metrics.timer(rate_limit.latency, key, key) .record(System.currentTimeMillis() - start, TimeUnit.MILLISECONDS); } }6. 常见问题排查指南6.1 Lua脚本报错NOSCRIPT错误现象突然出现NOSCRIPT No matching script...错误原因与解决Redis重启后脚本缓存丢失解决方案实现脚本预加载机制PostConstruct public void preloadScript() { redisTemplate.execute(rateLimitScript, Collections.singletonList(__preload__), 0, 1, 1); }6.2 限流不生效现象明明设置了10次/秒但实际通过了更多请求检查清单KEY设计是否唯一不同用户/接口是否混用时间单位是否正确毫秒 vs 秒Redis命令是否真的原子执行确保使用Lua脚本6.3 性能瓶颈现象高并发下Redis响应变慢优化方向使用pipeline批量处理适合批量接口考虑本地缓存分布式限流的多级方案监控Redis CPU和网络负载7. 进阶扩展思路7.1 动态限流规则通过配置中心实现动态调整RefreshScope Configuration public class RateLimitConfig { Value(${rate.limit.default:10}) private int defaultLimit; Value(${rate.limit.window:60}) private int defaultWindow; }7.2 分级限流策略根据不同用户等级设置不同阈值RateLimit(key #user.id : #user.level, limit #user.level VIP ? 100 : 10, window 60) public ApiResponse doSomething(User user) { // ... }7.3 预热模式仿照Guava RateLimiter的预热机制-- 在Lua脚本中实现动态计算limit local warmup_factor 1 - math.exp(-1 * (current_count / warmup_period)) local actual_limit base_limit * warmup_factor在实际项目中落地这套方案后我们的系统在618大促期间成功抵御了每秒5万次的恶意刷单请求核心API的可用性保持在99.99%以上。特别提醒两点经验一是Lua脚本中一定要设置KEY的过期时间二是生产环境务必添加详细的监控指标。