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

相关推荐
Chrikk1 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*1 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue1 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man1 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
customer083 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
Yaml44 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
水月梦镜花5 小时前
redis:list列表命令和内部编码
数据库·redis·list
小码编匠5 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#