mongodb做网站pv统计

作者:杨润炜
日期:2016/3/20 15:00

这周做了公司给的门户网站pv统计的任务,主要是运用了mongodb聚合查询对大量数据的运算及统计,在这里记录下我的做法。

需求分析

首先要来了解下这个日志表的结构及数据量。

文档结构

accessArticle文档主要有两个字段:

  1. // 文章id
  2. articleId: {type: String, required: true},
  3. // 访问时间
  4. createTime: { type: Date, default: Date.now, required: true}
数据量

目前数据量有1千多万,每日增长量10万左右。

需求

1.按天统计一段时间内总访问量;
2.按天统计一段时间内某个文章的访问量;
3.一段时间内访问量前20文章的信息及访问量;
4.按小时统计某一天的访问量;
5.按小时统计某一天某个文章的访问量。

实现各个需求的具体方案

下面就几个产品需求来讲述下具体的实现方案。

按天统计一段时间内总访问量

刚接到这个需求时,我便尝试着直接查accessArticle文档,指定一个月的时间,用find的方法(当时还不知道聚合查询),效率很低,大概要几秒钟,而且这还不包括运算统计,并且面对日益增多的庞大数据,效率简直就是恶梦,所以显然这种方式是不行的。因为日志产生过后已是历史,不会再被更新,如果做pv统计时每次都去重新做统计运算,等于在统计运算上做了重复劳动。经过一番尝试与考虑,决定每天对accessArticle文档进行统计,将统计结果按天存入另一个文档countHistory中。这样每天只增多一条记录,即使是10年也最多只有3660条,查询的效率就不是问题了。下面再详细说一下方案吧。
countHistory文档结构:

  1. // 该天日志的日期
  2. time: {type: Date, required: true},
  3. // 访问量
  4. num: { type: Number, required: true}

每天更新统计历史(伪代码)

  1. exports.updateHistory = function(){
  2. async.auto({
  3. getLast: function (callback){
  4. // 计算或者是获取到要统计那天的日期
  5. },
  6. countAccess: ['getLast',function(callback, results){
  7. // 计算统计那天的pv
  8. }],
  9. addHistory: ['countAccess', function(callback, results){
  10. // 插入countHistory
  11. }]
  12. }, function(err, results){
  13. });
  14. };

用node-schedule做类似linux下的crontab任务,代码如下:

  1. var schedule = require('node-schedule');
  2. schedule.scheduleJob('0 2 * * *', updateHistory); // 每天凌晨3:00统计前天访问日志

查找某个时间段内的访问量(伪代码):

  1. var query = {
  2. $gte: startDate,
  3. $lte: endDate
  4. }
  5. countHistory.find(query, "num time", options, callback);

这样查询出来的num数据便是startDate到endDate内每个time的pv了。

聚合统计每天文章数据

接下来的需求是需要把某个文章的信息加上去,统计该文章的访问量。因为涉及到计算每天某个文章访问量的问题,我便想节省这个运算,把结果存入countHistory。这时便需要新建一个countArticle字段来存储结果,因为每天文章数很多,所以这个字段是数组类型,每个数组项是一个文章信息,数组项里只有一个属性及值,属性是文章id,值是该文章当天的访问量。所以这时countHistory的文档结构为:

  1. // 该天日志的日期
  2. time: {type: Date, required: true},
  3. // 访问量
  4. num: { type: Number, required: true},
  5. // 文章统计
  6. countArticle: [
  7. {
  8. _id: {type: String}, // 文章id
  9. hotScore: { type: Number} // 当天该文章访问量
  10. }
  11. ]

这时每天定时更新统计历史的代码便需要增加多文章的统计了,先看看完整的伪代码。
每天更新统计历史(伪代码)

  1. exports.updateHistory = function(){
  2. async.auto({
  3. getLast: function (callback){
  4. // 计算或者是获取到要统计那天的日期
  5. },
  6. countAccess: ['getLast',function(callback, results){
  7. // 计算统计那天的pv
  8. }],
  9. getAccess: ['getLast', function (cb, results) {
  10. // 文章统计
  11. }],
  12. addHistory: ['countAccess', function(callback, results){
  13. // 插入countHistory
  14. }]
  15. }, function(err, results){
  16. });
  17. };

