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

文章目录

      • 场景说明
      • 方案设计
        • 数据结构
      • 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());
        }
    });
}

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

在阿里云上部署 Docker并通过 Docker 安装 Dify

目录 一、在服务器上安装docker和docker compose 1.1 首先关闭防火墙 1.2 安装docker依赖包 1.3 设置阿里云镜像源并安装docker-ce社区版 1.4 开启docker服务并设置开机自启动 1.5 查看docker版本信息 1.6 设置镜像加速 1.7 将docker compose环境复制到系统的bin目录下…

DM8守护集群部署、数据同步验证、主备切换

1. 环境描述 实例详情 端口详情 2. 部署步骤 2.1 数据准备 2.1.1主库初始化 [dmdbaray1 ~]$ cd /dmdba/dmdbms/bin [dmdbaray1 bin]$ ./dminit path/dmdba/data PAGE_SIZE32 EXTENT_SIZE32 CASE_SENSITIVEy CHARSET1 DB_NAMEGRP1_RT_01 INSTANCE_NAMEGRP1_RT_01 PORT_NU…

C++——入门基础(上)

目录 一、C参考文档 二、C在工作领域的应用 三、C学习书籍 四、C的第一个程序 五、命名空间 &#xff08;1&#xff09;namespace的定义 (2)命名空间的使用 六、C的输入和输出 七、缺省函数 八、函数重载 九、写在最后 一、C参考文档 &#xff08;1&#xff09;虽…

第46课 Scratch入门篇:狙击望远镜

无限画中画 故事背景: 手拿一把狙击枪,第一次按下空格键的时候瞄准镜放大一倍,再按一次再放大一倍。开枪设计,瞬间击毁! 程序原理: 1、瞄准的物品放大,其实是角色的变化,我们把背景设置成角色,原始的角色是 480360,第一次放大的图为 14401080,放大了 3 倍。第二级…

【Java 并发编程】(二) 从对象内存布局开始聊 synchronized

