Redis实现排行榜

1.为什么要做实时排行榜?

活动排行榜是一种有效的营销策略,可以刺激用户参与度。排行榜本身就是一种竞争性的元素,在这种情况下,人们通常会努力争取竞争优势,以获得更好的排名。同时再加以奖励激励,提升用户粘性。实时的榜单相较于定时刷新的榜单,更能刺激用户的参与欲望,提升用户的活跃度。

2.需求

  • 服务化设计,可提供多业务方接入。
  • 按照用户在活动内获得的总代币数量进行排名,总代币数量越多的排名越靠前。
  • 总代币数量相同的情况,获得时间越早的用户排名靠前。
  • 用户获得代币后榜单实时更新
  • 提供查询整个排行排名列表的接口
  • 提供查询用户个人排名的接口
  • 支持子榜单(月榜周榜等)

3.技术选型

  • Redis zset (Sorted Set) 实现排行榜实时榜单的数据排名。

Redis zset (Sorted Set)

zset底层使用了两种不同的存储结构,分别是 zipList(压缩列表)和 skipList(跳跃列表),大部分场景下都会使用skipList(跳跃列表)的存储结构,所以简单介绍下跳表的优点。跳跃表是一种基于链表实现的数据结构,它利用了链表的有序性和"跳跃"的特性,以实现快速的查找、插入和删除操作。

跳跃表的数据结构示意图:

跳跃表具有以下优点:

  1. 快速查找 :跳跃表的查找时间复杂度为O(log n),与二叉搜索树相当,但跳跃表的实现相对简单,且不需要进行平衡操作。
  2. 高效插入和删除 :跳跃表的插入和删除时间复杂度为O(log n),与查找相同,且只需要修改少量指针即可完成操作,不需要像平衡树那样进行大量的旋转操作。
  3. 支持有序性 :跳跃表中的元素按照顺序排列,可以方便地进行有序性操作,如区间查找和范围删除。
  4. 可扩展性 :跳跃表的高效性和可扩展性使得它成为一种常用的数据结构,许多数据库和分布式系统中都使用跳跃表实现索引和排序等操作。

zset具备高并发场景下的插入和查询,并且有序的特性使其成为非常适合于实现实时排行榜的数据结构。

4.设计要点

  • 创建排行榜
  • 更新排行榜
  • 查询用户个人排名详情
  • 查询topN排名
  • 缓存重构

为了实现缓存重构的功能,需要记录用户的账户(分数)信息,需要创建2张MySQL表,表结构如下:

分数明细表:

分数汇总表:

4.1 排行榜状态

  • 已开启-当第一条数据写入后,排行榜的状态为已开启
  • 计算中-当redis失效后,需要重建排行榜缓存,重建缓存期间排行榜为计算中的状态

4.2 排行榜的配置元素

榜单的配置元素如下,可以用系统配置进行管理,或者使用管理后台的方式管理,本次使用系统配置的方式实现。

public class RankConfig {

/**

* 排行榜榜单大小

*/

private Integer rankSize;

/**

* 榜单编码

*/

private String rankCode

/**

* 分数类型

*/

private String scoreType;

/**

* 榜单开始时间

*/

private String rankStartTime;

/**

* 榜单截止时间

*/

private String rankEndTime;

/**

* 榜单zset缓存保留时间(天)

*/

private Long rankZsetCacheDay;

}

4.3 接入方的数据上报

  1. 通过接口调用的方式上报数据
  2. 通过消息队列的方式上报数据

4.4 zset的value和score结构

上面需求有提到相同的分数,分数获取时间早的排名靠前,所以需要把分数的获取时间也计入到score的维度里,value为userId,socre为用户的分数 + 秒级时间戳拼接值,由于double类型在zset内部会有精度问题,所以将score转为整数类型来处理,避免因为精度问题导致的数据异常,score由2部分组成:

  • 橙色部分,score的后7位(不足7位在前面补0),是排行榜的结束时间与用户获取分数的时间的秒差值。
  • 绿色部分,score的除后7位外的部分,是用户的分数。

4.5 更新排行榜

用户获取分数的时间必须要在榜单规定的时间区间内,否则视为无效的数据。