具体的文章统计便需要下面的聚合查询来做:

  1. accessArticle.aggregate([
  2. {$match: {createTime: results.setTimeQuery}}, // 匹配条件。必需放在最前面才有效
  3. {$group : {_id : "$articleId", hotScore : {$sum : 1}}}// 将集合中的文档分组,可用于统计结果
  4. ]);

$match跟find查询的query参数一样,在这里是先把某天的数据记录取出来。
$group是分组聚合,将同了articleId的记录合并为一条记录,并且新增一个hotScore字段,值为合并的记录数之和。
之后再把结果放在countArticle字段里,再插入到countHistory文档中,便完成了每日统计日志的任务。

按天统计一段时间内某个文章的访问量

现在countArticle字段里已经有文章的pv信息,只要我们能够取出某个文章id的数据,就能实现这个需求。恰好mongodb聚合查询里$unwind,$project,$group可以满足我们的需求。
完整的聚合查询为:

  1. countHistory.aggregate(
  2. [
  3. {$match: query},
  4. {$unwind: "$countArticle"},
  5. {
  6. $project: {
  7. countArticle: 1,
  8. time: { $dateToString: { format: "%Y-%m-%d", date: "$time" } },
  9. articleId: "$countArticle._id"
  10. }
  11. },
  12. {$match: {articleId: articleId}},
  13. {$group : {_id : "$time", hotScore : {$sum "$countArticle.hotScore"}}},
  14. {$sort: {_id: 1}}
  15. ]
  16. )

下面来具体分析一下这个聚合查询。
$match跟find查询的query参数一样,在这里是先把符合时间条件的记录取出来。
$unwind能把数组字段里的每一项都拆分出来,再构建新的记录。
如果2016-03-20这天的记录是这样的:

  1. {
  2. time: '2016-03-20 15:59:59.999Z',
  3. num:2,
  4. countArticle: [
  5. {_id: 1, hotScore: 10},
  6. {_id: 2, hotScore: 11}
  7. ]
  8. }

那么 经过$unwind之后,数据会变成这样:

  1. {
  2. time: '2016-03-20 15:59:59.999Z',
  3. num:2,
  4. countArticle: [
  5. {_id: 1, hotScore: 10}
  6. ]
  7. }
  8. {
  9. time: '2016-03-20 15:59:59.999Z',
  10. num:2,
  11. countArticle: [
  12. {_id: 2, hotScore: 11}
  13. ]
  14. }

再利用mongodb聚合查询的管道特性(即每次查询的结果都会作为下一次聚合的输入),再对查询的结果进行$project聚合,它可以修改文档的结构,将数组里的文章id提上一级。并且用$dateToString把时间格式化,具体显示到天。即上述演示数据结果为:

  1. {
  2. _id: 1
  3. time: '2016-03-20',
  4. countArticle: [
  5. {_id: 1, hotScore: 10}
  6. ]
  7. }
  8. {
  9. _id: 2,
  10. time: '2016-03-20',
  11. num:2,
  12. countArticle: [
  13. {_id: 2, hotScore: 11}
  14. ]
  15. }

再用$match根据文章id过滤掉不需要的文章。
$group是一个分组的聚合操作,将同一天的记录归入同一组,并用$sum把文章当天访问量累加起来,操作之后的结果便是:

  1. {
  2. _id: '2016-03-20',
  3. hotScore: 21
  4. }

最后$sort运算符可以将数据以time字段排序,这样传到前端将是比较整洁的结果。

一段时间内访问量前20文章的信息及访问量

理解了上一个需求的做法,那这个需求也就迎刃而解了。先来看看完整的实现聚合查询代码:

  1. countHistory.aggregate(
  2. [
  3. {$match: query},
  4. {$unwind: "$countArticle"}, // 将文档中的countArticle数组类型字段拆分成多条,每条包含数组中的一个值。
  5. {
  6. $group : {
  7. _id : "$countArticle._id",
  8. hotScore : {$sum : "$countArticle.hotScore"}
  9. }
  10. }, // 将集合中的文档分组,可用于统计结果
  11. {$sort: {hotScore: -1}}, // 热度降序排序
  12. {$limit: 20}
  13. ]
  14. );