对象的内存布局 首先抛出一个经典面试题: 一个 Object 对象占多大? 这里我用工具打印了出来, 发现是 “16bytes”, 也就是 16B; 为什么? 请继续往下看; 普通对象(除了数组), 由markword, 类型指针, 实例数据(就是对象里的成员), 对齐填充(整个对象大小要能被8B整数, 方便6…

思科OSPF动态路由配置8

#路由协议实现# #任务八OSPF动态路由配置8# 开放式最短路径优先&#xff08;Open Shortest Path First,OSPF&#xff09;协议是目前网络中应用最广泛的动态路由协议之一。它也属于内部网关路由协议&#xff0c;能够适应各种规模的网络环境&#xff0c;是典型的链路状态路由协…

ZooKeeper 集群的详细部署

ZooKeeper 集群部署 一、ZooKeeper 简介1.1 什么是 ZooKeeper1.2 ZooKeeper 特点 二 ZooKeeper 的架构和设计4.1 ZooKeeper 数据模型4.1.1 Znode 节点特性 三、ZooKeeper 的集群安装前准备工作3.1 需要的准备工作3.2 Linux 系统 3 个节点准备3.2.1 克隆3.2.2 配置另外两台服务器…

【RabbitMQ】 相关概念 + 工作模式

本文将介绍一些MQ中常见的概念&#xff0c;同时也会简单实现一下RabbitMQ的工作流程。 MQ概念 Message Queue消息队列。是用来存储消息的队列&#xff0c;多用于分布式系统之间的通信。 系统间调用通常有&#xff1a;同步通信和异步通信。MQ就是在异步通信的时候使用的。 同…

高考志愿智能推荐系统-计算机毕设Java|springboot实战项目

&#x1f34a;作者&#xff1a;计算机毕设匠心工作室 &#x1f34a;简介&#xff1a;毕业后就一直专业从事计算机软件程序开发&#xff0c;至今也有8年工作经验。擅长Java、Python、微信小程序、安卓、大数据、PHP、.NET|C#、Golang等。 擅长&#xff1a;按照需求定制化开发项目…

第三方软件测评中心分享:软件系统测试内容和作用

近年来&#xff0c;随着信息技术的迅猛发展&#xff0c;软件系统的应用范围不断扩大。保证软件质量的关键措施之一就是软件系统测试。软件系统测试是指在软件开发生命周期中&#xff0c;通过一系列特定的测试活动来验证和确认软件系统的性能、功能及安全性&#xff0c;确保软件…

优优嗨聚集团:餐饮合作新未来引领美食产业新风尚

在快速变化的21世纪&#xff0c;餐饮行业作为民生消费的重要组成部分&#xff0c;正经历着前所未有的变革与挑战。随着消费者需求的多元化、个性化以及科技的不断进步&#xff0c;餐饮合作的新模式正悄然兴起&#xff0c;为行业带来了前所未有的发展机遇与活力。本文将探讨餐饮…

【Redis】Redis 数据类型与结构—(二)

Redis 数据类型与结构 一、值的数据类型二、键值对数据结构三、集合数据操作效率 一、值的数据类型 Redis “快”取决于两方面&#xff0c;一方面&#xff0c;它是内存数据库&#xff0c;另一方面&#xff0c;则是高效的数据结构。 Redis 键值对中值的数据类型&#xff0c;也…

网页版IntelliJ IDEA部署

在服务器部署网页 IntelliJ IDEA 引言 大家好&#xff0c;我是小阳&#xff0c;今天要为大家带来一个黑科技——如何在云端部署和使用WEB版的IntelliJ IDEA&#xff0c;让你在任何地方都可以随心所欲地进行Java开发。这个方法特别适合那些用着老旧Windows电脑&#xff0c;部署…

MySQL集群+Keepalived实现高可用部署

Mysql高可用集群-双主双活-myqlkeeplived 一、特殊情况 常见案例&#xff1a;当生产环境中&#xff0c;当应用服务使用了mysql-1连接信息&#xff0c;在升级打包过程中或者有高频的数据持续写入【对数据一致性要求比较高的场景】&#xff0c;这种情况下&#xff0c;数据库连接…

Springboot 整合 Swagger3(springdoc-openapi)

使用springdoc-openapi这个库来生成swagger的api文档 官方Github仓库&#xff1a; https://github.com/springdoc/springdoc-openapi 官网地址&#xff1a;https://springdoc.org 目录题 1. 引入依赖2. 拦截器设置3. 访问接口页面3.1 添加配置项&#xff0c;使得访问路径变短…

贪吃蛇(C语言详解)

贪吃蛇游戏运行画面-CSDN直播 目录 贪吃蛇游戏运行画面-CSDN直播 1. 实验目标 2. Win32 API介绍 2.1 Win32 API 2.2 控制台程序&#xff08;Console&#xff09; 2.3 控制台屏幕上的坐标COORD 2.4 GetStdHandle 2.5 GetConsoleCursorlnfo 2.5.1 CONSOLE_CURSOR_INFO …

开源通用验证码识别OCR —— DdddOcr 源码赏析(一)

文章目录 [toc] 前言DdddOcr环境准备安装DdddOcr使用示例 源码分析实例化DdddOcr实例化过程 分类识别分类识别过程 未完待续 前言 DdddOcr 源码赏析 DdddOcr DdddOcr是开源的通用验证码识别OCR 官方传送门 环境准备 安装DdddOcr pip install ddddocr使用示例 示例图片如…

Wyn商业智能助力零售行业数字化决策高效驱动

最新技术资源&#xff08;建议收藏&#xff09; https://www.grapecity.com.cn/resources/ 项目背景及痛点 百利商业的业务覆盖赛格、 SKP、奥莱、王府井等多地区具有代表性的商场&#xff0c;并创立了多个自有品牌。随着新零售模式的兴起&#xff0c;百利商业紧跟时代步伐&am…

培训学校课程管理系统-计算机毕设Java|springboot实战项目

&#x1f34a;作者&#xff1a;计算机毕设匠心工作室 &#x1f34a;简介&#xff1a;毕业后就一直专业从事计算机软件程序开发&#xff0c;至今也有8年工作经验。擅长Java、Python、微信小程序、安卓、大数据、PHP、.NET|C#、Golang等。 擅长&#xff1a;按照需求定制化开发项目…

web开发,过滤器,前后端交互

目录 web开发概述 web开发环境搭建 Servlet概述 Servlet的作用&#xff1a; Servlet创建和使用 Servlet生命周期 http请求 过滤器 过滤器的使用场景&#xff1a; 通过Filter接口来实现&#xff1a; 前后端项目之间的交互&#xff1a; 1、同步请求 2、异步请求 优化…