MongoDB相关分享

MongoDB相关分享

以班车系统为例

文档型数据库MongoDB

先简单介绍下文档型数据库是什么。

面向文档的数据库(英语:Document-oriented database)或文档储存,是一种被设计用于储存、检索和管理文档导向信息(也称为“半结构化数据”)的计算机程序。文档导向的数据库是 NoSQL 数据库的一个主要类别,文档导向的数据库的普及程度已经随着 NoSQL 本身被不断使用而有所增长。 ——维基百科

文档型数据库与传统的关系型数据库存在着差异。

关系数据库通常将数据存储在相互独立的表格中,这些表格由程序开发者定义,单独一个的对象可以散布在若干表格中。 对于数据库中某单一实例中的一个给定对象,文档数据库存储其所有信息,并且每一个被存储的对象可与任一其它对象不同。这使得将对象映射入数据库简单化,并通常会消除任何类似于对象关系映射的事物。这也使得文档数据库对网络应用有较大价值,因为后者的数据处在不断变化中,而且对于后者来说,部署速度是一个重要问题。

关系型数据库存在ACID原则,即:

  • 原子性(Atomic)

要么全做,要么全不做

  • 一致性(Consistency)

数据在各个表中是一致的

  • 隔离性(Identity)

两个对统一数据表操作时,通过锁机制防止重复写脏数据。

  • 持久性(Consistency)

数据持久化,即数据最终是以文件的形式存在于文件系统中,不会由于系统崩溃或是突然断电等外部因素引发数据丢失。

文档型数据库主要有五个特点:

  • 数据模型丰富

逻辑上是将对象按照文档模型来存储,同时支持了数组以及文档内嵌。文档的键不会事先定义,也不会固定不变,这就使得在需求经常变更情况下做修改更为便利,数据模型变更也更方便。

  • 容易扩展

MongoDB支持主备切换,可以配置自动在多台服务器之间分割数据,以及进行主从复制,备份数据。它还可以平衡集群的数据和负载,自动重排文档。

  • 功能丰富

支持索引、JavaScript函数、数据聚合功能等。文件存储是通过BSON格式,没有关系型数据库中常见联接(join)和事务。

  • 不牺牲速度

MongoDB使用MongoDB传输协议作为与服务器交互的主要方式(与之对应的协议,像是HTTP/REST,需要更多的开销)。它对文档进行动态填充,预分配数据文件,用空间换取性能的文档。默认的存储引擎中使用了内存映射文件,将内存管理工作交给操作系统去处理。动态查询优化器会“记住”执行查询最高效的方式。总之,MongoDB在各方面都充分考虑了性能。

  • 管理简便

MongoDB尽量让服务器自治来简化数据库的管理。除了启动数据库服务器之外,几乎没有什么必要的管理操作。如果主服务器挂掉了,MongoDB会自动切换到备份服务器上,并且将备份服务器提升为活跃服务器。在分布式环境下,集群只需要知道有新增加的节点,就会自动集成和配置新节点

与关系型数据库范式设计上的区别

一对一关系

一对一关系
与关系型数据库中的表示方式基本一致,在一个对象中新增对另一个对象的引用,通常是通过另一个对象的ID来表示。在这个关系上,两种数据库的设计模式一致。

一对多关系

与上述的关系基本一致,同样是和关系型数据库类似,在“多”对应的一方的文档上添加对“一”那一方的引用。

多对多关系

多对多关系
在关系型数据库的情况下,如果存在多对多关系,多对多关系本身会被单拿出来,由一个新的关系表来处理,每条记录代表一个一对一单向关系。由于MongoDB支持数组,因此不需要像关系型数据库那样新增一个关系表,来表示多对多关系。在对引用进行操作的情况(增加、修改)不多的情况下,可以使用双向嵌套,即是在两个文档上分别增加对对方的引用(通常是ID,也可以是内嵌)。

在需要对数据读取进行优化的场合(或者是对引用的操作不多的情况下),可以使用单向嵌套的方式,通常是属性相对较少的文档作为内嵌子文档。

举个栗子

班车系统E-R图
项目涉及到的主要是用户、班车、站点、活动类型这4个实体,下面将逐个一一介绍:

用户类型

1
2
3
4
5
6
const ActivityTypeSchema = new Schema({
name: {
type: String,
unique: true,
},
});

此处描述的是仅有一个唯一键的字段name。由于MongoDB本身自带id属性,所以可以不考虑自己添加id字段。

站点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const StationSchema = new Schema({
comment: String,
name: {
type: String,
trim: true,
index: true,
required: true,
},
longitude: {
type: Number,
required: true,
},
latitude: {
type: Number,
required: true,
},
bus: [Schema.Types.ObjectId],
});

