《MongoDB实战》读书笔记

在国庆先后看了《MongoDB实战》,结合上半年工做中的云数据库的工做和本身使用mongo的一些的经验,作一下总结。javascript

本文来自「心谭博客」《基础、编码和优化》《进阶:索引、复制和分片》,更多文章放在了Github仓库欢迎Starhtml

MongoDB特性和介绍

1. 简介

MongoDB的特色:扩展策略、直观的数据模型。在mongodb中,编程语言定义的对象能被“原封不变”地持久化,消除对象结构和程序映射的复杂性。前端

2. 主要特性

数据模型

关系型与正规化:对于关系型数据库,数据表本质上是扁平的,所以表示多个一对多关系就须要多张表。常常用到的技术是拆表,这种技术是正规化java

但对于Mongo来讲,文档支持嵌套等多种格式,无需事先定义Schemanode

即时查询

mysql和mongodb都支持即时查询,不一样的是:前者依赖正则化的模型;后者假定查询字段是存储与文档中的。mysql

二级索引

mongodb支持二级索引,是经过b-tree实现的。git

复制

经过副本集的拓扑结构来提供复制功能,其目的是:提供数据的冗余github

副本集的主节点能接受读写操做,但从节点是只读的。主节点出问题,会自动故障转移,选取一个从节点升级为主节点。正则表达式

写速度和持久性

写速度:给定时间内,数据库能够处理的插入、更新和删除操做的数量。sql

持久性:数据库保持上述写操做的结果不改变的,所用的时间长短。

DB领域,写速度和持久性存在一种相反关系。很好理解,例如memcached,直接写入内存,写速度很是快,但同时数据彻底易失。

mongodb的写操做,默认是fire-and-forget:经过TCP发送写操做,不要求数据库应答。用户能够开启安全模式,保证写操做正确无误写入db。而且安全模式能够配置,用于阻塞操做

对于高容量、低价值数据(点击流、日志),默认模式更优;对于重要数据,倾向于安全模式。

mongo中,Journaling日志默认开启。全部写操做会被提交到一个只能追加的日志中。以应对故障后的,重启修复服务。

数据库扩展

  • 垂直扩展(向上扩展):升级硬件,来提升单点性能
  • 水平扩展(向外扩展):将数据库分布到多台机器,是基于自动分片。其中,单独的分片由一个副本集组成,至少有2个节点,保证没有单点失败。

3. 核心服务器和工具

核心服务器

经过mongod能够运行核心服务器。数据文件存储在/data/db中。若是下载编译mongo的源代码,须要手动建立/data/db,而且为其分配权限。

其中,mongo的内存管理是由操做系统来处理的。数据文件经过mmap()系统API,映射成系统的虚拟内存。

命令行

是基于JavaScript编写的。因此能看到不少通用的语法,以及输出的格式。

数据库驱动

针对多个语言,都提供了驱动使用。而且风格几乎保持统一的API接口。

命令行工具

安装到MaxOS后,全局会多出如下命令:

  • mongodumpmongorestore:前者用BSON格式,来备份数据库数据。方便后者恢复。
  • mongoexportmongoimport:导入导出JSON、CSV和TSV格式数据。

4. Mongo的场景

适用于事先没法知晓数据结构的数据,或者数据结构常常不肯定性较大的数据。

除此以外,还适用于与分析相关的场景。mongo提供一种固定集合,经常使用于日志,特色是分配的大小固定,相似于循环队列。

5. 局限

因为使用内存映射,32位系统只能对4GB内存寻址。一半内存被os占用,那么只有2GB能用来作映射文件。因此,必须部署在64位操做系统上

程序编写基础

mongo驱动的find方法,返回的是游标对象,能够理解为迭代器的下标。在NodeJS中,它的名字和类型是Cursor

在Nodejs中,

1. 驱动工做原理

主要有3个功能:

  1. 生成MongoDB对象的ID,它是存储在_id字段中的默认值
  2. 驱动会把特定语言的文档表述,和BSON互换
  3. 使用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的详细参数,能够看这篇文档:docs.mongodb.com/manual/refe…

