MongoDB联表查询

MongoDB是典型的NoSQL数据库,因此不提供JOIN操作。但有时我们仍然希望引用其他集合中的文档。最近使用到了MongoDB的联表查询,总结一下。

“mongodb”的图片搜索ç"“æžœ

首先我们知道,在关系型数据库中,通过连接运算符可以实现多个表联合查询。而非关系型数据库的特点是数据是文档型,表内无模式,表之间属于弱关联,其本身特性不建议对多Collection关联处理,自然就很难在关系型数据库中非常擅长的多表关联上发挥作用。

MongoDB作为NoSQL代表,虽然属于非关系型数据库,但也是最像关系型数据库的一种NoSQL,提供了一些方法来解决此类问题。

建模和关系

MongoDB是非关系型数据库,具有灵活的模式。因为支持内嵌对象和数组类型,所以MongoDB建模也可以有两种方式,一种是内嵌(Embed),另一种是引用(References)。

而关系表示多个文档之间在逻辑上的相互联系,关系可以有:1:1(1对1)、1: N(1对多)、N: 1(多对1)、N: N(多对多)4种。文档间可以通过嵌入和引用来建立联系,也对应了MongoDB建模的两种方式。

接下来我们来以用户与用户地址的关系为例。一个用户可以有多个地址,所以是一对多的关系。

嵌入式关系

使用嵌入式方式(即内嵌)建模,我们可以把用户地址嵌入到用户的文档中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"_id":ObjectId("5bdc18178bf520001167208b"),
"contact": "123456",
"name": "小明",
"address": [
{
"building": "22 A, 中心大厦",
"pincode": 123456,
"city": "上海",
"state": "浦东"
},
{
"building": "301 A, 首东国际",
"pincode": 456789,
"city": "北京",
"state": "东城"
}]
}

以上数据保存在单一的文档中,可以比较容易的获取和维护数据。这种数据结构的缺点是,如果用户和用户地址在不断增加,数据量不断变大,会影响读写性能。

引用式关系

引用式关系是设计数据库时经常用到的方法,这种方法把用户数据文档和用户地址数据文档分开,通过引用文档的 id 字段来建立关系。

手动引用(Manual References)

1
2
3
4
5
6
7
8
9
{
"_id":ObjectId("5bdc18178bf520001167208b"),
"contact": "123456",
"name": "小明",
"address_ids": [
ObjectId("52ffc4a5d85242602e000000"),
ObjectId("52ffc4a5d85242602e000001")
]
}

以上实例中,用户文档的 address_ids 字段包含用户地址的对象id(ObjectId)数组。我们可以读取这些用户地址的对象id(ObjectId)来获取用户的详细地址信息。这种方法需要两次查询,第一次查询用户地址的对象id(ObjectId),第二次通过查询的id获取用户的详细地址信息。

1
2
const result = db.users.findOne({"name":"Tom Benzamin"},{"address_ids":1})
const addresses = db.address.find({"_id":{"$in":result["address_ids"]}})

DBRefs

考虑这样的一个场景,我们在不同的集合中(address_home,address_office,address_mailing等)存储不同的地址(住址,办公室地址,邮件地址等)。这样,我们在调用不同地址时,也需要指定集合,一个文档从多个集合引用文档,我们应该使用 DBRefs。

DBRef的形式:

1
{ $ref : , $id : , $db :  }

三个字段表示的意义为:

  • $ref:集合名称
  • $id:引用的id
  • $db:数据库名称,可选参数

以下实例中用户数据文档使用了 DBRef, 字段 address:

1
2
3
4
5
6
7
8
9
10
{
"_id":ObjectId("5bdc18178bf520001167208b"),
"address": {
"$ref": "address_home",
"$id": ObjectId("534009e4d852427820000002"),
"$db": "runoob"
},
"contact": "123456",
"name": "小明"
}

address DBRef 字段指定了引用的地址文档是在 runoob 数据库下的 address_home 集合,id 为 534009e4d852427820000002。

以下代码中,我们通过指定 $ref 参数(address_home 集合)来查找集合中指定id的用户地址信息:

1
2
3
const user = db.users.findOne({"name":"小明"})
const dbRef = user.address
db[dbRef.$ref].findOne({"_id":(dbRef.$id)})

以上实例返回了 address_home 集合中的地址数据:

1
2
3
4
5
6
7
{
"_id" : ObjectId("534009e4d852427820000002"),
"building" : "301 A, 首东国际",
"pincode" : 123456,
"city" : "北京",
"state" : "东城"
}

综上,针对过于复杂或数据量很大的字段,我们使用引用而不是内嵌。并且,从广义上讲,这些是标准化数据模型。

$lookup

在MongoDb 3.2之前,我们需要使用DBRefs进行引用的设置。MongoDB 3.2 中增加了$lookup,而且放到了Aggregation(聚合)这种重量级的pipeline分析框架上。

MongoDB中Aggregation(聚合)主要用于处理数据(诸如统计平均值,求和等),并返回计算后的数据结果。有点类似sql语句中的 count(*)。MongoDB的聚合管道将MongoDB文档在一个管道处理完毕后将结果传递给下一个管道处理,并且管道操作是可以重复的。举例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
db.getCollection('courses').aggregate([{
$sort: { createtime: -1 },
},{
$lookup: {
from: "transcode_tasks",
as: "video",
localField: "id",
foreignField: "course_id",
},
},{
$match: {
project_id: 'f9606053-aca9-4186-880f-de1179388cbf',
'audit.status': { $in: [0, 2] },
}
}])

以上语句首先声明以createtime倒序排列,然后以courses表为基础,合并了transcode_tasks表联合查询,作为video字段,主键为id,外键为course_id,并且加入了筛选匹配了project_idaudit.status字段。MongoDB不是一个关系型数据库,但以上我们实际上使用$lookup实现了一次左连接操作。

populate

Mongoose提供了node.js中优雅的mongodb对象建模方案。MongoDB在版本> = 3.2中具有类似连接的$ lookup聚合运算符。而Mongoose有一个更强大的替代方法叫做populate(),它允许你引用其他集合中的文档。

Population(填充)是使用来自其他集合的文档自动替换文档中的指定路径的过程。我们可以填充单个文档,多个文档,普通对象,多个普通对象或从查询返回的所有对象。我们来看一些例子。

外键引用

在Schema字段的定义中,可以添加ref属性来指向另一个Schema。 该ref属性在此后被填充(populate)时将被读Mongoose取。 下面是存在互相引用的PersonStory的Schema定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const mongoose = require('mongoose'), Schema = mongoose.Schema

const personSchema = Schema({
// _id默认为Schema.Types.ObjectId类型
_id : Number,
name : String
});

const storySchema = Schema({
creator : { type: Number, ref: 'Person' },
title : String,
// 可以看到外键引用可以定义在嵌套的属性中。
fans : [{ type: Number, ref: 'Person' }]
});

const Story = mongoose.model('Story', storySchema);
const Person = mongoose.model('Person', personSchema);

外键的类型可以是ObjectId, Number, String, Buffer中任何一种,在赋值与填充时保持一致即可。

保存与填充

Story中保存Person对象的_id,此后在Query上调用.populate()即可用Person的文档来替换掉原来的字段。

1
2
3
4
5
6
7
8
9
10
const alice = new Person({ _id: 0, name: 'Alice'});
# 保存其ID即可
const story = new Story({ title: 'xx', creator: alice._id });

Story.findOne({title: 'yy'})
.populate('creator')
.exec(function(err, story){
if(err) throw err;
console.log(story.creator.name);
});

更复杂的填充方式可以参考「在mongoose中填充外键」中的介绍。

动态填充

上文中调用.populate()之前有一个条件:被填充的字段已被设置过ref选项。Mongoose会去ref指定的集合中去查找对应ID。 如果是动态字段怎么办?可以在填充的同时指定其ref

1
2
3
4
5
6
7
8
9
10
11
const studentSchema = new Schema({
_id: Number,
name: String,
teacher: Number
});
Student.
findOne({ name: 'Val' }).
populate({
path: 'teacher',
model: 'Teacher' // 在User集合中查找该ID
})

参考链接

MongoDB 关系

在mongoose中填充外键