【技术派后端篇】Redis实现统计计数

news2025/4/22 4:08:25

在互联网项目中,计数器有着广泛的应用场景。以技术派项目为例,诸如文章点赞数、收藏数、评论数以及用户粉丝数等都离不开计数器的支持。在技术派源码中,提供了基于数据库操作记录实时更新和基于 Redis 的 incr 特性实现计数器这两种方案,本文将重点探讨基于 Redis 的实现方式。

1 计数的业务场景

技术派中使用计数器的场景主要分为两大类(业务计数 + PV/UV),三个细分领域(用户、文章、站点):

  1. 用户的相关统计信息:包括文章数、文章总阅读数、粉丝数、关注作者数、文章被收藏数、被点赞数量等。
  2. 文章的相关统计信息:如文章点赞数、阅读数、收藏数、评论数等。
  3. 站点的 PV/UV 等统计信息:涵盖网站的总 PV/UV、某一天的 PV/UV 以及某个 URI 的 PV/UV 等。

2 Redis 计数器

Redis 计数器主要借助原生的 incr 指令来实现原子的 +1/-1 操作,且不仅 string 类型支持 incrhashzset 数据类型同样也支持。

2.1 incr 指令

Redis 的 Incr 命令用于将 key 中存储的数值增一。若 key 不存在,会先初始化为 0 再执行 INCR 操作;若值的类型错误或不能表示为数字,则返回错误;并且该操作的值限制在 64 位有符号数字表示之内。

  • 技术派的封装实现:在 RedisClient 中对 hIncr 方法进行了封装,用于实现 hash 类型数据的自增操作。代码如下:
    public static Long hIncr ( String key , String filed , Integer cnt ) {
        return template . execute ( ( RedisCallback <Long >) con ->con . hIncrBy ( keyBytes ( key ) , valBytes ( filed ) , cnt ) ) ;
    }
    

2.2 用户计数统计

在技术派项目中,每个用户的相关计数都存储在一个hash数据结构中。具体结构如下:

  • keyuser_statistic_${userId}
  • field 包含 followCount(关注数)、fansCount(粉丝数)、articleCount(已发布文章数)、praiseCount(文章点赞数)、readCount(文章被阅读数)、collectionCount(文章被收藏数)等。

在业务场景中,为避免计数器与业务代码强耦合,技术派采用消息机制。在 com.github.paicoding.forum.service.statistics.listener.UserStatisticEventListener 中,通过监听不同的消息事件(如 NotifyMsgEventArticleMsgEvent)来实现用户和文章的计数变更。例如,当收到点赞消息(PRAISE)时,会对用户和文章的点赞数进行相应的 +1 操作;取消点赞时则进行 -1 操作。

@EventListener(classes = NotifyMsgEvent.class)
@Async
public void notifyMsgListener(NotifyMsgEvent msgEvent) {
    switch (msgEvent.getNotifyType()) {
        case COMMENT:
        case REPLY:
            // 评论/回复
            CommentDO comment = (CommentDO) msgEvent.getContent();
            RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + comment.getArticleId(), CountConstants.COMMENT_COUNT, 1);
            break;
        case DELETE_COMMENT:
        case DELETE_REPLY:
            // 删除评论/回复
            comment = (CommentDO) msgEvent.getContent();
            RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + comment.getArticleId(), CountConstants.COMMENT_COUNT, -1);
            break;
        case COLLECT:
            // 收藏
            UserFootDO foot = (UserFootDO) msgEvent.getContent();
            RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.COLLECTION_COUNT, 1);
            RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.COLLECTION_COUNT, 1);
            break;
        case CANCEL_COLLECT:
            // 取消收藏
            foot = (UserFootDO) msgEvent.getContent();
            RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.COLLECTION_COUNT, -1);
            RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.COLLECTION_COUNT, -1);
            break;
        case PRAISE:
            // 点赞
            foot = (UserFootDO) msgEvent.getContent();
            RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.PRAISE_COUNT, 1);
            RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.PRAISE_COUNT, 1);
            break;
        case CANCEL_PRAISE:
            // 取消点赞
            foot = (UserFootDO) msgEvent.getContent();
            RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.PRAISE_COUNT, -1);
            RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.PRAISE_COUNT, -1);
            break;
        case FOLLOW:
            UserRelationDO relation = (UserRelationDO) msgEvent.getContent();
            // 主用户粉丝数 + 1
            RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getUserId(), CountConstants.FANS_COUNT, 1);
            // 粉丝的关注数 + 1
            RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getFollowUserId(), CountConstants.FOLLOW_COUNT, 1);
            break;
        case CANCEL_FOLLOW:
            relation = (UserRelationDO) msgEvent.getContent();
            // 主用户粉丝数 - 1
            RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getUserId(), CountConstants.FANS_COUNT, -1);
            // 粉丝的关注数 - 1
            RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getFollowUserId(), CountConstants.FOLLOW_COUNT, -1);
            break;
        default:
    }
}

