Redis Sorted Set 实战案例分析
没有最好的技术,只有最合适的技术。根据不同的业务场景,选用合适的技术实现,才是一个程序员应该做的事情。
本文通过引用企业中实际业务场景,记录设计思路和方案演进。此处不做具体技术讲解,重在系统设计思想和技术选型。
1. 需求背景
-
用户端学员听课上传听课记录 和 管理端课件变更,都会引起学员班级听课率的变化,所以需要触发重新计算;
-
上传听课记录 和 课件变更,带来的听课率重算,需要区分优先级,即优先处理学员听课导致的重算,次级处理课件变更导致的重算;
-
管理端课件变更,重算数据需要去重,场景:多次变更同一班级下的多个课件,最终只需执行一次该班级-学员维度的数据重算即可,所以需要去重;
-
重算听课率时,因业务逻辑要查询的数据量较多,为防止重算数据过多时,导致服务资源(CPU、内存等)和数据库压力过大,所以需要做消费限流。
功能实现总结:
- 数据优先级
- 重复数据去重
- 消费限流
2. 设计思路
2.1. 触发听课率重算
- 结合canal,监控课件表,有相关字段变化时触发重算;
- 在管理端课件变更的相关接口发送MQ消息,消费重算。
思考:
- 使用canal,监控相关数据库表,能够集中处理触发的条件,不用在众多的相关接口中添加代码发送消息,但是结合业务逻辑,所涉及的数据库表不一,所以不采用该方案;
- 在相关接口中发送MQ消息,虽然涉及的接口较多,但是更为直观,且不会频繁变更。
结论:
- 采用第二种,在相关接口中发送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,可以同时实现数据优先级和重复数据去重。
结论:
- 采用Redis的有序集合Sorted Set。
2.2.2. 消费限流
a. Semphore信号量
b. RabbitMQ配置 channel.basic-qos ,并且将 queue.auto-ack设置成false
思考:
a. Semphore在多线程访问时可以使用,但是此时是限制MQ消费速度,不适用。
结论:
- 使用RabbitMQ自身配置,设置basic-qos的数量。
3. 方案演进
3.1. MySQL实现
- 新建数据待处理表,将管理端课件变更发送的MQ消息消费存储到待处理表中,再手动指定消费速度。
3.2. PriorityBlockingQueue
- 采用Java优先队列,做数据优先级;
- 数据存储在内存,服务重启会导致数据丢失,且存在内存溢出的风险,弃用。
3.3. Redis Sorted Set
- 采用redis的有序集合,做数据优先级和数据去重;
- 计算听课率的数据存入redis,服务重启不会导致数据丢失;
- 结合定时任务,从redis中取数据消费计算;
- 定时任务加锁,保证不同节点不会取重复数据重复计算。
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技术 和 其他小知识等