这周做了公司给的门户网站pv统计的任务,主要是运用了mongodb聚合查询对大量数据的运算及统计,在这里记录下我的做法。
首先要来了解下这个日志表的结构及数据量。
accessArticle文档主要有两个字段:
// 文章id
articleId: {type: String, required: true},
// 访问时间
createTime: { type: Date, default: Date.now, required: true}
目前数据量有1千多万,每日增长量10万左右。
1.按天统计一段时间内总访问量;
2.按天统计一段时间内某个文章的访问量;
3.一段时间内访问量前20文章的信息及访问量;
4.按小时统计某一天的访问量;
5.按小时统计某一天某个文章的访问量。
下面就几个产品需求来讲述下具体的实现方案。
刚接到这个需求时,我便尝试着直接查accessArticle文档,指定一个月的时间,用find的方法(当时还不知道聚合查询),效率很低,大概要几秒钟,而且这还不包括运算统计,并且面对日益增多的庞大数据,效率简直就是恶梦,所以显然这种方式是不行的。因为日志产生过后已是历史,不会再被更新,如果做pv统计时每次都去重新做统计运算,等于在统计运算上做了重复劳动。经过一番尝试与考虑,决定每天对accessArticle文档进行统计,将统计结果按天存入另一个文档countHistory中。这样每天只增多一条记录,即使是10年也最多只有3660条,查询的效率就不是问题了。下面再详细说一下方案吧。
countHistory文档结构:
// 该天日志的日期
time: {type: Date, required: true},
// 访问量
num: { type: Number, required: true}
每天更新统计历史(伪代码)
exports.updateHistory = function(){
async.auto({
getLast: function (callback){
// 计算或者是获取到要统计那天的日期
},
countAccess: ['getLast',function(callback, results){
// 计算统计那天的pv
}],
addHistory: ['countAccess', function(callback, results){
// 插入countHistory
}]
}, function(err, results){
});
};
用node-schedule做类似linux下的crontab任务,代码如下:
var schedule = require('node-schedule');
schedule.scheduleJob('0 2 * * *', updateHistory); // 每天凌晨3:00统计前天访问日志
查找某个时间段内的访问量(伪代码):
var query = {
$gte: startDate,
$lte: endDate
}
countHistory.find(query, "num time", options, callback);
这样查询出来的num数据便是startDate到endDate内每个time的pv了。
接下来的需求是需要把某个文章的信息加上去,统计该文章的访问量。因为涉及到计算每天某个文章访问量的问题,我便想节省这个运算,把结果存入countHistory。这时便需要新建一个countArticle字段来存储结果,因为每天文章数很多,所以这个字段是数组类型,每个数组项是一个文章信息,数组项里只有一个属性及值,属性是文章id,值是该文章当天的访问量。所以这时countHistory的文档结构为:
// 该天日志的日期
time: {type: Date, required: true},
// 访问量
num: { type: Number, required: true},
// 文章统计
countArticle: [
{
_id: {type: String}, // 文章id
hotScore: { type: Number} // 当天该文章访问量
}
]
这时每天定时更新统计历史的代码便需要增加多文章的统计了,先看看完整的伪代码。
每天更新统计历史(伪代码)
exports.updateHistory = function(){
async.auto({
getLast: function (callback){
// 计算或者是获取到要统计那天的日期
},
countAccess: ['getLast',function(callback, results){
// 计算统计那天的pv
}],
getAccess: ['getLast', function (cb, results) {
// 文章统计
}],
addHistory: ['countAccess', function(callback, results){
// 插入countHistory
}]
}, function(err, results){
});
};
具体的文章统计便需要下面的聚合查询来做:
accessArticle.aggregate([
{$match: {createTime: results.setTimeQuery}}, // 匹配条件。必需放在最前面才有效
{$group : {_id : "$articleId", hotScore : {$sum : 1}}}// 将集合中的文档分组,可用于统计结果
]);
$match跟find查询的query参数一样,在这里是先把某天的数据记录取出来。
$group是分组聚合,将同了articleId的记录合并为一条记录,并且新增一个hotScore字段,值为合并的记录数之和。
之后再把结果放在countArticle字段里,再插入到countHistory文档中,便完成了每日统计日志的任务。
现在countArticle字段里已经有文章的pv信息,只要我们能够取出某个文章id的数据,就能实现这个需求。恰好mongodb聚合查询里$unwind,$project,$group可以满足我们的需求。
完整的聚合查询为:
countHistory.aggregate(
[
{$match: query},
{$unwind: "$countArticle"},
{
$project: {
countArticle: 1,
time: { $dateToString: { format: "%Y-%m-%d", date: "$time" } },
articleId: "$countArticle._id"
}
},
{$match: {articleId: articleId}},
{$group : {_id : "$time", hotScore : {$sum "$countArticle.hotScore"}}},
{$sort: {_id: 1}}
]
)
下面来具体分析一下这个聚合查询。
$match跟find查询的query参数一样,在这里是先把符合时间条件的记录取出来。
$unwind能把数组字段里的每一项都拆分出来,再构建新的记录。
如果2016-03-20这天的记录是这样的:
{
time: '2016-03-20 15:59:59.999Z',
num:2,
countArticle: [
{_id: 1, hotScore: 10},
{_id: 2, hotScore: 11}
]
}
那么 经过$unwind之后,数据会变成这样:
{
time: '2016-03-20 15:59:59.999Z',
num:2,
countArticle: [
{_id: 1, hotScore: 10}
]
}
{
time: '2016-03-20 15:59:59.999Z',
num:2,
countArticle: [
{_id: 2, hotScore: 11}
]
}
再利用mongodb聚合查询的管道特性(即每次查询的结果都会作为下一次聚合的输入),再对查询的结果进行$project聚合,它可以修改文档的结构,将数组里的文章id提上一级。并且用$dateToString把时间格式化,具体显示到天。即上述演示数据结果为:
{
_id: 1,
time: '2016-03-20',
countArticle: [
{_id: 1, hotScore: 10}
]
}
{
_id: 2,
time: '2016-03-20',
num:2,
countArticle: [
{_id: 2, hotScore: 11}
]
}
再用$match根据文章id过滤掉不需要的文章。
$group是一个分组的聚合操作,将同一天的记录归入同一组,并用$sum把文章当天访问量累加起来,操作之后的结果便是:
{
_id: '2016-03-20',
hotScore: 21
}
最后$sort运算符可以将数据以time字段排序,这样传到前端将是比较整洁的结果。
理解了上一个需求的做法,那这个需求也就迎刃而解了。先来看看完整的实现聚合查询代码:
countHistory.aggregate(
[
{$match: query},
{$unwind: "$countArticle"}, // 将文档中的countArticle数组类型字段拆分成多条,每条包含数组中的一个值。
{
$group : {
_id : "$countArticle._id",
hotScore : {$sum : "$countArticle.hotScore"}
}
}, // 将集合中的文档分组,可用于统计结果
{$sort: {hotScore: -1}}, // 热度降序排序
{$limit: 20}
]
);
有了上一个需求的基础,相信大家看到这段代码的注释也能够理解各个管道运算了。这里我就简述一下思路吧。
先取出符合时间条件的数据,分解数组字段countArticle,暴露其每个子项,然后再按其中的文章id分组,并且计算各个文章的访问量,最后依照访问量排序,取出其中的前20个,便达到需求的目的了。
因为前面的countHistory只存储了以天为维度的统计数据,对于这种按小时来统计的,便无法满足。虽然可以再存储这些运算结果,但成本比较大,考虑到mongodb专门设计了一些运算符来解决这个问题,并且效率不错,便尝试着运用它来实现这个需求。
mongodb有专门为这种统计方式设计了一些运算符,这里我便用到了其中的$hour。它能够按小时来对时间进行分组统计。先来看看完整的聚合查询代码吧:
accessArticle.aggregate([
{$match: query },
{$group: {
_id: {
"$hour": "$createTime"
},
pv: {$sum: 1}}
}
]);
这样查询出来的数据格式是:
[
{_id: 0, pv:100},
{_id: 1, pv:200},
···
{_id: 23, pv:100}
]
_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小时制来计算,便能得到正确的结果。我的做法是这样:
// results.getAccess为$hour聚合的结果
// UTC转CST
_.each(results.getAccess, function (item) {
item._id = parseInt(item._id, 10)+8;
if (item._id >= 24) {
item._id = item._id - 24;
}
});
与上一个需求类似,只要在$match条件里加多一个过滤掉不符合要求的文章id即可。
通过学习mongodb的聚合查询,更加了解到它的强大。经过这番需求的实现及聚合查询的运用,给我印象最深刻的是聚合查询的管道特性,它十分的方便,并一次聚合的结果可以作为下一次运算的输入,是一个特别棒的功能,可以实现更为复杂地运算或查询。所以聚合查询的运算符虽然放在数组里,但它的顺序是有规矩的。$match放在靠后位置查询不出结果也是我入的第一个坑。
还有遇到另一个现在还没有找到答案的问题,$filter聚合用于部署在阿里云上面的服务器的mongodb3.2.3版本里报错,信息是不支持该运算符。但我用同样的命令在测试环境里相同版本的mongodb却能够正常执行。这真是个奇葩的问题。希望能有解决此问题的大侠出现。
感谢您的阅读!
如果看完后有任何疑问,欢迎拍砖。
欢迎转载,转载请注明出处:http://www.yangrunwei.com/a/44.html
邮箱:glowrypauky@gmail.com
QQ: 892413924