Redis大显身手:实时用户活跃排行榜

news2024/12/25 13:31:07

文章目录

      • 场景说明
      • 方案设计
        • 数据结构
      • Redis使用方案
      • 排行榜实现
        • 更新用户活跃积分
        • 幂等策略
        • 榜单评分更新
        • 触发活跃度更新
        • 排行榜查询

技术派项目源码地址 :

  • Gitee :技术派 - https://gitee.com/itwanger/paicoding
  • Github :技术派 - https://github.com/itwanger/paicoding

效果如图 :

image.png

场景说明

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

用户活跃度计算方式:

  1. 用户每访问一个新的页面 +1分
  2. 对于一篇文章,点赞、收藏 +2分;取消点赞、取消收藏,将之前的活跃分收回
  3. 文章评论 +3分
  4. 发布一篇审核通过的文章 +10分

方案设计

数据结构

排行榜,一般而言都是连续的,借此我们可以联想到一个合适的数据结构LinkedList,
好处在于排名变动时,不需要数组的拷贝

image.png

Redis使用方案

这里主要使用的是redis的ZSET数据结构,带权重的集合,下面分析一下可能性

  • set: 集合确保里面元素的唯一性

  • 权重:这个可以看做我们的score,这样每个元素都有一个score;

  • zset:根据score进行排序的集合

  • 从zset的特性来看,我们每个用户的积分,丢到zset中,就是一个带权重的元素,

  • 而且是已经排好序的了,只需要获取元素对应的index,就是我们预期的排名

排行榜实现

更新用户活跃积分

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

  1. 根据业务实体,计算需要增加/减少的活跃度
  2. 对于增加活跃度时:
  • 做一个幂等,防止重复添加,因此需要判断下之前有没有重复添加过相关的活跃度
  • 若幂等了,则直接返回;否则,执行更新,并做好幂等保存
  1. 对于减少活跃度时:
  • 判断之前有没有加过活跃度,防止扣减为负数
  • 之前没有扣减过,则直接返回;否则,执行扣减,并移除幂等判定

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

  • 怎么做幂等?
  • 如何更新榜单的评分?
幂等策略

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

基于上面这个思路,很容易想到的一个方案就是

每个用户维护一个活跃更新操作历史记录表,我们先尽量设计得轻量级一点

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

  • key: activity_rank_{user_id}_{年月日}
  • field: 活跃度更新key
  • value: 添加的活跃度
榜单评分更新
  • 对有序集合中的某个成员的分数进行增加操作,并返回增加后的总分值

image.png

  • 具体实现代码如下
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);
                }
            }
        }
    }
}
触发活跃度更新
  • 文章/用户的相关操作事件监听,并更新对应的活跃度
  • 添加了@Async注解, 作为异步处理, 不参与原本的业务逻辑当中
/**
 * 用户操作行为,增加对应的积分
 *
 * @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()));
    }
  • 基于用户浏览行为的活跃度更新,这个就可以再Filte/Inteceptor层来实现了
@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            Permission permission = handlerMethod.getMethod().getAnnotation(Permission.class);
            if (permission == null) {
                permission = handlerMethod.getBeanType().getAnnotation(Permission.class);
            }

            if (permission == null || permission.role() == UserRole.ALL) {
                if (ReqInfoContext.getReqInfo() != null) {
                    // 用户活跃度更新
                    SpringUtil.getBean(UserActivityRankService.class).addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPath(ReqInfoContext.getReqInfo().getPath()));
                }
                return true;
            }

            if (ReqInfoContext.getReqInfo() == null || ReqInfoContext.getReqInfo().getUserId() == null) {
                if (handlerMethod.getMethod().getAnnotation(ResponseBody.class) != null
                        || handlerMethod.getMethod().getDeclaringClass().getAnnotation(RestController.class) != null) {
                    // 访问需要登录的rest接口
                    response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    response.getWriter().println(JsonUtil.toStr(ResVo.fail(StatusEnum.FORBID_NOTLOGIN)));
                    response.getWriter().flush();
                    return false;
                } else if (request.getRequestURI().startsWith("/api/admin/") || request.getRequestURI().startsWith("/admin/")) {
                    response.sendRedirect("/admin");
                } else {
                    // 访问需要登录的页面时,直接跳转到登录界面
                    response.sendRedirect("/");
                }
                return false;
            }
            if (permission.role() == UserRole.ADMIN && !UserRole.ADMIN.name().equalsIgnoreCase(ReqInfoContext.getReqInfo().getUser().getRole())) {
                // 设置为无权限
                response.setStatus(HttpStatus.FORBIDDEN.value());
                return false;
            }
        }
        return true;
    }
排行榜查询
  1. 从redis中获取topN的用户+评分
  2. 查询用户的信息
  3. 根据用户评分进行排序,并更新每个用户的排名
@Override
public List<RankItemDTO> queryRankList(ActivityRankTimeEnum time, int size) {
    String rankKey = time == ActivityRankTimeEnum.DAY ? todayRankKey() : monthRankKey();
    // 1. 获取topN的活跃用户
    List<ImmutablePair<String, Double>> rankList = RedisClient.zTopNScore(rankKey, size);
    if (CollectionUtils.isEmpty(rankList)) {
        return Collections.emptyList();
    }
    // 2. 查询用户对应的基本信息
    // 构建userId -> 活跃评分的map映射,用于补齐用户信息
    Map<Long, Integer> userScoreMap = rankList.stream().collect(Collectors.toMap(s -> Long.valueOf(s.getLeft()), s -> s.getRight().intValue()));
    List<SimpleUserInfoDTO> users = userService.batchQuerySimpleUserInfo(userScoreMap.keySet());
    // 3. 根据评分进行排序
    List<RankItemDTO> rank = users.stream()
            .map(user -> new RankItemDTO().setUser(user).setScore(userScoreMap.getOrDefault(user.getUserId(), 0)))
            .sorted((o1, o2) -> Integer.compare(o2.getScore(), o1.getScore()))
            .collect(Collectors.toList());
    // 4. 补齐每个用户的排名
    IntStream.range(0, rank.size()).forEach(i -> rank.get(i).setRank(i + 1));
    return rank;
}
  • 核心的实现如下, 基于 zRangeWithScores 获取指定排名的用户+对应分数,其中topN的写法如下
/**
 * 找出排名靠前的n个
 *
 * @param key
 * @param n
 * @return
 */
