🦋基于 redis 的简易滑动窗口实现

🦋基于 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 不足

  1. member可能会重复,在分布式场景下可能会出现(System.currentTimeMillis())重复,如果重复可以设置一个uuid
  2. 数据达到服务器的时间和采集时间可能不一致,计算不准确(Flink可以解决这种问题)

4.2 性能

  1. 高消耗命令:即时间复杂度为O(N)或更高的命令。通常情况下,命令的时间复杂度越高,在执行时会消耗较多的资源,从而导致CPU使用率上升。关于各命令对应的时间复杂度信息
  2. redis的连接数量,当达到一定数量的时候不能继续处理
  3. QPS很大的情况,应该采用Flink等流式计算的中间件
  4. 涉及大量、复杂的流计算则不适合

五、参考资料

一种基于滑动窗口的阈值告警计算方法: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...

相关推荐
维尔切12 分钟前
Linux中基于Centos7使用lamp架构搭建个人论坛(wordpress)
linux·运维·架构
他日若遂凌云志19 分钟前
深入剖析 Fantasy 框架的消息设计与序列化机制:协同架构下的高效转换与场景适配
后端
快手技术35 分钟前
快手Klear-Reasoner登顶8B模型榜首,GPPO算法双效强化稳定性与探索能力!
后端
二闹44 分钟前
三个注解,到底该用哪一个?别再傻傻分不清了!
后端
用户49055816081251 小时前
当控制面更新一条 ACL 规则时,如何更新给数据面
后端
林太白1 小时前
Nuxt.js搭建一个官网如何简单
前端·javascript·后端
码事漫谈1 小时前
VS Code 终端完全指南
后端
知白守黑2671 小时前
lamp架构部署wordpress
架构
该用户已不存在1 小时前
OpenJDK、Temurin、GraalVM...到底该装哪个?
java·后端
怀刃2 小时前
内存监控对应解决方案
后端