Redis实现用户活跃排行榜

news2024/10/2 17:17:59

在这里用户活跃度排行榜,主要是基于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>() {
            @Override
            public Double doInRedis(RedisConnection connection) throws DataAccessException {
                return connection.zIncrBy(keyBytes(key), score, valBytes(value));
            }
        });
    }

1.3 具体实现

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


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

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

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

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

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

1.4触发活跃度更新

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

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

 /**
     * 用户操作行为,增加对应的积分
     *
     * @param msgEvent
     */
    @EventListener(classes = NotifyMsgEvent.class)
    @Async
    public 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.coloradmin.cn/o/1504958.html

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

相关文章

惬意上手MySQL

大家好&#xff0c;我又来写博客了&#xff0c;今天给大家介绍一下MySQL,如果你只想让MySQL作为自己的辅助开发工具&#xff0c;那这一篇文章就够了&#xff0c;如果想作为一门语言来学习&#xff0c;那你可以看此文章了解一些基础。 MySQL介绍 数据库可分为关系型数据库和非关…

《C语言都有哪些字符串处理函数?》

目录 17个字符串处理函数 1. gets()--读 2.fgets()--从指定文件内读 3.puts()--输出 4.fputs()--写入到指定文件中 5.strlen()--计算字符串长度 6.strcpy()--复制 7.strncpy()--复制前n个字符 8.strcat()--字符串连接 9.strncat()--将前n个字符连接 10.strcmp()--比…

Paimon 与 Spark 的集成(二):查询优化

Paimon Apache Paimon (incubating) 是一项流式数据湖存储技术&#xff0c;可以为用户提供高吞吐、低延迟的数据摄入、流式订阅以及实时查询能力。Paimon 采用开放的数据格式和技术理念&#xff0c;可以与 Flink / Spark / Trino 等诸多业界主流计算引擎进行对接&#xff0c;共…

视频远程监控平台EasyCVR集成后播放只有一帧画面的原因排查与解决

智慧安防视频监控平台EasyCVR能在复杂的网络环境中&#xff08;专网、局域网、广域网、VPN、公网等&#xff09;将前端海量的设备进行统一集中接入与视频汇聚管理&#xff0c;平台可支持的接入协议包括&#xff1a;国标GB28181、RTSP/Onvif、RTMP&#xff0c;以及厂家的私有协议…

增量式编码器与绝对值编码器基础详解

文章目录 1 使用什么样的电信号来表示旋转和角度信息?1.1 表示相对角度的增量法1.2 表示绝对角度的绝对方法1.3 用脉冲信号表示绝对角度的伪绝对法2 相对角和绝对角的优缺点3 总结1 使用什么样的电信号来表示旋转和角度信息? 在第二部分中,我们解释了旋转和角度信息大致分为…

C++程序设计-第六/七/八章 运算符重载/包含与继承/虚函数和多态性【期末复习|考研复习】

前言 总结整理不易&#xff0c;希望大家点赞收藏。 给大家整理了一下C程序设计中的重点概念&#xff0c;以供大家期末复习和考研复习的时候使用。 C程序设计系列文章传送门&#xff1a; 第一章 面向对象基础 第四/五章 函数和类和对象 第六/七/八章 运算符重载/包含与继承/虚函…

Qt之输入框带自动补全提示功能

这个功能主要是提升人机交互的体验,在输入信息时,自动读取历史信息,协助用户自动补全信息,帮助用户快速输入。 一、使用的控件 使用QComboBox代替传统文本输入框,同时将其属性改为可编辑。 二、使用方式 可以不输入信息,下拉选择项:代码中使用QStringList作为提示信息…

汽车协议学习

ⅠOBD 1.OBD接口 OBD有16个引脚&#xff0c;每个引脚的电压不同&#xff08;可以对应不同的协议&#xff09; 车端&#xff1a; 16- 9 (短一点点的) 8-1 &#xff08;长一点的&#xff09; 2.基于OBDⅡ的通信协议 CAN &#xff08;ISO-15765&am…

如何基于 esp-at 固件测试 TCP (UART 转 WiFi 透传)吞吐?

测试工具&#xff1a; windows/Ubuntu/Android&#xff08;电脑或手机与 ESP 开发板连接相同路由器&#xff09;iperf2 工具ESP 系列的开发板USB-TTL 串口调试工具路由器 测试固件&#xff1a; AT 固件 AT 固件硬件接线说明 不同环境下的 Iperf 工具安装说明 Iperf 工具用于…

用C语言执行SQLite3的gcc编译细节

