判断一个网站值不值钱的一个重要标准就是看pv/uv,那么你知道pv,uv是怎么统计的么?当然现在有第三方做的比较完善的可以直接使用,但如果让我们自己来实现这么一个功能,应该怎么做呢?
本篇内容较长,源码如右 ➡️ https://github.com/liuyueyi/spring-boot-demo/tree/master/spring-case/124-redis-sitecount
I. 背景及需求
为了看看我的博客是不是我一个人的单机游戏,所以就想着统计一下总的访问量,每日的访问人数,哪些博文又是大家感兴趣的,点击得多的;
因此就萌发了自己撸一个pv/uv统计的服务,当然我这个也不需要特别完善高大上,能满足我自己的基本需要就可以了
- 希望统计站点(域名)总访问次数
- 希望统计站点总的访问人数,当前访问者在访问人数中的排名(即这个ip是所有访问ip中的第多少位访问的这个站点)
- 每个子页面都有访问次数,访问总人数,当前ip访问的排名统计
- 同一个ip,同一天内访问同一个子页面,pv次数只加1次;隔天之后,再次访问pv+1
II. 方案设计
前面的背景和需求,可以说大致说明了我们要做个什么东西,以及需要注意哪些事项,再进行方案设计的过程中,则需要对需求进行详细拆解
1. 术语说明
前面提到了pv,uv,在我们的实际实现中,会发现这个服务中对于pv,uv的定义和标准定义并不是完全一致的,下面进行说明
a. pv
page viste, 每个页面的访问次数,在本服务中,我们的pv指的是总量,即从开始接入时,到现在总的访问次数
但是这里有个限制: 一个合法的ip,一天之内pv统计次数只能+1次
- 根据ip进行区分,因此需要获取访问者ip
- 同一天内,这个ip访问相同的URI,只能算一次有效pv;第二天之后,再次访问,则可以再算一次有效pv
b. hot
前面的pv针对ip进行了限制,一个ip同一天的访问,只能计算一次,大部分情况下这种统计并没有什么问题,但是如果一个文章写得特别有参考意义,导致有人重复的看,仔细的看,换着花样的刷新看,这个时候统计下总的访问次数是不是也挺好的
因此在这个服务中,引入了hot(热度)的概念,对于一个uri而言,只要一次点击,hot+1
c. uv
unique visitor, 这个就是统计URI的访问ip数
2. 流程图
通过前面三个术语的定义,我们的操作流程就相对清晰了,我们的服务接收一个IP和URI,然后操作对应的pv,uv,hot并返回
- 首先判断这个ip是否为第一次访问这个URI
- 是,则pv+1, uv+1, hot+1
- 否,表示之前访问过,uv就不能变了
- 判断是否今天第一次访问
- 是,今天访问过,那么pv不变,hot+1
- 否,之前访问过,今天没有,pv可以+1, hot+1
对应的流程图如下

3. 数据结构
流程清晰之后,接下来就需要看下pv,uv,hot三个数据怎么存了
a. pv
pv保存的就是访问次数,与ip无关,所以kv存储就可以满足我们的需求了,这里的key为uri,value则保存pv的值
b. hot
hot和pv类似,同样用kv可以满足要求
c. uv
uv这里有两个数据,一个是uv总数,要给是这个ip的访问排名,redis中有个zset数据结构正好就可以做这个
zset数据结构中,我们定义value为ip,score为ip的排名,那么uv就是最大的score了
d. 结构图

4. 方案设计
流程清晰,结构设计出来之后,就可以进入具体的方案设计环节了,在这个环节中,我们引入一个app的维度,这样我们的服务就可以通用了;
每个使用者都申请一个app,那么这个使用者的请求的所有站点统计数据,都关联到这个app上,这样也有利于后续统计了
a. 接口API
引入了app之后,结合前面的两个参数ip + URI,我们的请求参数就清晰了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Data public class VisitReqDTO {
private String app;
private String ip;
private String uri; }
|
然后我们返回的数据,pv + uv + rank + hot,所以返回的基础VO如下
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
|
@Data @AllArgsConstructor public class VisitVO implements Serializable {
private Long pv;
private Long uv;
private Long rank;
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也需要随之统计)
因此我们最终的返回对象应该是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Data @NoArgsConstructor @AllArgsConstructor public class SiteVisitDTO {
private VisitVO siteVO;
private VisitVO uriVO;
}
|
有输出,又返回,那么访问api就简单了
1
| SiteVisitDTO visit(VisitReqDTO reqDTO);
|
b. hot相关api
hot数据结构为hash,每次请求过来,都是次数+1,因此直接使用redis的 hIncrBy,实现计数+1,并返回最终的计数
- key:
"hot_cnt_" + app 作为hash的key
- field: 使用URI作为hash的field
- value: 保存具体的hot,整型

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
private String buildHotKey(String app) { return "hot_cnt_" + app; }
public Long addHot(String key, String uri);
|
c. pv相关api
pv与hot不一样的是并不是每次都需要计数+1,所以它需要有一个查询pv的接口,和一个计数+1的接口
- key:
"site_cnt_" + app 作为hash的key
- field: 使用URI作为hash的field
- value: 保存具体的pv,整型

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
|
private String buildPvKey(String app) { return "site_cnt_" + app; }
public Long getPv(String key, String uri);
public void addPv(String key, String uri)
|
d. uv相关api
前面说到uv采用的是zset数据结构,其中ip作为value,排名作为score;所以uv就是最大的score
- key: 根据app和uri来确定uv的key
- value: 存储访问者ip(ipv4格式的)
- score: 排名,整型