其中,重要的是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
}
复制代码

集合

一、重命名操做:

> use test
> db.orders.renameCollection( "orders2014" )
复制代码

二、固定集合

对应日志统计之类的、只有最近的数据才有价值的场景下,可使用固定集合:一旦容量到上限,后续插入会逐步覆盖最早插入的文档。

建立时候,须要同时指定createCollection的capped和size参数:

db.createCollection('logs',{ capped : true, size : 5242880 })
复制代码

为了性能优化,mongo不会为固定集合建立针对_id的索引。同时,不能从中删除doc,也不能执行任何更改文档大小的更新操做。

三、键名选择

慎重选择键名,例如,用dob代替date_of_birth,一个文档能够省下10字节。

查询和聚合

1. 查询常见技巧

分页查询能够经过skiplimit配合使用实现。

空值查询能够经过驱动的空值字面量实现,好比在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. 查询选项

投影

一、使用选择字段进行返回,下降网络传输:find给定第二个参数。

二、返回保存在结果数组中的某个范围的值:$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以前不支持的事务特性。思路是:

  1. 获取最初的文档数据
  2. 利用findAndModify进行针对性更新,更新字段中须要携带本次的更新标示(好比时间戳)。findAndModify操做符回返回更新后的字段。
  3. 将更新后的字段中的更新标示与本地保存的标示作对比,若是不相同,说明有别的端更新了数据,数据发生了污染,为了保证事务原子性的特色,将文档恢复为第1步得到原始数据;若是相同,那么继续进行。

在MongoDB 4.0中,就是经过相似第二步的思路,提供了一个seesionID来实现了事务的,保证了事务特性。

4. 更多的更新命令

update:multi参数不给,默认只更新匹配到的第一个文档。

unset:删除文档中的指定键。

rename:重命名键。

addToSet:数组中不存在时候,才会加入。

pull:删除数组指定位置的元素。

5. 更新本质和优化

更新分为3种:

  • 只改变单值,但BSON文档不变:$inc操做符
  • 改变文档和结构,会重写整个文档:$push
  • 改变文档形成空间不够,所有总体迁移到新空间:提早利用填充因子来减小影响

索引与查询优化

查询是很是高频的操做,大数据、高频读的场景下,查询的效率会是性能的瓶颈。设置合适的索引,能够充分利用数据结构(B数)和物理硬件的优点。

1. 索引理论

复合索引和分离索引

一个查询中,要是有多个字段,好比2个字段。分离索引是:查找每一个索引的匹配集合,取得这些匹配集合的交集。复合索引是:逐步根据索引的顺序作查询。

好比有一个食谱,咱们根据种类和菜名来作索引:

肉类
	- 辣子鸡:第12页
	- 鱼肉:第139页
复制代码

⚠️:复合索引中的顺序是很是重要的,若是设置的索引不合适,那么就至关于现行扫描文档。抽象来讲,若是有一个针对a-b的复合索引,那么仅针对a的索引就是冗余的。好比例子中,仅针对种类的索引就是冗余的,可是种类索引能够下降扫描时间(和Btree有关)。

索引效率

正确的索引,也不必定会有快速的查询:索引和数据集没法所有放入内存

若是内存充足,全部使用的数据文件都会载入内存,对应内存发生变化时(好比写操做),结果会异步刷到磁盘上。

若是内存不足,就没法所有装入内存,出现页错误,操做系统会频繁访问磁盘读取须要数据。数据集过大时候,任何写操做都要去磁盘,会出现颠簸状况,性能下滑。

所以,应该首先保证索引都能装入内存,复合索引时,尽可能减小键的数量。

B树

在Mongo Version2中,B树仅用于索引。集合存储是双向列表。