public static List<ImmutablePair<String, Double>> zTopNScore(String key, int n) {
    return template.execute(new RedisCallback<List<ImmutablePair<String, Double>>>() {
        @Override
        public List<ImmutablePair<String, Double>> doInRedis(RedisConnection connection) throws DataAccessException {
            Set<RedisZSetCommands.Tuple> set = connection.zRangeWithScores(keyBytes(key), -n, -1);
            if (set == null) {
                return Collections.emptyList();
            }
            return set.stream()
                    .map(tuple -> ImmutablePair.of(toObj(tuple.getValue(), String.class), tuple.getScore()))
                    .sorted((o1, o2) -> Double.compare(o2.getRight(), o1.getRight())).collect(Collectors.toList());
        }
    });
}

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

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

相关文章

maven项目的目录结构

今天用jdk17创建maven项目时候出的问题 那就一步步自己整了 <?xml version"1.0" encoding"UTF-8"?> <project xmlns"http://maven.apache.org/POM/4.0.0" xmlns:xsi"http://www.w3.org/2001/XMLSchema-instance"xsi:schema…

车载以太网AVB系统方案搭建基于100BASE-T1车载以太网

1.音频传输系统需求 音频是智能座舱的核心功能&#xff0c;涵盖车载音响、语音识别、e-Call、消噪及回声消除等应用&#xff0c;随着汽车智能网联化的发展&#xff0c;对音频的开发要求也越来越高。传统的车载音频系统采用模拟并行音频信号传输方式&#xff0c;难以在功能增加…

RabbitMQ消息持久化实现

RabbitMQ消息持久化实现 1. 交换器的持久化2. 队列的持久化3. 消息的持久化 &#x1f496;The Begin&#x1f496;点点关注&#xff0c;收藏不迷路&#x1f496; RabbitMQ作为流行的消息队列&#xff08;MQ&#xff09;产品&#xff0c;提供了全面的持久化机制&#xff0c;确保…

Leetcode每日刷题之209.长度最小的子数组(C++)

1.题目解析 根据题目我们知道所给的数组均是正整数&#xff0c;我们需要找到的是该数组的子数组&#xff0c;使其子数组内所有元素之和大于或等于给出的目标数字target&#xff0c;然后返回其长度&#xff0c;最终找出所以满足条件的子数组&#xff0c;并且要返回长度最小的子数…

【Redis分析】(二) Sentinel

哨兵 - 高可用 哨兵&#xff08;Sentinel&#xff09; 是 Redis 的高可用性解决方案&#xff1a;由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器&#xff0c;以及这些主服务器属下的所有从服务器。 Sentinel 可以在被监视的主服务器进入下线状态时…

mysql windows安装与远程连接配置

