设为首页收藏本站
网站公告 | 这是第一条公告
     

 找回密码
 立即注册
缓存时间20 现在时间20 缓存数据 和聪明人交流,和靠谱的人恋爱,和进取的人共事,和幽默的人随行。晚安!

和聪明人交流,和靠谱的人恋爱,和进取的人共事,和幽默的人随行。晚安!

查看: 339|回复: 0

基于Redis 实现网站PV/UV数据统计

[复制链接]

  离线 

TA的专栏

  • 打卡等级:即来则安
  • 打卡总天数:20
  • 打卡月天数:0
  • 打卡总奖励:260
  • 最近打卡:2025-11-23 20:44:19
等级头衔

等級:晓枫资讯-上等兵

在线时间
0 小时

积分成就
威望
0
贡献
346
主题
296
精华
0
金钱
1232
积分
686
注册时间
2023-2-11
最后登录
2025-11-23

发表于 2025-9-1 07:54:53 | 显示全部楼层 |阅读模式
在网站的数据分析中,PV(Page View,页面浏览量)和 UV(Unique Visitor,独立访客数)是两个重要的指标,几乎每个网站都需要对其进行统计。市面上有很多成熟的统计产品,例如百度的站点统计功能,而本文将介绍如何借助 Redis 的计数器功能,实现一套属于自己的站点统计服务。

1 方案设计


1.1 术语说明

在我们的实际实现中,对 PV 和 UV 的定义与标准定义存在一定差异:

  • PV(Page View):指的是每个页面的访问次数。在本服务中,PV 是总量概念,一个独立的 IP 每访问一次 URL,对应的访问计数就加 1。我们希望按自然日统计每个 URL 的访问计数,同时也能统计总的访问计数,以此判断哪些页面更受读者喜爱。
  • UV(Unique Visitor):用于统计 URI 的访问 IP 数,同样按照自然日和总数进行区分。

1.2 统计流程

用户访问时,首先获取目标 IP,然后根据其访问情况更新对应的计数:

  • 首次访问目标资源:总 PV 加 1,总 UV 加 1;当天 PV 加 1,当天 UV 加 1。
  • 非首次访问,但为当天第一次访问:总 PV 加 1,总 UV 不变;当天 PV 加 1,当天 UV 加 1。
  • 当天非首次访问:总 PV 加 1,总 UV 不变;当天 PV 加 1,当天 UV 不变。
1.png


1.3 数据结构

我们使用 Redis 的 hash 来存储访问信息,具体需要存储以下三类信息:

  • 站点的总访问信息:包括站点的 PV/UV,以及每个 URI 的 PV/UV。
  • 某一天的访问信息:涵盖某一天站点的总访问 PV/UV,以及某一天每个 URI 的 PV/UV。由于计算 UV 时需要存储用户是否访问过某个资源的信息,所以额外添加了存储单元保存用户访问历史。
  • 用户的访问信息:包含用户访问站点的总次数,以及访问每个 URI 的总次数。用户每天的访问信息存储在每天的访问信息结构中,因为每天的访问信息通常不需要持久化保存,比如只存储最近一个月的情况,可设置 Redis 的有效期为 30 天,到期自动清除。
完整的 hash 定义如下:

  • 站点总统计 hash

    • key:visit_info
    • field:

      • pv:站点的总 PV
      • uv:站点的总 UV
      • pv_path:站点某个资源的总访问 PV
      • uv_path:站点某个资源的总访问 UV


  • 每天统计 hash

    • key:visit_info_20230822(每日记录,一天一条记录)
    • field:

      • pv:12(field = 月日_pv,PV 的计数)
      • uv:5(field = 月日_uv,UV 的计数)
      • pv_path:2(资源的当前访问计数)
      • uv_path:资源的当天访问 UV
      • pv_ip:用户当天的访问次数
      • pv_path_ip:用户对资源的当天访问次数


  • 用户访问统计

    • key:visit_info_ip
    • field:

      • pv:用户访问的站点总次数
      • path_pv:用户访问的路径总次数


2.png


2 实现方式


2.1 统计计数

核心计数的实现路径为
  1. com.github.paicoding.forum.service.sitemap.service.SitemapServiceImpl#saveVisitInfo