@Async
@EventListener(ArticleMsgEvent.class)
public void publishArticleListener(ArticleMsgEvent<ArticleDO> event) {
    ArticleEventEnum type = event.getType();
    if (type == ArticleEventEnum.ONLINE || type == ArticleEventEnum.OFFLINE || type == ArticleEventEnum.DELETE) {
        Long userId = event.getContent().getUserId();
        int count = articleDao.countArticleByUser(userId);
        RedisClient.hSet(CountConstants.USER_STATISTIC_INFO + userId, CountConstants.READ_COUNT, count);
    }
}

2.3 用户统计信息查询

查询用户的统计信息时,直接使用 hgetall 命令即可获取用户对应的所有统计数据,源码路径:com.github.paicoding.forum.service.statistics.service.impl.CountServiceImpl#queryUserStatisticInfo

@Override
public UserStatisticInfoDTO queryUserStatisticInfo(Long userId) {
    Map<String, Integer> ans = RedisClient.hGetAll(CountConstants.USER_STATISTIC_INFO + userId, Integer.class);
    UserStatisticInfoDTO info = new UserStatisticInfoDTO();
    info.setFollowCount(ans.getOrDefault(CountConstants.FOLLOW_COUNT, 0));
    info.setArticleCount(ans.getOrDefault(CountConstants.ARTICLE_COUNT, 0));
    info.setPraiseCount(ans.getOrDefault(CountConstants.PRAISE_COUNT, 0));
    info.setCollectionCount(ans.getOrDefault(CountConstants.COLLECTION_COUNT, 0));
    info.setReadCount(ans.getOrDefault(CountConstants.READ_COUNT, 0));
    info.setFansCount(ans.getOrDefault(CountConstants.FANS_COUNT, 0));
    return info;
}

2.4 缓存一致性

为保证缓存与实际数据的一致性,技术派采用简单的定时同步方案,每天对用户统计信息和文章统计信息进行全量同步。

  • 用户统计信息每天全量同步com.github.paicoding.forum.service.statistics.service.impl.CountServiceImpl#autoRefreshAllUserStatisticInfo

     /**
      * 每天4:15分执行定时任务,全量刷新用户的统计信息
      */
     @Scheduled(cron = "0 15 4 * * ?")
     public void autoRefreshAllUserStatisticInfo() {
         Long now = System.currentTimeMillis();
         log.info("开始自动刷新用户统计信息");
         Long userId = 0L;
         int batchSize = 20;
         while (true) {
             List<Long> userIds = userDao.scanUserId(userId, batchSize);
             userIds.forEach(this::refreshUserStatisticInfo);
             if (userIds.size() < batchSize) {
                 userId = userIds.get(userIds.size() - 1);
                 break;
             } else {
                 userId = userIds.get(batchSize - 1);
             }
         }
         log.info("结束自动刷新用户统计信息,共耗时: {}ms, maxUserId: {}", System.currentTimeMillis() - now, userId);
     }
    
  • 文章统计信息每天全量同步com.github.paicoding.forum.service.sitemap.service.impl.SitemapServiceImpl#initSiteMap以及com.github.paicoding.forum.service.statistics.service.impl.CountServiceImpl#refreshArticleStatisticInfo

    /**
     * fixme: 加锁初始化,更推荐的是采用分布式锁
     */
    private synchronized void initSiteMap() {
        long lastId = 0L;
        RedisClient.del(SITE_MAP_CACHE_KEY);
        while (true) {
            List<SimpleArticleDTO> list = articleDao.getBaseMapper().listArticlesOrderById(lastId, SCAN_SIZE);
            // 刷新文章的统计信息
            list.forEach(s -> countService.refreshArticleStatisticInfo(s.getId()));
    
            // 刷新站点地图信息
            Map<String, Long> map = list.stream().collect(Collectors.toMap(s -> String.valueOf(s.getId()), s -> s.getCreateTime().getTime(), (a, b) -> a));
            RedisClient.hMSet(SITE_MAP_CACHE_KEY, map);
            if (list.size() < SCAN_SIZE) {
                break;
            }
            lastId = list.get(list.size() - 1).getId();
        }
    }
    
    /**
    *刷新文章的统计信息
    */
     public void refreshArticleStatisticInfo(Long articleId) {
         ArticleFootCountDTO res = userFootDao.countArticleByArticleId(articleId);
         if (res == null) {
             res = new ArticleFootCountDTO();
         } else {
             res.setCommentCount(commentReadService.queryCommentCount(articleId));
         }
    
         RedisClient.hMSet(CountConstants.ARTICLE_STATISTIC_INFO + articleId,
                 MapUtils.create(CountConstants.COLLECTION_COUNT, res.getCollectionCount(),
                         CountConstants.PRAISE_COUNT, res.getPraiseCount(),
                         CountConstants.READ_COUNT, res.getReadCount(),
                         CountConstants.COMMENT_COUNT, res.getCommentCount()
                 )
         );
     }
    

