判斷一個網站值不值錢的一個重要標準就是看pv/uv,那麼你知道pv,uv是怎麼統計的麼?固然如今有第三方作的比較完善的能夠直接使用,但若是讓咱們本身來實現這麼一個功能,應該怎麼作呢?java
本篇內容較長,源碼如右 ➡️ github.com/liuyueyi/sp…git
爲了看看個人博客是否是我一我的的單機遊戲,因此就想着統計一下總的訪問量,每日的訪問人數,哪些博文又是你們感興趣的,點擊得多的;github
所以就萌發了本身擼一個pv/uv統計的服務,固然我這個也不須要特別完善高大上,能知足我本身的基本須要就能夠了web
前面的背景和需求,能夠說大體說明了咱們要作個什麼東西,以及須要注意哪些事項,再進行方案設計的過程當中,則須要對需求進行詳細拆解redis
前面提到了pv,uv,在咱們的實際實現中,會發現這個服務中對於pv,uv的定義和標準定義並非徹底一致的,下面進行說明spring
page viste
, 每一個頁面的訪問次數,在本服務中,咱們的pv指的是總量,即從開始接入時,到如今總的訪問次數api
可是這裏有個限制: 一個合法的ip,一天以內pv統計次數只能+1次數組
前面的pv針對ip進行了限制,一個ip同一天的訪問,只能計算一次,大部分狀況下這種統計並無什麼問題,可是若是一個文章寫得特別有參考意義,致使有人重複的看,仔細的看,換着花樣的刷新看,這個時候統計下總的訪問次數是否是也挺好的安全
所以在這個服務中,引入了hot(熱度)的概念,對於一個uri而言,只要一次點擊,hot+1bash
unique visitor
, 這個就是統計URI的訪問ip數
經過前面三個術語的定義,咱們的操做流程就相對清晰了,咱們的服務接收一個IP和URI,而後操做對應的pv,uv,hot並返回
對應的流程圖以下
流程清晰以後,接下來就須要看下pv,uv,hot三個數據怎麼存了
pv保存的就是訪問次數,與ip無關,因此kv存儲就能夠知足咱們的需求了,這裏的key爲uri,value則保存pv的值
hot和pv相似,一樣用kv能夠知足要求
uv這裏有兩個數據,一個是uv總數,要給是這個ip的訪問排名,redis中有個zset數據結構正好就能夠作這個
zset數據結構中,咱們定義value爲ip,score爲ip的排名,那麼uv就是最大的score了
流程清晰,結構設計出來以後,就能夠進入具體的方案設計環節了,在這個環節中,咱們引入一個app的維度,這樣咱們的服務就能夠通用了;
每一個使用者都申請一個app,那麼這個使用者的請求的全部站點統計數據,都關聯到這個app上,這樣也有利於後續統計了
引入了app以後,結合前面的兩個參數ip + URI,咱們的請求參數就清晰了
@Data
public class VisitReqDTO {
/** * 應用區分 */
private String app;
/** * 訪問者ip */
private String ip;
/** * 訪問的URI */
private String uri;
}
複製代碼
而後咱們返回的數據,pv + uv + rank + hot,因此返回的基礎VO以下
/** * Created by @author yihui in 16:19 19/5/12. */
@Data
@AllArgsConstructor
public class VisitVO implements Serializable {
/** * pv,與傳統的有點區別,這裏表示這個url的總訪問次數;每一個ip,一天次數只+1 */
private Long pv;
/** * uv 頁面總的ip訪問數 */
private Long uv;
/** * 當前ip,第一次訪問本url的排名 */
private Long rank;
/** * 熱度,每次訪問計數都+1 */
private Long hot;
public VisitVO() {
}
public VisitVO(VisitVO visitVO) {
this.pv = visitVO.pv;
this.uv = visitVO.uv;
this.rank = visitVO.rank;
this.hot = visitVO.hot;
}
}
複製代碼
此外須要注意一點的是,發起一個子頁面的請求時,這個時候咱們基於域名的站點總數統計也應該被觸發(簡單來講,訪問http://spring.hhui.top/spring-blog/
時,不只這個uri的統計須要更新, spring.hhui.top
這個域名的pv,uv,hot也須要隨之統計)
所以咱們最終的返回對象應該是
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SiteVisitDTO {
/** * 站點訪問統計 */
private VisitVO siteVO;
/** * 頁面訪問統計 */
private VisitVO uriVO;
}
複製代碼
有輸出,又返回,那麼訪問api就簡單了
SiteVisitDTO visit(VisitReqDTO reqDTO);
複製代碼
hot數據結構爲hash,每次請求過來,都是次數+1,所以直接使用redis的 hIncrBy
,實現計數+1,並返回最終的計數
"hot_cnt_" + app
做爲hash的key/** * 應用的熱度統計計數 * * @param app * @return */
private String buildHotKey(String app) {
return "hot_cnt_" + app;
}
/** * 熱度,每訪問一次,計數都+1 * * @param key * @param uri * @return */
public Long addHot(String key, String uri);
複製代碼
pv與hot不同的是並非每次都須要計數+1,因此它須要有一個查詢pv的接口,和一個計數+1的接口
"site_cnt_" + app
做爲hash的key/** * 應用的pv統計計數 * * @param app * @return */
private String buildPvKey(String app) {
return "site_cnt_" + app;
}
/** * 獲取pv * * pv存儲結果爲hash,一個應用一個key; field 爲uri; value爲pv * * @return null表示首次有人訪問;這個時候須要+1 */
public Long getPv(String key, String uri);
/** * pv 次數+1 * * @param key * @param uri */
public void addPv(String key, String uri) 複製代碼
前面說到uv採用的是zset數據結構,其中ip做爲value,排名做爲score;因此uv就是最大的score
由於uv須要返回兩個結構,因此咱們的返回須要注意
/**
* app+uri 對應的uv
*
* @param app
* @param uri
* @return
*/
private String buildUvKey(String app, String uri) {
return "uri_rank_" + app + "_" + uri;
}
/**
* 獲取uri對應的uv,以及當前訪問ip的歷史訪問排名
* 使用zset來存儲,key爲uri惟一標識;value爲ip;score爲訪問的排名
*
* @param key : 由app與URI來生成,即一個uri維護一個uv集
* @param ip: 訪問者ip
* @return 返回uv/rank, 若是對應的值爲0,表示沒有訪問過
*/
public ImmutablePair</** uv */Long, /** rank */Long> getUv(String key, String ip)
/**
* uv +1
*
* @param key
* @param ip
* @param rank
*/
public void addUv(String key, String ip, Long rank)
複製代碼
前面的都還算比較簡單,接下來有個很是有意思的地方了,如何判斷這個ip,今天訪問沒訪問?
方案一
要實現這個功能,一個天然而然的想法就出來了,直接kv就好了
uri_年月日_ip
若是value存在,表示今天訪問過,若是不存在,則沒有訪問過
方案二
前面那個卻是沒啥問題,若是我但願統計今天某個uri的ip訪問數,上面的就不太好處理,很容易想到用hash來替換
uri_年月日
ip
一樣value存在,則表示今天訪問過;不然沒有訪問過
若是須要統計今天訪問的總數,hlen一把就能夠;還能夠獲取今天全部訪問過的ip
方案三
前面的方案看似挺好的,可是有個缺陷,若是我這個站點特別火,天天幾百萬的uv,這個存儲量就有點誇張了
# 簡單的算一下 10w uv的存儲開銷
field: ip # 一個ip(255.255.255.255) 字符串存儲算 16B;
value: 1 # 算 1B
10w uv = 10w * 17B = 1.7MB
# 假設這個站點有100個10w uv的子頁面,天天存儲須要 170MB
複製代碼
經過上面簡單的計算能夠看出這存儲開銷對於比較火的站點而言,有點嚇人;而後能夠找其餘的存儲方式了,因此bitmap能夠隆重登場了
咱們將位數組分紅四節,分別於ip的四段對應,由於ipv4每一段取值是(0-2^8),因此咱們的位數組,也只須要(4 * 8b = 4B),相比較前面的方案來講,存儲空間大大減小
看到上面這個結構,會有一個疑問,爲何分紅四節?將ip轉成整形,做爲下標,一個就能夠了
4Gb
,顯然不如上面優雅方案肯定
上面三個方案中,咱們選擇了第三個,對應的api設計也比較簡單了
// 獲取今天的日期,格式爲 20190512
public static String getToday() {
LocalDate date = LocalDate.now();
int year = date.getYear();
int month = date.getMonthValue();
int day = date.getDayOfMonth();
StringBuilder buf = new StringBuilder(8);
return buf.append(year).append(month < 10 ? "0" : "").append(month).append(day < 10 ? "0" : "").append(day)
.toString();
}
/** * 每日訪問統計 * * @param app * @param uri * @return */
private String buildUriTagKey(String app, String uri) {
return "uri_tag_" + DateUtil.getToday() + "_" + app + "_" + uri;
}
/** * 標記ip訪問過這個key * * @param key * @param ip */
public void tagVisit(String key, String ip) 複製代碼
前面接口設計出來,按照既定思路實現就屬於比較輕鬆的環節了
pv兩個接口,一個訪問,一個計數+1,均可以直接使用redisTemplate的基礎操做完成
/** * 獲取pv * * pv存儲結果爲hash,一個應用一個key; field 爲uri; value爲pv * * @return null表示首次有人訪問;這個時候須要+1 */
public Long getPv(String key, String uri) {
return redisTemplate.execute(new RedisCallback<Long>() {
@Override
public Long doInRedis(RedisConnection connection) throws DataAccessException {
byte[] ans = connection.hGet(key.getBytes(), uri.getBytes());
if (ans == null || ans.length == 0) {
return null;
}
return Long.parseLong(new String(ans));
}
});
}
/** * pv 次數+1 * * @param key * @param uri */
public void addPv(String key, String uri) {
redisTemplate.execute(new RedisCallback<Void>() {
@Override
public Void doInRedis(RedisConnection connection) throws DataAccessException {
connection.hIncrBy(key.getBytes(), uri.getBytes(), 1);
return null;
}
});
}
複製代碼
只有一個計數+1的接口
/** * 熱度,每訪問一次,計數都+1 * * @param key * @param uri * @return */
public Long addHot(String key, String uri) {
return redisTemplate.execute(new RedisCallback<Long>() {
@Override
public Long doInRedis(RedisConnection connection) throws DataAccessException {
return connection.hIncrBy(key.getBytes(), uri.getBytes(), 1);
}
});
}
複製代碼
uv的獲取會麻煩一點,首先獲取uv值,而後獲取ip對應的排名;若是uv爲0,排名也就不須要再獲取了
/**
* 獲取uri對應的uv,以及當前訪問ip的歷史訪問排名
* 使用zset來存儲,key爲uri惟一標識;value爲ip;score爲訪問的排名
*
* @param key : 由app與URI來生成,即一個uri維護一個uv集
* @param ip: 訪問者ip
* @return 返回uv/rank, 若是對應的值爲0,表示沒有訪問過
*/
public ImmutablePair</** uv */Long, /** rank */Long> getUv(String key, String ip) {
// 獲取總uv數,也就是最大的score
Long uv = redisTemplate.execute(new RedisCallback<Long>() {
@Override
public Long doInRedis(RedisConnection connection) throws DataAccessException {
Set<RedisZSetCommands.Tuple> set = connection.zRangeWithScores(key.getBytes(), -1, -1);
if (CollectionUtils.isEmpty(set)) {
return 0L;
}
Double score = set.stream().findFirst().get().getScore();
return score.longValue();
}
});
if (uv == null || uv == 0L) {
// 表示尚未人訪問過
return ImmutablePair.of(0L, 0L);
}
// 獲取ip對應的訪問排名
Long rank = redisTemplate.execute(new RedisCallback<Long>() {
@Override
public Long doInRedis(RedisConnection connection) throws DataAccessException {
Double score = connection.zScore(key.getBytes(), ip.getBytes());
return score == null ? 0L : score.longValue();
}
});
return ImmutablePair.of(uv, rank);
}
/**
* uv +1
*
* @param key
* @param ip
* @param rank
*/
public void addUv(String key, String ip, Long rank) {
redisTemplate.execute(new RedisCallback<Void>() {
@Override
public Void doInRedis(RedisConnection connection) throws DataAccessException {
connection.zAdd(key.getBytes(), rank, ip.getBytes());
return null;
}
});
}
複製代碼
前面選擇位數組方式來記錄是否訪問過,這裏的實現選擇了簡單的實現方式,利用四個bitmap來分別對應ip的四段;(實際上一個也能夠實現,能夠想想應該怎麼作)
/** * 判斷ip今天是否訪問過 * 採用bitset來判斷ip是否有訪問,key由app與uri惟一肯定 * * @return true 表示今天訪問過/ false 表示今天沒有訪問過 */
public boolean visitToday(String key, String ip) {
// ip地址進行分段 127.0.0.1
String[] segments = StringUtils.split(ip, ".");
for (int i = 0; i < segments.length; i++) {
if (!contain(key + "_" + i, Integer.valueOf(segments[i]))) {
return false;
}
}
return true;
}
private boolean contain(String key, Integer val) {
return redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
return connection.getBit(key.getBytes(), val);
}
});
}
/** * 標記ip訪問過這個key * * @param key * @param ip */
public void tagVisit(String key, String ip) {
String[] segments = StringUtils.split(ip, ".");
for (int i = 0; i < segments.length; i++) {
int finalI = i;
redisTemplate.execute(new RedisCallback<Void>() {
@Override
public Void doInRedis(RedisConnection connection) throws DataAccessException {
connection.setBit((key + "_" + finalI).getBytes(), Integer.valueOf(segments[finalI]), true);
return null;
}
});
}
}
複製代碼
前面基本的接口實現以後,api就是流程圖的翻譯了,也沒有什麼特別值得說到的地方,惟一須要注意的就是URI的解析,域名做爲站點;uri由path + segment構成
public static ImmutablePair</**host*/String, /**uri*/String> foramtUri(String uri) {
URI u = URI.create(uri);
String host = u.getHost();
if (u.getPort() > 0 && u.getPort() != 80) {
host = host + ":80";
}
String baseUri = u.getPath();
if (u.getFragment() != null) {
baseUri = baseUri + "#" + u.getFragment();
}
if (StringUtils.isNotBlank(baseUri)) {
baseUri = host + baseUri;
} else {
baseUri = host;
}
return ImmutablePair.of(host, baseUri);
}
/**
* uri 訪問統計
*
* @param reqDTO
* @return
*/
public SiteVisitDTO visit(VisitReqDTO reqDTO) {
ImmutablePair<String, String> uri = URIUtil.foramtUri(reqDTO.getUri());
// 獲取站點的訪問記錄
VisitVO uriVisit = doVisit(reqDTO.getApp(), uri.getRight(), reqDTO.getIp());
VisitVO siteVisit;
if (uri.getLeft().equals(uri.getRight())) {
siteVisit = new VisitVO(uriVisit);
} else {
siteVisit = doVisit(reqDTO.getApp(), uri.getLeft(), reqDTO.getIp());
}
return new SiteVisitDTO(siteVisit, uriVisit);
}
private VisitVO doVisit(String app, String uri, String ip) {
String pvKey = buildPvKey(app);
String hotKey = buildHotKey(app);
String uvKey = buildUvKey(app, uri);
String todayVisitKey = buildUriTagKey(app, uri);
Long hot = visitService.addHot(hotKey, uri);
// 獲取pv數據
Long pv = visitService.getPv(pvKey, uri);
if (pv == null || pv == 0) {
// 歷史沒有訪問過,則pv + 1, uv +1
visitService.addPv(pvKey, uri);
visitService.addUv(uvKey, ip, 1L);
visitService.tagVisit(todayVisitKey, ip);
return new VisitVO(1L, 1L, 1L, hot);
}
// 判斷ip今天是否訪問過
boolean visit = visitService.visitToday(todayVisitKey, ip);
// 獲取uv及排名
ImmutablePair</**uv*/Long, /**rank*/Long> uv = visitService.getUv(uvKey, ip);
if (visit) {
// 今天訪問過,則不須要修改pv/uv;能夠直接返回所需數據
return new VisitVO(pv, uv.getLeft(), uv.getRight(), hot);
}
// 今天沒訪問過
if (uv.left == 0L) {
// 首次有人訪問, pv + 1; uv +1
visitService.addPv(pvKey, uri);
visitService.addUv(uvKey, ip, 1L);
visitService.tagVisit(todayVisitKey, ip);
return new VisitVO(pv + 1, 1L, 1L, hot);
} else if (uv.right == 0L) {
// 這個ip首次訪問, pv +1; uv + 1
visitService.addPv(pvKey, uri);
visitService.addUv(uvKey, ip, uv.left + 1);
visitService.tagVisit(todayVisitKey, ip);
return new VisitVO(pv + 1, uv.left + 1, uv.left + 1, hot);
} else {
// 這個ip的今天第一次訪問, pv + 1 ; uv 不變
visitService.addPv(pvKey, uri);
visitService.tagVisit(todayVisitKey, ip);
return new VisitVO(pv + 1, uv.left, uv.right, hot);
}
}
複製代碼
搭建一個簡單的web服務,開始測試
/** * Created by @author yihui in 18:58 19/5/12. */
@Controller
public class VisitController {
@Autowired
private SiteVisitFacade siteVisitFacade;
@RequestMapping(path = "visit")
@ResponseBody
public SiteVisitDTO visit(VisitReqDTO reqDTO) {
return siteVisitFacade.visit(reqDTO);
}
}
複製代碼
# 首次訪問,返回的全是1
http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/home
複製代碼
# 再次訪問,由於一樣是今天訪問,除了hot爲2;其餘的都是1
http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/home
複製代碼
# 同一ip,換個uri;除站點返回hot爲3,其餘的全是1
http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/index
複製代碼
# 換個ip,這個uri;主站點hot=4, pv,uv,rank=2; uriVO全是2
http://localhost:8080/visit?app=demo&ip=192.168.0.2&uri=http://hhui.top/index
複製代碼
# 換個ip,這個uri;主站點hot=5, pv,uv,rank=2; uriVO hot爲3,其餘全是2
http://localhost:8080/visit?app=demo&ip=192.168.0.2&uri=http://hhui.top/home
複製代碼
真要次日操做有點麻煩,爲了驗證,直接幹掉今天的佔位標記
# 模擬次日訪問, pv + 1, uv不變, hot+1
http://localhost:8080/visit?app=demo&ip=192.168.0.2&uri=http://hhui.top/home
複製代碼
本文能夠說是redis學習以後,一個挺好的應用場景,涉及到了咱們經常使用和不經常使用的幾個數據結構,包括hash,zset,bitmap, 其中關於bitmap的使用我的感受仍是很是有意思的;
對於redis操做不太熟的,能夠參考下前面幾篇博文
注意
上面這個服務,在實際使用中,須要考慮併發問題,很明顯咱們上的設計並非多線程安全的,也就是說,在併發量大的時候,獲取的數據極有可能和預期的不一致
擴展
上文的設計中,每一個uri都有一組位圖,咱們能夠經過遍歷,獲取value爲1的下標,來統計這個頁面今天的pv數,以及更相信的今天哪些ip訪問過;一樣也能夠分析站點的今日UV數,以及對應的訪問ip
盡信書則不如,以上內容,純屬一家之言,因我的能力有限,不免有疏漏和錯誤之處,如發現bug或者有更好的建議,歡迎批評指正,不吝感激