🦋基于 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...

相关推荐
GraduationDesign15 分钟前
基于SpringBoot的蜗牛兼职网的设计与实现
java·spring boot·后端
颜淡慕潇26 分钟前
【K8S问题系列 | 20 】K8S如何删除异常对象(Pod、Namespace、PV、PVC)
后端·云原生·容器·kubernetes
小小小妮子~27 分钟前
深入理解 MySQL 架构
数据库·mysql·架构
customer0830 分钟前
【开源免费】基于SpringBoot+Vue.JS安康旅游网站(JAVA毕业设计)
java·vue.js·spring boot·后端·kafka·开源·旅游
搬码后生仔2 小时前
将 ASP.NET Core 应用程序的日志保存到 D 盘的文件中 (如 Serilog)
后端·asp.net
Suwg2092 小时前
《手写Mybatis渐进式源码实践》实践笔记(第七章 SQL执行器的创建和使用)
java·数据库·笔记·后端·sql·mybatis·模板方法模式
雪球不会消失了2 小时前
MVC架构模式
架构·mvc
凡人的AI工具箱3 小时前
每天40分玩转Django:Django文件上传
开发语言·数据库·后端·python·django
spcodhu3 小时前
在 Ubuntu 上搭建 MinIO 服务器
linux·后端·minio
LYX36933 小时前
Docker 安装mysql ,redis,nacos
redis·mysql·docker