目标

当用户访问接口时,统计数据并放到 Redis 中,并且在每天 0:10 将 Redis 的数据保存到 MySQL 中。
数据统计 - 图1

解释

  • 统计的数据放到 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 而没有社区详情,此时需要跨数据库关联,方法也超级简单:

  1. /**
  2. * 关联社区
  3. * @return HasOne
  4. */
  5. public function community()
  6. {
  7. // 跨数据库关联使用 setConnection
  8. return $this->setConnection('mysql')->hasOne(CommunityModel::class, 'id', 'community_id');
  9. }

查询语句

例如:统计 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();
}