排行榜是一个很常见的需求场景了,当然对应的技术选型方案也可以说非常成熟,在技术派项目中,我们也引入了一个用户活跃度的排行榜,主要是基于redis的zset数据结构来实现,给大家实例演示一下,如何实现一个生产可用的排行榜
方案设计
在具体的代码介绍之前,先来了解一下业务场景
1. 场景说明
技术派中,提供了一个用户的活跃排行榜,当然作为一个博客社区,更应该实现的是作者排行榜;出于让大家更有参与感的目的,我们以用户活跃度来设计一个排行榜,区分日/月两个榜单
用户活跃度计算方式:
- 用户每访问一个新的页面 +1分
- 对于一篇文章,点赞、收藏 +2分;取消点赞、取消收藏,将之前的活跃分收回
- 文章评论 +3分
- 发布一篇审核通过的文章 +10分
榜单:
- 展示活跃度最高的前三十名用户
实际的榜单效果如下(可以在首页活跃排行榜侧边栏点击进入)
2. 方案设计
排行榜的业务属性比较清晰简单,对应的数据结构也可以很容易设计出来,核心的信息如下
存储单元
表示排行榜中每一位上应该持有的信息如下
// 用来表明具体的用户
long userId;
// 用户在排行榜上的排名
long rank;
// 用户的历史最高积分,也就是排行榜上的积分
long score;
数据结构
排行榜,一般而言都是连续的,借此我们可以联想到一个合适的数据结构LinkedList,好处在于排名变动时,不需要数组的拷贝
上图演示,当一个用户活跃度改变时,需要向前遍历找到合适的位置,插入并获取新的排名, 在更新和插入时,相比较于ArrayList要好很多,但依然有以下几个缺陷
问题1:用户如何获取自己的排名?
使用LinkedList
在更新插入和删除的带来优势之外,在随机获取元素的支持会差一点,最差的情况就是从头到尾进行扫描
问题2:并发支持的问题?
当有多个用户同时更新score时,并发的更新排名问题就比较突出了,当然可以使用jdk中类似写时拷贝数组的方案
上面是我们自己来实现这个数据结构时,会遇到的一些问题,当然我们的主题是借助redis来实现排行榜,下面则来看下,利用redis可以怎么简单的支持我们的需求场景
3. redis使用方案
这里主要使用的是redis的ZSET数据结构,带权重的集合,下面分析一下可能性
- set: 集合确保里面元素的唯一性
- 权重:这个可以看做我们的score,这样每个元素都有一个score;
- zset:根据score进行排序的集合
从zset的特性来看,我们每个用户的积分,丢到zset中,就是一个带权重的元素,而且是已经排好序的了,只需要获取元素对应的index,就是我们预期的排名
排行榜实现
接下来我们看一下技术派中的活跃排汗榜单是如何实现的
核心包路径:com.github.paicoding.forum.service.rank
核心代码实现:com.github.paicoding.forum.service.rank.service.impl.UserActivityRankServiceImpl
1. 更新用户活跃积分
我们先实现一个更新用户活跃的方法,首先定义一个涵盖上面业务场景的参数传递实体ActivityScoreBo
接下来我们先思考一下,这个具体的应该怎么实现,先梳理实现的业务流程
- 根据业务实体,计算需要增加/减少的活跃度
- 对于增加活跃度时:
- 2.1 做一个幂等,防止重复添加,因此需要判断下之前有没有重复添加过相关的活跃度
- 2.2 若幂等了,则直接返回;否则,执行更新,并做好幂等保存
- 对于减少活跃度时:
- 3.1 判断之前有没有加过活跃度,防止扣减为负数
- 3.2 之前没有扣减过,则直接返回;否则,执行扣减,并移除幂等判定
上面的业务逻辑清晰之后,在看一下我们实现的关键要素
- 怎么做幂等?
- 如何更新榜单的评分?
1.1 幂等策略
放了防止重复加活跃度,怎么做幂等呢? 一个简单的方案就是将用户的每个加分项,都直接记录下来,在执行具体加分时,基于此来做幂等判定
基于上面这个思路,很容易想到的一个方案就是,每个用户维护一个活跃更新操作历史记录表,我们先尽量设计得轻量级一点
直接将用户得历史日志,保存在redis的hash数据结构中,每天一个记录
- key:
activity_rank_{user_id}_{年月日}
- field:
活跃度更新key
- value:
添加的活跃度
1.2 榜单评分更新
这个就相对而言比较简单了,直接基于zset的incr即可
我们同样是扩展了一下RedisClient的工具类,增加上了zset的相关操作
1.3 具体实现
接下来我们看一下具体的实现代码
public void addActivityScore(Long userId, ActivityScoreBo activityScore) {
if (userId == null) {
return;
}
// 1. 计算活跃度(正为加活跃,负为减活跃)
String field;
int score = 0;
if (activityScore.getPath() != null) {
field = "path_" + activityScore.getPath();
score = 1;
} else if (activityScore.getArticleId() != null) {
field = activityScore.getArticleId() + "_";
if (activityScore.getPraise() != null) {
field += "praise";
score = BooleanUtils.isTrue(activityScore.getPraise()) ? 2 : -2;
} else if (activityScore.getCollect() != null) {
field += "collect";
score = BooleanUtils.isTrue(activityScore.getCollect()) ? 2 : -2;
} else if (activityScore.getRate() != null) {
// 评论回复
field += "rate";
score = BooleanUtils.isTrue(activityScore.getRate()) ? 3 : -3;
} else if (BooleanUtils.isTrue(activityScore.getPublishArticle())) {
// 发布文章
field += "publish";
score += 10;
}
} else if (activityScore.getFollowedUserId() != null) {
field = activityScore.getFollowedUserId() + "_follow";
score = BooleanUtils.isTrue(activityScore.getFollow()) ? 2 : -2;
} else {
return;
}
final String todayRankKey = todayRankKey();
final String monthRankKey = monthRankKey();
// 2. 幂等,判断之前是否有更新过相关的活跃度信息
final String userActionKey = ACTIVITY_SCORE_KEY + userId + DateUtil.format(DateTimeFormatter.ofPattern("yyyyMMdd"), System.currentTimeMillis());
Integer ans = RedisClient.hGet(userActionKey, field, Integer.class);
if (ans == null) {
// 2.1 之前没有加分记录,执行具体的加分
if (score > 0) {
// 记录加分记录
RedisClient.hSet(userActionKey, field, score);
// 个人用户的操作记录,保存一个月的有效期,方便用户查询自己最近31天的活跃情况
RedisClient.expire(userActionKey, 31 * DateUtil.ONE_DAY_SECONDS);
// 更新当天和当月的活跃度排行榜
Double newAns = RedisClient.zIncrBy(todayRankKey, String.valueOf(userId), score);
RedisClient.zIncrBy(monthRankKey, String.valueOf(userId), score);
if (log.isDebugEnabled()) {
log.info("活跃度更新加分! key#field = {}#{}, add = {}, newScore = {}", todayRankKey, userId, score, newAns);
}
if (newAns <= score) {
// 日活跃榜单,保存31天;月活跃榜单,保存1年
RedisClient.expire(todayRankKey, 31 * DateUtil.ONE_DAY_SECONDS);
RedisClient.expire(monthRankKey, 12 * DateUtil.ONE_MONTH_SECONDS);
}
}
} else if (ans > 0) {
// 2.2 之前已经加过分,因此这次减分可以执行
if (score < 0) {
Boolean oldHave = RedisClient.hDel(userActionKey, field);
if (BooleanUtils.isTrue(oldHave)) {
Double newAns = RedisClient.zIncrBy(todayRankKey, String.valueOf(userId), score);
RedisClient.zIncrBy(monthRankKey, String.valueOf(userId), score);
if (log.isDebugEnabled()) {
log.info("活跃度更新减分! key#field = {}#{}, add = {}, newScore = {}", todayRankKey, userId, score, newAns);
}
}
}
}
}
基本上,前面的业务逻辑清楚之后,再看上面的实现,应该没什么太大的难度,但是请注意,上面的整个实现,无懈可击么?
- 事务问题:多次的redis操作,存在事务问题
- 并发问题:没有做并发,幂等无法100%生效,依然可能存在重复添加/扣减活跃度的情况
上面抛出了两个问题,是我们再做真实的排行榜时,需要重点考虑的,我们这里先不进行扩散,提几个关键知识点(并发通过加锁,事务通过最终一致性来保障)
1.4 触发活跃度更新
前面只是提供了一个增加活跃度的方法,但是什么时候调用它呢?我们这里借助之前实现的Event/Listenter方式来处理活跃度更新
- 文章/用户的相关操作事件监听,并更新对应的活跃度
- 发布文章事件
然后就是基于用户浏览行为的活跃度更新,这个就可以再Filte/Inteceptor层来实现了
2. 排行榜查询
前面的实现,我们的数据层,一个完整的排行榜就已经存储下来了,接下来就是将这个榜单展示给用户看
基本流程如下:
- 从redis中获取topN的用户+评分
- 查询用户的信息
- 根据用户评分进行排序,并更新每个用户的排名
核心的redis实现如下,直接基于 zRangeWithScores
获取指定排名的用户+对应分数,其中topN的写法如下
3. 小结
基于此,后端测的排行榜单的功能就已经全部实现;至于前端展示,前后端交互的细节,我们这里就不详细展开;整体来讲,我们提供了一个基础、简单可用的排行榜的设计以及实现全流程
当我们的项目足够大,用户体量也很大时,上面还是有不少点需要我们进一步进行完善
如:
- 如何做防刷?
- 并发问题怎么规避?
- 由非原子的redis操作,引入的事务问题怎么避免?
- 性能测试可以怎么进行?
- 数据量大时,存储的用户操作记录导致的庞大存储压力有解决方案么?
欢迎有想法的小伙伴,在评论区/知识星球,给出你的答案,我是你们的老朋友一灰灰,觉得不错的点个赞呗
知识星球
目前技术派已经整理出 89 篇文章(已完成 83 篇
,✅表示已经完成),为了方便大家学习,文章标题后面追加了 2 个标签,分别为“🌟新人必看”和“👍强烈推荐”,方便大家查阅,妥妥细节控~~
技术派教程是星球推出的主打服务,推出的「技术派」开源项目,已收获 1000+ Star,除此之外,还包括其它多项福利,详见 技术派知识星球 。
原价 129 元,送大家一张 30 元优惠券,券后仅 99 元。
说明:楼仔的「技术派」星球,和沉默王二的「Java程序员进阶之路」星球合并了,之前是发的“技术派”的星球优惠券,大家可以直接进入二哥的星球,除了以上所说的内容,还能享受更多福利。
如果觉得不满意,支持 3 天无理由退款哈~~
回复