StationSchema.virtual('longlat').get(function() {
return `${this.longitude}${sperator}${this.latitude}`;
});

StationSchema.virtual('longlat').set(function(value) {
var parts = value.split(sperator);
if(parts.length === 2) {
this.longitude = parts[0];
this.latitude = parts[1];
}
});

这里有四个属性,同时还有一个虚拟属性longlat,用于更方便的同时读取以及写入经纬度的两个属性。bus为一个数组,表示的是站点所属的班车线路,内部存放的是bus的id。

用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const UserSchema = new Schema({
account: {
type: String,
},
name: {
type: String,
trim: true,
required: true,
},
email: String,
phone: {
type: String,
},
residence: StationSchema,
familyMemberNums: [{
bus: Schema.Types.ObjectId,
familyMemberNum: {
type: Number,
min: 0,
default: 0,
}
}],
bus: [{
station: StationSchema,
frequency: {
type: Number,
min: 1,
},
commutingType: String,
// 班次
bus: Schema.Types.ObjectId,
}],
});

residence其中内嵌了一个站点的子文档,familyMemberNums表示某位用户在某个线路上带家属的个数,bus也是一个数组,包括了上车的站点以及所在的班车路线。

班车

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
const BusSchema = new Schema({
// 编号
identifier: {
type: String,
trim: true,
required: isCommutingBus,
},
activityType: {
type: Schema.Types.ObjectId,
ref: 'ActivityType',
},
enrollStartTime: Date,
enrollEndTime: {
type: Date,
validate: function(value) {
return value >= this.enrollStartTime;
},
},
route: [StationSchema],
users: [UserSchema],
// 通勤类型,早/晚班车
commutingType: {
required: isCommutingBus,
type: String,
trim: true,
enum: ['morning', 'evening'],
},
topLimit: {
required: function(value) {
// 类型为通勤时必填
return this.commutingType;
},
type: Number,
min: 0,
default: 2000,
},
departureTime: {
type: Date,
required: isCommutingBus,
},
licenceNum: {
required: isCommutingBus,
type: String,
trim: true,
unique: true,
sparse: true,
},
});

BusSchema.virtual('enrollPeriod').get(function() {
const startTime = getFormattedMoment(this.enrollStartTime);
const endTime = getFormattedMoment(this.enrollEndTime);
return `${startTime}${sperator}${endTime}`;
});

BusSchema.virtual('enrollPeriod').set(function(value) {
var parts = value.split(seprator);
if(parts.length === 2) {
this.enrollStartTime = parts[0];
this.enrollEndTime = parts[1];
}
});

BusSchema.virtual('enrollCount').get(function(value) {
if(this.users) {
const id = this.id;
const familys = compact(this.users.map(user => {
const familyMemberNumObj = find(user.familyMemberNums, (num) => num.bus.equals(id));
return familyMemberNumObj && familyMemberNumObj.familyMemberNum;
}))
const familyMemberNum = (familys.length && familys.reduce((prev, next) => prev + next)) || 0;
return this.users.length + familyMemberNum;
}
});

acitivityType为线路所属的活动类型id,体现的是活动类型对班车的一对多关系。而route和users两个数组则分别体现了与站点以及用户的多对多关系。enrollCount则是获取到报名人以及所带家属的人数。

在框架中的组织模式

Schema

这是一种以文件形式存储的数据库模型骨架,不具备数据库的操作能力。
像是上面的这些都是Schema的主要内容,在模块中只要导出Schema对象以便在之后获取model对象并集成就好。

获取model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const db = Object.create(null);

[ActivityTypeSchema, StationSchema, UserSchema, BusSchema, AnnouncementSchema, FeedbackSchema].map(schema => {
schema.virtual('id').get(function() {
return this._id.toHexString();
});
schema.set('toObject', {
virtuals: true,
});
schema.set('toJSON', {
virtuals: true,
});
});

db.mongoose = mongoose;
db.Schema = mongoose.Schema;
db.Announcement = mongoose.model('Announcement', AnnouncementSchema);
db.Feedback = mongoose.model('Feedback', FeedbackSchema);
db.ActivityType = mongoose.model('ActivityType', ActivityTypeSchema);
db.Station = mongoose.model('Station', StationSchema);
db.Bus = mongoose.model('Bus', BusSchema);
db.User = mongoose.model('User', UserSchema);

module.exports = db;

mongoose.model获取的是由Schema发布生成的模型,具有抽象属性和行为的数据库操作。 可以用new 关键字model的方式创建实体(Entitiy)

建立数据库连接

