关于本书
作者张友东,阿⾥云⾼级技术专家,主要关注分布式存储与数据库等技术领域,先后参与淘宝分布式⽂件系统TFS、阿
⾥云数据库(POLARDB、MySQL、MongoDB、Redis ...)等项⽬的开发⼯作,致⼒于让开发者⽤上最好的云数据库服
务。
点击这⾥体验 MongoDB 云数据库
本书是我在开发阿⾥云 MongoDB 云数据库过程中积累的源码开发、线上运维管理、⽤户最佳实践经验,所有的⽂章都
曾发布到 阿⾥云栖社区 以及 Mongoing 中⽂社区,近期有很多读者反馈⽂章中有部分图⽚⽆法显示,影响对⽂章的理
解,我特地将所有的⽂章进⾏分类整理成电⼦书,⽅便⼤家学习,如果你觉得本书对你有帮助,可以友情打赏⿎励⼀
下,也欢迎向我反馈问题。
邮箱 & ⽀付宝账号:[email protected]
mongoDB复制原理
MongoDB 知识点汇总
1、复制集简介
MongdoDB复制集由一组Mongod实例组成,包含一个Primary 节点和多个Secondary节点,
Mongodb Driver(客户端)的所有数据都写入Primary,Secondary 从Primary 同步写入的数据,
以保持复制集内的所有成员存储相同的数据。
2、Primary 选举
复制集通过replSetlnitiate 命令(或mongo shell 的rs.initate) 进行初始化,初始化后各个
成员间开始发送心跳信息,并发起Primary选举操作,获得大多数成员投票的支持的节点,
会成为Primary,其余节点成为Secondary。
初始化复制集
config = {_id:"my_repl_set",members :[{_id: 0,host:"rs1.example.net:27017"}, \
{_id:1,host: "rs2.example.net:27017"},{_id: 2,host:"rs3.example.net:27017"}]}
rs.initiate(config)
批注: 大多数指的是总数的二分之一加一(N/2 + 1)
3、特殊的Secondary
正常情况下,复制集的Secondary 会参与Primary 选举(自身也可以被选为Primary),
并从Primary 同步 ,以保证数据的一支持。
Secondary 可以提供读服务,增加Secondary 节点可以提供复制集的读服务能力,同时提升复制集的可用性。
另外Mongodb支持对复制集的Secondary 节点进行灵活的配置,以适应多种场景的需求。
1、Arbiter
Arbiter 节点只参与投票,不能被选为Primary,也不从Primary同步数据。只是提到投票的作用。
2、Priority 0
Priority 0 节点的选举优先级为0,不会被选举为Primary。
3、vote0
Mongodb 3.0 里,复制集成员最多50个,参与Primary 选举投票的最多7个,
其他成员(Vote0)的属性必须设置为0,即不参与投票。
4、Hidden
Hidden 节点不能为选择为主(Primary为0),并且对Driver(客户端)不可见。因为Hidden节点不会接受Driver的请求,可以使用Hidden
节点做一些数据备份、离线计算的任务,不会影响复制集的服务。
5、Delayed
Delayed 节点必须是hidden节点,并且其数据落后于Primary一段时间(
可配置,比如1个小时)。因Delayed 节点数据比Primary 落后一段时间,
如果出现误操作可以通过Delaryed节点的数据来恢复到之前的时间点。
4、数据同步
primary 与secondary 之间通过oplog 来同步数据,primary上写操作完成后,
会向local.oplog.rs集合中写入一条oplog,Secondary 不断的从Primary
获取新的oplog并应用。
因oplog的数据会不断增加,local.oplog.rs被设置为一个capped集合,当
容量达到配置的上线时,会将最旧的数据删除掉。另外考虑到oplog在secondary 上可能重复应用,
oplog必须具有幂等性,集重复应用也会得到相同的结果。
如下oplog的格式,包含ts、h、op、ns、o等字段。
{
"ts" : Timestamp(1446011584, 2),
"h" : NumberLong("1687359108795812092"),
"v" : 2,
"op" : "i",
"ns" : "test.nosql",
"o" : { "_id" : ObjectId("563062c0b085733f34ab4129"), "name" : "mongodb", "score" : "100" }
}
上述oplog里各个字段的含义如下:
ts: 操作时间,当前timestamp + 计数器,计数器每秒都被重置
n: 操作的全局唯一标识符
v:oplog的版本信息
op:操作类型
i: 插入操作
u: 更新操作
d: 删除操作
c: 执行操作
n: 空操作,特殊用途
ns:操作针对的集合
o: 操作内容,如果是更新操作
o2:操作查询条件,仅update操作包含该字段
同步原理:
Secondary 初次同步数据时,会先进行init sync,从Primary(或其他数据更新的secondary)
同步全量数据,然后不断通过tailable cursor 从Primary 的local.oplog.rs 集合里查询最新
的oplog并应用到自身。
init sync 过程包含如下步骤:
1、T1时间,从primary 同步所有数据库的数据(local除外),通过listDatabase + listCollections + cloneCollection
命令组合完成,假设T2 时间完成所有操作。
2、从Primary应用[T1 - T2]时间段内的oplog,可能部分操作已经包含在步骤1,但由于oplog的幂等性,可重复应用。
3、根据Primary各集合的index设置,在Secondary上为相应的集合创建index(每个
集合_id的index已在步骤1 中完成)。
批注: oplog集合的大小应该根据DB规模及应用写入需求合理配置,配置太大,会造成存储空间浪费;
配置太小,可能造成secondary的init sync一直无法成功。比如在步骤1里由于
DB数据太多、并且oplog配置太小,导致oplog不足以存储【T1-T2]时间内的所有oplog,这就是secondary无法从primary上同步完整的数据集。
4、修改复制集的配置
当需要修改复制集时,比如增加成员、删除成员、或者修改成员配置(比如priorty、vote、
hidden、delary等属性),可以通过replsetReconfig命令(rs.config()) 对复制集进行重新配置。不如将复制集的第2个
成员priority设置为2,可以执行下面命令:
cfg = rs.config();
cfg.members[1].prority =2;
rs.reconfig(cfg)
5、细说Primary选举
Primary选举除了复制集初始化时发生、还有如下场景
1、复制集被reconfig
2、secondary 节点检查到Primary宕机时,会触发新Primary的选举
3、当有Primary节点主动降级时(stepDown)时,也出触发
新的Primary选举。
Primary 的选举受节点间心跳、优先级、最新的oplog时间等多种因素影响。
节点心跳
复制集成员间默认每2秒发送一次心跳信息,如果10s未收到某个节点的心跳信息,
则认为该节点以宕机;如果宕机的节点为primary,secondary(前提是可以被priamry)
会发起新的primary选举。
节点优先级
* 每个节点都倾向于投票给优先级最高的节点
* 优先级为0的及诶但不会主动发起primary选举
* 当Primary发现有优先级更改seconday,并且该secongary的数据落后在10s内,
则primary会主动降级,让优先级更改的secondary 成为primary的机会。
optime
拥有最新optime(最近一条oplog的时间)的节点才能被选为主。
网络分区
只有大多数投票节点间保持网络连通,才有机会被选Primary;如果10s未收到某个节点的心跳信息,primary与大多数的节点断开连接,primary会主动降级为secondary。当发生网络分区时,可能在短时间内出现多个primary,故Driver在写入时,最好设置【大多数成功的策略】,这样即使出现多个primary,也只有一个primary能成功写入。
5、复制集的读写设置
1、Read Preference
默认情况下,复制集的所有读请求都发送到primary,Driver可通过设置Read
Preference 来讲读请求路由到其他的节点。
× primary: 默认规则,所有读请求发到primary
× primaryPreferred:Primary 优先,如果primary 不可达,请求secondary
× secondary: 所有的读请求都发送到secondary
× secondaryPreferred:secondary优先,当所有secondary不可达时,请求primary
× nearest: 读请求发送到最近的可达节点上(通过ping探测得出最近的节点)
2、Write concern
默认情况下,primary完成写操作即返回,Driver可通过设置Read【WriteConcern]的成功的规则。
如下的write concern 规则设置写必须在大多数节点上成功,超时时间为5s。
默认情况下,Primary完成写操作即返回,Driver可通过设置[Write
Concern(https://docs.mongodb.org/manual/core/write-concern/)来设置写成功的规则。
db.products.insert(
{item:"envelopes", qty: 100,type: "clasp"},
{writeConcern:{w:majority,wtimeout:5000}}
)
上面的设置方式是针对单个请求的,也可以修改副本集默认的
writeConcern,这样就不用每个请求单独设置:
cfg=rs.config()
cfg.settings = {}
cfg.settings.getLastErrorDefaults = {w: "majority",wtimeout: 5000}
rs.reconfig(cfg)
异常处理(rollback)
当Primary宕机时,如果有数据未同步到Secondary,当primary重新加入时,如果新的primary上已经发生了写操作,则旧primary需要回滚部分操作,以保证数据集与
与新的primary一致。旧primary将回滚的数据写到单独的rollback目录下,
数据库管理员可根据需要使用mongorestore进行恢复。
5、MongoDB同步原理解析
mongodb 副本集数据同步主要包含2个步骤:
1、inital sync ,可以理解为全量同步
2、replication 追加同步oplog,可以理解为增量同步
本文是对mongodb 高可用复制集原理的补充,会详细介绍mongodb数据同步实现的原理。
1、initial sync
secondary 节点出现如下状况时,需要先进行全量同步
1、oplog 为空
2、local.replset.minvalid集合里_initialSyncFlog字段设置为true
3、内存标记initialSyncRequested 设置为true
这个3个场景分表对应
1、新节点加入,无任何oplog,此时需先进行initial sync
2、initial sync 开始时,会主动将_initialSyncFlag 字段设置为true,正常结束后在设置为false;
如果节点重启时,发现_initialSyncFlag为true,说明上次全量同步中途失败了,此时应该重新进行initial sync
3、当用户发送resync命令时,initialSyncRequested 会设置为true,此时会重新开始一次initial sync
initial sync流程
1、全量同步开始,设置minvalid集合的_initialSyncFlag
2、获取同步源上最新oplog时间戳为T1
3、全量同步集合数据(耗时)
4、获取同步源上最新oplog时间戳为T2
5、从服务器重新应用[T1,T2]范围内的所有oplog
6、获取同步源上最新的oplog时间戳T3
7、从服务器重新应用[t2,t3]范围内的所有oplog
8、建立集合所有索引(耗时)
9、获取同步源上最新oplog时间戳为T4
10、重放[t3,t4]范围内的所有oplog
11、全量同步结束,清除minvalid集合的_initialSyncFlag
Replication
initial sync结束后,接下来Secondary 就会不断拉取主上新产生的oplog并重放,这个过程在Secondary同步
慢问题分析也介绍过,这里从另一个角度分析下。
图1
注意事项:
× initial sync 单线程复制数据,效率比较低,生产环境应该尽量避免initial sync出现,需要合理配置oplog,按照默认5%的可用磁盘空间来设置oploag在绝大部分场景下都能满足需求,特殊的case可根据实际情形进行设置oplog的大小。
× 新加入节点时,可以通过物理复制的方式来避免initial sync,将Primary 上的dbpath拷贝到新的节点,直接启动,这样效率更高。
× 当secondary 上需要的oplog 在同步源上的已经滚掉时,secondary的同步将无法正常进行,会进入recovering的状态,
需向secondary 主动发送resync 命令重新同步。3.2 版本目前有个bug。可能导致resync 不能呢个正常工作,必须强制(kill -9
)后重启节点,详情参考server-24773
× 生产环境,最好通过db.printSlaveReplicationInfo() 来监控主备同步滞后的情况,当secondary 落后太多时,要及时调查清楚原因。
× 当secondary 同步滞后是因为主上并发写入太高导致,(db.serverStatus().metrics.repl.buffer.sizeBytes持续接近db.serverStatus().metrics.repl.buffer.maxSizeBytes),
可以通过调整Secondary上replWrite并发线程数来提升同步速度。
6、MongoDB 索引原理
1、 为什么需要索引?
当你抱怨MongoDB 集合查询效率低的时候,可能你就需要考虑使用索引了,为了方便后续介绍,先科普一下mongodb里的
索引机制(同样适用于其他的数据库比如mysql)。
mongo-9552:PRIMARY> db.person.find()
{ "_id" : ObjectId("571b5da31b0d530a03b3ce82"), "name" : "jack", "age" : 19 }
{ "_id" : ObjectId("571b5dae1b0d530a03b3ce83"), "name" : "rose", "age" : 20 }
{ "_id" : ObjectId("571b5db81b0d530a03b3ce84"), "name" : "jack", "age" : 18 }
{ "_id" : ObjectId("571b5dc21b0d530a03b3ce85"), "name" : "tony", "age" : 21 }
{ "_id" : ObjectId("571b5dc21b0d530a03b3ce86"), "name" : "adam", "age" : 18 }
当你往某个集合插入多个文档后,每个文档在经过底层的存储引擎持久化后,会有一个位置信息,通过这个位置信息,
就能从存储引擎里读出该文档。比如mmapv1引擎里,位置信息是[文件id +文件内offset],在writedtiger存储引擎(一个kv存储引擎)
里,位置信息是wiredtiger 在存储文档时生成的一个key,通过这个key能访问到对应的文档;
为方便介绍统一用pos来代表位置信息。
⽐如上⾯的例⼦⾥, person 集合⾥包含插⼊了4个⽂档,假设其存储后位置信息如下(为⽅便描述,⽂档省去_id字段)
位置信息⽂档
pos1 {"name" : "jack", "age" : 19 }
pos2 {"name" : "rose", "age" : 20 }
pos3 {"name" : "jack", "age" : 18 }
pos4 {"name" : "tony", "age" : 21}
pos5 {"name" : "adam", "age" : 18}
假设现在有个查询 db.person.find( {age: 18} ) , 查询所有年龄为18岁的⼈,这时需要遍历所有的⽂档(『全表扫
描』),根据位置信息读出⽂档,对⽐age字段是否为18。当然如果只有4个⽂档,全表扫描的开销并不⼤,但如果集
合⽂档数量到百万、甚⾄千万上亿的时候,对集合进⾏全表扫描开销是⾮常⼤的,⼀个查询耗费数⼗秒甚⾄⼏分钟都有
如果想加上db.person.find({age:18}),就需要考虑对person表的age字段建立索引。
db.person.createIndex({age:1}) // 按age字段创建升序索引
批注: 1: 代表升序,-1: 代表降序
建立索引后,mongodb 会额外存储一份按age字段升序排序的索引数据,索引结构类似如下,索引通常采用类似btree的
结构持久化存储,以保证从索引里快速找出某个age值对应的位置信息,然后根据位置信息就能读取出对应的文档;
简单的说,索引就是将文档安装某个(或某些)字段顺序组织起来,以便能根据该字段高效的查询。
有了索引,至少能优化如下场景的效率:
× 查询,比如查询年龄为18的所有人
× 更新/删除, 将年为18的所有人的信息更新或删除,因为更新或删除,需要根据
条件先查出所有符合条件的后进行更新/删除操作。查询时间短了,更新/删除,的时间也就缩短了。
× 排序,将所有人的信息按照年龄排序,如果没有索引,需要全表扫描文档,然后在对扫描的结果进行排序。
众所周知,mongodb默认会为插入的文档生成_id 字段(如果应用本身没有指定该字段),_id是稳定唯一标识,为了保证根据文档id快速查找文档,mongodb默认会为集合创建_id字段的索引。
primary> db.person.getIndexes() //查询集合的索引信息
{
"ns" : "test.person", // 集合名
"v" : 1, // 索引版本
"key" : { // 索引的字段及排序⽅向
"_id" : 1 // 根据_id字段升序索引
},
"name" : "_id_" // 索引的名称
}
]
MongoDB 索引类型
MongoDB 支持多种类型的索引,包括单字段索引、符合索引、多key索引、文本索引等,
每种类型的索引有不同的使用场合。
单字段索引
> db.person.createindex({age: 1})
上述语句针对age创建单字段索引,能加速对age查询请求。{age:1} 代表升序,-1:
也可以通过{age: -1}创建降序索引。升序降序对于单字段索引效果是一样的。
复合索引
复合索引是单字段索引的升级版本,它针对多个字段联合创建索引,先按照第一个字段排序,第一个字段相同的文档按第二个字段排序,以此类推,如下oplog的格式,包含ts、h、op、ns、o等字段。
如下针对age、name这个字段创建一个复合索引。
>db.person.createIndex({age:1,name: 1})
上述索引对应的数据组织类似下表,与{age: 1}索引不同的时,当age字段相同时,在根据name字段进⾏排序,所以
pos5对应的⽂档排在pos3之前。
age,name 位置信息
18,adam pos5
18,jack pos3
19,jack pos1
20,rose pos2
21,tony pos4
复合索引能满足的查询场景比单字段索引更丰富,不光能满足多个字段字段组合起来的查询,
比如db.person.find({age:18,name: 'jack'}),也能满足所有能匹配复合索引前缀的查询。所以类似db.preson.find({age:18})的查询也能通过该索引来加速;但db.person.find({name: javck}) 则无法使用该复合索引。
如果经常需要根据name字段以及name和age的字段组合来查询,则应该创建如下的复合索引。
>db.person.createIndex({name: 1,age: 1})
除了查询的需求能够影响索引的顺序,字段的值分布也是一个重要的考量因素,即使person集合所有的查询都是【name和age】组合
字段的顺序也是有影响的。
age字段的取值很有限,即拥有相同age字段的文档会有很多;而name字段的取值则很丰富,拥有相同name字段的文档很少;
显然先按照name字段查询,再在相同name的文档里查找age字段更为高效。
多key索引
当索引的字段为数组时,创建出的索引称为多key索引,多key索引会为数组的每个元素建立一条索引,比如person表
加入一个habbit字段(数组)用于描述兴趣爱好,需要查询有相同兴趣爱好的人就可以利用habbit字段的多key索引。
{"name" :"jack","age":19,habbit:["fooball","running"]}
>db.person.createIndex({habbit:1}) //自动创建多key索引
>db.person.find({habbit:"football"})
其他类型索引
哈希索引
是指按照某个字段的hash值来建立索引,目前主要用于mongodb sharded cluster 的hash分片,
hash 索引只能满足字段完全匹配的查询,不能满足范围查询等。
地理位置索引
能很好的解决O2O 的应用场景,比如[查找附近的美食]、[查找某个区域内的车站]等。
文本索引
文本索引能解决文本查找的需求,比如有一个博客文章集合,需要根据博客的内容来
快速查找,则可以对博客内容建立文本索引。
索引额外属性
MongoDB 除了支持多种不同类型的索引,还能对索引定制一些特殊的属性。
× 唯一索引(unique index): 保证索引对应的字段不会出现相同的值,比如 _id索引
× TTL索引: 可以针对某个时间字段,指定文档的过期时间(经过指定时间后过期或在某个时间点过期)
× 部分索引:只针对符合某个特定条件的文档建立索引,3.2 版本才支持该特性。
× 稀疏索引:只针对存在索引字段的文档建立索引,可以看作是部分索引的一种特殊情况。
7、索引优化
db profiling
MongoDB支持对DB的请求进行profiling,目前支持3种级别的profiling。
× 0: 不开启profiling
× 1: 将处理时间超过某个阈值(默认100ms)的请求都记录到DB下的system.profile集合(类似于mysql、redis的 slow log)
× 2: 将所有的请求都记录到DB下的system.profile集合(生产环境慎用)
通常,生产环境建议使用1级别的profiling,并根据自身配置合理的阈值,用于监测慢请求的情况,并及时的做索引优化。
如果能在集合创建的时候就能【根据业务查询需求决定该创建哪些索引】,当然是最佳的选择;但是由于业务需求多变,要根据实际情况不断的进行优化。索引 并不是越多越好,集合的索引太多,会影响写入、更新的性能,
每次写入都需要更新所有索引的数据;索引你system.profile里的慢请求可能是索引建立的不够导致,也可能是索引过多导致的。
查询计划
索引已经建立了,但查询还是很慢怎么破?这时就的深入的分析一下索引的使用情况了,可以通过查看详细的查询计划来决定如何优化。
通过执行计划可以看出如下问题:
1、根据某个/某些字段查询,但没有建立索引
2、根据某个/某些字段查询,但建立了多个索引,执行查询时没有使用预期的索引。
建立索引前,db.person.find({age: 18}) 必须执行collscan,即全表扫描。
mongo-9552:PRIMARY> db.person.find({age: 18}).explain()
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.person",
"indexFilterSet" : false,
"parsedQuery" : {
"age" : {
"$eq" : 18
}
},
"winningPlan" : {
"stage" : "COLLSCAN",
"filter" : {
"age" : {
"$eq" : 18
}
},
"direction" : "forward"
},
"rejectedPlans" : [ ]
},
"serverInfo" : {
"host" : "localhost",
"port" : 9552,
"version" : "3.2.3",
"gitVersion" : "b326ba837cf6f49d65c2f85e1b70f6f31ece7937"
},
"ok" : 1
}
建立索引后,通过查询计划可以看出,先进行IXSCAN(从索引中查找),然后FETCH,读取出满足条件的文档。
mongo-9552:PRIMARY> db.person.find({age: 18}).explain()
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.person",
"indexFilterSet" : false,
"parsedQuery" : {
"age" : {
"$eq" : 18
}
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"age" : 1
},
"indexName" : "age_1",
"isMultiKey" : false,
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 1,
"direction" : "forward",
"indexBounds" : {
"age" : [
"[18.0, 18.0]"
]
}
}
},
"rejectedPlans" : [ ]
},
"serverInfo" : {
"host" : "localhost",
"port" : 9552,
"version" : "3.2.3",
"gitVersion" : "b326ba837cf6f49d65c2f85e1b70f6f31ece7937"
},
"ok" : 1
}
参考资料
MongoDB索引介绍
createIndex命令
MongoDB Sharded Cluster
唯⼀索引 (unique index)
TTL索引
部分索引 (partial index)
稀疏索引(sparse index)
database profiling