【技术派后端篇】基于 Redis 实现网站 PV/UV 数据统计
在网站的数据分析中,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.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 实现方式
2.1 统计计数
核心计数的实现路径为 com.github.paicoding.forum.service.sitemap.service.SitemapServiceImpl#saveVisitInfo。其原理是:用户站点总 PV 加 1,若返回的最新计数是 1,表示是站点的新用户,所有 UV 加 1;今日 PV 加 1,若返回的最新计数是 1,表示当前用户今日首次访问,进入的 UV 加 1 。
/*** 保存站点数据模型* <p>* 站点统计hash:* - visit_info:* ---- pv: 站点的总pv* ---- uv: 站点的总uv* ---- pv_path: 站点某个资源的总访问pv* ---- uv_path: 站点某个资源的总访问uv* - visit_info_ip:* ---- pv: 用户访问的站点总次数* ---- path_pv: 用户访问的路径总次数* - visit_info_20230822每日记录, 一天一条记录* ---- pv: 12 # field = 月日_pv, pv的计数* ---- uv: 5 # field = 月日_uv, uv的计数* ---- pv_path: 2 # 资源的当前访问计数* ---- uv_path: # 资源的当天访问uv* ---- pv_ip: # 用户当天的访问次数* ---- pv_path_ip: # 用户对资源的当天访问次数** @param visitIp 访问者ip* @param path 访问的资源路径*/@Overridepublic void saveVisitInfo(String visitIp, String path) {String globalKey = SitemapConstants.SITE_VISIT_KEY;String day = SitemapConstants.day(LocalDate.now());String todayKey = globalKey + "_" + day;// 用户的全局访问计数+1Long globalUserVisitCnt = RedisClient.hIncr(globalKey + "_" + visitIp, "pv", 1);// 用户的当日访问计数+1Long todayUserVisitCnt = RedisClient.hIncr(todayKey, "pv_" + visitIp, 1);RedisClient.PipelineAction pipelineAction = RedisClient.pipelineAction();if (globalUserVisitCnt == 1) {// 站点新用户// 今日的uv + 1pipelineAction.add(todayKey, "uv", (connection, key, field) -> {connection.hIncrBy(key, field, 1);});pipelineAction.add(todayKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));// 全局站点的uvpipelineAction.add(globalKey, "uv", (connection, key, field) -> connection.hIncrBy(key, field, 1));pipelineAction.add(globalKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));} else if (todayUserVisitCnt == 1) {// 判断是今天的首次访问,更新今天的uv+1pipelineAction.add(todayKey, "uv", (connection, key, field) -> connection.hIncrBy(key, field, 1));if (RedisClient.hIncr(todayKey, "pv_" + path + "_" + visitIp, 1) == 1) {// 判断是否为今天首次访问这个资源,若是,则uv+1pipelineAction.add(todayKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));}// 判断是否是用户的首次访问这个path,若是,则全局的path uv计数需要+1if (RedisClient.hIncr(globalKey + "_" + visitIp, "pv_" + path, 1) == 1) {pipelineAction.add(globalKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));}}// 更新pv 以及 用户的path访问信息// 今天的相关信息 pvpipelineAction.add(todayKey, "pv", (connection, key, field) -> connection.hIncrBy(key, field, 1));pipelineAction.add(todayKey, "pv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));if (todayUserVisitCnt > 1) {// 非当天首次访问,则pv+1; 因为首次访问时,在前面更新uv时,已经计数+1了pipelineAction.add(todayKey, "pv_" + path + "_" + visitIp, (connection, key, field) -> connection.hIncrBy(key, field, 1));}// 全局的 PVpipelineAction.add(globalKey, "pv", (connection, key, field) -> connection.hIncrBy(key, field, 1));pipelineAction.add(globalKey, "pv" + "_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));// 保存访问信息pipelineAction.execute();if (log.isDebugEnabled()) {log.info("用户访问信息更新完成! 当前用户总访问: {},今日访问: {}", globalUserVisitCnt, todayUserVisitCnt);}}
2.2 Redis 管道封装
Redis 管道技术允许在服务端未响应时,客户端继续向服务端发送请求,并最终一次性读取所有服务端的响应,从而实现批量操作。通过对 Redis pipeline 使用姿势的封装,简化了调用过程,例如 com.github.paicoding.forum.core.cache.RedisClient.PipelineAction 中的相关代码:
/*** redis 管道执行的封装链路*/
public static class PipelineAction {private List<Runnable> run = new ArrayList<>();private RedisConnection connection;public PipelineAction add(String key, BiConsumer<RedisConnection, byte[]> conn) {run.add(() -> conn.accept(connection, RedisClient.keyBytes(key)));return this;}public PipelineAction add(String key, String field, ThreeConsumer<RedisConnection, byte[], byte[]> conn) {run.add(() -> conn.accept(connection, RedisClient.keyBytes(key), valBytes(field)));return this;}public void execute() {template.executePipelined((RedisCallback<Object>) connection -> {PipelineAction.this.connection = connection;run.forEach(Runnable::run);return null;});}
}@FunctionalInterface
public interface ThreeConsumer<T, U, P> {void accept(T t, U u, P p);
}
2.3 计数更新与使用
PV/UV 的更新可以在 Filter 中统一调用,为避免计数影响实际业务操作,采用异步更新策略:com.github.paicoding.forum.web.hook.filter.ReqRecordFilter#initReqInfo。
private HttpServletRequest initReqInfo(HttpServletRequest request, HttpServletResponse response) {if (isStaticURI(request)) {// 静态资源直接放行return request;}StopWatch stopWatch = new StopWatch("请求参数构建");try {stopWatch.start("traceId");// 添加全链路的traceIdMdcUtil.addTraceId();stopWatch.stop();stopWatch.start("请求基本信息");// 手动写入一个session,借助 OnlineUserCountListener 实现在线人数实时统计request.getSession().setAttribute("latestVisit", System.currentTimeMillis());ReqInfoContext.ReqInfo reqInfo = new ReqInfoContext.ReqInfo();reqInfo.setHost(request.getHeader("host"));reqInfo.setPath(request.getPathInfo());if (reqInfo.getPath() == null) {String url = request.getRequestURI();int index = url.indexOf("?");if (index > 0) {url = url.substring(0, index);}reqInfo.setPath(url);}reqInfo.setReferer(request.getHeader("referer"));reqInfo.setClientIp(IpUtil.getClientIp(request));reqInfo.setUserAgent(request.getHeader("User-Agent"));reqInfo.setDeviceId(getOrInitDeviceId(request, response));request = this.wrapperRequest(request, reqInfo);stopWatch.stop();stopWatch.start("登录用户信息");// 初始化登录信息globalInitService.initLoginUser(reqInfo);stopWatch.stop();ReqInfoContext.addReqInfo(reqInfo);stopWatch.start("pv/uv站点统计");// 更新uv/pv计数AsyncUtil.execute(() -> SpringUtil.getBean(SitemapServiceImpl.class).saveVisitInfo(reqInfo.getClientIp(), reqInfo.getPath()));stopWatch.stop();stopWatch.start("回写traceId");// 返回头中记录traceIdresponse.setHeader(GLOBAL_TRACE_ID_HEADER, Optional.ofNullable(MdcUtil.getTraceId()).orElse(""));stopWatch.stop();} catch (Exception e) {log.error("init reqInfo error!", e);} finally {if (!EnvUtil.isPro()) {log.info("{} -> 请求构建耗时: \n{}", request.getRequestURI(), stopWatch.prettyPrint(TimeUnit.MILLISECONDS));}}return request;
}
目前站点的统计信息在前台只显示全局站点的统计情况,使用时直接从 hash 中获取对应的计数即可:com.github.paicoding.forum.service.sitemap.service.impl.SitemapServiceImpl#querySiteVisitInfo。
/*** 查询站点某一天or总的访问信息** @param date 日期,为空时,表示查询所有的站点信息* @param path 访问路径,为空时表示查站点信息* @return*/
@Override
public SiteCntVo querySiteVisitInfo(LocalDate date, String path) {String globalKey = SitemapConstants.SITE_VISIT_KEY;String day = null, todayKey = globalKey;if (date != null) {day = SitemapConstants.day(date);todayKey = globalKey + "_" + day;}String pvField = "pv", uvField = "uv";if (path != null) {// 表示查询对应路径的访问信息pvField += "_" + path;uvField += "_" + path;}Map<String, Integer> map = RedisClient.hMGet(todayKey, Arrays.asList(pvField, uvField), Integer.class);SiteCntVo siteInfo = new SiteCntVo();siteInfo.setDay(day);siteInfo.setPv(map.getOrDefault(pvField, 0));siteInfo.setUv(map.getOrDefault(uvField, 0));return siteInfo;
}
前台使用路径:

3 小结

基于 Redis 实现 PV/UV 统计主要依靠两个关键知识点:
- hash: incr:利用 Redis 的 hash 结构结合 incr 命令实现原子计数。
- pipeline:通过管道方式实现批量操作,提高操作效率。
最后提出一个思考问题:当站点访问量剧增,一天达到几百万的访问量时,通过记录 IP 来实现 UV 计数会导致用户访问记录存储开销巨大,此时可以考虑使用 Redis 中的 HyperLoglog 来解决这一问题,它利用数学上的概率统计分布原理,能在空间复杂度较低的情况下实现近似的计数统计。
希望本文对大家理解和实现网站的 PV/UV 数据统计有所帮助,欢迎大家一起交流探讨相关技术问题。
4 参考链接
- 技术派数据统计PV/UV
- 项目仓库(GitHub)
- 项目仓库(码云)
相关文章:
【技术派后端篇】基于 Redis 实现网站 PV/UV 数据统计
在网站的数据分析中,PV(Page View,页面浏览量)和 UV(Unique Visitor,独立访客数)是两个重要的指标,几乎每个网站都需要对其进行统计。市面上有很多成熟的统计产品,例如百…...
JAVA:利用 Apache Tika 提取文件内容的技术指南
1、简述 Apache Tika 是一个强大的工具,用于从各种文件中提取内容和元数据。📄Tika 支持解析文档、📸图像、🎵音频、🎥视频文件以及其他多种格式,非常适合构建🔍搜索引擎、📂内容管理系统和📊数据分析工具。 样例代码:https://gitee.com/lhdxhl/springboot-…...
【AI】SpringAI 第二弹:接入 DeepSeek 官方服务
一、接入 DeepSeek 官方服务 通过一个简单的案例演示接入 DeepSeek 实现简单的问答功能 1.添加依赖 <dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-openai</artifactId> </dependency> 2…...
XMLXXE 安全无回显方案OOB 盲注DTD 外部实体黑白盒挖掘
# 详细点: XML 被设计为传输和存储数据, XML 文档结构包括 XML 声明、 DTD 文档类型定义(可 选)、文档元素,其焦点是数据的内容,其把数据从 HTML 分离,是独立于软件和硬件的 信息传输…...
SQL之DML(查询语句:select、where)
🎯 本文专栏:MySQL深入浅出 🚀 作者主页:小度爱学习 select查询语句 在开发中,查询语句是使用最多,也是CRUD中,复杂度最高的sql语句。 查询的语法结构 select *|字段1 [, 字段2 ……] from 表…...
Oracle--用户管理
前言:本博客仅作记录学习使用,部分图片出自网络,如有侵犯您的权益,请联系删除 用户管理在 Oracle 数据库中至关重要。一个服务器通常只运行一个 Oracle 实例,而一个 Oracle 用户代表一个用户群,他们通过该用…...
MYDB仿MySQL手写数据库项目总结
声明:该项目是一个开源项目,我是在学习该项目,该项目的github地址如下: MYDB项目地址:https://github.com/CN-GuoZiyang/MYDB MYDB项目采用C/S结构,支持启动一个服务器,并有多个客户端去连接…...
24.中医知识问答删除历史对话功能前端代码实现
前端实现对话删除功能的完整指南 功能概述 前篇文章介绍了删除历史对话的后端开发,本篇将介绍如何在前端实现一个完整的对话删除功能,包括用户确认、API调用、状态管理和错误处理等关键环节。 功能拆解 1. 用户确认机制 javascript const confirmDe…...
git忽略已跟踪的文件/指定文件
在项目开发中,有时候我们并不需要git跟踪所有文件,而是需要忽略掉某些指定的文件或文件夹,怎么操作呢?我们分两种情况讨论: 1. 要忽略的文件之前并未被git跟踪 这种情况常用的方法是在项目的根目录下创建和编辑.gitig…...
RAG(检索增强生成)、ReAct(推理与行动) 和 多模态AI 的详细解析,包括三者的定义、工作原理、应用场景及协同关系
以下是 RAG(检索增强生成)、ReAct(推理与行动) 和 多模态AI 的详细解析,包括三者的定义、工作原理、应用场景及协同关系: 一、RAG(Retrieval-Augmented Generation) 1. 核心原理 …...
6.QT-常用控件-QWidget|windowTitle|windowIcon|qrc机制|windowOpacity|cursor(C++)
windowTitle API说明windowTitle()获取到控件的窗⼝标题.setWindowTitle(const QString& title)设置控件的窗⼝标题. 注意!上述设置操作针对不同的widget可能会有不同的⾏为. 如果是顶层widget(独⽴窗⼝),这个操作才会有效. 如果是⼦widget,这个操作⽆任何效果. 代码⽰例…...
Excel/WPS表格中图片链接转换成对应的实际图片
Excel 超链图变助手(点击下载可免费试用) 是一款将链接转换成实际图片,批量下载表格中所有图片的转换工具,无需安装,双击打开即可使用。 表格中链接如下图所示: 操作方法: 1、双击以下图标&a…...
PostgreSQL基础
一、PostgreSQL介绍 PostgreSQL是一个功能强大的 开源 的关系型数据库。底层基于C实现。 PostgreSQL的开源协议和Linux内核版本的开源协议是一样的。。BDS协议,这个协议基本和MIT开源协议一样,说人话,就是你可以对PostgreSQL进行一些封装&a…...
win11修改文件后缀名
一、问题描述 win11系统中,直接添加.py后缀后仍然是txt文本文件 二、处理方式: 点击上方三个小点点击“选项”按钮 点击“查看”取消“隐藏已知文件类型的扩展名”选项点击“应用” 此时,“.txt”文件后缀显示出来了。将txt删去,…...
【python实用小脚本系列】用Python打造你的专属智能语音助手
用Python打造你的专属智能语音助手 在科技飞速发展的今天,语音助手已经成为了我们生活中的“小帮手”。无论是苹果的Siri,还是亚马逊的Alexa,它们都能通过语音指令帮我们完成各种任务。今天,我来给大家分享一个用Python打造的简单…...
《Java工程师面试核心突破》专栏简介
《Java工程师面试核心突破》专栏简介 🔥 大厂Offer收割机 | 源码级技术纵深 | 90%高频考点覆盖 专栏定位 「拒绝八股文,直击技术本质」 本专栏专为Java中高级工程师量身定制,通过6大核心模块、30个硬核专题,系统性拆解大厂面试…...
Navicat连接数据库密码忘了如何解析
1、首先打开Navicat导出密码 打开文件---》导出链接----》选择连接并勾选导出密码 2、用编辑器打开复制密码 把password后面的密码复制出来 3、打开php编辑器,我这边因为平时不用就在网上找了一个在线编辑器 地址: https://www.w3cschool.cn/tryrun/runcode?la…...
OpenStack Yoga版安装笔记(22)Swift笔记20250418
一、官方文档 https://docs.openstack.org/swift/yoga/admin/objectstorage-components.html#https://docs.openstack.org/swift/yoga/admin/objectstorage-components.html# 二、对象存储简介(Introduction to Object Storage) OpenStack 对象存储&a…...
基于若依的ruoyi-vue-plus的nbmade-boot在线表单的设计(七)后端方面的设计
希望大家一起能参与我的新开源项目nbmade-boot: 宁波智能制造低代码实训平台 主要目标是类似设计jeecgboot那样的online表单功能,因为online本身没有开源这部分代码,而我设计这个是完全开源的,所以希望大家支持支持,开源不容易。 今天主要是讲后端部门之前漏的文件。 下面主…...
18、TimeDiff论文笔记
TimeDiff **1. 背景与动机****2. 扩散模型基础****3. TimeDiff 模型****3.1 前向扩散过程****3.2 后向去噪过程** 4、TimeDiff(架构)原理训练推理其他关键点解释 DDPM(相关数学)1、正态分布2、条件概率1. **与多个条件相关**&…...
【Rust 精进之路之第11篇-借用·实践】切片 (Slices):安全、高效地引用集合的一部分
系列: Rust 精进之路:构建可靠、高效软件的底层逻辑 作者: 码觉客 发布日期: 2025年4月20日 引言:不止整个借用,我们还能“借”片段! 在上一篇【借用规则】中,我们深入理解了 Rust 的引用 (&T, &mut T) 和借用规则。我们知道,引用允许我们在不获取所有权的情…...
Linux环境下使用ADB命令做嵌入式开发
介绍 adb一般是给Android开发用的,但是换个思路也能做嵌入式Linux开发 安装 以Ubuntu20.04为例 sudo apt install android-tools-adb android-tools-fastboot安装成功可以查看adb版本号 命令 查看adb设备 登录shell 上传文件到设备指定目录 拉取设备上的文件到…...
Ubuntu20.04安装Pangolin遇到的几种报错的解决方案
1.添加两个编译选项 /usr/include/OpenEXR/half.h:121:13: note: because ‘half’ has user-provided ‘half& half::operator(half)’121 | half & operator (half h);| ^~~~~~~~ 解决方案: 在CMakeList中添加以下两句: …...
SQL问题分析与诊断(8)——分析方法2
8.4. 方法 8.4.2. 目测评估方法 8.4.2.1. 方法说明 与Oracle等其他关系库类似,SQL Server中,作为其最核心、最重要的组件之一,CBO内置了相当复杂而高级的模型和算法,当将SQL语句及其相关统计数据等信息作为参数输入其中后,CBO会对该SQL语句各候选查询计划及其中各节点的…...
自动驾驶最新算法进展
自动驾驶技术的算法进展迅速,涵盖感知、预测、规划、端到端学习等多个领域。以下是2023年至2024年的关键进展及实例: 1. 感知与融合 BEVTransformer的进化:特斯拉的Occupancy Networks升级至支持动态场景建模,结合NeRF技术…...
深度学习方向急出成果,是先广泛调研还是边做实验边优化?
目录 有限资源下本科生快速发表深度学习顶会论文的实战策略 1.短周期内可出成果的研究路径 2.论文阅读与复现的优先顺序 3.无一对一指导时的调研与实验组织 4.成功案例:本科生顶会论文经验 5.快速上手的研究子方向推荐 大家好这里是AIWritePaper官方账号&…...
级联vs端到端、全双工、轮次检测、方言语种、商业模式…语音 AI 开发者都在关心什么?丨Voice Agent 学习笔记
编者按: A16Z在《AI Voice Agents: 2025 Update》中提到: 语音是 AI 应用公司最强大的突破之一。 它是人类沟通中最频繁(也是信息密度最高的)形式,AI 也让其首次变得“可编程”。 在13期Z沙龙,我们聚焦AI…...
阿里云镜像加速仅支持阿里云产品了
最近在拉取docker镜像时一直报超时的错误: docker pull hello-world Using default tag: latest Error response from daemon: Get "https://registry-1.docker.io/v2/": net/http: request canceled while waiting for connection (Client.Timeout exce…...
深入理解 DML 和 DQL:SQL 数据操作与查询全解析
深入理解 DML 和 DQL:SQL 数据操作与查询全解析 在数据库管理中,SQL(结构化查询语言)是操作和查询数据的核心工具。其中,DML(Data Manipulation Language,数据操作语言) 和 DQL&…...
05--MQTT物联网协议
一、MQTT的概念 MQTT 协议快速入门 2025:基础知识和实用教程 | EMQ 1.MQTT(Message Queuing Telemetry Transport)是一种轻量级、基于发布-订阅模式的消息传输协议,适用于资源受限的设备和低带宽、高延迟或不稳定的网络环境。它…...
