这是阅读《MongoDB 实战》所做的,关于基础、编码和优化方面的读书笔记。
MongoDB 特性和介绍
1. 简介
MongoDB 的特点:扩展策略、直观的数据模型。在 mongodb 中,编程语言定义的对象能被“原封不变”地持久化,消除对象结构和程序映射的复杂性。
2. 主要特性
数据模型
关系型与正规化:对于关系型数据库,数据表本质上是扁平的,因此表示多个一对多关系就需要多张表。经常用到的技术是拆表,这种技术是正规化。
但对于 Mongo 来说,文档支持嵌套等多种格式,无需事先定义Schema。
即时查询
mysql 和 mongodb 都支持即时查询,不同的是:前者依赖正则化的模型;后者假定查询字段是存储与文档中的。
二级索引
mongodb 支持二级索引,是通过 b-tree 实现的。
复制
通过副本集的拓扑结构来提供复制功能,其目的是:提供数据的冗余。
副本集的主节点能接受读写操作,但从节点是只读的。主节点出问题,会自动故障转移,选取一个从节点升级为主节点。
写速度和持久性
写速度:给定时间内,数据库可以处理的插入、更新和删除操作的数量。
持久性:数据库保持上述写操作的结果不改变的,所用的时间长短。
DB 领域,写速度和持久性存在一种相反关系。很好理解,例如 memcached,直接写入内存,写速度非常快,但同时数据完全易失。
mongodb 的写操作,默认是fire-and-forget:通过 TCP 发送写操作,不要求数据库应答。用户可以开启安全模式,保证写操作正确无误写入 db。并且安全模式可以配置,用于阻塞操作。
对于高容量、低价值数据(点击流、日志),默认模式更优;对于重要数据,倾向于安全模式。
mongo 中,Journaling 日志默认开启。所有写操作会被提交到一个只能追加的日志中。以应对故障后的,重启修复服务。
数据库扩展
- 垂直扩展(向上扩展):升级硬件,来提高单点性能
- 水平扩展(向外扩展):将数据库分布到多台机器,是基于自动分片。其中,单独的分片由一个副本集组成,至少有 2 个节点,保证没有单点失败。
3. 核心服务器和工具
核心服务器
通过mongod
可以运行核心服务器。数据文件存储在/data/db
中。如果下载编译 mongo 的源代码,需要手动创建/data/db
,并且为其分配权限。
其中,mongo 的内存管理是由操作系统来处理的。数据文件通过mmap()
系统 API,映射成系统的虚拟内存。
命令行
是基于 JavaScript 编写的。所以能看到很多通用的语法,以及输出的格式。
数据库驱动
针对多个语言,都提供了驱动使用。并且风格几乎保持统一的 API 接口。
命令行工具
安装到 MaxOS 后,全局会多出以下命令:
mongodump
和mongorestore
:前者用BSON
格式,来备份数据库数据。方便后者恢复。mongoexport
和mongoimport
:导入导出 JSON、CSV 和 TSV 格式数据。
4. Mongo 的场景
适用于事先无法知晓数据结构的数据,或者数据结构经常不确定性较大的数据。
除此之外,还适用于与分析相关的场景。mongo 提供一种固定集合,常用于日志,特点是分配的大小固定,类似于循环队列。
5. 局限
由于使用内存映射,32 位系统只能对 4GB 内存寻址。一半内存被 os 占用,那么只有 2GB 能用来做映射文件。所以,必须部署在 64 位操作系统上。
程序编写基础
mongo 驱动的 find 方法,返回的是游标对象,可以理解为迭代器的下标。在 NodeJS 中,它的名字和类型是Cursor
。
在 Nodejs 中,
1. 驱动工作原理
主要有 3 个功能:
- 生成 MongoDB 对象的 ID,它是存储在
_id
字段中的默认值 - 驱动会把特定语言的文档表述,和
BSON
互换 - 使用 TCP 套接字与数据库通信
对象 ID
在自带的交互式命令行中:
1 | > id = ObjectId() |
对于生成的5d9413867cc8dacf9247fe3e
:
1 | - 5d941386 ,这4个字节是时间戳,单位秒数 |
2. 安全写入模式(Write Concern)
对所有的写操作(插入、更新或删除)都能开启此模式。以此保证,操作一定在数据库层面生效。
在 v4.0 中,以 insert 为例,文档如下:
1 | db.collection.insert( |
关于 Write Concern 的详细参数,可以看这篇文档:https://docs.mongodb.com/manual/reference/write-concern/
其中,重要的是w
参数,它可以指定是否使用应答写入。目前默认是 1,应答式写入。设置为 0,则是非应答式。
面向文档的数据
1. Schema 设计原则
设计数据库 Schema 式根据数据库特点和应用程序需求的情况下,为数据集选择最佳表述的过程。
2. 设计电子商务数据模型
一对多:产品和分类
假设一个电商场景,要对一个商品 doc 进行设计。对于商品,它有多个分类 category,因此需要一对多操作,同时,mongo 不支持联结操作(join)。
因此解决方案是,在商品的一个字段中,保存分类指针的数组。这里的指针,就是 mongo 中的对象 ID。
下面是一个简单的例子:
1 | > db.products.find() |
一对多:用户与订单
和前面的关系不同,这里的“多”体现在“订单”上。这里的订单中,保存着指向用户的指针。
评论
每个产品会有多个评论,而每个评论,可能会有点赞人列表。当要展示返回给前端的时候,需要获取产品评论,并且获取点赞人列表。
方案 1:点赞人列表,保存着由指针组成的集合。可以先查询产品评论后,再对点赞做 2 次查询。
方案 2:由于仅需要点赞人的头像和名称(少量信息),可以使用去正规化,不再保存指针,而是简单信息。
上面 2 种方案,都可以防止重复点赞的发生。
3. 具体细节
数据库
即使使用use
切换一个新的数据库,如果没有 insert 数据,该数据库并不会创建。
mongodb 会为数据、集合、索引进行空间分配,并且采取的是预分配的方式,每次空间不够的时候,扩充 2 倍。
通过 db.stats()
可以查看当前 db 的状态,下面是一个示例:
1 | > db.stats() |
集合
1、重命名操作:
1 | > use test |
2、固定集合
对应日志统计之类的、只有最近的数据才有价值的场景下,可以使用固定集合:一旦容量到上限,后续插入会逐步覆盖最先插入的文档。
创建时候,需要同时指定createCollection
的 capped 和 size 参数:
1 | db.createCollection('logs',{ capped : true, size : 5242880 }) |
为了性能优化,mongo不会为固定集合创建针对**_id
**的索引。同时,不能从中删除 doc,也不能执行任何更改文档大小的更新操作。
3、键名选择
慎重选择键名,例如,用dob
代替date_of_birth
,一个文档可以省下 10 字节。
查询和聚合
1. 查询常见技巧
分页查询可以通过skip
和limit
配合使用实现。
空值查询可以通过驱动的空值字面量实现,比如在 node 中,想查询logs
中不包含name
字段的记录:db.logs.find({ name: null })
。
减少序列化和网络传输,可以通过给定 find 的第二个参数,来选定数据库返回给驱动的文档的字段,比如:db.products.find({}, {_id: 1})
。这条命令,只返回文档的_id
字段。
复合索引,复合索引的设定,遵循着「从准确到宽泛」的规则。比如对于订单记录,有着下单人和时间 2 个字段。应该先为下单人字段设置索引,再为时间字段设置索引。可以理解为前者是精确查找,可以大大缩小查找结果集;后者是范围查找。
嵌套字段查询,对于负责对象字段的查询,直接通过.
运算符即可。例如:db.demos.find({a: {b : 1}})
和db.demos.find({"a.b": 1})
是等效的。
2. 常见查询语言
MongoDB 的查询本质:实例化了一个游标,并获取它的结果集。
范围查询
范围操作符用法很简单,但注意:不要在范围查找时候误用重复搜索键。
错误:db.users.find({age: { $gte: 0 }, age: { $lte: 30 } })
正确:db.users.find({age: {$gte: 0, $lte: 30}})
集合操作
集合操作符一共有 3 个:$in
、$all
、$nin
。
in 和 nin 是一对,in 相当于使用多个 OR 操作符:db.products.find({'tags': {$in: [ObjectId('...'), ObjectId('...')]}})
all 的作用属性,必须是数组形式:db.products.find({tags: {$all: ['a', 'b']}})
⚠️ 注意:in 和 all 可以利用索引;nin 不能利用索引,只能使用集合扫描。这和 BTree 结构有关。
布尔操作
常见的有:$ne
、$not
、 $or
、 $and
、$exists
。同样的,$ne
不能利用索引。
对于 not 的使用,如果使用的操作符或者正则表达式不存在否定形式,才配合 not。例如大于,就有小于等于操作符。
对与 or 的使用,or 可以表示不同键的值的关系,而 in 只能表示一个键的值的关系。例如:db.products.find({ $or: [{ name: 'a' }, { name: 'b' }] })
子文档
对于内嵌对象匹配,用.
运算符即可,正如前面的嵌套字段查询所述。
不推荐对于整个对象的查询,需要严格保证查询字段的顺序。
数组
如果数组中元素是基础对象,那么直接查询即可。mongo 识别字段是数组类型,会自动查询字段是否位于其中。
例如:
1 | > db.products.insert({tags: ['a', 'b']}) |
如果数组中元素是负责对象,可以借助.
运算符进行访问:
1 | > db.products.insert({address: [{name: 'home'}]}) |
同样地,你也可以指定针对特定顺序的数组元素:
1 | > db.products.find({"address.0.name": 'home'}) |
如果要同时将多个条件限制在同一个子文档上,下面是错误和正确的做法 👇
错误:db.products.find({"address.name": 'home', 'address.state': 'NY'})
正确:db.products.find({address: {$elemMatch: {name: 'home', state: 'NY'} }})
Javascript 查询
对于一些复杂查询,借助$where
可以使用 js 表达式。还是以刚才的数据为例:
1 | > db.products.find({$where: "function() {return this.address && this.address.length}" }) |
在使用的时候,需要启动 js 解释器和上下文,因此开销大。在使用的时候,尽量带上其他标准查询操作,来缩小查询范围。
除此之外,还有注入攻击的可能。主要体现在驱动使用时候,如果后端传给 db 的字段是没做检验的,可能发生注入攻击。
正则表达式
主要体现在驱动使用上。
如果支持 js 的正则,那么可以: find({text: /best/i})
如果不支持,那么:find({text: {$regex: 'best', $options: 'i'}})
类型
通过$type
,可以根据指定字段类型进行查询。不同的值,代表不同的类型。请见官方文档。
3. 查询选项
投影
1、使用选择字段进行返回,降低网络传输:find
给定第二个参数。
2、返回保存在结果数组中的某个范围的值:$slice([start, limit])
。例如:db.products.find({}, { comments: {$slice: 12}})
排序
能够对多个字段进行升序/降序排列。例如:db.comments.find().sort({rating: -1, votes: -1})
skip 和 limit
如果向 skip 传入很大的值,需要扫描同等数量的文档,浪费资源。
最好的方法是:通过查询条件,缩小要扫描的文档。
4. 聚合指令
在 v2 的版本中,mongo 只能通过 map、reduce 等基础操作来支持聚合搜索。但在 v3 的版本后,mongo 本身提供了丰富的聚合阶段(aggregation pipeline)和聚合运算符(aggregation operator)。
以$group
和$sum
为例,插入了 a 和 b 两种售卖货物以及价钱:
1 | > db.sales.find() |
利用聚合操作,就可以便捷算出每种货物的总价:
1 | > db.sales.aggregate([{ $group: { _id: "$name", total: { $sum: "$coin" } } }]) |
最后说一下,聚合的意义在于数据库提供给使用者此种功能以及相关优化。当然,使用者完全可以在逻辑层面查询到需要的集合,代码中进行计算。但对于服务的提供商,完整的服务是必不可少的。
更新、原子操作与删除
1. 文档更新入门
文档更新分为:替换更新和针对性更新。相较而言,针对性更新具有性能好、传输数据少和允许原子性更新的优点。
利用$set
和$push
可以针对文档和其中的数组字段进行针对性更新,下面是针对性更新的例子:
1 | db.products.update( |
如果是替换更新,遇到增加计数器值之类的场景,在不使用乐观锁的情况下,无法保证原子性更新。因为需要先读出数据,然后再更新。此过程中,可能会有其他并发程序重写字段,从而造成脏数据。
以更新计数器的针对性更新为例:
1 | db.products.update( |
2. 电子商务数据模型中的更新
冗余字段设计
对于一些常见的结果,比如:总数、平均值等。为了避免每次都重新聚合运算,可以在文档中保存额外的字段缓存相关数据。
之后的业务查询,仅仅需要查询一次即可。
$
操作符
作用:确定数组中一个要被更新的元素的位置,而不用具体指定该元素在数组中的位置。
如下所示,不需要知道在 grades 数组中匹配的具体位置,用$
指代即可:
1 | > db.students.insert([ |
upsert
操作符
作用:如果不存在,则会自动insert
。
对于添加到商品到购物车等场景,非常适用。
3. 事务性工作流
这里主要使用的是findAndModify
命令。这个命令,支持传入 query 参数,来做匹配筛选;支持 update,来做针对性更新(原子更新)。最重要的特性是:可以根据**new
**参数,来返回更新前后的文档数据状态。
借助可以返回更新文档数据的特性,可以 mock 一下 mongo 4.0 之前不支持的事务特性。思路是:
- 获取最初的文档数据
- 利用 findAndModify 进行针对性更新,更新字段中需要携带本次的更新标示(比如时间戳)。findAndModify 操作符回返回更新后的字段。
- 将更新后的字段中的更新标示与本地保存的标示做对比,如果不相同,说明有别的端更新了数据,数据发生了污染,为了保证事务原子性的特点,将文档恢复为第 1 步获得原始数据;如果相同,那么继续进行。
在 MongoDB 4.0 中,就是通过类似第二步的思路,提供了一个 seesionID 来实现了事务的,保证了事务特性。
4. 更多的更新命令
update:multi 参数不给,默认只更新匹配到的第一个文档。
unset:删除文档中的指定键。
rename:重命名键。
addToSet:数组中不存在时候,才会加入。
pull:删除数组指定位置的元素。
5. 更新本质和优化
更新分为 3 种:
- 只改变单值,但 BSON 文档不变:
$inc
操作符 - 改变文档和结构,会重写整个文档:
$push
- 改变文档造成空间不够,全部整体迁移到新空间:提前利用填充因子来减少影响