Redis Sorted Set 实战案例分析

没有最好的技术,只有最合适的技术。根据不同的业务场景,选用合适的技术实现,才是一个程序员应该做的事情。

本文通过引用企业中实际业务场景,记录设计思路和方案演进。此处不做具体技术讲解,重在系统设计思想和技术选型。

1. 需求背景

  • 用户端学员听课上传听课记录 和 管理端课件变更,都会引起学员班级听课率的变化,所以需要触发重新计算;

  • 上传听课记录 和 课件变更,带来的听课率重算,需要区分优先级,即优先处理学员听课导致的重算,次级处理课件变更导致的重算;

  • 管理端课件变更,重算数据需要去重,场景:多次变更同一班级下的多个课件,最终只需执行一次该班级-学员维度的数据重算即可,所以需要去重;

  • 重算听课率时,因业务逻辑要查询的数据量较多,为防止重算数据过多时,导致服务资源(CPU、内存等)和数据库压力过大,所以需要做消费限流。

功能实现总结:

  1. 数据优先级
  2. 重复数据去重
  3. 消费限流

2. 设计思路

2.1. 触发听课率重算

  1. 结合canal,监控课件表,有相关字段变化时触发重算;
  2. 在管理端课件变更的相关接口发送MQ消息,消费重算。
思考:
  1. 使用canal,监控相关数据库表,能够集中处理触发的条件,不用在众多的相关接口中添加代码发送消息,但是结合业务逻辑,所涉及的数据库表不一,所以不采用该方案;
  2. 在相关接口中发送MQ消息,虽然涉及的接口较多,但是更为直观,且不会频繁变更。
结论:
  1. 采用第二种,在相关接口中发送MQ消息,消费重算。

2.2. 数据优先级和消费限流

2.2.1. 数据优先级

a. 使用MySQL存储,单独设置一个字段表示优先级;

b. 使用Java自带的PriorityBlockingQueue优先队列;

c. 使用Redis的有序集合Sorted Set 或者 列表List。

思考:

a. MySQL存储,常规方案,虽然能实现,但是因为业务逻辑本身对MySQL的查询较多,所以会进一步增加MySQL的读写压力;

b. PriorityBlockingQueue,可以很容易的实现数据优先级,但是无法实现数据去重;

c. Redis的列表List,根据优先级使用List的LPush或RPush,可以实现数据优先级,且能分散该业务对MySQL的压力,但是无法实现数据去重;

d. Redis的有序集合Sorted Set,可以同时实现数据优先级和重复数据去重。

结论:
  1. 采用Redis的有序集合Sorted Set。
2.2.2. 消费限流

a. Semphore信号量

b. RabbitMQ配置 channel.basic-qos ,并且将 queue.auto-ack设置成false

思考:

a. Semphore在多线程访问时可以使用,但是此时是限制MQ消费速度,不适用。

结论:
  1. 使用RabbitMQ自身配置,设置basic-qos的数量。

3. 方案演进

3.1. MySQL实现

  • 新建数据待处理表,将管理端课件变更发送的MQ消息消费存储到待处理表中,再手动指定消费速度。
    MySQL实现

3.2. PriorityBlockingQueue

  • 采用Java优先队列,做数据优先级;
  • 数据存储在内存,服务重启会导致数据丢失,且存在内存溢出的风险,弃用。
    PriorityBlockingQueue

3.3. Redis Sorted Set

  • 采用redis的有序集合,做数据优先级和数据去重;
  • 计算听课率的数据存入redis,服务重启不会导致数据丢失;
  • 结合定时任务,从redis中取数据消费计算;
  • 定时任务加锁,保证不同节点不会取重复数据重复计算。
    Redis Sorted Set

4. 代码展示

  • 不同触发条件

/**
 * 保存或更新班级课件模块听课率
 *
 * @param consumerStatisticsBO 消费统计bo
 * @return boolean
 */
