Redis实现用户活跃排行榜

在这里用户活跃度排行榜,主要是基于redis的zset数据结构来实现的,下面来看一下实例。

方案设计

来看一下业务场景先

1.场景说明

在技术派中,提供了一个用户的活跃排行榜,当然作为一个博客社区,更应该实现的是作者排行榜;但为了更好的活跃用户,让用户有参与感,所以用用户活跃度来设计一个排行榜,区分日/月两个排行榜单

用户活跃度计算方式:

1.用户每访问一个新的页面+1分

2.对于每一篇文章,点赞、收藏+2分;取消点赞、取消收藏,将之前的活跃分收回

3.文章评论+3分

4.发布一篇审核通过的文章+10分

榜单:

展示活跃度最高的前三十名用户

实际的榜单效果如下

2.设计方案

排行榜的业务属性比较去清晰简单,对应的数据结构也可以很容易设计出来,核心的信息如下

存储单元

表示排行榜中每一位上应该持有的所有的信息如下。

数据结构

排行榜,一般而言都是连续的,借此我们可以联想到一个合适的数据结构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

核心代码实现:src/main/java/com/github/paicoding/forum/service/rank/service/impl/UserActivityRankServiceImpl.java

1.更新用户活跃积分

我们先实现一个更新用户活跃的方法,首先定义一个涵盖上面业务场景的参数传递实体ActivityScoreBo

接下来我们先思考一下,这个具体的应该怎么实现,先梳理实现的业务流程

1.根据业务实体,计算需要增加/减少的活跃度

2.对于增加活跃度时:

        2.1做一个幂等,防止重复添加,因此需要判断下之前有没有重复添加过相关的活跃度

        2.2 若幂等了,则直接返回;否则,执行更新,并做好幂等保存。

3.对于减少活跃度时:

        3.1 判断之前有没有加过活跃度,防止减扣为负数

        3.2 之前没有减扣过,则直接返回;否则;执行箭扣,并移除幂等判断

上面的业务逻辑清晰之后,再看一下我们实现的关键因素

  • 1.怎样做幂等?
  • 2.如何更新榜单的评分?

1.1幂等策略

为了防止重复添加活跃度,怎么做幂等呢?一个简单的方案就是将用户的每个加分项,都直接记录下来,在执行具体加分时,基于此来做幂等判定

基于上面这个思路,很容易想到的一个方案就是,每个用户维护一个活跃更新操作历史记录表,我们设计得尽量轻量级点

直接将用户的历史日志,保存在redis的hash数据结构中,每天一个记录

        key: activity_rank_{user_id}_{年月日}

        field:活跃度更新key

        value:添加的活跃度

1.2 榜单评分更新

这个就相对而言比较容易,直接基于zset的incr即可

我们同样是扩展一下RedisClient的工具类,增加上了zset的相关操作。

/*** 分数更新** @param key* @param value* @param score* @return*/public static Double zIncrBy(String key, String value, Integer score) {return template.execute(new RedisCallback<Double>() {@Overridepublic Double doInRedis(RedisConnection connection) throws DataAccessException {return connection.zIncrBy(keyBytes(key), score, valBytes(value));}});}

1.3 具体实现

接下来我们看一下具体的实现代码

/*** 添加活跃分** @param userId* @param activityScore*/@Overridepublic 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);}}}}}

基本上,前面的业务逻辑清楚之后,再看上面的实现,应该没有什么太大的难度,还有点问题?

1.事务问题:多次的redis操作,存在事务问题

2.并发问题:没有做并发,幂等无法100%生效,依然可能存在重复添加/减扣活跃度的情况

上面抛出了两个问题,是在做真实的排行榜时,需要重点考虑的,这里先不进行扩散,提几个关键知识点(并发通过加锁,事务通过最终一致性来保障)

1.4触发活跃度更新

前面只是提供了一个增加活跃度的方法,但啥时候调用它?这里我们借助值之前实现的Event/Listenter方式来处理活跃度更新

        文章/用户的相关操作事件监听,并更新对应的活跃度

 /*** 用户操作行为,增加对应的积分** @param msgEvent*/@EventListener(classes = NotifyMsgEvent.class)@Asyncpublic void notifyMsgListener(NotifyMsgEvent msgEvent) {switch (msgEvent.getNotifyType()) {case COMMENT:case REPLY:CommentDO comment = (CommentDO) msgEvent.getContent();userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setRate(true).setArticleId(comment.getArticleId()));break;case COLLECT:UserFootDO foot = (UserFootDO) msgEvent.getContent();userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setCollect(true).setArticleId(foot.getDocumentId()));break;case CANCEL_COLLECT:foot = (UserFootDO) msgEvent.getContent();userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setCollect(false).setArticleId(foot.getDocumentId()));break;case PRAISE:foot = (UserFootDO) msgEvent.getContent();userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPraise(true).setArticleId(foot.getDocumentId()));break;case CANCEL_PRAISE:foot = (UserFootDO) msgEvent.getContent();userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPraise(false).setArticleId(foot.getDocumentId()));break;case FOLLOW:UserRelationDO relation = (UserRelationDO) msgEvent.getContent();userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setFollow(true).setArticleId(relation.getUserId()));break;case CANCEL_FOLLOW:relation = (UserRelationDO) msgEvent.getContent();userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setFollow(false).setArticleId(relation.getUserId()));break;default:}}

        发布文章事件

 /*** 发布文章,更新对应的积分** @param event*/@Async@EventListener(ArticleMsgEvent.class)public void publishArticleListener(ArticleMsgEvent<ArticleDO> event) {ArticleEventEnum type = event.getType();if (type == ArticleEventEnum.ONLINE) {userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPublishArticle(true).setArticleId(event.getContent().getId()));}}