为了防止接入方重复推送数据,规定每一条明细数据必须带有唯一标识,在分数明细表定义唯一索引判断非重复数据后再进行处理。

第一次写榜单数据时,需要写一个redis标记,标识榜单已开始写入数据,用于判断榜单是未开启还是缓存失效需要重构缓存。

为了节省zset占用的内存空间,无需将所有的用户数据都存入zset,只需要在满足2个条件的情况下才将用户的数据更新到zset内。

zset内成员数没有达到排行榜大小

用户代币数大于zset内最小的代币数

写入zset的流程

  1. 判断整数位的分数大于zset内的整数位分数或zset内没有数据,取当前的总分数作为score的分数位
  2. 计算lastIncomTime和榜单截止时间得到的秒级差值并补齐7位后拼接分数位
  3. 更新score

4.6 子榜单的更新

子榜单也视为一个独立的榜单,相较于总榜单,使用的用户分数明细数据是相同的一份数据,只不过是统计的时间维度不同,所以根据分数的类型去找关联的榜单列表时,需要将时间因素加进去,对找出来的所有榜单同时去写排名相关的数据,就可以实现子榜单的玩法。

4.7 查询榜单

在异常情况下会出现redis失效的情况,此时需要对榜单进行缓存重构,缓存重构期间为了友好体验,返回一个排行榜正在计算中的状态。

4.8 榜单缓存重建

由于存在写入场景比较频繁的情况,在扫描分数表的过程中,在用户A的数据已经被扫描并记录到内存后,如果用户A继续获得分数,会导致出现最终结果与用户A最新的分数不一致的情况,所以在缓存重构期间实时榜单队列仍然需要记录最新的数据,最后与扫描分数表的结果队列进行合并,如果有重叠用户则取实时榜单队列内的分数为准。

时序示意图

5.代码实现

5.1 zset的score处理

/**

* 将zset的score的小数部分还原为分数获取的真实时间

* @param rankCode

* @param zsetScore

* @return

*/

public String getScoreTime(String rankCode,double zsetScore){

String[] arr = String.valueOf(zsetScore).split("\\.");

long timeOffSet = Long.valueOf(arr[1]);

long timestamp = getRankStartTimestamp(getRankZsetKey(rankCode)) + timeOffSet;

return SfDateUtil.formatDateTime(SfDateUtil.parseDateTime(timestamp * 1000));

}

/**

* 将zset的score的整数部分还原为真实的分数

* @param zsetScore

* @return

*/

public Long getScore(double zsetScore){

return new Double(Math.floor(zsetScore)).longValue();

}

/**

* 构造zset的score

* @param rankCode

* @param sumScore

* @param scoreTime

* @return

*/

public double genZsetScore(String rankCode,Long sumScore, Date scoreTime){

// 与榜单开启时间的差值得出小数位

long timeOffset = (scoreTime.getTime()/1000) - getRankStartTimestamp(rankCode);

// 分数作为整数位与小数位拼接

String scoreStr = sumScore + "." + timeOffset;

// 转换为double

return Double.valueOf(scoreStr);

}

5.2更新排行榜数据

榜单的数据来源可以有多种形式,我使用消息队列的形式来消费分数数据,定义分数的消息数据类型如下:

public class RankScoreMessageDTO {

/**

* 排行分数类型

*/

private String scoreType;

/**

* 分数变更的时间

*/

private String scoreTime;

/**

* 用户id

*/

private String userId;

/**

* 变更的分数 大于0累加 小于0扣减

*/

private Long score;

/**

* 分数流水(不允许重复)

*/

private String scoreFlow;

}

更新排行榜的主要逻辑

  1. 校验必要参数
  2. 判断非重复消费后写分数明细表
  3. 根据分数类型找出有哪些榜单引用该种分数类型
  4. 给所有榜单批量写入分数汇总表
  5. 判断符合写zset后写zset