对于复合索引的底层结构,如下面为例,是根据姓、名和生日来创建的复合索引。若是要查询(Akroyd, Kirsten, 1978-11-02)的数据,那么会先按照顺序查找,根据第一个索引,找到了只有左侧两个复合要求;再在左侧两个集合中查找第二个索引;直到找到符合要求的数据为止。

image-20191011110751982

参考连接:

2. 索引实践

索引类型

根据索引设置时的属性的不一样,常见的有:惟一性索引、稀疏索引和多键索引。

惟一性索引

说明:被设置为索引的字段,不能重复出现,不然会报错。

建立方式:db.col.createIndex({name: 1}, {unique: true})

⚠️:适用于插入数据前先建立索引的状况

稀疏索引

说明:索引默认是密集型的,是指为集合中每一个文档都创建索引。例如前面的例子,即便文档没有name字段,那么查询索引时候,没有name字段的文档匹配null便可。

建立方式:db.col.createIndex({name: 1}, {sparse: true})

优势:

  • 占用较少的空间
  • 适用于不是为全部文档增长惟一性索引
  • 适用于历史遗留的文档,没法保证字段存在

多键索引

说明:在数组字段上创建索引。mongo中,多键索引是默认开启的。

原理:数组中每一个元素,都指向文档。

3. 查询管理

构建索引

分为为索引值排序、排序值插入索引中,而且会占用写锁,其余程序没法读写数据库。

在迁移历史数据和索引的时候,先迁移数据再构建集合,比线构建集合再迁移数据 的作法更优秀。

后台索引

设置background为true。虽然仍会占用写锁,但会停下来,让其余操做读写操做访问数据库。适合在流量最低的时候,完成索引构建。

备份

mongodump和mongorestore只能保存集合和索引说明。

若是想备份索引,必须直接备份mongo的数据文件。

压紧删除

对于删除大量数据,可能形成索引碎片化。解决方法是重建索引或者执行db.col.reIndex().

应该在子节点执行此命令,再进行节点替换,由于它会占用写锁,形成没法读写操做。

4. 查询优化

两个比较重要的原理,一个是覆盖索引,一个复合键的顺序

覆盖索引是指:查询的关键字和索引彻底一致。

复合键的顺序遵循:搜索成本由低到高的原则排列。

复制

1. 复制概述

定义:在多台服务器上分布并管理数据库服务器。有2种复制风格:主从复制和副本集(生产环境推荐)。

复制的做用是冗余,由于复制是异步的,所以任何节点的延迟都不会影响主节点性能。

副本不是备份替代品:备份是某事刻的快照;副本是最新的。

做用:故障转移、均衡读负载。

2. 副本集

最小的副本集由3个节点组成:主节点、从节点、仲裁节点。主从节点是一等的;仲裁节点不复制数据,中立观察。

image-20191011153854119

副本集基于两个机制:oplog和心跳。oplog是记录数据的变动;心跳是检测主节点是否有效。

在副本集中,「提交」是指:数据变更都被复制到从节点。不然就是未提交。

3. 主从复制

不推荐,副本集才是正道,缘由以下:

  • 故障转移手动操做(没有仲裁节点)
  • oplog近存在主节点,恢复苦难

4. 写关注和读拓展

写关注:设置writeConcern参数,经过属性设置来指定wtimeout、w。

读拓展:单台服务器没法承受程序的读负载,将查询分配到副本上。

分片

这是一个有意思的概念,尤为是「复制」对比的时候。复制是指数据都保存在单机上,向其余副本迁移,理论上全部主从节点数据是一致的;分片是指因为空间有限,单机承受不了数据量,同一个数据库分布在不一样的数据库上,这些数据库造成了一个宏观意义上的节点。

image-20191011155847647

值得称赞的是,mongo提供分片机制,无需变更代码。

最后

做为中前台开发,开发中几乎接触不到复制、分片和部署的逻辑。专业事还得交给专业的人来作,毕竟每一个人精力有限,不能面面俱到。但了解复制和分片的原理,有助于加深对mongo的理解,也可能会在之后作架构的时候发挥做用。

相关文章
相关标签/搜索