共计 12372 个字符,预计需要花费 31 分钟才能阅读完成。
判断一个网站值不值钱的一个重要标准就是看 pv/uv,那么你知道 pv,uv 是怎么统计的么?当然现在有第三方做的比较完善的可以直接使用,但如果让我们自己来实现这么一个功能,应该怎么做呢?
本篇内容较长,源码如右 ➡️ https://github.com/liuyueyi/spring-boot-demo/tree/master/spring-case/124-redis-sitecount
<!– more –>
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,我们的请求参数就清晰了
@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);
b. hot 相关 api
hot 数据结构为 hash,每次请求过来,都是次数 +1,因此直接使用 redis 的 hIncrBy
,实现计数 +1,并返回最终的计数
- key:
"hot_cnt_" + app
作为 hash 的 key - field: 使用 URI 作为 hash 的 field
- value: 保存具体的 hot,整型
/**
* 应用的热度统计计数
*
* @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);
c. pv 相关 api
pv 与 hot 不一样的是并不是每次都需要计数 +1,所以它需要有一个查询 pv 的接口,和一个计数 + 1 的接口
- key:
"site_cnt_" + app
作为 hash 的 key - field: 使用 URI 作为 hash 的 field
- value: 保存具体的 pv,整型
/**
* 应用的 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)
d. uv 相关 api
前面说到 uv 采用的是 zset 数据结构,其中 ip 作为 value,排名作为 score;所以 uv 就是最大的 score
- key: 根据 app 和 uri 来确定 uv 的 key
- value: 存储访问者 ip(ipv4 格式的)
- 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)
e. 今日是否访问
前面的都还算比较简单,接下来有个非常有意思的地方了,如何判断这个 ip,今天访问没访问?
方案一
要实现这个功能,一个自然而然的想法就出来了,直接 kv 就行了
- key:
uri_年月日_ip
- value: 1
如果 value 存在,表示今天访问过,如果不存在,则没有访问过
方案二
前面那个倒是没啥问题,如果我希望统计今天某个 uri 的 ip 访问数,上面的就不太好处理,很容易想到用 hash 来替换
- key:
uri_年月日
- field:
ip
- value: 1
同样 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 转成整形,作为下标,一个就可以了
- 答:将 ip 转为整型,取值将是 (0 – 2^32),需要的 bitmap 空间为
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)
III. 服务实现
前面接口设计出来,按照既定思路实现就属于比较轻松的环节了
1. pv 接口实现
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;
}
});
}
2. hot 接口实现
只有一个计数 + 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);
}
});
}
3. uv 接口实现
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;
}
});
}
4. 今天是否访问过
前面选择位数组方式来记录是否访问过,这里的实现选择了简单的实现方式,利用四个 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;
}
});
}
}
4. api 接口实现
前面基本的接口实现之后,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);
}
}
IV. 测试与小结
1. 测试
搭建一个简单的 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);
}
}
a. 首次访问
# 首次访问,返回的全是 1
http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/home
b. 再次访问
# 再次访问,因为同样是今天访问,除了 hot 为 2;其他的都是 1
http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/home
c. 同 ip,不同 URI
# 同一 ip,换个 uri;除站点返回 hot 为 3,其他的全是 1
http://localhost:8080/visit?app=demo&ip=192.168.0.1&uri=http://hhui.top/index
d. 不同 ip,接上一个 URI
# 换个 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
e. 上一个 ip,换第一个 uri
# 换个 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
f. 第二天访问
真要第二天操作有点麻烦,为了验证,直接干掉今天的占位标记
# 模拟第二天访问,pv + 1,uv 不变,hot+1
http://localhost:8080/visit?app=demo&ip=192.168.0.2&uri=http://hhui.top/home
2. 小结
本文可以说是 redis 学习之后,一个挺好的应用场景,涉及到了我们常用和不常用的几个数据结构,包括 hash,zset,bitmap, 其中关于 bitmap 的使用个人感觉还是非常有意思的;
对于 redis 操作不太熟的,可以参考下前面几篇博文
- 181029-SpringBoot 高级篇 Redis 之基本配置
- 181101-SpringBoot 高级篇 Redis 之 Jedis 配置
- 181108-SpringBoot 高级篇 Redis 之 String 数据结构的读写
- 181109-SpringBoot 高级篇 Redis 之 List 数据结构使用姿势
- 181202-SpringBoot 高级篇 Redis 之 Hash 数据结构使用姿势
- 181211-SpringBoot 高级篇 Redis 之 Set 数据结构使用姿势
- 181212-SpringBoot 高级篇 Redis 之 ZSet 数据结构使用姿势
- 181225-SpringBoot 应用篇之借助 Redis 实现排行榜功能
注意
上面这个服务,在实际使用中,需要考虑并发问题,很明显我们上的设计并不是多线程安全的,也就是说,在并发量大的时候,获取的数据极有可能和预期的不一致
扩展
上文的设计中,每个 uri 都有一组位图,我们可以通过遍历,获取 value 为 1 的下标,来统计这个页面今天的 pv 数,以及更相信的今天哪些 ip 访问过;同样也可以分析站点的今日 UV 数,以及对应的访问 ip
0. 项目
- 工程:https://github.com/liuyueyi/spring-boot-demo
- 源码: https://github.com/liuyueyi/spring-boot-demo/tree/master/spring-case/124-redis-sitecount
1. 一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛
尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现 bug 或者有更好的建议,欢迎批评指正,不吝感激
- 一灰灰 Blog 个人博客 https://blog.hhui.top
- 一灰灰 Blog-Spring 专题博客 http://spring.hhui.top