如何使用 Redis 完成 PV,UV 統(tǒng)計?
面試中,我們經常會被問題 PV,UV,那么,什么是 PV?什么又是UV?如何使用 Redis 統(tǒng)計 PV 和 UV?這篇文章,我們將詳細介紹如何在 Java 中使用 Redis 實現 PV 和 UV 的統(tǒng)計。
1. 什么是 PV 和 UV?
- PV(Page Views):指頁面被訪問的總次數。每一次頁面加載或刷新都會增加一次 PV,無論訪問者是誰。
- UV(Unique Visitors):指獨立訪客數。通常通過用戶的唯一標識(如用戶 ID、IP 地址、Cookie 等)來統(tǒng)計同一用戶在一定時間范圍內的訪問次數,確保每個獨立訪客只計數一次。
2. Redis 如何統(tǒng)計 PV 和 UV?
(1) 統(tǒng)計 PV
統(tǒng)計 PV 可以通過 Redis 的 INCR 命令實現。這是一個原子操作,可以確保在高并發(fā)情況下準確計數。
(2) 統(tǒng)計 UV
統(tǒng)計 UV 可以使用 Redis 的 HyperLogLog 或 Bitmap 數據結構:
- HyperLogLog:適合大規(guī)模去重統(tǒng)計,占用內存小,但只能估算基數,誤差約為 0.81%。
- Bitmap:通過位圖記錄用戶訪問情況,適合用戶 ID 范圍固定且不大的場景。
本示例中將使用 HyperLogLog 來統(tǒng)計 UV,因為它適用于大規(guī)模和動態(tài)用戶場景,且實現簡單。
(3) 數據結構設計
假設我們要統(tǒng)計某個頁面(例如 /home)每日的 PV 和 UV,可以設計如下 Redis 鍵:
- pv:home:20250301 — 存儲 /home 頁面在 2025年3月1日的 PV 計數。
- uv:home:20250301 — 存儲 /home 頁面在 2025年3月1日的 UV 計數。
3. 示例代碼
為了更好地理解如何使用 Redis統(tǒng)計 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);
}
/**
* 統(tǒng)計 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);
}
}
/**
* 統(tǒng)計 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 統(tǒng)計
* @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 統(tǒng)計
* @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 統(tǒng)計
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 天后過期
// 獲取統(tǒng)計結果
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 可以調整連接池的相關參數,如最大連接數等。
②統(tǒng)計 PV
- 使用 INCR 命令對 PV 鍵進行自增。
- 鍵的命名規(guī)范為 pv:{pageName}:{date}(例如 pv:home:20250301)。
- 每訪問一次頁面,調用 incrementPv 方法即可增加 PV 計數。
③統(tǒng)計 UV
- 使用 PFADD 命令將用戶的唯一標識添加到 HyperLogLog 結構中。
- 鍵的命名規(guī)范為 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. 擴展與優(yōu)化
(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 腳本優(yōu)化
為了減少 Redis 交互次數,可以使用 Lua 腳本將多個命令合并為一個原子操作。例如,可以在一次 Lua 腳本中同時對 PV 和 UV 進行操作。
(3) 分布式環(huán)境下的 Redis 集群
在分布式系統(tǒng)中,可以使用 Redis 集群來提高可用性和擴展性。Jedis 提供了 JedisCluster 類來支持 Redis 集群。
(4) 選擇合適的唯一標識
為了準確統(tǒng)計 UV,選擇唯一標識非常關鍵。常見的方式包括:
- 用戶登錄 ID:最可靠,但僅適用于已認證用戶。
- IP 地址:簡單但可能不夠準確,受 NAT 和代理影響。
- Cookie:通過生成唯一的 Cookie 標識符,即使用戶未登錄也可以追蹤。
根據業(yè)務需求選擇合適的方式,并注意隱私和數據保護。
(5) 持久化與備份
確保 Redis 的持久化機制(RDB 或 AOF)已正確配置,以防止數據丟失。
5. 總結
本文,我們分析了如何使用 Redis 統(tǒng)計 PV 和 UV,通過 Redis 的 INCR 和 HyperLogLog 數據結構,可以高效地實現 PV 和 UV 的統(tǒng)計。另外,實際工作中,我們可以根據實際業(yè)務需求,可以進一步優(yōu)化和擴展,如設置鍵過期時間、使用 Lua 腳本、部署 Redis 集群等。