目标
当用户访问接口时,统计数据并放到 Redis 中,并且在每天 0:10 将 Redis 的数据保存到 MySQL 中。
解释
- 统计的数据放到 Redis 中大概是因为它的访问速度快,而且支持的并发量更高吧。
- 将 Redis 的数据保存到 MySQL 的原因是 MySQL 统计数据更方便,比如 MySQL 自带的 count、sum 函数,可以很快地得到某项数据在一定时间范围内的总和。
- 采用定时保存而不是每分钟保存的原因是:避免将过多的数据保存到 MySQL,假设有一个有序列表,用来统计排名。即使每次只保存前 50 条数据,但是这一分钟的前五十条可以下一分钟就不是前五十了,换句话说,可能会不断有新的数据排在前五十。所以在保存的时间间隔越小,保存的数据就越多,而且很多数据最终并不会展示出来。
- 另一方面,既然统计的最小单位是天,那么今天还没有结束,统计出来的数据价值不大,所以统计的时间是第二天凌晨。
- 如果需要查看今日的实时数据,可以提供单独的接口,直接从 Redis 中读取数据。
数据类型
今日 pv
使用字符串类型,表现在 Redis 中就是:KEY=pv_20191109,VALUE=今日的 pv 数。
使用字符串类型的好处是比较简单,而且自带自增 value 的方法(前提是 value 是数字类型)。
今日某个社区的帖子数
使用 SortedSet 类型,表现在 Redis 中就是:KEY=community_post_num_20191109,MEMBER=社区id,SCORE=该社区今日的帖子数。
使用 SortedSet 的好处是:可以排序,而 String 不支持。因为只需要保存热门社区的信息,而不是统计每个社区的信息,所以到拉取 Redis 数据到 MySQL 的时候,仅需要保存前 N 条数据即可。
今日 uv
使用 HyperLogLog 类型,表现在 Redis 中就是:KEY=uv_20191109,VALUE=今日的 uv 数。
使用 HyperLogLog 的好处是:添加相同数据不会增加个数总量。具体来说就是,它提供了 pfadd 方法和 pfcount 方法。假设多次调用 pfadd 方法并传入相同的值,调用 pfcount 得到的仍然是 1。所以统计 uv 时,多次调用 pfadd(用户 id),uv 也仅仅是加一。
今日某个社区的 uv
这个功能的意义在于,当有人恶意刷 pv 来影响热门社区排行时,增加他的难度。不过目前没有实现这个功能,如果需要实现的话,可能需要 SortedSet 和 HyperLogLog 组合,增加社区 uv 数前判断是否已经统计过该用户。
在 Laravel 中操作 Redis
在 Laravel 中用到的函数和 Redis 中的命令是一样的,可以在 Redis 命令参考 查找命令的用户。
因为 Redis 的基本数据类型只有几个,所以可以创建 BaseStringKv、BaseSortedSetKv、BaseHyperLogLog 几个类,让实际操作 Redis 的类继承,这样一来,子类需要重写的东西就很少了。
需要注意的几点:
- 使用字符串类型和 SortedSet 类型时,尽量使用 Redis 自带的自增方法。
- 修改完键的值之后,记得设置该键的过期时间,因为以后展示数据是从 MySQL 中读取,所以 Redis 中的数据过几天就可以删除了。
- 真正将数据存到 Redis 的方法可能抛出异常,但是不在此方法(假设叫 A 方法)中捕获异常。再定义一个方法(假设叫 B 方法),B 方法调用 A 方法,并捕获异常。这么做的目的是:子类根据需要重写 A 方法,外部调用时调用父类的 B 方法。如果以后要修改异常处理的方式,只需要修改几个父类的 B 方法,而不需要在每个子类中做修改。
统计数据的位置
- 对于 pv、uv 直接在中间件中统计
- 对于 xx 的访问量(MySQL 中不需要添加新记录情形)在调用对应接口的时候统计
- 对于评论数(在 MySQL 产生新数据的情形),可以在业务代码中统计,也可以通过订阅 Model 的 created 事件来实现。以评论为例,产生一条评论会增加今日社区评论数、今日帖子评论数,所以利用订阅事件的方式更方便。只能在业务代码中统计的情形是:需要区分安卓手机和苹果手机,订阅事件的方式会在任务队列中运行,导致无法读取到真正的请求头。
跨数据库关联
业务数据和统计数据在不同表中,数据统计表只有社区 id 而没有社区详情,此时需要跨数据库关联,方法也超级简单:
/**
* 关联社区
* @return HasOne
*/
public function community()
{
// 跨数据库关联使用 setConnection
return $this->setConnection('mysql')->hasOne(CommunityModel::class, 'id', 'community_id');
}
查询语句
例如:统计 2019-11-06 ~ 2019-11-07 帖子最多的热门社区
public static function fun()
{
// 根据时间过滤, 按社区 id 分组, 计算各项数据, 按照帖子数降序排列, 限制 n 条数据
CommunityStatModel::where('date', '>=', '20191106')
->where('date', '<=', '20191107')
->groupBy('community_id')
->orderByDesc('num_post')
->limit(50)
->selectRaw('community_id, sum(pv) as pv, sum(uv) as uv, sum(num_post) as num_post, sum(num_user) as num_user, sum(num_comment) as num_comment')
->get();
}