public void consumeScore(RankScoreMessageDTO rankScoreMessageDTO){

// 判断是否缺少必要字段

if(isRequiredParamMissing(rankScoreMessageDTO)){

log.warn("RankManager comsumeScore fail,参数缺失: {}",rankScoreMessageDTO);

return;

}

try{

// 写入分数明细表 根据唯一键约束校验是否重复消费

RankScoreDetailPO rankScoreDetailPO = new RankScoreDetailPO();

BeanUtils.copyProperties(rankScoreMessageDTO,rankScoreDetailPO);

rankScoreDetailMapper.insert(rankScoreDetailPO);

}catch (DuplicateKeyException e){

log.warn("RankManager comsumeScore fail,重复消费: {}",rankScoreMessageDTO);

return;

}

// 查询分数类型对应的榜单编码 根据分数类型和时间找出榜单的rankCode

List<String> rankCodes = rankUtils.getRankCodeByScoreType(rankScoreMessageDTO.getScoreType(),rankScoreMessageDTO.getScoreTime());

if(SfCollectionUtil.isNotEmpty(rankCodes)){

// 写入分数汇总表

writeScoreSum(rankCodes,rankScoreMessageDTO);

// 循环处理每个榜单

for(String rankCode: rankCodes){

// 榜单配置

RankConfig rankConfig = rankUtils.getRankConfigByRankCode(rankCode);

// 本次更新后的最新总分

RankScoreSumPO rankScoreSumPO = getUserRankScoreSum(rankCode, rankScoreMessageDTO.getUserId());

// 是否符合入队资格

if(canWriteZset(rankScoreSumPO)){

// 写入zset

writeZset(rankScoreSumPO,rankConfig);

}

}

}

}

写入分数汇总表使用了INSERT ON DUPLICATE KEY UPDATE的操作,第一次写入是插入操作,后续同一个用户在同一个榜单内的数据都是update sum_score字段的操作,并判断是否更新last_incom_time字段。

判断是否能写入zset的实现:

private boolean canWriteZset(RankScoreSumPO rankScoreSumPO){

RankConfig rankConfig = rankUtils.getRankConfigByRankCode(rankScoreSumPO.getRankCode());

if(rankConfig == null){

return false;

}

// 队列是否已满

String rankZsetKey = rankUtils.getRankZsetKey(rankScoreSumPO.getRankCode());

long zsetSize = sfRedisUtil.zSetSize(rankZsetKey);

if(rankConfig.getRankSize() > zsetSize){

return true;

}

// zset按score升序排序 集合内第一个值分数最低

String minScoreValue = sfRedisUtil.zSetRange(rankZsetKey,0,0).stream().findFirst().orElse(null);

// 是否大于排行榜分数最小的分数

double minZsetScore = StringUtils.isEmpty(minScoreValue) ? 0 : sfRedisUtil.zSetScore(rankZsetKey,minScoreValue);

// 是否大于排行榜分数最小的分数

if(rankScoreSumPO.getSumScore() > rankUtils.getScore(minZsetScore)){

return true;

}

// 2个条件都不满足 不用写入zset

return false;

}

5.3 写入zset的实现

/**

* 将用户分数更新至zset榜单

* @param rankScoreSumPO

*/

private void writeZset(RankScoreSumPO rankScoreSumPO,RankConfig rankConfig){

// zset开始写数据的flag

zsetStartWriteFlag(rankConfig);

// 先更新整数位的分数值

String key = rankUtils.getRankZsetKey(rankConfig.getRankCode());

// 待写入zset的score的整数位 下面去拼接小数部分

double zsetScore = rankScoreSumPO.getSumScore();

// zset内的score 与rankScoreSumPO参数做对比 决定本次更新的zsetScore

Double lastScore = sfRedisUtil.zSetScore(key,rankScoreSumPO.getRankUser());

// lastScore的时间戳

long lastScoreTimestamp = lastScore == null ? 0 : rankUtils.getScoreTimestamp(rankConfig.getRankCode(), lastScore);

// 本次获取分数的时间戳

long scoreTimestamp = rankScoreSumPO.getLastIncomTime().getTime()/1000;

// 取较大的时间戳

scoreTimestamp = scoreTimestamp > lastScoreTimestamp ? scoreTimestamp : lastScoreTimestamp;

// 构造写入zset内的score

zsetScore = rankUtils.genZsetScore(rankConfig.getRankCode(), rankScoreSumPO.getSumScore(), scoreTimestamp);

// 待写入zset的score大于zset里面的score 或者zset里面的score为空才做写入操作

if(lastScore == null || zsetScore > lastScore){

sfRedisUtil.zSetAdd(key,rankScoreSumPO.getRankUser(), zsetScore);

}

}