因为uv需要返回两个结构,所以我们的返回需要注意
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
|
private String buildUvKey(String app, String uri) { return "uri_rank_" + app + "_" + uri; }
public ImmutablePair</** uv */Long, Long> getUv(String key, String ip)
public void addUv(String key, String ip, Long rank)
|
e. 今日是否访问
前面的都还算比较简单,接下来有个非常有意思的地方了,如何判断这个ip,今天访问没访问?
方案一
要实现这个功能,一个自然而然的想法就出来了,直接kv就行了
如果value存在,表示今天访问过,如果不存在,则没有访问过
方案二
前面那个倒是没啥问题,如果我希望统计今天某个uri的ip访问数,上面的就不太好处理,很容易想到用hash来替换
- key:
uri_年月日
- field:
ip
- value: 1
同样value存在,则表示今天访问过;否则没有访问过
如果需要统计今天访问的总数,hlen一把就可以;还可以获取今天所有访问过的ip
方案三
前面的方案看似挺好的,但是有个缺陷,如果我这个站点特别火,每天几百万的uv,这个存储量就有点夸张了
1 2 3 4 5 6 7
| # 简单的算一下 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转成整形,作为下标,一个就可以了
- 答:将ip转为整型,取值将是 (0 - 2^32),需要的bitmap空间为
4Gb,显然不如上面优雅
方案确定
上面三个方案中,我们选择了第三个,对应的api设计也比较简单了
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
|
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(); }
private String buildUriTagKey(String app, String uri) { return "uri_tag_" + DateUtil.getToday() + "_" + app + "_" + uri; }
public void tagVisit(String key, String ip)
|
III. 服务实现
前面接口设计出来,按照既定思路实现就属于比较轻松的环节了
1. pv接口实现
pv两个接口,一个访问,一个计数+1,都可以直接使用redisTemplate的基础操作完成
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
|
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)); } }); }
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; } }); }
|
2. hot接口实现
只有一个计数+1的接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
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); } }); }
|
3. uv接口实现
uv的获取会麻烦一点,首先获取uv值,然后获取ip对应的排名;如果uv为0,排名也就不需要再获取了
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
|
public ImmutablePair</** uv */Long, Long> getUv(String key, String ip) { 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); }
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); }
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; } }); }
|
4. 今天是否访问过
前面选择位数组方式来记录是否访问过,这里的实现选择了简单的实现方式,利用四个bitmap来分别对应ip的四段;(实际上一个也可以实现,可以想一想应该怎么做)
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
|
public boolean visitToday(String key, String ip) { 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); } }); }
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; } });
} }
|
4. api接口实现
前面基本的接口实现之后,api就是流程图的翻译了,也没有什么特别值得说到的地方,唯一需要注意的就是URI的解析,域名作为站点;uri由path + segment构成
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
| public static ImmutablePair</**host*/String, 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); }
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);
Long pv = visitService.getPv(pvKey, uri); if (pv == null || pv == 0) { visitService.addPv(pvKey, uri); visitService.addUv(uvKey, ip, 1L); visitService.tagVisit(todayVisitKey, ip); return new VisitVO(1L, 1L, 1L, hot); }
boolean visit = visitService.visitToday(todayVisitKey, ip);
ImmutablePair</**uv*/Long, Long> uv = visitService.getUv(uvKey, ip);
if (visit) { return new VisitVO(pv, uv.getLeft(), uv.getRight(), hot); }
if (uv.left == 0L) { 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) { 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 { visitService.addPv(pvKey, uri); visitService.tagVisit(todayVisitKey, ip); return new VisitVO(pv + 1, uv.left, uv.right, hot); } }
|
IV. 测试与小结
1. 测试
搭建一个简单的web服务,开始测试
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
@Controller public class VisitController { @Autowired private SiteVisitFacade siteVisitFacade;
@RequestMapping(path = "visit") @ResponseBody public SiteVisitDTO visit(VisitReqDTO reqDTO) { return siteVisitFacade.visit(reqDTO); } }
|
a. 首次访问
1 2
| http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/home
|

b. 再次访问
1 2
| http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/home
|

c. 同ip,不同URI
1 2
| http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/index
|

d. 不同ip,接上一个URI
1 2
| http://localhost:8080/visit?app=demo&ip=192.168.0.2&uri=http://hhui.top/index
|

e. 上一个ip,换第一个uri
1 2
| http://localhost:8080/visit?app=demo&ip=192.168.0.2&uri=http://hhui.top/home
|

f. 第二天访问
真要第二天操作有点麻烦,为了验证,直接干掉今天的占位标记

1 2
| http://localhost:8080/visit?app=demo&ip=192.168.0.2&uri=http://hhui.top/home
|

2. 小结
本文可以说是redis学习之后,一个挺好的应用场景,涉及到了我们常用和不常用的几个数据结构,包括hash,zset,bitmap, 其中关于bitmap的使用个人感觉还是非常有意思的;
对于redis操作不太熟的,可以参考下前面几篇博文
注意
上面这个服务,在实际使用中,需要考虑并发问题,很明显我们上的设计并不是多线程安全的,也就是说,在并发量大的时候,获取的数据极有可能和预期的不一致
扩展
上文的设计中,每个uri都有一组位图,我们可以通过遍历,获取value为1的下标,来统计这个页面今天的pv数,以及更相信的今天哪些ip访问过;同样也可以分析站点的今日UV数,以及对应的访问ip
0. 项目
1. 一灰灰Blog
一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛
2. 声明
尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激
3. 扫描关注
一灰灰blog

知识星球

打赏
如果觉得我的文章对您有帮助,请随意打赏。
微信打赏
支付宝打赏