@Override
public boolean saveOrUpdate(CcConsumerStatisticsBO consumerStatisticsBO) {
    log.info("计算模块听课率 - saveOrUpdate -> consumerStatisticsBO:[{}]", consumerStatisticsBO);
    if (consumerStatisticsBO == null || consumerStatisticsBO.getConsumerTypeEnum() == null) {
        return false;
    }

    switch (consumerStatisticsBO.getConsumerTypeEnum()) {
        // 管理端课件变动
        case COURSEWARE_CHANGE:
            if (CollectionUtils.isEmpty(consumerStatisticsBO.getCoursewareIds())) {
                return false;
            }
            boolean noData = this.refreshModuleRateByCoursewareChange(consumerStatisticsBO);
            if (noData) {
                return true;
            }
            break;

        // 用户端听课记录上传
        case PLAY_RECORD:
            this.refreshModuleRateByPlayRD(consumerStatisticsBO);
            break;

        // 重算班级听课率
        case REFRESH_CLASS_RATE:
            this.refreshClassRate(consumerStatisticsBO);
    }

    return true;
}
  • 存入redis
/**
 * 添加计算数据到redis
 *
 * @param classModuleUserInfoBOList 班级模块用户详情bo集合
 */
private void addCalculateDataToRedis(List<CcClassModuleUserInfoBO> classModuleUserInfoBOList) {
    Set<DefaultTypedTuple> typedTupleSet = classModuleUserInfoBOList.parallelStream()
            .map(classModuleUserInfoBO -> {
                // 课件id置为null,用来在set中去重,实现去重计算模块听课率的数据(屌不屌0_0)
                classModuleUserInfoBO.setCoursewareId(null);
                return new DefaultTypedTuple(classModuleUserInfoBO, classModuleUserInfoBO.getPriority());
            })
            .collect(Collectors.toSet());
    redisTemplate.opsForZSet().add(RedisConst.CALCULATE_MODULE_STATS, typedTupleSet);
}
  • 定时任务取数据

/**
 * redis中单次取模块听课率计算数据的size
 */
@Value("${redis.calculate.module.stats.range.size}")
private Integer rangeSize;

/**
 * 计算模块统计数据
 * 每10秒执行一次,每次从redis取100条数据
 */
@Scheduled(cron = "0/10 * * * * ?")
public void calculateModuleStats() {
    log.info("定时任务 - CalculateModuleStatsSchedule - 开始执行");

    List<CcClassModuleUserInfoBO> classModuleUserInfoBOList = this.getClassModuleUserInfoFromRedis();
    if (!CollectionUtils.isEmpty(classModuleUserInfoBOList)) {
        classModuleUserInfoBOList.parallelStream()
                .forEach(classModuleUserInfoBO -> {
                    // 计算模块统计数据
                    try {
                        userClassSubdivisionStatisticsService.againCalculateClassModuleCourseRate(classModuleUserInfoBO);
                        // 在redis中移除该数据
                        Long remove = redisTemplate.opsForZSet().remove(RedisConst.CALCULATE_MODULE_STATS, classModuleUserInfoBO);
                        log.info("定时任务 - CalculateModuleStatsSchedule - 移除重算过的数据 -> redisTemplate.opsForZSet().remove:{}", remove);
                    } catch (Exception e) {
                        log.error("定时任务 - CalculateModuleStatsSchedule - 调用计算模块统计数据方法或redis.remove方法出错 -> 异常信息:{}",
                                ExceptionUtils.getStackTrace(e));
                    }
                });
    }
}

/**
 * 从redis获取计算班级模块听课率的数据
 *
 * @return {@link List<CcClassModuleUserInfoBO>}
 */
