在现代分布式系统中,API 限流是保护服务稳定性的关键防线。我曾亲历一个未做限流的支付接口被脚本刷爆,导致整个系统雪崩的惨痛教训。本文将深入探讨如何利用 Redis 实现高性能的滑动窗口限流算法,并提供可落地的 Java 实现方案。
一、为什么需要滑动窗口限流?
固定窗口算法(如每分钟100次)存在临界突变问题:在窗口切换瞬间可能承受双倍流量冲击。滑动窗口通过动态时间区间统计,实现了更平滑精确的流量控制,特别适用于高频敏感场景。
二、核心设计:Redis 有序集合(ZSET)
实现原理:
-
使用 ZSET 存储请求时间戳(score=timestamp)
-
每个请求到来时:
-
清除窗口外的旧数据(ZREMRANGEBYSCORE)
-
统计当前窗口内请求数(ZCARD)
-
添加新时间戳(ZADD)
-
-
通过 TTL 自动清理过期数据
import redis.clients.jedis.Jedis; import java.time.Instant; public class SlidingWindowRateLimiter { private final Jedis jedis; private final String key; private final int maxRequests; private final long windowMillis; public SlidingWindowRateLimiter(Jedis jedis, String key, int maxRequests, long windowMillis) { this.jedis = jedis; this.key = key; this.maxRequests = maxRequests; this.windowMillis = windowMillis; } public boolean isAllowed() { long now = Instant.now().toEpochMilli(); long windowStart = now - windowMillis; // 原子操作:移除旧数据并添加新请求 jedis.zremrangeByScore(key, 0, windowStart); jedis.zadd(key, now, String.valueOf(now)); // 设置键过期避免内存泄漏 jedis.expire(key, windowMillis / 1000 + 1); // 获取当前窗口请求数 long count = jedis.zcard(key); return count <= maxRequests; } }
三、生产环境优化策略
-
Lua脚本保证原子性
高并发下需防止竞态条件,使用Redis执行Lua脚本:local key = KEYS[1] local now = tonumber(ARGV[1]) local windowStart = tonumber(ARGV[2]) local maxRequests = tonumber(ARGV[3]) local expireSec = tonumber(ARGV[4]) redis.call('ZREMRANGEBYSCORE', key, 0, windowStart) redis.call('ZADD', key, now, now) redis.call('EXPIRE', key, expireSec) local count = redis.call('ZCARD', key) return count <= maxRequests and 1 or 0
时间分片优化性能
当QPS>10k时,可改用时间桶方案:// 将1秒窗口分为10个100ms桶 long bucketSize = 100; long bucket = now / bucketSize * bucketSize;
-
集群扩展方案
-
相同用户路由到固定Redis节点
-
本地缓存+Redis的二级限流
-
-
时间同步问题
多服务器时需使用NTP同步时间,否则会出现时间漂移导致限流失效 -
热Key解决方案
对高频key(如秒杀商品)添加随机后缀分片: -
四、基准测试对比
算法类型 QPS上限 内存消耗 时间精度 固定窗口 12k 低 低 滑动窗口(Lua) 8k 中 高 令牌桶 15k 高 中 测试环境:Redis 6.2, 4核CPU, 100并发线程
五、真实场景踩坑记录
通过Redis的ZSET实现滑动窗口限流,不仅解决了固定窗口的临界突变问题,还能在分布式环境中保持高一致性。实际部署时建议结合监控系统(如Prometheus)实时观察限流效果。
-
时间同步问题
多服务器时需使用NTP同步时间,否则会出现时间漂移导致限流失效 -
热Key解决方案
对高频key(如秒杀商品)添加随机后缀分片:String shardKey = key + "_" + ThreadLocalRandom.current().nextInt(10);
突发流量处理
结合令牌桶算法实现预热机制:// 每秒补充10个令牌 jedis.incrBy(key, 10); jedis.expire(key, 1);
六、完整生产级实现
public class RateLimiter { private final JedisPool jedisPool; private final String luaScript; public RateLimiter(JedisPool pool) { this.jedisPool = pool; this.luaScript = "local current = redis.call('zcard', KEYS[1])\n" + "if current >= tonumber(ARGV[1]) then\n" + " return 0\n" + "end\n" + "redis.call('zadd', KEYS[1], ARGV[2], ARGV[3])\n" + "redis.call('expire', KEYS[1], ARGV[4])\n" + "return 1"; } public boolean tryAcquire(String key, int limit, int windowSec) { try (Jedis jedis = jedisPool.getResource()) { long now = System.currentTimeMillis(); String member = UUID.randomUUID().toString(); // 唯一标识 Object result = jedis.eval(luaScript, 1, key, String.valueOf(limit), String.valueOf(now), member, String.valueOf(windowSec * 2) // 双倍TTL ); return "1".equals(result.toString()); } } }
七、结论与选型建议
滑动窗口在精确控制场景下表现优异,但需根据业务特点选择:
-
电商秒杀:滑动窗口+本地缓存
-
API网关:令牌桶算法
-
金融交易:多层限流(用户/IP/接口)