MongoDB:聚合框架:按分组ID获取最近日期的文档

11
我想要获取每个站点的最新文档,并包含所有其他字段:
{
        "_id" : ObjectId("535f5d074f075c37fff4cc74"),
        "station" : "OR",
        "t" : 86,
        "dt" : ISODate("2014-04-29T08:02:57.165Z")
}
{
        "_id" : ObjectId("535f5d114f075c37fff4cc75"),
        "station" : "OR",
        "t" : 82,
        "dt" : ISODate("2014-04-29T08:02:57.165Z")
}
{
        "_id" : ObjectId("535f5d364f075c37fff4cc76"),
        "station" : "WA",
        "t" : 79,
        "dt" : ISODate("2014-04-29T08:02:57.165Z")
}

我需要获取每个站点最新日期(latest dt)的t和station信息。 使用聚合框架:

db.temperature.aggregate([{$sort:{"dt":1}},{$group:{"_id":"$station", result:{$last:"$dt"}, t:{$last:"$t"}}}])

返回

{
        "result" : [
                {
                        "_id" : "WA",
                        "result" : ISODate("2014-04-29T08:02:57.165Z"),
                        "t" : 79
                },
                {
                        "_id" : "OR",
                        "result" : ISODate("2014-04-29T08:02:57.165Z"),
                        "t" : 82
                }
        ],
        "ok" : 1
}

这是最高效的方法吗?

谢谢。


3
你接受了NeilLunn的答案是事实上不正确的。自然顺序不能保证是插入顺序(除了被限制的集合),而且只有当所有客户端机器都进行时间同步时,_id才能保证单调递增。 - Asya Kamsky
3个回答

10
直接回答您的问题,是的,这是最有效的方法。但我认为我们需要澄清为什么会这样。
正如在替代方案中所建议的那样,人们正在查看的一个事项是在传递给$group阶段之前对结果进行“排序”,他们正在查看的是“时间戳”值,因此您需要确保所有内容都按照“时间戳”顺序排列,因此形式如下:
db.temperature.aggregate([
    { "$sort": { "station": 1, "dt": -1 } },
    { "$group": {
        "_id": "$station", 
        "result": { "$first":"$dt"}, "t": {"$first":"$t"} 
    }}
])

正如所述,您当然希望有一个索引来反映这一点,以使排序更加高效:

然而,这是真正的关键。其他人似乎已经忽略了这一点(如果你没有忽略),即所有这些数据很可能已经按时间顺序插入,因为每个读数都记录为添加。

因此,这样做的好处在于_id字段(具有默认的ObjectId)已经按照“时间戳”顺序排列,因为它本身实际上包含一个时间值,这使得该语句成为可能:

db.temperature.aggregate([
    { "$group": {
        "_id": "$station", 
        "result": { "$last":"$dt"}, "t": {"$last":"$t"} 
    }}
])

而且它更快。为什么?因为你不需要选择索引(额外的调用代码),也不需要在文档之外“加载”索引。
我们已经知道文档是按照_id排序的,所以$last边界是完全有效的。无论如何,你都要扫描所有内容,并且你也可以基于_id值进行“范围”查询,同样适用于两个日期之间。
唯一真正需要说的是,在“实际使用”中,当进行这种累加操作时,可能更实用的方法是在日期范围内进行$match,而不是获取“第一个”和“最后一个”_id值来定义一个“范围”或类似的东西。
那么这方面的证据在哪里?好吧,它相当容易复制,所以我通过生成一些示例数据来证明。
var stations = [ 
    "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL",
    "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA",
    "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE",
    "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK",
    "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT",
    "VA", "WA", "WV", "WI", "WY"
];


for ( i=0; i<200000; i++ ) {

    var station = stations[Math.floor(Math.random()*stations.length)];
    var t = Math.floor(Math.random() * ( 96 - 50 + 1 )) +50;
    dt = new Date();

    db.temperatures.insert({
        station: station,
        t: t,
        dt: dt
    });

}

