🦋基于 redis 的简易滑动窗口实现
特别说明:该文是基于阿里云 redis 构建的滑动窗口:云数据库 Redis (alibabacloud.com)
关键字:滑动窗口、流式计算、lua脚本、redis、zset、starter
概要:本文封装 redis 的API,实现简易滑动窗口,分别从业务背景、窗口理解、redis 的 zset 结构,lua 脚本,注意事项、不足等进行讲解
一、业务背景
规则预警,在特定时间触发规则达到 n 次后发出告警信息,例如:5 分钟之内失败 2 次,当满足条件后会一条通知告警;数值可以根据实际情况动态配置。
下图是动态展示滑动窗口的示意图,按照黄色线固定窗口进行移动,窗口内会出现各种数值点,对窗口数字进行统计:
借助 redis 的 zset 有序集合能力,其中 score 字段要求有序,因此使用时间戳做 score,这样既保证顺序也能根据时间窗口计算窗口内的个数,通过计算时间窗口内的个数再与业务做判断;另外为了保障原子能力,使用lua脚本
二、redis版功能实现
通过 Lua 脚本实现 CAS(check-and-set)命令。
关于窗口在业务上的诉求,我分了三种场景,分别如下所示:
2.1 场景一、统计时间窗口内是否达到预定阈值,返回true和false, 并且达到阈值后清除
描述:1. 添加计数,2.将时间窗口外的数据移除;3.统计当前窗口的个数;4.判断是否超过阈值,5.超过清理并返回,否则返回false
lua
redis.pcall('zadd', KEYS[1], ARGV[1], ARGV[1]);
redis.pcall('zremrangebyscore', KEYS[1], 0, ARGV[2]);
redis.pcall("expire", KEYS[1], ARGV[3]);
if tonumber(redis.pcall('zcard',KEYS[1])) >= tonumber(ARGV[4])
redis.pcall('zremrangebyscore', KEYS[1], 0, ARGV[1]);
then return true end;
return false;
注意:不要使用下面这种方式。 集群方式下,不支持local变量,另外尽量少用变量,减少lua脚本占用过多内存
lua
local key = KEYS[1];
local current_time = ARGV[1];
local pre_time = ARGV[2];
local expire_second = ARGV[3];
local threshold = ARGV[4];
redis.pcall("zadd", key, current_time, current_time);
redis.pcall("zremrangebyscore", key, 0, pre_time);;
local count = redis.pcall("zcard",key);
redis.pcall("expire", key, expire_second);
if tonumber(count) >= tonumber(threshold) then
redis.pcall("zremrangebyscore", key, 0, current_time);
return true end;
return false;
2.2 场景二、统计时间窗口内是否达到预定阈值,返回true和false, 满足true的时候不做清理
lua
redis.pcall('zadd', KEYS[1], ARGV[1], ARGV[1]);
redis.pcall('zremrangebyscore', KEYS[1], 0, ARGV[2]);
redis.pcall("expire", KEYS[1], ARGV[3]);
if tonumber(redis.pcall('zcard',KEYS[1])) >= tonumber(ARGV[4])
then return true end;
return false;
2.3 场景三、统计时间窗口内的个数
只统计个数,不做其他的
lua
redis.pcall('zadd', KEYS[1], ARGV[1], ARGV[1]);
redis.pcall('zremrangebyscore', KEYS[1], 0, ARGV[2]);
redis.pcall("expire", KEYS[1], ARGV[3]);
return redis.pcall("zcard",KEYS[1]);
当然在实际落地的过程,会遇到一些其他问题,比如使用lua限制,分布式限制等
2.3 限制和优化建议
lua出现错误 or 使用限制
为了保证脚本里面的所有操作都在相同 slot 进行,云数据库 Redis 集群版本会对 Lua 脚本做如下限制:
所有key都应该由KEYS数组来传递,redis.call/pcall中调用的 redis 命令,key的位置必须是 KEYS array(不能使用Lua变量替换KEYS),否则直接返回错误信息: ERR bad lua script for redis cluster, all the keys that the script uses should be passed using the KEYS array, and KEYS should not be in expression
阿里云redis中Lua脚本使用规范
Lua脚本会占用较多的计算和内存资源,且无法被多线程加速,过于复杂或不合理的Lua脚本可能导致资源被占满的情况
help.aliyun.com/document_de...
集群中Lua脚本的限制
集群条件下:保证 slot 相同。
三、java 关键代码
注意DefaultRedisScript 保持单例
Java
// 采用了RedisTemplate, 可以用Jedis (ShardJedis目前没有执行脚本的接口)
private <T> T doExecute(
String key, Long currentTime, Long windowLengthInMs, Long threshold,
Long expireSeconds, DefaultRedisScript<T> defaultRedisScript
) {
String currentTimeScore = Long.toString(currentTime);
String preTimeScore = Long.toString(currentTime - windowLengthInMs);
return (T) redisTemplate.execute(
defaultRedisScript,
redisSerializer,
redisSerializer,
Lists.newArrayList(key),
currentTimeScore,
preTimeScore,
Long.toString(expireSeconds),
Long.toString(threshold)
);
}
Redis Config 配置
Java
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = getJsonRedisSerializer();
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);
template.setValueSerializer(jsonRedisSerializer);
template.setHashValueSerializer(jsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
/**
* 计算是否达到阈值并清理
* @return
*/
@Bean
@Qualifier("calculateAndCleanUpScript")
public DefaultRedisScript<Boolean> calculateAndCleanUpScript() {
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(
new ClassPathResource("META-INF/scripts/sliding_windows_calculate_and_cleanup_script.lua"))
);
redisScript.setResultType(Boolean.class);
return redisScript;
}
/**
* 计算是否达到阈值
* @return
*/
@Bean
@Qualifier("calculateScript")
public DefaultRedisScript<Boolean> calculateScript() {
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(
new ClassPathResource("META-INF/scripts/sliding_windows_calculate_script.lua"))
);
redisScript.setResultType(Boolean.class);
return redisScript;
}
@Bean
@Qualifier("calculateCountScript")
public DefaultRedisScript<Long> calculateCountScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(
new ClassPathResource("META-INF/scripts/sliding_windows_calculate_count_script.lua"))
);
redisScript.setResultType(Long.class);
return redisScript;
}
@Bean
@Qualifier("redisStringRedisSerializer")
public RedisSerializer redisSerializer() {
return new StringRedisSerializer();
}
/**
* 设置jackson的序列化方式
*/
private Jackson2JsonRedisSerializer<Object> getJsonRedisSerializer() {
Jackson2JsonRedisSerializer<Object> redisSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
redisSerializer.setObjectMapper(om);
return redisSerializer;
}
}
下面类是实现滑动窗口的核心的计算类
Java
@Slf4j
@Component
public class PlentifulRedisService {
@Autowired
RedisTemplate redisTemplate;
@Autowired
@Qualifier("calculateAndCleanUpScript")
DefaultRedisScript<Boolean> calculateAndCleanUpScript;
@Autowired
@Qualifier("calculateScript")
DefaultRedisScript<Boolean> calculateScript;
@Autowired
@Qualifier("calculateCountScript")
DefaultRedisScript<Long> calculateCountScript;
@Autowired
@Qualifier("redisStringRedisSerializer")
RedisSerializer redisSerializer;
/**
* 通过 sorted set + lua脚本 实现滑动窗口。 统计是否超过阈值
*
* @param key 计算key
* @param currentTime 当前时间
* @param windowLengthInMs 窗口大小
* @param expireSeconds 过期时间, 建议把expireSeconds 设置的比windowLengthInMs偏大
* @return
*/
public Boolean slidingWindowCalculate(
String key, Long currentTime, Long windowLengthInMs,
Long threshold, Long expireSeconds
) {
Boolean result = doExecute(key, currentTime, windowLengthInMs,
threshold, expireSeconds, calculateScript);
Long count = redisTemplate.opsForZSet().zCard(key);
Long expire = redisTemplate.opsForZSet().getOperations().getExpire(key);
log.info("===>>> redisKey:{}, isThreshold:{}, currentCount:{},expire:{}", key, result, count, expire);
return result;
}
/**
* 通过 sorted set + lua脚本 实现滑动窗口,
*
* @param key 计算key
* @param currentTime 当前时间
* @param windowLengthInMs 窗口大小
* @param expireSeconds 过期时间, 建议把expireSeconds 设置的比windowLengthInMs偏大
* @return
*/
public Boolean slidingWindowCalculateAndCleanUp(
String key, Long currentTime, Long windowLengthInMs,
Long threshold, Long expireSeconds
) {
return doExecute(key, currentTime, windowLengthInMs,
threshold, expireSeconds, calculateAndCleanUpScript
);
}
/**
* 通过 sorted set + lua脚本 实现滑动窗口, 时间窗口统计个数。
*
* @param key 计算key
* @param currentTime 当前时间
* @param windowLengthInMs 窗口大小
* @param expireSeconds 过期时间, 建议把expireSeconds 设置的比windowLengthInMs偏大
* @return
*/
public Long slidingWindowCalculateCount(String key, Long currentTime, Long windowLengthInMs,
Long expireSeconds
) {
return doExecute(key, currentTime, windowLengthInMs,
0L, expireSeconds, calculateCountScript
);
}
private <T> T doExecute(
String key, Long currentTime, Long windowLengthInMs, Long threshold,
Long expireSeconds, DefaultRedisScript<T> defaultRedisScript
) {
String currentTimeScore = Long.toString(currentTime);
String preTimeScore = Long.toString(currentTime - windowLengthInMs);
return (T) redisTemplate.execute(
defaultRedisScript,
redisSerializer,
redisSerializer,
Lists.newArrayList(key),
currentTimeScore,
preTimeScore,
Long.toString(expireSeconds),
Long.toString(threshold)
);
}
}
四、不足和性能
4.1 不足
- member可能会重复,在分布式场景下可能会出现(System.currentTimeMillis())重复,如果重复可以设置一个uuid
- 数据达到服务器的时间和采集时间可能不一致,计算不准确(Flink可以解决这种问题)
4.2 性能
- 高消耗命令:即时间复杂度为O(N)或更高的命令。通常情况下,命令的时间复杂度越高,在执行时会消耗较多的资源,从而导致CPU使用率上升。关于各命令对应的时间复杂度信息
- redis的连接数量,当达到一定数量的时候不能继续处理
- QPS很大的情况,应该采用Flink等流式计算的中间件
- 涉及大量、复杂的流计算则不适合
五、参考资料
一种基于滑动窗口的阈值告警计算方法:blog.csdn.net/qq_34485626...
云数据库 Redis官方文档:help.aliyun.com/product/263...
redis官网命令:www.redis.cn/commands.ht...
关于redis的更多应用场景:help.aliyun.com/document_de...
redis性能排查与优化:help.aliyun.com/document_de...