如何按不同字段分组

6
我希望查找所有名为“Hans”的用户,并通过分组汇总他们的“年龄”和“孩子数”。假设我在我的数据库“users”中有以下内容。
{
    "_id" : "01",
    "user" : "Hans",
    "age" : "50"
    "childs" : "2"
}
{
    "_id" : "02",
    "user" : "Hans",
    "age" : "40"
    "childs" : "2"
}
{
    "_id" : "03",
    "user" : "Fritz",
    "age" : "40"
    "childs" : "2"
}
{
    "_id" : "04",
    "user" : "Hans",
    "age" : "40"
    "childs" : "1"
}

结果应该类似于以下内容:
"result" : 
[
  { 
    "age" : 
      [
        {
          "value" : "50",
          "count" : "1"
        },
        {
          "value" : "40",
          "count" : "2"
        }
      ]
  },
  { 
    "childs" : 
      [
        {
          "value" : "2",
          "count" : "2"
        },
        {
          "value" : "1",
          "count" : "1"
        }
      ]
  }  
]

如何实现这个目标?

在这里最好的方法是并行运行两个查询。除了非常小的数据样本外,这是唯一实际可行的方法。这将是一个很好的示例答案,但非常冗长。对于“小数据”的愚蠢方法是将其推到数组中并逐个处理。但这在大多数“现实世界”的数据集上都不起作用。 - user3561036
1
如果不强制使用聚合框架,我认为可以考虑使用Map-Reduce。在这种情况下,对于较大的数据集,可能比其他更繁琐的解决方案具有更好的性能。 - Sylvain Leroux
@SylvainLeroux 同样的问题适用。为了通常“按两个或更多的事物分组”,则需要以合并形式表示每个数据集的不同数据集。在SQL术语中,这基本上是一个“UNION”,但联合实际上是一个“连接”操作,因此MongoDB不执行该操作。就像我说的那样,有“愚蠢”的方法,但它们无法扩展。明智的做法是使用单独的“并行”查询并组合结果以发送到您的API。通过这种方法,它们是快速可扩展的。就像我说的那样,这将是一个很好的FAQ,但解释很长。 - user3561036
@user3561036 这听起来是一个相当有趣的替代方案。你有一些指向示例或描述它的文档吗?我找到了http://pauldone.blogspot.fr/2014/03/mongoparallelaggregation.html,但那不完全是同样的东西。 - Sylvain Leroux
3
@SylvainLeroux 很乐意帮忙,如果您能等一天左右,我可能会有时间或其他人也可以。请注意,已经提交了一个使用“ A / B”变体的答案,这是我之前提到的对初始“愚蠢”回答的修订,它在某种程度上缓解了“填充数组”的问题,但性能也不是很好。您的实际语言实现应该是可以发出“并行”操作并努力组合结果的。Node.js [async](https://github.com/caolan/async#parallel)是一个模板示例。 - user3561036
@SylvainLeroux 哦,好吧。我会因此而睡在狗窝里,但我刚刚提交了“冗长”的(有点缩写)版本。 - user3561036
2个回答

6
这几乎可以成为MongoDB常见问题解答,主要因为这是一个真实的例子,展示了如何从SQL处理方式转变思维,并接受像MongoDB这样的引擎所做的事情。
基本原则是“MongoDB不使用连接(join)”。无论如何,“构建SQL查询”的任何方式都需要执行“连接”操作。典型的形式是“UNION”,实际上就是一种“连接”。
那么在不同的范式下如何实现呢?首先,让我们看看如何做以及理解原因(即使当然它对你个别的小样本数据有效):

困难模式

db.docs.aggregate([
    { "$group": {
        "_id": null,
        "age": { "$push": "$age" },
        "childs": { "$push": "$childs" }
    }},
    { "$unwind": "$age" },
    { "$group": {
        "_id": "$age",
        "count": { "$sum": 1  },
        "childs": { "$first": "$childs" }
    }},
    { "$sort": { "_id": -1 } },
    { "$group": {
        "_id": null,
        "age": { "$push": {
            "value": "$_id",
            "count": "$count"
        }},
        "childs": { "$first": "$childs" }
    }},
    { "$unwind": "$childs" },
    { "$group": {
        "_id": "$childs",
        "count": { "$sum": 1 },
        "age": { "$first": "$age" }
    }},
    { "$sort": { "_id": -1 } },
    { "$group": {
        "_id": null,
        "age": { "$first": "$age" },
        "childs": { "$push": {
            "value": "$_id",
            "count": "$count"
        }}
    }}
])

这将会给你一个类似这样的结果:
{
    "_id" : null,
    "age" : [
            {
                    "value" : "50",
                    "count" : 1
            },
            {
                    "value" : "40",
                    "count" : 3
            }
    ],
    "childs" : [
            {
                    "value" : "2",
                    "count" : 3
            },
            {
                    "value" : "1",
                    "count" : 1
            }
    ]
}

所以这为什么不好?主要问题应该在第一个管道阶段显而易见:
    { "$group": {
        "_id": null,
        "age": { "$push": "$age" },
        "childs": { "$push": "$childs" }
    }},

我们需要做的是将集合中我们想要的值分组并将这些结果用$push推入数组中。当数据较小时,这种方法可行,但真实世界中的大型集合会导致管道中出现“单个文档”,其大小超过所允许的16MB BSON限制,这是不好的。
其余逻辑按照每个数组的自然顺序进行操作。但是在真实世界的情况下,这几乎总是不可行的。
您可以通过执行诸如将文档“复制”为“年龄”或“子代”类型,并逐个类型分组的操作来避免这种情况。但这有点“过于复杂”,也不是一种稳定的做法。
自然的反应是“联合查询”,但由于MongoDB不执行“join”操作,那么该如何解决呢?

更好的方法(又名新的希望)

从架构和性能角度考虑,您最好的方法是通过客户端API向服务器“同时”提交“两个”查询(是的,两个)。当接收到结果后,您可以将它们“合并”成一个单独的响应,然后将其作为数据源发送回最终的“客户端”应用程序。
不同的编程语言对此有不同的处理方式,但通常情况下,您需要寻找一个“异步处理”API,以便同时执行这些操作。
在这里,我的示例目的使用node.js作为“异步”部分基本上是“内置的”,并且相对直观。事情的“组合”部分可以是任何类型的“哈希/映射/字典”表实现,只是简单地进行示例:
var async = require('async'),
    MongoClient = require('mongodb');

MongoClient.connect('mongodb://localhost/test',function(err,db) {

  var collection = db.collection('docs');

  async.parallel(
    [
      function(callback) {
        collection.aggregate(
          [
            { "$group": {
              "_id": "$age",
              "type": { "$first": { "$literal": "age" } },
              "count": { "$sum": 1 }
            }},
            { "$sort": { "_id": -1 } }
          ],
          callback
        );
      },
      function(callback) {
        collection.aggregate(
          [
            { "$group": {
              "_id": "$childs",
              "type": { "$first": { "$literal": "childs" } },
              "count": { "$sum": 1 }
            }},
            { "$sort": { "_id": -1 } }

          ],
          callback
        );
      }
    ],
    function(err,results) {
      if (err) throw err;
      var response = {};
      results.forEach(function(res) {
        res.forEach(function(doc) {
          if ( !response.hasOwnProperty(doc.type) )
            response[doc.type] = [];

          response[doc.type].push({
            "value": doc._id,
            "count": doc.count
          });
        });
      });

      console.log( JSON.stringify( response, null, 2 ) );
    }
  );
});

这将会得到可爱的结果:

{
  "age": [
    {
      "value": "50",
      "count": 1
    },
    {
      "value": "40",
      "count": 3
    }
  ],
  "childs": [
    {
      "value": "2",
      "count": 3
    },
    {
      "value": "1",
      "count": 1
    }
  ]
}

所以需要注意的关键点是,“分离”聚合语句本身其实很简单。你需要做的就是将它们结合在最终结果中。有许多方法可以“结合”,特别是处理每个查询的大结果时,但这是执行模型的基本示例。
重点如下:
- 对于大数据集,聚合管道中的数据洗牌是可能的,但不够高效。 - 使用支持“并行”和“异步”执行的语言实现和API,这样您就可以一次性“加载”所有或“大部分”操作。 - API 应支持某种“组合”方法,或者允许单独的“流式”写入将收到的每个结果集处理为一个结果。 - 忘记 SQL 的方式。NoSQL 的方式将诸如“连接”之类的处理委托给“数据逻辑层”,该层包含了此处所示的代码。它采用这种方式是因为它可扩展到非常大的数据集。在大型应用程序中,你的“数据逻辑”处理节点的工作是将其传递给最终 API。 - 这比我可能描述的任何其他形式的“操纵”都要快。 “NoSQL” 思考的一部分是“忘掉你所学的”,以不同的方式看待事物。如果这种方式表现不佳,则坚持 SQL 方法进行存储和查询。 - 这就是为什么存在替代方案的原因。

很棒的答案!但是,还有一个问题:如果集合正在并发更新,我们会不会出现两个查询返回的结果不一致? - Sylvain Leroux
3
如果集合被同时更新,那么无论采取什么方法,都可能得到不同的结果。在MongoDB中加锁是一个“嗯”的问题,所以我就不多说了。很难真正地“快照”任何东西,因此基本上需要另一篇非常冗长的博客文章(或者系列文章)来进行讲解。 - user3561036

2

真是一个棘手的问题!

首先,是最基本的解决方案:

db.test.aggregate([
 { "$match": { "user": "Hans" } },
 // duplicate each document: one for "age", the other for "childs"
 { $project: { age: "$age", childs: "$childs",
               data: {$literal: ["age", "childs"]}}},
 { $unwind: "$data" },
 // pivot data to something like { data: "age", value: "40" }
 { $project: { data: "$data",
               value: {$cond: [{$eq: ["$data", "age"]},
                               "$age", 
                               "$childs"]} }},
 // Group by data type, and count
 { $group: { _id: {data: "$data", value: "$value" }, 
             count: { $sum: 1 }, 
             value: {$first: "$value"} }},
 // aggregate values in an array for each independant (type,value) pair
 { $group: { _id: "$_id.data", values: { $push: { count: "$count", value: "$value" }} }} ,
 // project value to the correctly name field
 { $project: { result: {$cond: [{$eq: ["$_id", "age"]},
                               {age: "$values" }, 
                               {childs: "$values"}]} }},
 // group all data in the result array, and remove unneeded `_id` field 
 { $group: { _id: null, result: { $push: "$result" }}},
 { $project: { _id: 0, result: 1}}
])

生产:

{
    "result" : [
        {
            "age" : [
                {
                    "count" : 3,
                    "value" : "40"
                },
                {
                    "count" : 1,
                    "value" : "50"
                }
            ]
        },
        {
            "childs" : [
                {
                    "count" : 1,
                    "value" : "1"
                },
                {
                    "count" : 3,
                    "value" : "2"
                }
            ]
        }
    ]
}

现在,让我们做一些解释:

这里的一个主要问题是每个传入的文档必须是两个不同总数的一部分。我通过向您的文档添加文字数组“age”、“childs”,然后通过该数组展开它们来解决这个问题。这样,每个文档将在后续阶段中呈现两次。

一旦完成了这个步骤,为了方便处理,我将数据表示更改为更易于管理的形式,例如{ data:“age”,value:“40” }

以下步骤将执行数据聚合本身。直到第三个 $ project 步骤将值字段映射到相应的 age childs 字段。

最后两个步骤将简单地将两个文档包装成一个文档,并删除不需要的 _id 字段。

哎呀!


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