复制代码
。其原理是:用户站点总 PV 加 1,若返回的最新计数是 1,表示是站点的新用户,所有 UV 加 1;今日 PV 加 1,若返回的最新计数是 1,表示当前用户今日首次访问,进入的 UV 加 1 。
  1. /**
  2.   * 保存站点数据模型
  3.   * <p>
  4.   * 站点统计hash:
  5.   * - visit_info:
  6.   * ---- pv: 站点的总pv
  7.   * ---- uv: 站点的总uv
  8.   * ---- pv_path: 站点某个资源的总访问pv
  9.   * ---- uv_path: 站点某个资源的总访问uv
  10.   * - visit_info_ip:
  11.   * ---- pv: 用户访问的站点总次数
  12.   * ---- path_pv: 用户访问的路径总次数
  13.   * - visit_info_20230822每日记录, 一天一条记录
  14.   * ---- pv: 12  # field = 月日_pv, pv的计数
  15.   * ---- uv: 5   # field = 月日_uv, uv的计数
  16.   * ---- pv_path: 2 # 资源的当前访问计数
  17.   * ---- uv_path: # 资源的当天访问uv
  18.   * ---- pv_ip: # 用户当天的访问次数
  19.   * ---- pv_path_ip: # 用户对资源的当天访问次数
  20.   *
  21.   * @param visitIp 访问者ip
  22.   * @param path    访问的资源路径
  23.   */
  24. @Override
  25. public void saveVisitInfo(String visitIp, String path) {
  26.      String globalKey = SitemapConstants.SITE_VISIT_KEY;
  27.      String day = SitemapConstants.day(LocalDate.now());

  28.      String todayKey = globalKey + "_" + day;

  29.      // 用户的全局访问计数+1
  30.      Long globalUserVisitCnt = RedisClient.hIncr(globalKey + "_" + visitIp, "pv", 1);
  31.      // 用户的当日访问计数+1
  32.      Long todayUserVisitCnt = RedisClient.hIncr(todayKey, "pv_" + visitIp, 1);

  33.      RedisClient.PipelineAction pipelineAction = RedisClient.pipelineAction();
  34.      if (globalUserVisitCnt == 1) {
  35.          // 站点新用户
  36.          // 今日的uv + 1
  37.          pipelineAction.add(todayKey, "uv"
  38.                  , (connection, key, field) -> {
  39.                      connection.hIncrBy(key, field, 1);
  40.                  });
  41.          pipelineAction.add(todayKey, "uv_" + path
  42.                  , (connection, key, field) -> connection.hIncrBy(key, field, 1));

  43.          // 全局站点的uv
  44.          pipelineAction.add(globalKey, "uv", (connection, key, field) -> connection.hIncrBy(key, field, 1));
  45.          pipelineAction.add(globalKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));
  46.      } else if (todayUserVisitCnt == 1) {
  47.          // 判断是今天的首次访问,更新今天的uv+1
  48.          pipelineAction.add(todayKey, "uv", (connection, key, field) -> connection.hIncrBy(key, field, 1));
  49.          if (RedisClient.hIncr(todayKey, "pv_" + path + "_" + visitIp, 1) == 1) {
  50.              // 判断是否为今天首次访问这个资源,若是,则uv+1
  51.              pipelineAction.add(todayKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));
  52.          }

  53.          // 判断是否是用户的首次访问这个path,若是,则全局的path uv计数需要+1
  54.          if (RedisClient.hIncr(globalKey + "_" + visitIp, "pv_" + path, 1) == 1) {
  55.              pipelineAction.add(globalKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));
  56.          }
  57.      }


  58.      // 更新pv 以及 用户的path访问信息
  59.      // 今天的相关信息 pv
  60.      pipelineAction.add(todayKey, "pv", (connection, key, field) -> connection.hIncrBy(key, field, 1));
  61.      pipelineAction.add(todayKey, "pv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));
  62.      if (todayUserVisitCnt > 1) {
  63.          // 非当天首次访问,则pv+1; 因为首次访问时,在前面更新uv时,已经计数+1了
  64.          pipelineAction.add(todayKey, "pv_" + path + "_" + visitIp, (connection, key, field) -> connection.hIncrBy(key, field, 1));
  65.      }


  66.      // 全局的 PV
  67.      pipelineAction.add(globalKey, "pv", (connection, key, field) -> connection.hIncrBy(key, field, 1));
  68.      pipelineAction.add(globalKey, "pv" + "_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));

  69.      // 保存访问信息
  70.      pipelineAction.execute();
  71.      if (log.isDebugEnabled()) {
  72.          log.info("用户访问信息更新完成! 当前用户总访问: {},今日访问: {}", globalUserVisitCnt, todayUserVisitCnt);
  73.      }
  74. }
复制代码
2.2 Redis 管道封装

Redis 管道技术允许在服务端未响应时,客户端继续向服务端发送请求,并最终一次性读取所有服务端的响应,从而实现批量操作。通过对 Redis pipeline 使用姿势的封装,简化了调用过程,例如
  1. com.github.paicoding.forum.core.cache.RedisClient.PipelineAction
复制代码
中的相关代码:
  1. /**
  2. * redis 管道执行的封装链路
  3. */
  4. public static class PipelineAction {
  5.     private List<Runnable> run = new ArrayList<>();

  6.     private RedisConnection connection;

  7.     public PipelineAction add(String key, BiConsumer<RedisConnection, byte[]> conn) {
  8.         run.add(() -> conn.accept(connection, RedisClient.keyBytes(key)));
  9.         return this;
  10.     }

  11.     public PipelineAction add(String key, String field, ThreeConsumer<RedisConnection, byte[], byte[]> conn) {
  12.         run.add(() -> conn.accept(connection, RedisClient.keyBytes(key), valBytes(field)));
  13.         return this;
  14.     }

  15.     public void execute() {
  16.         template.executePipelined((RedisCallback<Object>) connection -> {
  17.             PipelineAction.this.connection = connection;
  18.             run.forEach(Runnable::run);
  19.             return null;
  20.         });
  21.     }
  22. }

  23. @FunctionalInterface
  24. public interface ThreeConsumer<T, U, P> {
  25.     void accept(T t, U u, P p);
  26. }
复制代码
2.3 计数更新与使用

PV/UV 的更新可以在 Filter 中统一调用,为避免计数影响实际业务操作,采用异步更新策略:
  1. com.github.paicoding.forum.web.hook.filter.ReqRecordFilter#initReqInfo
复制代码
  1. private HttpServletRequest initReqInfo(HttpServletRequest request, HttpServletResponse response) {
  2.     if (isStaticURI(request)) {
  3.         // 静态资源直接放行
  4.         return request;
  5.     }

  6.     StopWatch stopWatch = new StopWatch("请求参数构建");
  7.     try {
  8.         stopWatch.start("traceId");
  9.         // 添加全链路的traceId
  10.         MdcUtil.addTraceId();
  11.         stopWatch.stop();

  12.         stopWatch.start("请求基本信息");
  13.         // 手动写入一个session,借助 OnlineUserCountListener 实现在线人数实时统计
  14.         request.getSession().setAttribute("latestVisit", System.currentTimeMillis());

  15.         ReqInfoContext.ReqInfo reqInfo = new ReqInfoContext.ReqInfo();
  16.         reqInfo.setHost(request.getHeader("host"));
  17.         reqInfo.setPath(request.getPathInfo());
  18.         if (reqInfo.getPath() == null) {
  19.             String url = request.getRequestURI();
  20.             int index = url.indexOf("?");
  21.             if (index > 0) {
  22.                 url = url.substring(0, index);
  23.             }
  24.             reqInfo.setPath(url);
  25.         }
  26.         reqInfo.setReferer(request.getHeader("referer"));
  27.         reqInfo.setClientIp(IpUtil.getClientIp(request));
  28.         reqInfo.setUserAgent(request.getHeader("User-Agent"));
  29.         reqInfo.setDeviceId(getOrInitDeviceId(request, response));

  30.         request = this.wrapperRequest(request, reqInfo);
  31.         stopWatch.stop();

  32.         stopWatch.start("登录用户信息");
  33.         // 初始化登录信息
  34.         globalInitService.initLoginUser(reqInfo);
  35.         stopWatch.stop();

  36.         ReqInfoContext.addReqInfo(reqInfo);
  37.         stopWatch.start("pv/uv站点统计");
  38.         // 更新uv/pv计数
  39.         AsyncUtil.execute(() -> SpringUtil.getBean(SitemapServiceImpl.class).saveVisitInfo(reqInfo.getClientIp(), reqInfo.getPath()));
  40.         stopWatch.stop();

  41.         stopWatch.start("回写traceId");
  42.         // 返回头中记录traceId
  43.         response.setHeader(GLOBAL_TRACE_ID_HEADER, Optional.ofNullable(MdcUtil.getTraceId()).orElse(""));
  44.         stopWatch.stop();
  45.     } catch (Exception e) {
  46.         log.error("init reqInfo error!", e);
  47.     } finally {
  48.         if (!EnvUtil.isPro()) {
  49.             log.info("{} -> 请求构建耗时: \n{}", request.getRequestURI(), stopWatch.prettyPrint(TimeUnit.MILLISECONDS));
  50.         }
  51.     }

  52.     return request;
  53. }
复制代码
目前站点的统计信息在前台只显示全局站点的统计情况,使用时直接从 hash 中获取对应的计数即可:
  1. com.github.paicoding.forum.service.sitemap.service.impl.SitemapServiceImpl#querySiteVisitInfo
复制代码
  1. /**
  2. * 查询站点某一天or总的访问信息
  3. *
  4. * @param date 日期,为空时,表示查询所有的站点信息
  5. * @param path 访问路径,为空时表示查站点信息
  6. * @return
  7. */
  8. @Override
  9. public SiteCntVo querySiteVisitInfo(LocalDate date, String path) {
  10.     String globalKey = SitemapConstants.SITE_VISIT_KEY;
  11.     String day = null, todayKey = globalKey;
  12.     if (date != null) {
  13.         day = SitemapConstants.day(date);
  14.         todayKey = globalKey + "_" + day;
  15.     }

  16.     String pvField = "pv", uvField = "uv";
  17.     if (path != null) {
  18.         // 表示查询对应路径的访问信息
  19.         pvField += "_" + path;
  20.         uvField += "_" + path;
  21.     }

  22.     Map<String, Integer> map = RedisClient.hMGet(todayKey, Arrays.asList(pvField, uvField), Integer.class);
  23.     SiteCntVo siteInfo = new SiteCntVo();
  24.     siteInfo.setDay(day);
  25.     siteInfo.setPv(map.getOrDefault(pvField, 0));
  26.     siteInfo.setUv(map.getOrDefault(uvField, 0));
  27.     return siteInfo;
  28. }
复制代码
前台使用路径:
3.png


3 小结

4.png

基于 Redis 实现 PV/UV 统计主要依靠两个关键知识点:

  • hash: incr:利用 Redis 的 hash 结构结合 incr 命令实现原子计数。
  • pipeline:通过管道方式实现批量操作,提高操作效率。
最后提出一个思考问题:当站点访问量剧增,一天达到几百万的访问量时,通过记录 IP 来实现 UV 计数会导致用户访问记录存储开销巨大,此时可以考虑使用 Redis 中的 HyperLoglog 来解决这一问题,它利用数学上的概率统计分布原理,能在空间复杂度较低的情况下实现近似的计数统计。
到此这篇关于基于Redis 实现网站PV/UV数据统计的文章就介绍到这了,更多相关Redis  PV/UV数据统计内容请搜索晓枫资讯以前的文章或继续浏览下面的相关文章希望大家以后多多支持晓枫资讯!

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
晓枫资讯-科技资讯社区-免责声明
免责声明:以上内容为本网站转自其它媒体,相关信息仅为传递更多信息之目的,不代表本网观点,亦不代表本网站赞同其观点或证实其内容的真实性。
      1、注册用户在本社区发表、转载的任何作品仅代表其个人观点,不代表本社区认同其观点。
      2、管理员及版主有权在不事先通知或不经作者准许的情况下删除其在本社区所发表的文章。
      3、本社区的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,举报反馈:点击这里给我发消息进行删除处理。
      4、本社区一切资源不代表本站立场,并不代表本站赞同其观点和对其真实性负责。
      5、以上声明内容的最终解释权归《晓枫资讯-科技资讯社区》所有。
http://bbs.yzwlo.com 晓枫资讯--游戏IT新闻资讯~~~
严禁发布广告,淫秽、色情、赌博、暴力、凶杀、恐怖、间谍及其他违反国家法律法规的内容。!晓枫资讯-社区
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

手机版|晓枫资讯--科技资讯社区 本站已运行

CopyRight © 2022-2025 晓枫资讯--科技资讯社区 ( BBS.yzwlo.com ) . All Rights Reserved .

晓枫资讯--科技资讯社区

本站内容由用户自主分享和转载自互联网,转载目的在于传递更多信息,并不代表本网赞同其观点和对其真实性负责。

如有侵权、违反国家法律政策行为,请联系我们,我们会第一时间及时清除和处理! 举报反馈邮箱:点击这里给我发消息

Powered by Discuz! X3.5

快速回复 返回顶部 返回列表