3 小结

基于 Redis 的 incr 特性能够轻松实现计数相关的需求。使用 Redis 实现计数器,相较于直接使用数据库原始数据进行统计,在项目发展到一定阶段、面临高并发访问时,性能更强,能直接在展示层获取最终结果。但数据库统计方式在项目初期或简单项目中也有其优势,如实现简单、迅速且不易出问题。实际选型时,应根据项目具体情况,优先选择实现代价最小的方案,同时也可预留重构的可能性。

4 思维导图

在这里插入图片描述

5 参考链接

  1. 技术派Redis实现计数统计
  2. 项目仓库(GitHub)
  3. 项目仓库(码云)

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

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

相关文章

JavaWeb 课堂笔记 —— 16 MyBatis 动态SQL

本系列为笔者学习JavaWeb的课堂笔记&#xff0c;视频资源为B站黑马程序员出品的《黑马程序员JavaWeb开发教程&#xff0c;实现javaweb企业开发全流程&#xff08;涵盖SpringMyBatisSpringMVCSpringBoot等&#xff09;》&#xff0c;章节分布参考视频教程&#xff0c;为同样学习…

Deepseek输出的内容如何直接转化为word文件?

我们有时候会直接利用deepseek翻译别人的文章或者想将deepseek输出的内容直接复制到word文档里。但是文本格式和word是不对应的。这时候需要输入如下命令&#xff1a; 以上翻译内容的格式和排版要求如下&#xff1a; 1、一级标题 字体为黑体&#xff08;三号&#xff09;&…

AI融合SEO关键词实战指南

内容概要 随着人工智能技术的迭代升级&#xff0c;SEO关键词策略正经历从人工经验驱动向数据智能驱动的范式转变。本指南聚焦AI技术在搜索引擎优化中的系统性应用&#xff0c;通过构建多层技术框架实现关键词全生命周期管理。核心方法论涵盖语义分析引擎的构建原理、基于NLP的…

快速入手-基于python和opencv的人脸检测

1、安装库 pip install opencv-python 如果下载比较卡的话&#xff0c;指向国内下载地址&#xff1a; pip3 install opencv-python -i https://pypi.tuna.tsinghua.edu.cn/simple 2、下载源码 https://opencv.org/ windows11对应的版本下载&#xff1a; https://pan.baidu…

汽车免拆诊断案例 | 2011款雪铁龙世嘉车刮水器偶尔自动工作

故障现象 一辆2011款雪铁龙世嘉车&#xff0c;搭载1.6 L 发动机&#xff0c;累计行驶里程约为19.8万km。车主反映&#xff0c;该车刮水器偶尔会自动工作&#xff0c;且前照灯偶尔会自动点亮。 故障诊断 接车后试车发现&#xff0c;除了上述故障现象以外&#xff0c;当用遥控器…

8.QT-按钮类控件|Push Button|Radio Button|Check Box|Tool Button(C++)

Push Button 使⽤ QPushButton 表⽰⼀个按钮.这也是当前我们最熟悉的⼀个控件了. QPushButton 继承⾃ QAbstractButton .这个类是⼀个抽象类.是其他按钮的⽗类 在Qt Designer中也能够看到这⾥的继承关系 属性说明text按钮中的⽂本icon按钮中的图标iconSize按钮中图标的尺⼨sh…

STM32嵌入式

一、创建工程项目 1、进入软件首页 2、新建项目,【file】->【new project】 3、选择需要的芯片 4、系统内核部分设置 ① 选择晶振&#xff08;使用外部的高速晶振&#xff09; ② 选择debug形式&#xff08;SW类型&#xff09; 5、时钟设置 6、选择自己需要的引脚设置&a…

Transformer系列(一):NLP中放弃使用循环神经网络架构

NLP中放弃使用循环神经网络架构 一、符号表示与概念基础二、循环神经网络1. 依赖序列索引存在的并行计算问题2. 线性交互距离 三、总结 该系列笔记阐述了自然语言处理&#xff08;NLP&#xff09;中不再采用循环架构&#xff08;recurrent architectures&#xff09;的原因&…

9.QT-显示类控件|Label|显示不同格式的文本|显示图片|文本对齐|自动换行|缩进|边距|设置伙伴(C++)