5.4 查询个人排名

使用zset的zrevrank命令,可以直接返回用户的排名数。

/**

* 查询用户个人排名

* @param rankCode 榜单code 排行榜的唯一标识

* @param userId 用户id

* @return

*/

public PersonalRankRespDTO getPersonalRank(String rankCode, String userId){

// 判断榜单状态

int rankStatus = rankUtils.getRankStatus(rankCode);

// 榜单计算中 直接返回计算中状态

if(rankStatus == RankStatusEnum.CALCULATING.getStatus()){

return PersonalRankRespDTO.builder().rankStatus(rankStatus).build();

}

// zset的key

String key = rankUtils.getRankZsetKey(rankCode);

// 个人排名

Long redisRankNo = sfRedisUtil.zSetReverseRank(key,userId);

// null值表示用户不在榜单上 否则+1(zset的排名从0开始)返回

RankListItemDTO personalRank = new RankListItemDTO();

if(redisRankNo == null){

personalRank.setRankNo(-1L);

} else {

double zSetScore = sfRedisUtil.zSetScore(key,userId);

personalRank.setValue(userId);

personalRank.setRankNo(redisRankNo + 1);

personalRank.setScoreTime(rankUtils.getScoreTime(rankCode,zSetScore));

personalRank.setScore(rankUtils.getScore(zSetScore));

}

return PersonalRankRespDTO.builder().rankStatus(rankStatus).personalRank(personalRank).build();

}

5.5 查询榜单区间排名列表

startNum,endNum为了方便理解,对外从1开始,查询时需要减1。 使用zset的zSetReverseRangeByScores命令,直接返回排序好的元素,非常方便。

/**

* 查询排行榜前N名列表

* @param rankCode 榜单code 排行榜的唯一标识

* @param startNum 需要查询的排行榜起始排名区间 对外从1开始,查询时需要减1

* @param endNum 需要查询的排行榜截止排名区间

* @return

*/

public RankListRespDTO getRankList(String rankCode, long startNum, long endNum){

// 判断榜单状态

int rankStatus = rankUtils.getRankStatus(rankCode);

// 榜单计算中 直接返回计算中状态

if(rankStatus == RankStatusEnum.CALCULATING.getStatus()){

return RankListRespDTO.builder().rankStatus(rankStatus).build();

}

// zset的key

String key = rankUtils.getRankZsetKey(rankCode);

// 按区间范围取数

Set<ZSetOperations.TypedTuple<String>> typedTuples = sfRedisUtil.zSetReverseRangeByScores(key,startNum,endNum);

// 构造返回结果

List<RankListItemDTO> rankList = Lists.newArrayList();

if(SfCollectionUtil.isNotEmpty(typedTuples)){

long rankNo = 1;

for(ZSetOperations.TypedTuple<String> typedTuple: typedTuples){

RankListItemDTO rankListItemDTO = new RankListItemDTO();

rankListItemDTO.setRankNo(rankNo++);

rankListItemDTO.setValue(typedTuple.getValue());

rankListItemDTO.setScore(rankUtils.getScore(typedTuple.getScore()));

rankListItemDTO.setScoreTime(rankUtils.getScoreTime(rankCode,typedTuple.getScore()));

rankList.add(rankListItemDTO);

}

} else {

// 取数出来为空 可能是榜单失效 判断是否需要重构缓存

if(needRebuildZset(rankCode)){

// 将榜单状态设置为计算中

rankUtils.setRankStatus(RankStatusEnum.CALCULATING);

// 抢到锁的处理重构任务

if(rankUtils.tryGetRebuildZsetLock(rankCode)){

RankConfig rankConfig = rankUtils.getRankConfigByRankCode(rankCode);

// 异步处理重建任务

COMMON_THREAD_POOL_TASK_EXECUTOR.execute(() -> {

rebuildZset(rankConfig);

});

}

// 返回榜单计算中的状态

rankStatus = RankStatusEnum.CALCULATING.getStatus();

}

}

return RankListRespDTO.builder().rankStatus(rankStatus).rankList(rankList).build();

}