有了上一个需求的基础,相信大家看到这段代码的注释也能够理解各个管道运算了。这里我就简述一下思路吧。
先取出符合时间条件的数据,分解数组字段countArticle,暴露其每个子项,然后再按其中的文章id分组,并且计算各个文章的访问量,最后依照访问量排序,取出其中的前20个,便达到需求的目的了。

按小时统计某一天的访问量

因为前面的countHistory只存储了以天为维度的统计数据,对于这种按小时来统计的,便无法满足。虽然可以再存储这些运算结果,但成本比较大,考虑到mongodb专门设计了一些运算符来解决这个问题,并且效率不错,便尝试着运用它来实现这个需求。
mongodb有专门为这种统计方式设计了一些运算符,这里我便用到了其中的$hour。它能够按小时来对时间进行分组统计。先来看看完整的聚合查询代码吧:

  1. accessArticle.aggregate([
  2. {$match: query },
  3. {$group: {
  4. _id: {
  5. "$hour": "$createTime"
  6. },
  7. pv: {$sum: 1}}
  8. }
  9. ]);

这样查询出来的数据格式是:

  1. [
  2. {_id: 0, pv:100},
  3. {_id: 1, pv:200},
  4. ···
  5. {_id: 23, pv:100}
  6. ]

_id的0到23对应每天的24小时,pv为统计的结果值。

本来觉得的相当简单的操作,但到了测试那天,才发现了另一个可怕的问题。

当天下午4点多的时候,我测试了在当天按小时统计数据,却意外地发现当天24小时都有数据。简直是见鬼了。之后经过了无数遍代码逻辑,发现都没问题,但是出来的数据却是错误的。曾经还一度怀疑是不是mongodb的bug。但我还是决定再调试调试,先把$match之后的数据再$project和$dateToString对时间格式化,具体到小时。这才发现,出来的数据包括了前一天的!!!难道真是mongodb的bug?还是我时间存错了?再看看mongdb的时间数据,发现了它是以ISODate的形式存的,再搜索了解ISODate,发现mongodb是以UTC格式存储时间,而这种时间比CST(中国标准时间)慢8个小时。因此,我看到$dateToString格式化的时间,其实是UTC的时间,所以才会出现前一天的数据。所以$hour是按UTC的时间标准来的,我们获取到之后,还要对其进行加工,做法是加上8小时,并且以24小时制来计算,便能得到正确的结果。我的做法是这样:

  1. // results.getAccess为$hour聚合的结果
  2. // UTC转CST
  3. _.each(results.getAccess, function (item) {
  4. item._id = parseInt(item._id, 10)+8;
  5. if (item._id >= 24) {
  6. item._id = item._id - 24;
  7. }
  8. });

按小时统计某一天某个文章的访问量

与上一个需求类似,只要在$match条件里加多一个过滤掉不符合要求的文章id即可。

总结

通过学习mongodb的聚合查询,更加了解到它的强大。经过这番需求的实现及聚合查询的运用,给我印象最深刻的是聚合查询的管道特性,它十分的方便,并一次聚合的结果可以作为下一次运算的输入,是一个特别棒的功能,可以实现更为复杂地运算或查询。所以聚合查询的运算符虽然放在数组里,但它的顺序是有规矩的。$match放在靠后位置查询不出结果也是我入的第一个坑。
还有遇到另一个现在还没有找到答案的问题,$filter聚合用于部署在阿里云上面的服务器的mongodb3.2.3版本里报错,信息是不支持该运算符。但我用同样的命令在测试环境里相同版本的mongodb却能够正常执行。这真是个奇葩的问题。希望能有解决此问题的大侠出现。

感谢您的阅读!
如果看完后有任何疑问,欢迎拍砖。
欢迎转载,转载请注明出处:http://www.yangrunwei.com/a/44.html
邮箱:glowrypauky@gmail.com
QQ: 892413924