基于Redis 實現(xiàn)網(wǎng)站PV/UV數(shù)據(jù)統(tǒng)計
在網(wǎng)站的數(shù)據(jù)分析中,PV(Page View,頁面瀏覽量)和 UV(Unique Visitor,獨立訪客數(shù))是兩個重要的指標,幾乎每個網(wǎng)站都需要對其進行統(tǒng)計。市面上有很多成熟的統(tǒng)計產品,例如百度的站點統(tǒng)計功能,而本文將介紹如何借助 Redis 的計數(shù)器功能,實現(xiàn)一套屬于自己的站點統(tǒng)計服務。
1 方案設計
1.1 術語說明
在我們的實際實現(xiàn)中,對 PV 和 UV 的定義與標準定義存在一定差異:
- PV(Page View):指的是每個頁面的訪問次數(shù)。在本服務中,PV 是總量概念,一個獨立的 IP 每訪問一次 URL,對應的訪問計數(shù)就加 1。我們希望按自然日統(tǒng)計每個 URL 的訪問計數(shù),同時也能統(tǒng)計總的訪問計數(shù),以此判斷哪些頁面更受讀者喜愛。
- UV(Unique Visitor):用于統(tǒng)計 URI 的訪問 IP 數(shù),同樣按照自然日和總數(shù)進行區(qū)分。
1.2 統(tǒng)計流程
用戶訪問時,首先獲取目標 IP,然后根據(jù)其訪問情況更新對應的計數(shù):
- 首次訪問目標資源:總 PV 加 1,總 UV 加 1;當天 PV 加 1,當天 UV 加 1。
- 非首次訪問,但為當天第一次訪問:總 PV 加 1,總 UV 不變;當天 PV 加 1,當天 UV 加 1。
- 當天非首次訪問:總 PV 加 1,總 UV 不變;當天 PV 加 1,當天 UV 不變。

1.3 數(shù)據(jù)結構
我們使用 Redis 的 hash 來存儲訪問信息,具體需要存儲以下三類信息:
- 站點的總訪問信息:包括站點的 PV/UV,以及每個 URI 的 PV/UV。
- 某一天的訪問信息:涵蓋某一天站點的總訪問 PV/UV,以及某一天每個 URI 的 PV/UV。由于計算 UV 時需要存儲用戶是否訪問過某個資源的信息,所以額外添加了存儲單元保存用戶訪問歷史。
- 用戶的訪問信息:包含用戶訪問站點的總次數(shù),以及訪問每個 URI 的總次數(shù)。用戶每天的訪問信息存儲在每天的訪問信息結構中,因為每天的訪問信息通常不需要持久化保存,比如只存儲最近一個月的情況,可設置 Redis 的有效期為 30 天,到期自動清除。
完整的 hash 定義如下:
- 站點總統(tǒng)計 hash:
- key:visit_info
- field:
- pv:站點的總 PV
- uv:站點的總 UV
- pv_path:站點某個資源的總訪問 PV
- uv_path:站點某個資源的總訪問 UV
- 每天統(tǒng)計 hash:
- key:visit_info_20230822(每日記錄,一天一條記錄)
- field:
- pv:12(field = 月日_pv,PV 的計數(shù))
- uv:5(field = 月日_uv,UV 的計數(shù))
- pv_path:2(資源的當前訪問計數(shù))
- uv_path:資源的當天訪問 UV
- pv_ip:用戶當天的訪問次數(shù)
- pv_path_ip:用戶對資源的當天訪問次數(shù)
- 用戶訪問統(tǒng)計:
- key:visit_info_ip
- field:
- pv:用戶訪問的站點總次數(shù)
- path_pv:用戶訪問的路徑總次數(shù)

