1. 滑动窗口限流原理与Lua实现
Redis作为高性能内存数据库,常被用于实现分布式限流。滑动窗口算法是其中一种精准度较高的限流方式,相比固定窗口算法能更好地处理边界问题。核心思想是通过维护一个时间窗口内的请求记录,动态判断当前请求是否超出限制。
1.1 为什么选择滑动窗口
固定窗口算法(如每分钟限流100次)存在临界问题:比如在59秒和61秒分别发起100次请求,实际在2秒内完成了200次请求但不会被限流。滑动窗口通过持续移动的时间窗口解决了这个问题,它能精确统计任意连续时间段内的请求量。
Redis的有序集合(ZSET)天然适合实现滑动窗口:
- 用时间戳作为score保证有序性
- ZREMRANGEBYSCORE命令可高效清理过期记录
- ZCARD命令快速获取当前窗口内请求数
1.2 Lua脚本的优势
在Redis中使用Lua脚本实现限流有三大优势:
- 原子性:所有操作在单次脚本执行中完成,避免并发问题
- 高性能:减少网络往返,脚本在Redis服务端直接执行
- 复用性:一次加载多次执行,适合高频调用的限流场景
提示:Redis执行Lua脚本默认是单线程的,这意味着脚本执行期间不会被打断,是天然的分布式锁方案。
2. 核心代码实现解析
2.1 Java调用层设计
java复制public boolean allowRequest(String key, Integer maxCallCount, Long windowMillisecond, String taskId) {
List<String> keys = Collections.singletonList(KEY_PREFIX + key);
List<String> args = Arrays.asList(
String.valueOf(maxCallCount),
String.valueOf(windowMillisecond),
String.valueOf(System.currentTimeMillis()),
taskId
);
Long result = stringRedisTemplate.execute(script, keys, args.toArray());
return result != null && result == 1;
}
参数说明:
key:限流维度标识(如用户ID)maxCallCount:窗口期内最大允许请求数windowMillisecond:窗口大小(毫秒)taskId:业务任务标识,用于区分不同业务场景
2.2 Lua脚本实现细节
lua复制local key = KEYS[1]
local maxRequests = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local task_id = ARGV[4]
-- 清理过期记录(窗口滑动)
redis.call('ZREMRANGEBYSCORE', key, 0, now-windowMs)
-- 获取当前请求数
local count = redis.call('ZCARD', key)
-- 判断是否限流
if count >= maxRequests then
return 0
else
-- 记录当前请求
redis.call('ZADD', key, now, task_id .. ':' .. now)
return 1
end
关键点解析:
ZREMRANGEBYSCORE清理窗口外的旧数据,确保只统计当前窗口期内的请求ZCARD获取集合元素数量即当前请求数- 新请求以
任务ID:时间戳格式存储,避免重复
注意:时间戳建议使用客户端统一时间,如果各服务节点时间不同步可能导致限流不准确。对于高精度要求的场景,建议使用Redis的TIME命令获取服务器时间。
3. 生产环境优化实践
3.1 性能优化方案
-
脚本缓存:提前加载Lua脚本到Redis,通过SHA1摘要调用而非每次传输完整脚本
java复制// Spring中预加载脚本 script.setScriptText(getFlowLimitLuaScript()); script.setResultType(Long.class); -
管道批处理:对批量限流判断使用pipeline减少网络开销
java复制
stringRedisTemplate.executePipelined(...) -
本地缓存:结合Caffeine做二级缓存,允许少量超限
3.2 多维度限流策略
实际业务中往往需要多级限流:
java复制// 用户级别限流
String userKey = "user:" + userId;
// 接口级别限流
String apiKey = "api:" + requestURI;
// 全局限流
String globalKey = "global";
可以通过组合不同维度的key实现立体防护:
code复制ni:ai:flow:limit:user:123:api:/order/create
3.3 监控与动态调整
建议通过以下方式增强可观测性:
- 记录限流日志到ELK
- 通过Redis的INFO命令监控内存使用
- 动态调整限流阈值:
java复制// 从配置中心获取最新阈值 int dynamicThreshold = configService.getInt("flow.limit", 100);
4. 常见问题与解决方案
4.1 时间同步问题
现象:集群中某节点频繁误限流
排查:
- 检查各服务器NTP服务状态
- 对比Redis服务器时间与应用服务器时间差
- 在Lua脚本中使用Redis时间:
lua复制local now = tonumber(redis.call('TIME')[1]) * 1000
4.2 Redis内存增长
现象:ZSET持续增长不释放
解决方案:
- 设置过期时间:
lua复制redis.call('EXPIRE', key, windowMs/1000 * 2) - 定期扫描清理空key
- 使用Redis的maxmemory-policy配置淘汰策略
4.3 高并发场景优化
当QPS超过10万时建议:
- 使用Redis Cluster分散压力
- 采用令牌桶算法做前置粗粒度限流
- 在Lua脚本中添加请求合并逻辑
5. 扩展应用场景
5.1 分布式锁改良版
基于相同技术可实现更灵活的分布式锁:
lua复制local lock = redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])
if lock then
return 1
else
-- 检查锁是否过期但未被释放
local ttl = redis.call('PTTL', KEYS[1])
if ttl == -1 then
redis.call('DEL', KEYS[1])
end
return 0
end
5.2 热点数据统计
滑动窗口也可用于实时统计:
lua复制-- 统计最近5分钟访问量
local count = redis.call('ZCOUNT', 'access:log', now-300000, now)
5.3 延迟队列实现
结合ZSET的排序特性实现延迟任务:
java复制// 添加延迟任务
redisTemplate.opsForZSet().add("delay:queue", taskId, System.currentTimeMillis() + delayMs);
// 消费任务
Set<String> readyTasks = redisTemplate.opsForZSet().rangeByScore(
"delay:queue", 0, System.currentTimeMillis());
在实际项目中,我通常会根据业务特点对基础脚本进行定制。比如电商秒杀场景会增加库存预扣减逻辑,API网关则会添加基于IP和用户ID的多级限流。一个经验是:Lua脚本不宜过长,超过100行就应该考虑拆分为多个脚本或移出部分逻辑到Java层实现。