然后就是基于用户浏览行为的活跃度更新,这个就可以在Filter/inteceptor层来实现了

2.排行榜查询

前面的实现,我们的数据层,一个完整的排行榜就已经存储下来了,接下来就是将这个榜单展示给用户看

基本流程如下:

  • 1.从redis中获取topN的用用户+评分
  • 2.查询用户信息
  • 3.根据用户评分进行排序,并更新每个用户的排名

核心的redis实现如下,直接基于zRangeWithScores获取指定排名的用户+对应分数,其中topN的写法如下

3.小结:

基于此,后端的排行榜单的功能就全部实现了;至于前后端交互细节不展开了,这里提供了一个基础、简单可用的排行榜设计及实现的全流程。至于复杂的,要考虑的问题如数据量大,存储的用户操作记录导致存储压力的问题等等不在展开。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/733289.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

连号区间数 刷题笔记

1.单个元素算一个连续区间 2.题意为 单独截取数组中的一段元素 例如 a数组假设为 3 1 2 5 4 6 7 假设取 a[3]-a[5] 则取出 5 4 6 重新排序后为 4 5 6 连续了 则ans; 假设 取a[i]-a[j]这一段元素 设该段元素的最大值为max,最小值为min 如果该段元素重新排序后…

Learn OpenGL 04 纹理

纹理环绕方式 纹理坐标的范围通常是从(0, 0)到(1, 1)&#xff0c;那如果我们把纹理坐标设置在范围之外会发生什么&#xff1f;OpenGL默认的行为是重复这个纹理图像&#xff08;我们基本上忽略浮点纹理坐标的整数部分&#xff09;&#xff0c;但OpenGL提供了更多的选择&#xf…

LCR 131. 砍竹子 I

解题思路&#xff1a;&#xff08;与砍竹子II的区别是&#xff0c;这里的竹子长度数量级较小&#xff09; 数学推导或贪心 切分规则&#xff1a; 等长&#xff0c;且尽量为3 b0时&#xff0c;pow(3,a) b1时&#xff0c;pow(3,a-1)*4 少一段3&#xff0c;并入b生成一…

YUNBEE云贝:3月9日-PostgreSQL中级工程师PGCE认证培训

课程介绍 根据学员建议和市场需求,规划和设计了《PostgreSQL CE 认证课程》,本课程以内部原理、实践实战为主&#xff0c;理论与实践相结合。课程包含PG 简介、安装使用、服务管理、体系结构等基础知识。同时结合一线实战案例&#xff0c; 面向 PG 数据库的日常维护管理、服务和…

Vue | 基于 vue-admin-template 项目的跨域问题解决方法

目录 一、现存问题 二、解决方法 2.1 修改的第一个地方 2.2 修改的第二个地方 2.3 修改的第三个地方 自存 一、现存问题 报错截图如下&#xff1a; 二、解决方法 2.1 修改的第一个地方 在 .env.development 文件中&#xff1a; # base api # VUE_APP_BASE_API /d…

springboot整合shiro的实战教程(一)

文章目录 1.权限的管理1.1 什么是权限管理1.2 什么是身份认证1.3 什么是授权 2.什么是shiro3.shiro的核心架构3.1 Subject3.2 SecurityManager3.3 Authenticator3.4 Authorizer3.5 Realm3.6 SessionManager3.7 SessionDAO3.8 CacheManager3.9 Cryptography 4. shiro中的认证4.1…

我的 4096 创作纪念日

作者&#xff1a;明明如月学长&#xff0c; CSDN 博客专家&#xff0c;大厂高级 Java 工程师&#xff0c;《性能优化方法论》作者、《解锁大厂思维&#xff1a;剖析《阿里巴巴Java开发手册》》、《再学经典&#xff1a;《Effective Java》独家解析》专栏作者。 热门文章推荐&am…

YOLOv8+DeepSort/ByteTrack-PyQt-GUI / yolov5 deepsort 行人/车辆(检测 +计数+跟踪+测距+测速)

