面试中,我们经常会被问题 PV,UV,那么,什么是 PV?什么又是UV?如何使用 Redis 统计 PV 和 UV?这篇文章,我们将详细介绍如何在 Java 中使用 Redis 实现 PV 和 UV 的统计。
1. 什么是 PV 和 UV?
- PV(Page Views):指页面被访问的总次数。每一次页面加载或刷新都会增加一次 PV,无论访问者是谁。
- UV(Unique Visitors):指独立访客数。通常通过用户的唯一标识(如用户 ID、IP 地址、Cookie 等)来统计同一用户在一定时间范围内的访问次数,确保每个独立访客只计数一次。
2. Redis 如何统计 PV 和 UV?
(1) 统计 PV
统计 PV 可以通过 Redis 的 INCR 命令实现。这是一个原子操作,可以确保在高并发情况下准确计数。
(2) 统计 UV
统计 UV 可以使用 Redis 的 HyperLogLog 或 Bitmap 数据结构:
- HyperLogLog:适合大规模去重统计,占用内存小,但只能估算基数,误差约为 0.81%。
- Bitmap:通过位图记录用户访问情况,适合用户 ID 范围固定且不大的场景。
本示例中将使用 HyperLogLog 来统计 UV,因为它适用于大规模和动态用户场景,且实现简单。
(3) 数据结构设计
假设我们要统计某个页面(例如 /home)每日的 PV 和 UV,可以设计如下 Redis 键:
- pv:home:20250301 — 存储 /home 页面在 2025年3月1日的 PV 计数。
- uv:home:20250301 — 存储 /home 页面在 2025年3月1日的 UV 计数。
3. 示例代码
为了更好地理解如何使用 Redis统计 PV,UV,确保在项目中添加 Jedis 依赖。
(1) 示例代码:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
publicclass RedisPvUvCounter {
// Redis 服务器配置
privatestaticfinal String REDIS_HOST = "localhost";
privatestaticfinalint REDIS_PORT = 6379;
privatestaticfinal String PAGE_NAME = "home"; // 页面名称
privatestaticfinal DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
private JedisPool jedisPool;
// 构造方法,初始化 Jedis 连接池
public RedisPvUvCounter() {
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(128); // 最大连接数,可根据需要调整
this.jedisPool = new JedisPool(poolConfig, REDIS_HOST, REDIS_PORT);
}
/**
* 统计 PV
* @param pageName 页面名称
*/
public void incrementPv(String pageName) {
String date = LocalDate.now().format(DATE_FORMATTER);
String pvKey = String.format("pv:%s:%s", pageName, date);
try (Jedis jedis = jedisPool.getResource()) {
jedis.incr(pvKey);
}
}
/**
* 统计 UV
* @param pageName 页面名称
* @param userId 用户唯一标识
*/
public void addUv(String pageName, String userId) {
String date = LocalDate.now().format(DATE_FORMATTER);
String uvKey = String.format("uv:%s:%s", pageName, date);
try (Jedis jedis = jedisPool.getResource()) {
jedis.pfadd(uvKey, userId);
}
}
/**
* 获取 PV 统计
* @param pageName 页面名称
* @return PV 数量
*/
public long getPv(String pageName) {
String date = LocalDate.now().format(DATE_FORMATTER);
String pvKey = String.format("pv:%s:%s", pageName, date);
try (Jedis jedis = jedisPool.getResource()) {
String pvStr = jedis.get(pvKey);
return pvStr != null ? Long.parseLong(pvStr) : 0;
}
}
/**
* 获取 UV 统计
* @param pageName 页面名称
* @return UV 数量
*/
public long getUv(String pageName) {
String date = LocalDate.now().format(DATE_FORMATTER);
String uvKey = String.format("uv:%s:%s", pageName, date);
try (Jedis jedis = jedisPool.getResource()) {
return jedis.pfcount(uvKey);
}
}
/**
* 设置键的过期时间(例如 2 天后过期)
* @param key キー
* @param seconds 秒数
*/
public void setExpire(String key, int seconds) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.expire(key, seconds);
}
}
/**
* 关闭 Jedis 连接池
*/
public void close() {
if (jedisPool != null) {
jedisPool.close();
}
}
public static void main(String[] args) {
RedisPvUvCounter counter = new RedisPvUvCounter();
String page = "home";
String user1 = "user_001";
String user2 = "user_002";
// 模拟 PV 和 UV 统计
counter.incrementPv(page);
counter.addUv(page, user1);
counter.incrementPv(page);
counter.addUv(page, user1); // 重复访问,不增加 UV
counter.incrementPv(page);
counter.addUv(page, user2);
// 设置键的过期时间(可选,根据实际需求)
String date = LocalDate.now().format(DATE_FORMATTER);
String pvKey = String.format("pv:%s:%s", page, date);
String uvKey = String.format("uv:%s:%s", page, date);
counter.setExpire(pvKey, 2 * 24 * 60 * 60); // PV 键 2 天后过期
counter.setExpire(uvKey, 2 * 24 * 60 * 60); // UV 键 2 天后过期
// 获取统计结果
long pv = counter.getPv(page);
long uv = counter.getUv(page);
System.out.println("PV 总数: " + pv); // 输出: PV 总数: 3
System.out.println("UV 总数: " + uv); // 输出: UV 总数: 2
// 关闭连接池
counter.close();
}
}
(2) 代码详解
①连接 Redis
使用 JedisPool 来管理 Redis 连接池,提升性能和资源利用率。通过配置 JedisPoolConfig 可以调整连接池的相关参数,如最大连接数等。
②统计 PV
- 使用 INCR 命令对 PV 键进行自增。
- 键的命名规范为 pv:{pageName}:{date}(例如 pv:home:20250301)。
- 每访问一次页面,调用 incrementPv 方法即可增加 PV 计数。
③统计 UV
- 使用 PFADD 命令将用户的唯一标识添加到 HyperLogLog 结构中。
- 键的命名规范为 uv:{pageName}:{date}(例如 uv:home:20250301)。
- userId 可以是用户的登录 ID、IP 地址或其他唯一标识。
- HyperLogLog 会自动去重,因此即使同一个用户多次访问,也只会计数一次。
④获取 PV 和 UV 数量
- PV 使用 GET 命令获取键的值,并转换为 long 类型。如果键不存在,则返回 0。
- UV 使用 PFCOUNT 命令获取 HyperLogLog 的估算基数。
⑤设置键的过期时间
为了避免 Redis 中存储过多历史数据,可以为 PV 和 UV 键设置过期时间。本示例中设置为 2 天后过期。可以根据实际需求调整。
⑥关闭连接池
使用完毕后,调用 close 方法关闭 JedisPool,释放资源。
(3) 运行示例
运行 main 方法后,将模拟以下操作:
- 用户 user_001 访问 /home 页面,PV 增加 1,UV 增加 1。
- 用户 user_001 再次访问 /home 页面,PV 增加 1,UV 不变。
- 用户 user_002 访问 /home 页面,PV 增加 1,UV 增加 1。
最终输出:
PV 总数: 3
UV 总数: 2
4. 扩展与优化
(1) 设置键的过期时间
可以在 incrementPv 和 addUv 方法中设置键的过期时间,以自动删除过期数据,避免 Redis 内存不断增长。
public void incrementPv(String pageName) {
String date = LocalDate.now().format(DATE_FORMATTER);
String pvKey = String.format("pv:%s:%s", pageName, date);
try (Jedis jedis = jedisPool.getResource()) {
jedis.incr(pvKey);
jedis.expire(pvKey, 2 * 24 * 60 * 60); // 设置过期时间为2天
}
}
public void addUv(String pageName, String userId) {
String date = LocalDate.now().format(DATE_FORMATTER);
String uvKey = String.format("uv:%s:%s", pageName, date);
try (Jedis jedis = jedisPool.getResource()) {
jedis.pfadd(uvKey, userId);
jedis.expire(uvKey, 2 * 24 * 60 * 60); // 设置过期时间为2天
}
}
(2) 使用 Lua 脚本优化
为了减少 Redis 交互次数,可以使用 Lua 脚本将多个命令合并为一个原子操作。例如,可以在一次 Lua 脚本中同时对 PV 和 UV 进行操作。
(3) 分布式环境下的 Redis 集群
在分布式系统中,可以使用 Redis 集群来提高可用性和扩展性。Jedis 提供了 JedisCluster 类来支持 Redis 集群。
(4) 选择合适的唯一标识
为了准确统计 UV,选择唯一标识非常关键。常见的方式包括:
- 用户登录 ID:最可靠,但仅适用于已认证用户。
- IP 地址:简单但可能不够准确,受 NAT 和代理影响。
- Cookie:通过生成唯一的 Cookie 标识符,即使用户未登录也可以追踪。
根据业务需求选择合适的方式,并注意隐私和数据保护。
(5) 持久化与备份
确保 Redis 的持久化机制(RDB 或 AOF)已正确配置,以防止数据丢失。
5. 总结
本文,我们分析了如何使用 Redis 统计 PV 和 UV,通过 Redis 的 INCR 和 HyperLogLog 数据结构,可以高效地实现 PV 和 UV 的统计。另外,实际工作中,我们可以根据实际业务需求,可以进一步优化和扩展,如设置键过期时间、使用 Lua 脚本、部署 Redis 集群等。