Label QLabel 可以⽤来显⽰⽂本和图⽚ 属性说明textQLabel中的⽂本textFormat⽂本的格式.• Qt::PlainText 纯⽂本• Qt::RichText 富⽂本(⽀持html标签)• Qt::MarkdownText markdown格式• Qt::AutoText 根据⽂本内容⾃动决定⽂本格式pixmapQLabel 内部包含的图⽚.scaledCo…

【c语言】深入理解指针1

深入理解指针1 一、数组名的理解二、使用指针访问数组三、一维数组传参本质四、二级指针 一、数组名的理解 数组名就是数组首元素的地址&#xff0c;类型是指针类型&#xff0c;但是存在两个例外&#xff1a; sizeof(arr) : 整个数组在内存中的大小 &arr : 整个数组的地址…

4.QT-信号和槽|存在意义|信号和槽的连接方式|信号和槽断开|lambda表达式|信号和槽优缺点(C++)

信号和槽存在意义 所谓的信号槽&#xff0c;终究要解决的问题&#xff0c;就是响应用户的操作 信号槽&#xff0c;其实在GUI开发的各种框架中&#xff0c;是一个比较有特色的存在 其他的GUI开发框架&#xff0c;搞的方式都要更简洁一些&#xff5e;~ 网页开发 (js dom api) 网…

单元测试的一般步骤

Qt Test Qt Test 是 Qt 开发人员发布的一个单元测试框架&#xff0c;用于测试基于 Qt 框架的应用程序或库。它提供了单元测试框架中常见的所有功能以及用于测试图形用户界面的扩展。 1.自动化测试包络ui测试>接口测试>单元测试&#xff1b;现问如何使用Qt进行单元测试&…

UE5 渲染视频

文章目录 概述插件开始渲染渲染透明背景的视频 概述 渲染视频需要使用关卡序列 渲染原理就是将一个关卡序列渲染为序列帧 序列帧放到AE里会自动变成视频 UE版本是5.4.4 插件 首先开启新的渲染插件&#xff0c;否则会自动使用旧的渲染插件 插件里搜Render&#xff0c;开启这…

pycharm无法识别到本地python的conda环境解决方法

问题一 现象描述&#xff1a; 本地已经安装了conda&#xff0c;但在pycharm中选择conda环境却识别不到&#xff0c; 解决方法&#xff1a;手动输入conda path&#xff0c;点击R eload environments基本就能修复&#xff0c;比如我的路径如下 /Users/test/conda/miniconda3/b…

LFM调制信号分类与检测识别

LFM调制信号分类与检测识别 LFM调制信号分类识别AlexNet网络识别InceptionV3、ResNet-18、ResNet-50网络识别 LFM调制信号检测识别 LFM调制信号分类识别 支持识别LFM信号、间歇采样干扰(ISRJ)、灵巧噪声干扰(SNJ)、扫频干扰(SJ)、瞄准干扰(AJ)、阻塞干扰(BJ)、密集假目标干扰(…

头歌实训之连接查询

&#x1f31f; 各位看官好&#xff0c;我是maomi_9526&#xff01; &#x1f30d; 种一棵树最好是十年前&#xff0c;其次是现在&#xff01; &#x1f680; 今天来学习C语言的相关知识。 &#x1f44d; 如果觉得这篇文章有帮助&#xff0c;欢迎您一键三连&#xff0c;分享给更…

常见的服务器硬盘接口

常见的服务器硬盘接口有SATA、SAS、M.2、U.2 一、SATA接口 SATA&#xff08;Serial Advanced Technology Attachment&#xff09;是广泛应用于存储设备的串行接口标准&#xff0c;在服务器中主要用于连接大容量机械硬盘&#xff08;HDD&#xff09;或经济型固态硬盘&#xff…

SpringBoot编写单元测试

pom.xml引入单元测试的坐标 <!--单元测试坐标--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>编写单元测试类 测试类…

目标分割模型优化自身参数都是梯度下降算法吗?

在计算机视觉的深度学习任务中&#xff0c;诸如 CNN、FCN、U-Net、DeepLab 系列模型已成为图像分类与图像分割任务的核心架构。它们在网络结构和任务上有所差异&#xff0c;但是否共享同一种优化机制&#xff1f;是否都使用梯度下降&#xff1f;优化过程中又有什么本质区别&…

基于springboot的商城

1 项目使用技术 后端框架&#xff1a;SpringBoot 数据库&#xff1a;MySQL 开发工具&#xff1a;IDEA 2 项目功能模块 商城功能包含前台和后台。 &#xff08;1&#xff09;前台主要包含&#xff1a;用户注册登录模块、首页模块、搜索模块、商品详情、购物车、提交订单、…