5.6 重构zset

  1. 扫描分数总表,取出topN的数据。
  2. 将从DB取出来的数据写入zset,与重构期间的实时数据合并。
  3. 设置榜单状态为已开启
  4. 释放重构zset的分布式锁

private void rebuildZset(RankConfig rankConfig){

try{

log.info("rankCode:{} rebuildZset start! ",rankConfig.getRankCode());

// 任务计时开始

long rebuildZsetStartTime = System.currentTimeMillis();

// 扫描分数汇总表时已排序完毕的列表

List<RankScoreSumPO> scanDBScoreSumList = getRebuildZsetScanDBList(rankConfig);

// 跑重建任务时 榜单的zset仍然在实时写数据 把扫描出来的数据写入实时榜单的zset做合并

for(RankScoreSumPO rankScoreSumPO: scanDBScoreSumList){

writeZset(rankScoreSumPO,rankConfig);

}

// 将榜单状态设置为已开启

rankUtils.setRankStatus(RankStatusEnum.OPENED);

log.info("rankCode:{} rebuildZset finished! cost:{} mill",rankConfig.getRankCode(),System.currentTimeMillis() - rebuildZsetStartTime);

}catch (Exception e){

log.error("rankCode:{} rebuildZset error!",e);

}finally {

// 释放重构zset的分布式锁

rankUtils.releaseRebuildZsetLock(rankConfig.getRankCode());

}

}

扫描分数表

遍历128个分片表,每次取topN且分数大于前面分片表得出的topN最小值

/**

* 获取扫描DB排序完成的榜单列表

* @return

*/

private List<RankScoreSumPO> getRebuildZsetScanDBList(RankConfig rankConfig){

List<RankScoreSumPO> rankedScoreSumList = new ArrayList<>();

for (int i = 1; i <= 128; i++) {

// 遍历分片表 依次合并topN

try{

log.info("rankCode:{} rebuildZset dataNode:{} start",i);

long dataNodeStartTime = System.currentTimeMillis();

// 分片名

String dataNode = "sstuinfo" + i;

// 每个分片去取topN的时候不用都取N条数据,只用取分数大于已排序好的队列内最小分数的数据

long miniSumScore = SfCollectionUtil.isNotEmpty(rankedScoreSumList)

? rankedScoreSumList.get(rankedScoreSumList.size() - 1).getSumScore() : 0;

// MySQL取数

List<RankScoreSumPO> unRankScoreSumList = rankScoreSumMapper.queryTopScoreSumList(rankConfig.getRankCode(),rankConfig.getRankSize(),miniSumScore,dataNode);

// 和历史已排名的前N进行排名得出新一轮的前N列表

rankedScoreSumList = mergeRankScoreSumList(rankedScoreSumList,unRankScoreSumList,rankConfig.getRankSize());

log.info("rankCode:{} rebuildZset dataNode:{} finished cost:{} mill",i,System.currentTimeMillis() - dataNodeStartTime);

}catch (Exception e){

log.error("rankCode:{} rebuildZset Error, dataNode:{},e:", i, e);

}

}

return rankedScoreSumList;

}

取数sql

扫描分数表期间的集合排序合并

/**

* 将已排序好的列表和刚从新的分片读出来的数据进行合并,排序后保留N条数据

* @param rankedScoreSumList

* @param unRankScoreSumList

* @param topN

* @return

*/

private List<RankScoreSumPO> mergeRankScoreSumList(List<RankScoreSumPO> rankedScoreSumList,List<RankScoreSumPO> unRankScoreSumList,int topN){

if(SfCollectionUtil.isNotEmpty(unRankScoreSumList)){

// 合并2个集合

rankedScoreSumList.addAll(unRankScoreSumList);

// 按总分和时间排序

rankedScoreSumList = rankedScoreSumList.stream().sorted(Comparator.comparing(RankScoreSumPO::getSumScore,Comparator.reverseOrder()).thenComparing(RankScoreSumPO::getLastIncomTime)).collect(Collectors.toList());

// 保留前N条数据

if(rankedScoreSumList.size() > topN){

rankedScoreSumList = rankedScoreSumList.subList(0,topN);

}

}

return rankedScoreSumList;

}

