如何使用 Redis完成 PV,UV 统计?

你好,我是猿java。

面试中,我们经常会被问题 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?

2.1 统计 PV

统计 PV 可以通过 Redis 的 INCR 命令实现。这是一个原子操作,可以确保在高并发情况下准确计数。

2.2 统计 UV

统计 UV 可以使用 Redis 的 HyperLogLogBitmap 数据结构:

  • HyperLogLog:适合大规模去重统计,占用内存小,但只能估算基数,误差约为 0.81%。
  • Bitmap:通过位图记录用户访问情况,适合用户 ID 范围固定且不大的场景。

本示例中将使用 HyperLogLog 来统计 UV,因为它适用于大规模和动态用户场景,且实现简单。

2.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public class RedisPvUvCounter {
// Redis 服务器配置
private static final String REDIS_HOST = "localhost";
private static final int REDIS_PORT = 6379;
private static final String PAGE_NAME = "home"; // 页面名称
private static final 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();
}
}

代码详解

  1. 连接 Redis

    使用 JedisPool 来管理 Redis 连接池,提升性能和资源利用率。通过配置 JedisPoolConfig 可以调整连接池的相关参数,如最大连接数等。

  2. 统计 PV

    • 使用 INCR 命令对 PV 键进行自增。
    • 键的命名规范为 pv:{pageName}:{date}(例如 pv:home:20250301)。
    • 每访问一次页面,调用 incrementPv 方法即可增加 PV 计数。
  3. 统计 UV

    • 使用 PFADD 命令将用户的唯一标识添加到 HyperLogLog 结构中。
    • 键的命名规范为 uv:{pageName}:{date}(例如 uv:home:20250301)。
    • userId 可以是用户的登录 ID、IP 地址或其他唯一标识。
    • HyperLogLog 会自动去重,因此即使同一个用户多次访问,也只会计数一次。
  4. 获取 PV 和 UV 数量

    • PV 使用 GET 命令获取键的值,并转换为 long 类型。如果键不存在,则返回 0
    • UV 使用 PFCOUNT 命令获取 HyperLogLog 的估算基数。
  5. 设置键的过期时间

    为了避免 Redis 中存储过多历史数据,可以为 PV 和 UV 键设置过期时间。本示例中设置为 2 天后过期。可以根据实际需求调整。

  6. 关闭连接池

    使用完毕后,调用 close 方法关闭 JedisPool,释放资源。

运行示例

运行 main 方法后,将模拟以下操作:

  1. 用户 user_001 访问 /home 页面,PV 增加 1,UV 增加 1。
  2. 用户 user_001 再次访问 /home 页面,PV 增加 1,UV 不变。
  3. 用户 user_002 访问 /home 页面,PV 增加 1,UV 增加 1。

最终输出:

1
2
PV 总数: 3
UV 总数: 2

4. 扩展与优化

4.1 设置键的过期时间

可以在 incrementPvaddUv 方法中设置键的过期时间,以自动删除过期数据,避免 Redis 内存不断增长。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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天
}
}

4.2 使用 Lua 脚本优化

为了减少 Redis 交互次数,可以使用 Lua 脚本将多个命令合并为一个原子操作。例如,可以在一次 Lua 脚本中同时对 PV 和 UV 进行操作。

4.3 分布式环境下的 Redis 集群

在分布式系统中,可以使用 Redis 集群来提高可用性和扩展性。Jedis 提供了 JedisCluster 类来支持 Redis 集群。

4.4 选择合适的唯一标识

为了准确统计 UV,选择唯一标识非常关键。常见的方式包括:

  • 用户登录 ID:最可靠,但仅适用于已认证用户。
  • IP 地址:简单但可能不够准确,受 NAT 和代理影响。
  • Cookie:通过生成唯一的 Cookie 标识符,即使用户未登录也可以追踪。

根据业务需求选择合适的方式,并注意隐私和数据保护。

4.5 持久化与备份

确保 Redis 的持久化机制(RDB 或 AOF)已正确配置,以防止数据丢失。

5. 总结

本文,我们分析了如何使用 Redis 统计 PV 和 UV,通过 Redis 的 INCRHyperLogLog 数据结构,可以高效地实现 PV 和 UV 的统计。另外,实际工作中,我们可以根据实际业务需求,可以进一步优化和扩展,如设置键过期时间、使用 Lua 脚本、部署 Redis 集群等。

6. 学习交流

如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。

drawing