1
2
3
4
5
// mongoose support
mongoose.connect(dbConfig.uri, dbConfig.option);
mongoose.Promise = global.Promise;
const db = mongoose.connection;
db.on('error', console.error.bind(console, 'MongoDB connection error:'));

在app.js中连接数据库,并用ES6的Promise替换mongoose中原有的A+的Promise实现。最后,监听报错信息,若有报错则打印错误。

Mongoose的一些坑

虚拟属性

虚拟属性本身算是mongoose自带的特性,MongoDB中并没有对应的支持,对注册虚拟属性的文档本身的属性读取十分方便,但在子文档中就不是这样了。在github上的issue中似乎提到已经fix这个问题但在5.0.2rc下似乎仍存在这个问题。像是上面提及的id以及enrollCount这样的虚拟属性,在子文档中就无法获取到。

无事务支持

MongoDB这种NoSQL对数据一致性的支持不大好,主要表现在不支持关系型数据库中常见的事务功能。在使用mongoose时,很可能发生报错位置上的Promise中的then部分已经执行了,并且在报错后不会回滚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
enroll(req, resp, next) {
const userName = req.session.user.userName;
const getBus = db.Bus.findById(req.body.busId);
getBus.then(bus => {
if(bus.activityType) {
return Promise.all([db.Bus.find({
activityType: bus.activityType,
}), bus]);
}else {
// 报名通勤班车的情况
}
}).then(([buses, targetBus]) => {
const getUser = db.User.findOne({ account: userName});
const getStation= db.Station.findById(req.body.stationId);
if(findIndex(buses, bus => findIndex(bus.users, ['account', userName]) >=0) >=0) {
resp.failed('您已在同活动类型下的其它班次报名过!请先取消报名');
}else if(req.body.stationId){
return Promise.all([getUser, getStation, targetBus]);
}else {
resp.failed('报名站点是必须的!');
}
}).then(([user, station, bus]) => {
if(bus && user && station && bus.enrollCount < bus.topLimit) {
// user.bus里没有当前bus,bus.users也没有当前用户
if(findIndex(user.bus, enrolledBus => enrolledBus.bus.equals(bus.id)) < 0 && findIndex(bus.users, ['account', userName] < 0)) {
user.bus.push({
station: station,
bus: bus.id,
});
user.familyMemberNums.push({ bus: bus.id, familyMemberNum: req.body.familyMemberNum });
bus.users.push(user);
return Promise.all([user.save(), bus.save()]);
}else {
resp.failed('报名失败,可能已经报名过');
}
}else {
resp.failed('当前报名人数已满');
}
}).then(() => {
resp.success(SUCCESS);
}).catch(next);
},

这里把之前Promise中获取到的targetBus对象,在最后一个Promise中和用户对象一起储存。而若是bus对象在之前的Promise中进行保存,那么假如user对象保存这一Promise失败的话,bus对象也已在之前的Promise中保存进数据库了,并且不能回退。把user和bus这样的有对数据库内容进行操作的步骤集中在一个Promise来完成,这样在某个Promise失败时也能确保另一个对数据库操作没有成功执行,在编码上实现了回退功能。

数据聚合

实际上是MongoDB自带的高级特性,mongoose中只是简单的封装了,在计算分页时比较方便,但是享受不到mongoose中虚拟属性的支持。

查询

  1. ID判等的时候使用equals方法。
    enrolledBus.bus.equals(bus.id))
  2. 使用findById方法时必须传入id(id 不能为undefined),而在使用find方法通过id进行检索时,可以是undefined。

唯一键

设置属性unique:true时,添加sparse:true来防止空值重复。像是上面提到的班车线路的model中licenseNum车牌号属性,在设置了唯一键的情况、但不设置sparse:true的情况,第一个没有licenseNum的班车线路可以正常保存,但在插入第二个没有车牌号的班车路线对象时则会提示,licenseNum为null重复。在加了sparse:true后无此问题。

在公司网络环境下MongoDB的一些坑

版本

公司稳定版本3.4.2。奇数号版本是开发版本(比如3.5)。版本号不符合可能会收到来自安全产品部同事的请喝茶( ^‐^)_~~

绑定ip配置

默认bindIP设置为127.0.0.1,即仅允许来自本地的数据库连接,web应用与数据库不在同一主机上的话,可以修改bindIP为0.0.0.0,即允许来自任何ip的数据库连接请求。

用户认证配置

先将需要连接数据库用户在需要操作的数据库上创建,并赋予读写权限。

1
2
Authentication: 
enable: true

然后在配置文件中添加以上认证配置。之后即可通过用户密码建立对数据库的连接。