2 實現(xiàn)方式
2.1 統(tǒng)計計數(shù)
核心計數(shù)的實現(xiàn)路徑為 com.github.paicoding.forum.service.sitemap.service.SitemapServiceImpl#saveVisitInfo。其原理是:用戶站點總 PV 加 1,若返回的最新計數(shù)是 1,表示是站點的新用戶,所有 UV 加 1;今日 PV 加 1,若返回的最新計數(shù)是 1,表示當前用戶今日首次訪問,進入的 UV 加 1 。
/**
* 保存站點數(shù)據(jù)模型
* <p>
* 站點統(tǒng)計hash:
* - visit_info:
* ---- pv: 站點的總pv
* ---- uv: 站點的總uv
* ---- pv_path: 站點某個資源的總訪問pv
* ---- uv_path: 站點某個資源的總訪問uv
* - visit_info_ip:
* ---- pv: 用戶訪問的站點總次數(shù)
* ---- path_pv: 用戶訪問的路徑總次數(shù)
* - visit_info_20230822每日記錄, 一天一條記錄
* ---- pv: 12 # field = 月日_pv, pv的計數(shù)
* ---- uv: 5 # field = 月日_uv, uv的計數(shù)
* ---- pv_path: 2 # 資源的當前訪問計數(shù)
* ---- uv_path: # 資源的當天訪問uv
* ---- pv_ip: # 用戶當天的訪問次數(shù)
* ---- pv_path_ip: # 用戶對資源的當天訪問次數(shù)
*
* @param visitIp 訪問者ip
* @param path 訪問的資源路徑
*/
@Override
public void saveVisitInfo(String visitIp, String path) {
String globalKey = SitemapConstants.SITE_VISIT_KEY;
String day = SitemapConstants.day(LocalDate.now());
String todayKey = globalKey + "_" + day;
// 用戶的全局訪問計數(shù)+1
Long globalUserVisitCnt = RedisClient.hIncr(globalKey + "_" + visitIp, "pv", 1);
// 用戶的當日訪問計數(shù)+1
Long todayUserVisitCnt = RedisClient.hIncr(todayKey, "pv_" + visitIp, 1);
RedisClient.PipelineAction pipelineAction = RedisClient.pipelineAction();
if (globalUserVisitCnt == 1) {
// 站點新用戶
// 今日的uv + 1
pipelineAction.add(todayKey, "uv"
, (connection, key, field) -> {
connection.hIncrBy(key, field, 1);
});
pipelineAction.add(todayKey, "uv_" + path
, (connection, key, field) -> connection.hIncrBy(key, field, 1));
// 全局站點的uv
pipelineAction.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+1
pipelineAction.add(todayKey, "uv", (connection, key, field) -> connection.hIncrBy(key, field, 1));
if (RedisClient.hIncr(todayKey, "pv_" + path + "_" + visitIp, 1) == 1) {
// 判斷是否為今天首次訪問這個資源,若是,則uv+1
pipelineAction.add(todayKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));
}
// 判斷是否是用戶的首次訪問這個path,若是,則全局的path uv計數(shù)需要+1
if (RedisClient.hIncr(globalKey + "_" + visitIp, "pv_" + path, 1) == 1) {
pipelineAction.add(globalKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1));
}
}
// 更新pv 以及 用戶的path訪問信息
// 今天的相關信息 pv
pipelineAction.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時,已經(jīng)計數(shù)+1了
pipelineAction.add(todayKey, "pv_" + path + "_" + visitIp, (connection, key, field) -> connection.hIncrBy(key, field, 1));
}
// 全局的 PV
pipelineAction.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 管道技術允許在服務端未響應時,客戶端繼續(xù)向服務端發(fā)送請求,并最終一次性讀取所有服務端的響應,從而實現(xiàn)批量操作。通過對 Redis pipeline 使用姿勢的封裝,簡化了調用過程,例如 com.github.paicoding.forum.core.cache.RedisClient.PipelineAction 中的相關代碼:
/**
* redis 管道執(zhí)行的封裝鏈路
*/
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 計數(shù)更新與使用
PV/UV 的更新可以在 Filter 中統(tǒng)一調用,為避免計數(shù)影響實際業(yè)務操作,采用異步更新策略:com.github.paicoding.forum.web.hook.filter.ReqRecordFilter#initReqInfo。
private HttpServletRequest initReqInfo(HttpServletRequest request, HttpServletResponse response) {
if (isStaticURI(request)) {
// 靜態(tài)資源直接放行
return request;
}
StopWatch stopWatch = new StopWatch("請求參數(shù)構建");
try {
stopWatch.start("traceId");
// 添加全鏈路的traceId
MdcUtil.addTraceId();
stopWatch.stop();
stopWatch.start("請求基本信息");
// 手動寫入一個session,借助 OnlineUserCountListener 實現(xiàn)在線人數(shù)實時統(tǒng)計
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站點統(tǒng)計");
// 更新uv/pv計數(shù)
AsyncUtil.execute(() -> SpringUtil.getBean(SitemapServiceImpl.class).saveVisitInfo(reqInfo.getClientIp(), reqInfo.getPath()));
stopWatch.stop();
stopWatch.start("回寫traceId");
// 返回頭中記錄traceId
response.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;
}
目前站點的統(tǒng)計信息在前臺只顯示全局站點的統(tǒng)計情況,使用時直接從 hash 中獲取對應的計數(shù)即可: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 實現(xiàn) PV/UV 統(tǒng)計主要依靠兩個關鍵知識點:
- hash: incr:利用 Redis 的 hash 結構結合 incr 命令實現(xiàn)原子計數(shù)。
- pipeline:通過管道方式實現(xiàn)批量操作,提高操作效率。
最后提出一個思考問題:當站點訪問量劇增,一天達到幾百萬的訪問量時,通過記錄 IP 來實現(xiàn) UV 計數(shù)會導致用戶訪問記錄存儲開銷巨大,此時可以考慮使用 Redis 中的 HyperLoglog 來解決這一問題,它利用數(shù)學上的概率統(tǒng)計分布原理,能在空間復雜度較低的情況下實現(xiàn)近似的計數(shù)統(tǒng)計。
到此這篇關于基于Redis 實現(xiàn)網(wǎng)站PV/UV數(shù)據(jù)統(tǒng)計的文章就介紹到這了,更多相關Redis PV/UV數(shù)據(jù)統(tǒng)計內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Redis KEYS查詢大批量數(shù)據(jù)替代方案
在使用 Redis 時,KEYS 命令雖然簡單直接,但其全表掃描的特性在處理大規(guī)模數(shù)據(jù)時會導致性能問題,甚至可能阻塞 Redis 服務,本文將介紹SCAN命令、有序集合、哈希表和RediSearch模塊四種替代 KEYS 的高效方案,需要的朋友可以參考下2024-12-12
Redis序列化反序列化不一致導致String類型值多了雙引號問題
這篇文章主要介紹了Redis序列化反序列化不一致導致String類型值多了雙引號問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-08-08