YoloV8结合可视化界面和GUI&#xff0c;实现了交互式目标检测与跟踪&#xff0c;为用户提供了一体化的视觉分析解决方案。通过YoloV8算法&#xff0c;该系统能够高效准确地检测各类目标&#xff0c;并实时跟踪它们的运动轨迹。 用户可以通过直观的可视化界面进行操作&#xff…

Unity性能优化篇(七) UI优化注意事项以及使用Sprite Atlas打包精灵图集

UI优化注意事项 1.尽量避免使用IMGUI(OnGUI)来做游戏时的UI&#xff0c;因为IMGUI的开销比较大。 2.如果一个UGUI的控件不需要进行射线检测&#xff0c;则可以取消勾选Raycast Target 3.尽量避免使用完全透明的图片和UI控件。因为即使完全透明&#xff0c;我们看不见它&#xf…

常见BUG如何在测试过程中分析定位

前言 在测试的日常工作中&#xff0c;相信经常有测试的小伙伴遇到类似的情况&#xff1a;在项目上线时&#xff0c;只要出现问题&#xff08;bug&#xff09;&#xff0c;就很容易成为“背锅侠”。 软件测试人员在工作中是无法避免的要和开发人员和产品经理打交道的&#xff…

117.龙芯2k1000-pmon(16)- linux下升级pmon

pmon的升级总是有些不方便&#xff0c;至少是要借助串口和串口工具 如果现场不方便连接串口&#xff0c;是不是可以使用网线升级pmon呢&#xff1f; 答案当然是可行的。 环境&#xff1a;2k1000linux3.10麒麟的文件系统 如今我已经把这个工具开发出来了。 GitHub - zhaozhi…

网络工程师笔记10 ( RIP / OSPF协议 )

RIP 学习路由信息的时候需要配认证 RIP规定超过15跳认定网络不可达 链路状态路由协议-OSPF 1. 产生lsa 2. 生成LSDB数据库 3. 进行spf算法&#xff0c;生成最有最短路径 4. 得出路由表

【探索C++容器:set和map的使用】

[本节目标] 1. 关联式容器 2. 键值对 3. 树形结构的关联式容器 1. 关联式容器 在初阶阶段&#xff0c;我们已经接触过STL中的部分容器&#xff0c;比如&#xff1a;vector、list、deque、forward_list(C11)等&#xff0c;这些容器统称为序列式容器&#xff0c;因为其底层为…

Toyota Programming Contest 2024#3(AtCoder Beginner Contest 344)(A~C)

A - Spoiler 竖线里面的不要输出&#xff0c;竖线只有一对&#xff0c;且出现一次。 #include <bits/stdc.h> //#define int long long #define per(i,j,k) for(int (i)(j);(i)<(k);(i)) #define rep(i,j,k) for(int (i)(j);(i)>(k);--(i)) #define debug(a) cou…

链表|面试题 02.07.链表相交

ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {ListNode *l NULL, *s NULL;int lenA 0, lenB 0, gap 0;// 求出两个链表的长度s headA;while (s) {lenA ;s s->next;}s headB;while (s) {lenB ;s s->next;}// 求出两个链表长度差if (lenA &…

stm32学习笔记:SPI通信协议原理(未完)

一、SPI简介(serial Peripheral Interface&#xff08;串行 外设 接口&#xff09;) 1、电路模式&#xff08;采用一主多从的模式&#xff09;、同步&#xff0c;全双工 1 所有SPI设备的SCK、MOSI、MISO分别连在一起 2 主机另外引出多条SS控制线&#xff0c;分别接到各从机的S…

DetNet论文速读

paper&#xff1a;DetNet: A Backbone network for Object Detection 存在的问题 最近的目标检测模型通常依赖于在ImageNet分类数据集上预训练的骨干网络。由于ImageNet的分类任务不同于目标检测&#xff0c;后者不仅需要识别对象的类别&#xff0c;而且需要对边界框进行空间…

音视频开发_音频基础知识

如何采集声音——模数转换原理 声音模数转换是将声音信号从模拟形式转换为数字形式的过程。它是数字声音处理的基础&#xff0c;常用于语音识别、音频编码等应用中。 音视频通信流程 音视频采集&#xff1a;首先是从麦克风、摄像头等设备中采集音频和视频数据&#xff0c;将现…

【Windows】VMware虚拟机应用(一):下载安装 VMware Workstation

目录 一、下载 二、注意事项 三、安装 四、密钥激活 4.1 密钥 4.2 激活 一、下载 进入官网下载页 VMware Customer Connect | The All-In-One VMware Product Support Portal 先登录&#xff0c;下载时要求登录。 点【Downloads】 进入产品下载页面&#xff0c;切换到…

手写简易操作系统(三)--加载Loader

前情提要 上一节我们讲了如何启动计算机&#xff0c;这一节我们讲如何加载内核&#xff0c;内核是存在于硬盘上的一段程序&#xff0c;要加载这段程序&#xff0c;那么必然需要从硬盘上读取数据&#xff0c;这里我们就需要使用 ATA PIO 模式 根据ATA规范&#xff0c;所有符合A…