非关系型数据库中的「关系」实现

这两三年来,伴随着大数据(Big Data)的空前火热,无论是在工程界还是科研界,非关系型数据库(NoSQL)都已经成为了一个热门话题。

相比于传统的关系型数据库,非关系型数据库天生从理念上就给数据存储提供了一种新的思路。而在实际应用中,它往往更轻巧灵活、扩展性高,并且更能胜任高性能、大数据量的场景。

值得一提的是,NoSQL并不是 “No SQL” 的意思,而是 “Not Only SQL” 的简写。

尽管非关系型数据库没有关系型数据库中很多预定义的死板模式的限制,但自然数据间总是充满联系的,所以在数据库中我们势必需要抽象出这种数据之间的联系。

本文就个人实践经验,总结一下 NoSQL 数据库中表现数据关系的常见办法,并且结合一个实践项目来举例说明(样例为 Node.js 项目,使用常用的文档型数据库 MongoDB(Mongoose 来操作)来举例)。

方法如下:

1. 嵌套

得益于非关系型数据库的灵活数据类型,我们可以直接将 Schema A 中的某个属性设置为「数组」类型,用以存储所有与它有 1:N 关系的其他数据对象。

举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var commentSchema = new Schema({
time: {
type: Date,
default: new Date()
},
content: String
});

var messageSchema = new Schema({
content: String,
time: {
type: Date,
default: new Date()
},
comments: {
type: [commentSchema],
default: []
}
});

在上例中,一个 message 文档可能包含很多 comment 文档,所以在 messageSchemacomments 属性中用一个数组来存储某个 message 的所有 comment

值得注意的是,这里 commentSchema 并不实际对应一个数据集合,它只用于在这里帮助定义 messageSchema

相比于下面要讲到的引用的办法,这个方法适合于查询频繁(少了引用查询)、有强逻辑联系的 1:N 关系(即每次显示 A 文档都需要显示众多 B 文档)、且 B 文档改动较少(毕竟嵌套操作相对复杂一些)的场景。

这种方法也是 NoSQL 数据库相比于传统的关系型数据库的优势所在。

2. 引用

NoSQL 数据库中并不存在传统关系型数据中类似于 join 的方法,所以这使得我们的复杂查询可能会变得相对困难,好在很多封装好的数据库包提供了很多便利。

引用方法又可分为两种:

Ⅰ. 手动引用

手动引用很简单,就是在 Schema A 中定义一个 Schema B 中唯一(unique)的属性(一般为_id),每次当查询 A 后又需要查询 B 时,需要自己根据 A 中定义的 _id 值手动去查询 B 的完整数据。

方法简单,不再举例赘述。

不过,在实践中唯一值得注意的是:A 中定义的与 B 相关的属性应该不具备业务语义,且基本不会被改动,否则当你对 B 中的相应属性进行改动的时候,所有引用此 B 文档的 A 文档,都需要对定义的引用属性进行更新,这是绝对需要避免的!这也是为什么一般引用 _id 的原因(一般在生命期内都不会被业务需求改变)。

Ⅱ. 自动引用

自动引用是借助于类似关系型数据库中定义的 Reference key 或 Foreign key 进行预先的引用定义。在查询时,数据库可以根据事先定义的「引用键」进行解引用,找到引用到的另一个集合中的文档。

在有一些封装好的数据库操作包中,可以实现自动解引用的功能,即凡是检测到引用键就自动的去查询对应的文档进而解引用。不过即便不是自动解引用,手动解引用也会花费开销进行查询,这也是为什么使用引用查询次数会更多的原因。试想如果对于「嵌套」方法中的样例,每次都进行自动解引用,那么在嵌套方法中可能进行的 1 次查询,在这里可能就需要 N+1 次了(N 为 messagecomments 数组的长度)。

样例如下:

在 user.js 中定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var userSchema = new Schema({
name: {
type: String,
unique: true
},
password: {
type: String,
required: true
},
email: {
type: String,
required: true
},
intro: {
type: String,
required: true
}
});

在 msgboard.js 中定义:

1
2
3
4
5
6
7
8
9
10
11
var messageSchema = new Schema({
user_id: {
type: mongoose.Schema.ObjectId,
ref: 'User'
},
content: String,
time: {
type: Date,
default: new Date()
}
});

这里,我们在 messageSchema 中定义了一个引用键 user_id 引用到 userSchema_id 字段。
注意:MongoDB 会自动为文档创建唯一的 _id 字段!

如此,便在 Schema 层次上定义好了引用。具体在查询时,我们可以根据具体使用的包的特性来决定如何进行解引用的操作。

在 Mongoose 里,可以使用 populate 方法。详细的使用方法可以参考 Mongoose API 文档,这里仅给出一个样例:

1
2
3
4
5
6
7
8
9
Message.find(query)
.populate('user_id', 'name')
.skip((page - 1) * NUM_EACH_PAGE)
.limit(NUM_EACH_PAGE)
.sort({time: -1}).exec()
.then(function (messages) {
// do something with messages
console.log(messages);
}

这里用 Promise(异步流程控制方式的一种) 链式操作的方进行对 messages 进行查询,同时 skiplimit 用于翻页。

重点可关注 populate 方法,我们在这里获取了引用到的 user 文档 name 字段的值。

对于自动引用方式而言,由于在同等数据量的情况下查询次数一般要多,所以适用于查询不大频繁、具有相对更弱逻辑性的数据关系之间(不是 A 出现 B 一定需要出现的关系),而且用它既定义了数据之间的关系,也方便对数据进行各种 CURD 操作(没有嵌套或少嵌套了)。

注:本文在 NoSQL 数据库中使用关系型数据库中的「字段」的概念,实际是表示 NoSQL 数据库文档中的属性。