在我的硬件设备上(8GB的笔记本电脑,带有旋转磁盘,性能不算太好,但绰绰有余),运行每种形式的语句都清楚地显示了使用索引和排序版本的显着暂停(索引上的键与排序语句相同)。这只是一个小的暂停,但差异足以引起注意。
即使查看解释输出(2.6及以上版本,或实际上在2.4.9中存在,但未记录),您也可以看到区别在于,尽管由于索引的存在,$sort被优化掉了,但所花费的时间似乎是选择索引并加载索引条目。对于“覆盖”索引查询,包括所有字段没有任何影响。
此外,仅对日期进行索引并仅按日期值进行排序会得到相同的结果。可能稍微快一点,但仍然比不排序的自然索引形式慢。
因此,只要您可以愉快地在第一个和最后一个_id值上“范围”,那么使用插入顺序的自然索引实际上是执行此操作的最有效方法。您的实际效果可能因是否对您实用而异,可能最终更方便地在日期上实现索引和排序。
但是,如果您愿意使用_id范围或查询中“最后”_id的值,那么也许可以进行一些调整,以便同时获取结果和值,这样您实际上可以存储并在后续查询中使用该信息。
db.temperature.aggregate([
    // Get documents "greater than" the "highest" _id value found last time
    { "$match": {
        "_id": { "$gt":  ObjectId("536076603e70a99790b7845d") }
    }},

    // Do the grouping with addition of the returned field
    { "$group": {
        "_id": "$station", 
        "result": { "$last":"$dt"},
        "t": {"$last":"$t"},
        "lastDoc": { "$last": "$_id" } 
    }}
])

如果您实际上是像这样“跟随”结果的,那么您可以确定ObjectId的最大值,并在下一个查询中使用它。

总之,玩得开心,但是再次强调,在这种情况下,该查询是最快的方式。


嗨,我正在使用相同的聚合管道格式,但是如果字段不存在,则$first或$last将从下一个记录中获取该字段的值,并且其余字段值将来自第一个记录,我们如何以这样的方式格式化查询,以便所有值都来自同一条记录? - Anoopkr05

2
一个索引就是你所需要的:
db.temperature.ensureIndex({ 'station': 1, 'dt': 1 })
for s in db.temperature.distinct('station'):
    db.temperature.find({ station: s }).sort({ dt : -1 }).limit(1)

当然要使用你的语言实际有效的语法。

编辑:您是正确的,像这样的循环会为每个站点产生一次往返,对于几个站点来说很好,但对于1000个站点来说就不太好了。 但是,您仍然希望在station+dt上建立复合索引,并利用降序排序的优势:

db.temperature.aggregate([
    { $sort: { station: 1, dt: -1 } },
    { $group: { _id: "$station", result: {$first:"$dt"}, t: {$first:"$t"} } }
])

1
你的代码将使n个查找。我有成千上万的站点...这就是为什么我想使用聚合框架只进行一次请求。感谢索引建议。 - hotips
所以,为了记录,在这种情况下定义这种排序实际上会运行得更慢。在这里需要考虑的是文档实际上已经按照插入顺序排好了。鉴于此,我编写了一个测试用例示例来证明这一点。 - Neil Lunn

1

就您发布的聚合查询而言,我建议您确保在dt上建立了索引:

db.temperature.ensureIndex({'dt': 1 })

这将确保聚合管道开头的$sort最有效。至于是否这是获取此数据的最有效方式,与使用循环中的查询相比,可能取决于您有多少数据点。在开始时,“数千个站点”和可能有数十万个数据点时,我认为聚合方法会更快。然而,随着越来越多的数据,一个问题是聚合查询将继续接触所有文档。随着规模扩大到几百万或更多文档,这将变得越来越昂贵。针对这种情况的一种方法是在$sort后立即添加$limit,以限制考虑的总文档数。这有点不精确但有助于限制需要访问的文档总数。

1
我可以使用 _id 进行排序,我认为这比 IsoDate 更快。 - hotips
实际上并非如此。 _id 值已经按照所需顺序排列,而一个测试用例(如所示)证明当情况如此时,定义索引和排序实际上会运行得更慢。 - Neil Lunn
1
@NeilLunn 不正确,_id值并不是已经按所需顺序排列的,除非您从索引中读取它们(这就是按_id排序时发生的情况)。 - Asya Kamsky

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接