错误信息&#xff1a; /tmp/cc3joSwp.o: In function main: execSqlite.c:(.text0x100): undefined reference to sqlite3_open execSqlite.c:(.text0x16c): undefined reference to sqlite3_exec execSqlite.c:(.text0x174): undefined reference to sqlite3_close execSqlit…

部署LVS负载均衡集群架构

目录 一、ipvsadm 工具 二、NAT模式下部署LVS负载均衡 1、部署NFS共享存储服务器 1.1 安装NFS软件 1.2 新建共享目录和站点文件 1.3 设置共享策略 2、部署节点服务器1 2.1 安装并启动nginx软件 2.2 挂载共享目录到网页站点目录 2.3 修改网关 3、部署节点服务器2 3.…

植物病害识别:YOLO甘蔗叶片病害识别分类数据集

YOLO甘蔗叶片病害识别数据集, 包含尾孢菌叶斑病&#xff0c;眼斑病&#xff0c;健康&#xff0c;红腐病&#xff0c;锈病&#xff0c;黄叶病6个常见病类别&#xff0c;3300多张图像&#xff0c;yolo标注完整&#xff0c;全部原始图像&#xff0c;未应用增强。 适用于CV项目&…

GEE错误——Landsat9数据集进行去云操作后显示白板

问题 我遇到了一些有关 Landsat9 图像中的云遮蔽和图像处理的问题。我正在分享我所使用的代码以及我感兴趣的区域(资产)。请帮我解决这个问题。我是一名 GEE 学习者。问题:最终图像在大面积上有云状覆盖。 这里我们查看了搜索出的代码发现并不是没有数据集导致的,该区域有…

【Java探索之旅】数据类型与变量,字面常量,整型变量

&#x1f3a5; 屿小夏 &#xff1a; 个人主页 &#x1f525;个人专栏 &#xff1a; Java入门到精通 &#x1f304; 莫道桑榆晚&#xff0c;为霞尚满天&#xff01; 文章目录 &#x1f4d1;前言一、字面常量二、数据类型三、变量3.1 变量概念3.2 语法格式 四、整型变量4.1 整型变…

【C++ vector 类】

1. 标准库中的vector类 vector 类 的介绍&#xff1a; 注意&#xff1a; 1. vector是表示可变大小数组的序列容器。 2. 就像数组一样&#xff0c;vector 也采用的连续存储空间来存储元素。也就是意味着可以采用下标对vector的元素进行访问&#xff0c;和数组一样高效。但是…

seo蜘蛛池的概念!蚂蚁SEO

蜘蛛池是一种特殊的网络营销技术&#xff0c;它的主要作用是吸引搜索引擎爬虫&#xff0c;提高网站的收录和排名&#xff0c;从而增加网站的流量和曝光度。 蚂蚁SEO是一个SEO工具&#xff0c;可以帮助您提高网站权重&#xff0c;吸引更多的搜索引擎爬虫&#xff0c;提高网站的…

物联网云原生云边协同

文章目录 一、物联网平台设计1.物联网平台设计2.物联网平台实现 二、部署环境1.节点配置2.版本信息 三、物联网平台部署1.部署 Kubernetes 集群2.部署 KubeEdge3.部署 ThingsBoard 集群4.部署 ThingsBoard Edge4.1.创建 Edge 实例4.2.部署 PostgreSQL4.3.创建数据库4.4.部署 Th…

Linux之生产消费者模型

(&#xff61;&#xff65;∀&#xff65;)&#xff89;&#xff9e;嗨&#xff01;你好这里是ky233的主页&#xff1a;这里是ky233的主页&#xff0c;欢迎光临~https://blog.csdn.net/ky233?typeblog 点个关注不迷路⌯▾⌯ 我们在条件满足的时候&#xff0c;唤醒指定的线程&a…

unity学习(53)——选择角色界面--分配服务器返回的信息

好久没写客户端了&#xff0c;一上手还不太适应 1.经过测试&#xff0c;成功登陆后&#xff0c;客户端请求list_request&#xff0c;成功返回&#xff0c;如下图&#xff1a; 可见此时model第三个位置的参数是1.也成功返回了所有已注册角色的信息。 2.之前已知创建的角色信息…

计算机服务器中了locked勒索病毒怎么解密,locked勒索病毒解密流程

科技的发展带动了企业生产&#xff0c;越来越多的企业开始利用计算机服务器办公&#xff0c;为企业的生产运营提供了极大便利&#xff0c;但随之而来的网络安全威胁也引起了众多企业的关注。近日&#xff0c;云天数据恢复中心接到许多企业的求助&#xff0c;企业的计算机服务器…