private List<CcClassModuleUserInfoBO> getClassModuleUserInfoFromRedis() {
    String lockKey = RedisConst.MODULE_STATS_SCHEDULE_LOCK;
    log.info("定时任务 - CalculateModuleStatsSchedule - 抢占锁 -> lockKey:{}, timeMillis:{}", lockKey, System.currentTimeMillis());
    RLock lock = redissonClient.getLock(lockKey);
    try {
        boolean tryLock = lock.tryLock(10, 30, TimeUnit.SECONDS);
        if (!tryLock) {
            log.error("定时任务 - CalculateModuleStatsSchedule - 获取锁失败 -> lockKey:{}, timeMillis:{}", lockKey, System.currentTimeMillis());
            return Collections.emptyList();
        }
    } catch (InterruptedException e) {
        log.error("定时任务 - CalculateModuleStatsSchedule - 获取锁失败 -> 异常信息:{}", ExceptionUtils.getStackTrace(e));
    }
    try {
        // 查看redis中是否有数据
        String redisKey = RedisConst.CALCULATE_MODULE_STATS;
        log.info("定时任务 - CalculateModuleStatsSchedule - redisKey:{}, timeMillis:{}", redisKey, System.currentTimeMillis());
        Long zCard = redisTemplate.opsForZSet().zCard(redisKey);
        log.info("定时任务 - CalculateModuleStatsSchedule - redis重算模块数据剩余数量zCard:{}, timeMillis:{}", zCard, System.currentTimeMillis());

        if (zCard == null || zCard <= 0) {
            return Collections.emptyList();
        }

        // 查看redis中是否有多节点不重复取数据标识,如果有,表示同一时刻只会有一个节点取数据
        String notRepeatRangeKey = RedisConst.MODULE_STATS_NOT_REPEAT_RANGE;
        Object notRepeatRange = redisTemplate.opsForValue().get(notRepeatRangeKey);
        if (!ObjectUtils.isEmpty(notRepeatRange)) {
            log.info("定时任务 - CalculateModuleStatsSchedule - notRepeatRange:{}, timeMillis:{}", notRepeatRange, System.currentTimeMillis());
            return Collections.emptyList();
        }

        // 获取从redis中range的开始下标
        String rangeStartKey = RedisConst.MODULE_STATS_RANGE_START;
        Object rangeStart = redisTemplate.opsForValue().get(rangeStartKey);
        long start = 0;
        if (rangeStart != null) {
            start = ((Integer) rangeStart).longValue();
        }
        long end = start + rangeSize - 1;
        if (end < start) {
            log.error("定时任务 - CalculateModuleStatsSchedule - 从redis中取数据的range下标错误 -> start:[{}], end:[{}], size:[{}]",
                    start, end, rangeSize);
        }
        log.info("定时任务 - CalculateModuleStatsSchedule - 从redis中取数据 -> start:[{}], end:[{}], size:[{}]", start, end, rangeSize);

        // 取数据
        Set range = redisTemplate.opsForZSet().range(redisKey, start, end);
        String jsonStr = JSONUtil.toJsonStr(range);
        List<CcClassModuleUserInfoBO> classModuleUserInfoBOList = JSONUtil.toList(jsonStr, CcClassModuleUserInfoBO.class);

        // 更新下次取数据的开始下标
        Long card = redisTemplate.opsForZSet().zCard(redisKey);
        long nextStart = 0;
        long remainNumber = 0;
        if (card != null && (remainNumber = card - end - 1) > 0) {
            nextStart = end + 1;
        }
        // 本次取出数据后,剩余0条数据,则其他节点不再重复取
        log.info("定时任务 - CalculateModuleStatsSchedule - 设置不同节点不重复取数据标识 -> remainNumber:[{}], remainNumber <= 0:{}",
                remainNumber, remainNumber <= 0);
        if (remainNumber <= 0) {
            log.info("定时任务 - CalculateModuleStatsSchedule - 设置不同节点不重复取数据标识 -> notRepeatRangeKey:[{}], timeMillis:{}",
                    notRepeatRangeKey, System.currentTimeMillis());
            redisTemplate.opsForValue().set(notRepeatRangeKey, 1, 8, TimeUnit.SECONDS);
        }
        log.info("定时任务 - CalculateModuleStatsSchedule - redis中还剩数据量:{},下次取数据的开始下标:{}",
                remainNumber < 0 ? 0 : remainNumber, nextStart);
        redisTemplate.opsForValue().set(rangeStartKey, nextStart);

        return classModuleUserInfoBOList;
    } finally {
        lock.unlock();
        log.info("定时任务 - CalculateModuleStatsSchedule - 释放锁 -> timeMillis:{}", System.currentTimeMillis());
    }
}

5. 其他

欢迎大家关注微信公众号 “金日成长”,不定期分享 企业实战案例分析、Java技术 和 其他小知识等
金日成长

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值