MongoDB 特性和介绍1. 简介2. 主要特性数据模型即时查询二级索引复制写速度和持久性数据库扩展3. 核心服务器和工具核心服务器命令行数据库驱动命令行工具4. Mongo 的场景5. 局限程序编写基础1. 驱动工作原理对象 ID2. 安全写入模式(Write Concern)面向文档的数据1. Schema 设计原则2. 设计电子商务数据模型一对多:产品和分类一对多:用户与订单评论3. 具体细节数据库集合查询和聚合1. 查询常见技巧2. 常见查询语言范围查询集合操作布尔操作子文档数组Javascript 查询正则表达式类型3. 查询选项投影排序skip 和 limit4. 聚合指令更新、原子操作与删除1. 文档更新入门2. 电子商务数据模型中的更新冗余字段设计$操作符upsert操作符3. 事务性工作流4. 更多的更新命令5. 更新本质和优化
这是阅读《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
在自带的交互式命令行中:
> id = ObjectId() ObjectId("5d9413867cc8dacf9247fe3e")
对于生成的
5d9413867cc8dacf9247fe3e
:- 5d941386 ,这4个字节是时间戳,单位秒数 - 7cc8da,机器ID - cf92,进程ID - 47fe3e,计数器
2. 安全写入模式(Write Concern)
对所有的写操作(插入、更新或删除)都能开启此模式。以此保证,操作一定在数据库层面生效。
在 v4.0 中,以 insert 为例,文档如下:
db.collection.insert( <document or array of documents>, { writeConcern: <document>, ordered: <boolean> } )
关于 Write Concern 的详细参数,可以看这篇文档:https://docs.mongodb.com/manual/reference/write-concern/
其中,重要的是
w
参数,它可以指定是否使用应答写入。目前默认是 1,应答式写入。设置为 0,则是非应答式。面向文档的数据
1. Schema 设计原则
设计数据库 Schema 式根据数据库特点和应用程序需求的情况下,为数据集选择最佳表述的过程。
2. 设计电子商务数据模型
一对多:产品和分类
假设一个电商场景,要对一个商品 doc 进行设计。对于商品,它有多个分类 category,因此需要一对多操作,同时,mongo 不支持联结操作(join)。
因此解决方案是,在商品的一个字段中,保存分类指针的数组。这里的指针,就是 mongo 中的对象 ID。
下面是一个简单的例子:
> db.products.find() { "_id" : ObjectId("5d9423257cc8dacf9247fe41"), "categories" : [ ObjectId("5d9423017cc8dacf9247fe3f") ] } > db.categories.find({}) { "_id" : ObjectId("5d9423017cc8dacf9247fe3f"), "name" : "分类1" } { "_id" : ObjectId("5d9423037cc8dacf9247fe40"), "name" : "分类2" }
一对多:用户与订单
和前面的关系不同,这里的“多”体现在“订单”上。这里的订单中,保存着指向用户的指针。
评论
每个产品会有多个评论,而每个评论,可能会有点赞人列表。当要展示返回给前端的时候,需要获取产品评论,并且获取点赞人列表。
方案 1:点赞人列表,保存着由指针组成的集合。可以先查询产品评论后,再对点赞做 2 次查询。
方案 2:由于仅需要点赞人的头像和名称(少量信息),可以使用去正规化,不再保存指针,而是简单信息。
上面 2 种方案,都可以防止重复点赞的发生。
3. 具体细节
数据库
即使使用
use
切换一个新的数据库,如果没有 insert 数据,该数据库并不会创建。mongodb 会为数据、集合、索引进行空间分配,并且采取的是预分配的方式,每次空间不够的时候,扩充 2 倍。
通过
db.stats()
可以查看当前 db 的状态,下面是一个示例:> db.stats() { "db" : "info_keeper", "collections" : 3, "views" : 0, "objects" : 11, "avgObjSize" : 255.8181818181818, "dataSize" : 2814, // 数据库中BSON对象实际大小 "storageSize" : 86016, // 包含了集合增长的预留空间和未分配的已删除空间 "numExtents" : 0, "indexes" : 5, "indexSize" : 155648, // 数据库索引大小的空间 "fsUsedSize" : 86272356352, "fsTotalSize" : 250685575168, "ok" : 1 }
集合
1、重命名操作:
> use test > db.orders.renameCollection( "orders2014" )
2、固定集合
对应日志统计之类的、只有最近的数据才有价值的场景下,可以使用固定集合:一旦容量到上限,后续插入会逐步覆盖最先插入的文档。
创建时候,需要同时指定
createCollection
的 capped 和 size 参数: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 识别字段是数组类型,会自动查询字段是否位于其中。
例如:
> db.products.insert({tags: ['a', 'b']}) WriteResult({ "nInserted" : 1 }) > db.products.find({tags: 'a'}) { "_id" : ObjectId("5d948025da0946c664997712"), "tags" : [ "a", "b" ] }
如果数组中元素是负责对象,可以借助
.
运算符进行访问:> db.products.insert({address: [{name: 'home'}]}) WriteResult({ "nInserted" : 1 }) > db.products.find({"address.name": 'home'}) { "_id" : ObjectId("5d948055da0946c664997713"), "address" : [ { "name" : "home" } ] }
同样地,你也可以指定针对特定顺序的数组元素:
> db.products.find({"address.0.name": 'home'}) { "_id" : ObjectId("5d948055da0946c664997713"), "address" : [ { "name" : "home" } ] }
如果要同时将多个条件限制在同一个子文档上,下面是错误和正确的做法 👇
错误:
db.products.find({"address.name": 'home', 'address.state': 'NY'})
正确:
db.products.find({address: {$elemMatch: {name: 'home', state: 'NY'} }})
Javascript 查询
对于一些复杂查询,借助
$where
可以使用 js 表达式。还是以刚才的数据为例:> db.products.find({$where: "function() {return this.address && this.address.length}" }) { "_id" : ObjectId("5d948055da0946c664997713"), "address" : [ { "name" : "home" } ] }
在使用的时候,需要启动 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 两种售卖货物以及价钱:> db.sales.find() { "_id" : ObjectId("5d98ca8094ffea590a8a85c6"), "name" : "a", "coin" : 100 } { "_id" : ObjectId("5d98ca8694ffea590a8a85c7"), "name" : "a", "coin" : 200 } { "_id" : ObjectId("5d98ca9094ffea590a8a85c8"), "name" : "b", "coin" : 800 }
利用聚合操作,就可以便捷算出每种货物的总价:
> db.sales.aggregate([{ $group: { _id: "$name", total: { $sum: "$coin" } } }]) { "_id" : "b", "total" : 800 } { "_id" : "a", "total" : 300 }
最后说一下,聚合的意义在于数据库提供给使用者此种功能以及相关优化。当然,使用者完全可以在逻辑层面查询到需要的集合,代码中进行计算。但对于服务的提供商,完整的服务是必不可少的。
更新、原子操作与删除
1. 文档更新入门
文档更新分为:替换更新和针对性更新。相较而言,针对性更新具有性能好、传输数据少和允许原子性更新的优点。
利用
$set
和$push
可以针对文档和其中的数组字段进行针对性更新,下面是针对性更新的例子:db.products.update( { _id: 100 }, { $set: { quantity: 500 } } )
如果是替换更新,遇到增加计数器值之类的场景,在不使用乐观锁的情况下,无法保证原子性更新。因为需要先读出数据,然后再更新。此过程中,可能会有其他并发程序重写字段,从而造成脏数据。
以更新计数器的针对性更新为例:
db.products.update( { sku: "abc123" }, { $inc: { quantity: -1 } } )
2. 电子商务数据模型中的更新
冗余字段设计
对于一些常见的结果,比如:总数、平均值等。为了避免每次都重新聚合运算,可以在文档中保存额外的字段缓存相关数据。
之后的业务查询,仅仅需要查询一次即可。
$
操作符
作用:确定数组中一个要被更新的元素的位置,而不用具体指定该元素在数组中的位置。
如下所示,不需要知道在 grades 数组中匹配的具体位置,用
$
指代即可:> db.students.insert([ { "_id" : 1, "grades" : [ 85, 80, 80 ] } ]) > db.students.updateOne( { _id: 1, grades: 80 }, { $set: { "grades.$" : 82 } } )
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
- 改变文档造成空间不够,全部整体迁移到新空间:提前利用填充因子来减少影响