安装包在主页资源中 一、安装(此安装教程为“mysql-installer-community-5.7.41.0.msi”安装教程&#xff0c;安装到win10环境) 保持默认选项&#xff0c;点击”Next“。 点开第一行加号展开一路展开找到“MySQL Server 5,7,41 - X64”点击选中点击一下中间只想右侧的箭头看到…

encoding with ‘idna‘ codec failed (UnicodeError: label empty or too long)

今天在使用Flask连接mysql的时候&#xff0c;遇到了一个报错&#xff1a;encoding with ‘idna’ codec failed (UnicodeError: label empty or too long) 网上查了一下说是字符集的问题&#xff0c;然后尝试修改了一下字符集&#xff0c;结果还是不行。 最后去翻阅SQLAlchemy…

AI时代的学术写作:Kimi如何助力文献综述?

在学术研究的浩瀚海洋中&#xff0c;文献综述无疑是探索知识边界的重要工具。它不仅是对现有研究的梳理&#xff0c;更是对未来研究方向的指引。然而&#xff0c;撰写一篇高质量的文献综述并非易事&#xff0c;它要求研究者具备广泛的文献检索能力、深刻的分析批判能力以及严谨…

基于springboot的城市垃圾分类管理系统--论文pf

TOC springboot487基于springboot的城市垃圾分类管理系统--论文pf 绪论 1.1 研究背景 当前社会各行业领域竞争压力非常大&#xff0c;随着当前时代的信息化&#xff0c;科学化发展&#xff0c;让社会各行业领域都争相使用新的信息技术&#xff0c;对行业内的各种相关数据进…

Spring发送邮件性能优化?如何集成发邮件?

Spring发送邮件安全性探讨&#xff01;Spring发送邮件功能有哪些&#xff1f; 邮件发送的性能逐渐成为影响用户体验的重要因素之一。AokSend将探讨如何在Spring框架中进行Spring发送邮件的性能优化&#xff0c;确保系统能够高效、稳定地处理大量邮件请求。 Spring发送邮件&am…

《机器学习》KNN算法搭配OpenCV训练模型、识别图片 No.2

一、使用KNN算法识别数字 1、明确目的&#xff1a; 有一张图片&#xff0c;其中有一份数据&#xff0c;其中共有0-9的不同写法的数字&#xff0c;共5000条&#xff0c;现在想要对这张图片中的数据进行训练&#xff0c;以完成当输入一张图片&#xff0c;图片内为手写的数字&…

MaxKB(三):通过修改代码去掉社区版限制

由于社区版对创建用户、创建应用、创建知识库等功能有数量显示&#xff0c;既然咱们都有源码了&#xff0c;那限制能否解除呢&#xff0c;经过尝试是可以的。 首选源代码需要能运行起来&#xff0c;具体见《MaxKB&#xff08;二&#xff09;&#xff1a;Ubuntu24.04搭建maxkb开…

暑假算法刷题日记 Day 10

目录 重点整理 054、 拼数 题目描述 输入格式 输出格式 输入输出样例 核心思路 代码 055、 求第k小的数 题目描述 输入格式 输出格式 输入输出样例 核心思路 代码 总结 这几天我们主要刷了洛谷上排序算法对应的一些题目&#xff0c;相对来说比较简单 一共是13道…

数据埋点系列 18| 数据驱动产品开发:用洞察力塑造卓越产品

在当今竞争激烈的市场中&#xff0c;数据驱动的产品开发已成为创造成功产品的关键。通过利用数据洞察&#xff0c;公司可以更准确地了解用户需求&#xff0c;做出更明智的产品决策&#xff0c;并持续优化产品性能。本文将探讨如何在产品开发的各个阶段应用数据驱动方法。 目录…

python发邮件

1. SMTP&#xff08;简单邮件传输协议&#xff09;基础 SMTP 协议概述 SMTP&#xff08;Simple Mail Transfer Protocol&#xff0c;简单邮件传输协议&#xff09;是用于在计算机网络上传输电子邮件的标准通信协议。它定义了发送邮件的基本规则和流程&#xff0c;确保邮件从发…

开源前端埋点监控插件Web-Tracing

Web-Tracing是一款专为前端项目设计的前端监控插件&#xff0c;它基于JavaScript设计&#xff0c;兼容跨平台使用&#xff0c;并提供了全方位的监控功能。 开源地址&#xff1a;https://gitee.com/junluoyu/web-tracing-analysis 以下是关于Web-Tracing的详细介绍&#xff1a;…

李沐:创业一年,人间三年

大家好&#xff0c;我是 Bob! &#x1f60a; 一个想和大家慢慢变富的 AI 程序员&#x1f4b8; 分享 AI 前沿技术、项目经验、面试技巧! 欢迎关注我&#xff0c;一起探索&#xff0c;一起破圈&#xff01;&#x1f4aa; 李沐&#xff1a;创业一年&#xff0c;人间三年 前不久&am…

【项目】Java文档搜索引擎测试报告

一、项目背景 随着Java技术的不断发展和广泛应用&#xff0c;Java开发者对于API文档的需求日益增加。高质量的API文档不仅能帮助开发者快速了解和掌握各种类、接口、方法等的功能与用法&#xff0c;还能显著提升开发效率。然而&#xff0c;在面对庞大的API文档集时&#xff0c…

爱心公益,向阳而生 ——共同家园 “向阳计划“温暖启航

“向阳计划”由大湾区共同家园线上运营建设共同家园社区公益团队。在这个快节奏的时代,总有一份温暖,能穿透喧嚣,照亮人心。今天,共同家园社区我们满怀激动与期待,正式推出“向阳计划”——一项旨在汇聚社会各界爱心力量,共同为需要帮助的人群送去光明与希望的公益行动。我们共…

鸿蒙 点击获取电话号拨打电话 @ohos.telephony.call (拨打电话)

1, 先看看效果 2, 直接CV 代码 import call from ohos.telephony.callEntry Component struct Index {Statephoto: string 15517189270build() {Column() {Row() {Text(this.photo)Image($r(app.media.ic_contacts_incoming_filled)).width(30).height(30).fillColor(Color.Or…