5.7 巡检JOB

由于实现负责度和性能考虑,zset的长度没有强制限定在规定长度,所以当有新的用户上榜后,zset内的数据会有一些冗余,略大于规定的长度N,在榜单开启后会起一个JOB定时去对zset进行一个长度修复,使zset的长度基本维持在规定的长度N左右,同时也可以在巡检JOB内拓展一些榜单异常数据检验的逻辑。

/**

* 定期巡检

* 1.消除zset的冗余长度数据

* 2.校验榜单第一名是否异常

* @param rankCode

*/

private void doPatrol(String rankCode){

try{

// 处理zset冗余长度

rankUtilManager.resetZsetSize(rankCode);

// 校验业务状态

dragonBoat2023RankManager.isOverRangeDragonBoat2023RankLimit();

}catch (Exception e){

log.error("实时排行榜巡检异常",e);

}

}

/**

* 将zset超出长度N的部分删除掉

* @param rankCode

*/

public void resetZsetSize(String rankCode){

RankConfig rankConfig = rankUtils.getRankConfigByRankCode(rankCode);

String key = rankUtils.getRankZsetKey(rankCode);

long rankSize = rankConfig.getRankSize();

long zsetSize = sfRedisUtil.zSetSize(key);

if(zsetSize <= rankSize){

return;

}

long deleteSize = zsetSize - rankSize;

Set<String> deleteRankUsers = sfRedisUtil.zSetRange(key,0,deleteSize-1);

sfRedisUtil.zSetRemove(key,deleteRankUsers.toArray());

}

/**

* 判断第一名是否超过配置的日上限

* 从活动开启日到今天的天数 * 每天上限

* @return

*/

public void isOverRangeDragonBoat2023RankLimit() {

DragonBoat2023RankConfig dragonBoat2023RankConfig = getRankConfig();

// 第一名判断是否超过上限值

int days = SfDateUtil.differentDays(dragonBoat2023RankConfig.getRankStartTime(),SfDateUtil.now()) + 1;

List<RankListItemDTO> list = rankManager.getRankList(DragonBoat2023Constant.ACT_CODE,1,1);

if(SfCollectionUtil.isEmpty(list)){

return;

}

if(list.get(0).getScore() > days * dragonBoat2023RankConfig.getDayScoreLimit()){

// 配置日志告警

log.error("排行榜超限制,请检查!");

// 超限先设置为计算中状态 介入排查

rankUtils.setRankStatus(DragonBoat2023Constant.ACT_CODE,RankStatusEnum.CALCULATING);

}

}

相关推荐
苏-言2 分钟前
Spring IOC实战指南:从零到一的构建过程
java·数据库·spring
土豆湿3 分钟前
拥抱极简主义前端开发:NoCss.js 引领无 CSS 编程潮流
开发语言·javascript·css
界面开发小八哥10 分钟前
更高效的Java 23开发,IntelliJ IDEA助力全面升级
java·开发语言·ide·intellij-idea·开发工具
草莓base23 分钟前
【手写一个spring】spring源码的简单实现--容器启动
java·后端·spring
Allen Bright36 分钟前
maven概述
java·maven
qystca38 分钟前
洛谷 B3637 最长上升子序列 C语言 记忆化搜索->‘正序‘dp
c语言·开发语言·算法
编程重生之路38 分钟前
Springboot启动异常 错误: 找不到或无法加载主类 xxx.Application异常
java·spring boot·后端
薯条不要番茄酱39 分钟前
数据结构-8.Java. 七大排序算法(中篇)
java·开发语言·数据结构·后端·算法·排序算法·intellij-idea
今天吃饺子44 分钟前
2024年SCI一区最新改进优化算法——四参数自适应生长优化器,MATLAB代码免费获取...
开发语言·算法·matlab
努力进修1 小时前
“探索Java List的无限可能:从基础到高级应用“
java·开发语言·list