MongoDB保姆级指南(中):从副本集群、分片集群起航,探索分布式存储的趋势!

引言

在上一篇关于《MongoDB入门》的文章中,咱们把单机版的MongoDB讲了个大概,但很多情况下,单节点服务往往并不能满足系统需求,毕竟单节点部署的方式有很多隐患:

  • ①没有灾备能力,在单节点宕机的情况下,服务不可用会拖垮整个系统;
  • ②吞吐量不够:系统并发较高、访问量过大时,单节点无法承载流量;
  • ③拓展性不足:只能通过升级单节点的硬件配置,无法横向拓展;
  • ④数据安全风险:在极端情况下,数据损坏或出现丢失,无法有效恢复。

而上述提到的这一系列问题,在采用集群方案部署的情况下迎刃而解,所以本文一起来聊聊MongoDB集群,顺带以MongoDB集群为起点,共同探讨一下分布式存储的发展趋势~

PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选Offer方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职......,为大家打造了一套"从求职到跳槽"的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE,感兴趣的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!

一、MongoDB副本集群

MongoDB副本集群,也被称之为复制集群,其实和Redis、MySQL中的主从集群概念类似,只不过叫法上有所差异,就好比MQ中,称之为镜像集群,名词千变万化,理念殊途同归,内在含义都是:一个主节点拥有读写能力,一或多个从节点全量拷贝主节点的数据,对外提供读的能力

只不过这里要注意:MongoDB副本集群和传统的主从集群不太一样 !无论是Redis,还是MySQL,它们只提供主从复制功能,即自动将主节点上写入的新数据,完整的同步给从节点。但它们并不提供故障迁移的功能!好比在Redis中,当主节点出现故障时,往往需要依靠外力来完成主从切换,因此通常会搭建"一主两从三哨兵"的架构,由哨兵来完成故障迁移的功能。

MongoDB副本集群,则无需任何外力介入,在主节点发生故障的情况下,无需任何外力介入,能自动完成主从切换,从而对外实现真正意义上的7*24h高可用,并且从节点还可以处理读请求,从而保障MongoDB服务的高性能。

总的来说,MongoDB副本集群源自于主从集群,但在主从的基础上做了很大拓展,其中总共有三种节点角色:

  • Primary主节点:拥有读写能力,为集群内的副本节点,提供数据拷贝的支持;
  • Replicate副本节点:拥有读能力,数据完全拷贝自主节点,即主从概念中的从节点;
  • Arbiter仲裁节点:不具备读写能力,用于故障恢复,提供故障检测、选举投票能力。

MongoDB副本集群中,出现了一个新概念:仲裁节点,其实作用等同于哨兵节点,但它并不是副本集群必须存在的节点,因为主节点、副本节点都拥有投票能力,它的存在只是为了维护集群内的平衡,如集群节点为偶数时,可以添加一个仲裁节点,让集群保持奇数特性,确保每轮选举一次就能推出新主,避免多轮无效竞选的现象出现(不了解选举机制的后面会说原理)。

好了,理论暂且说到这里,咱们先来搭建一个副本集群玩一玩,简单的实战一把。

1.1、副本集群实战

因为目前只是简单学习,这里选择搭建成本最低的"一主、一副本、一仲裁"集群,并且也不采用三台机器构建,而是基于"伪集群"的思想:

  • 单机模式:一台机器对外提供服务;
  • 集群模式:多台机器对外提供同一服务;
  • 伪集群模式:单台机器上启动多个实例,模拟多机器场景对外提供服务。

当然,如果你会Docker、K8S这些容器化技术,使用它们来构建的速度会更快,但咱们现在追求原汁原味,就一步步手动配置搭建出整个集群,整个集群的节点关系如下:

其中主节点、副本节点、仲裁节点的端口分别为:27018、27019、27020

①为每个节点创建存放数据、配置文件、日志的目录:

shell 复制代码
[root@~]# mkdir /soft/mongodb/data/replication-cluster && 
          mkdir /soft/mongodb/log/replication-cluster && 
          mkdir /soft/mongodb/conf/replication-cluster && 
          mkdir /soft/mongodb/data/replication-cluster/27018 && 
          mkdir /soft/mongodb/data/replication-cluster/27019 && 
          mkdir /soft/mongodb/data/replication-cluster/27020 && 
          mkdir /soft/mongodb/log/replication-cluster/27018 && 
          mkdir /soft/mongodb/log/replication-cluster/27019 && 
          mkdir /soft/mongodb/log/replication-cluster/27020

②编写主节点的配置文件:

shell 复制代码
[root@~]# vi /soft/mongodb/conf/replication-cluster/27018.conf

# MongoDB日志存储相关配置
systemLog:
    # 将所有日志写到指定文件中
    destination: file
    # 记录所有日志信息的文件路径
    path: "/soft/mongodb/log/replication-cluster/27018/mongo.log"
    # 当服务重启时,将新日志以追加形式写到现有日志尾部
    logAppend: true
storage:
    # 指定MongoDB存储数据的目录
    dbPath: "/soft/mongodb/data/replication-cluster/27018"
processManagement:
    # 以后台进程方式运行MongoDB服务
    fork: true
    # 指定保存mongo进程ID的文件位置
    pidFilePath: "/soft/mongodb/conf/replication-cluster/27018.pid"
net:
    # 绑定服务实例的IP,默认是localhost,这里换成本机IP
    bindIp: 192.168.229.135
    #绑定的端口,默认是27017
    port: 27018
replication:
    # 指定副本集群的名称
    replSetName: zhuzi

将上述配置信息复制、并根据自己的环境修改后,接着按下esc,输入:wq保存并退出。

③启动主节点服务(依旧使用之前bin目录下的文件,只是换掉配置文件即可):

shell 复制代码
[root@~]# /soft/mongodb/bin/mongod -f /soft/mongodb/conf/replication-cluster/27018.conf

④编写副本(从)节点的配置文件,由于和主节点的配置完全相同,因此直接copy一份,接着将相关目录、端口,批量替换成规划中的27019即可:

shell 复制代码
[root@~]# cd /soft/mongodb/conf/replication-cluster/
[root@~]# cp 27018.conf 27019.conf
[root@~]# vi /soft/mongodb/conf/replication-cluster/27019.conf

接着不要按insert键,直接输入:%s/27018/27019/g,完成批量替换(记得保存再退出)。

⑤启动副本节点服务:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongod -f /soft/mongodb/conf/replication-cluster/27019.conf

⑥编写仲裁节点的配置文件,这里依旧拷贝27018.conf文件进行批量替换:

shell 复制代码
[root@~]# cp 27018.conf 27020.conf
[root@~]# vi /soft/mongodb/conf/replication-cluster/27020.conf

输入:%s/27018/27020/g,完成批量替换,不要忘记保存。

⑦启动仲裁节点服务:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongod -f /soft/mongodb/conf/replication-cluster/27020.conf

⑧通过mongosh客户端工具,连接27018主节点,初始化副本集群:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongosh 192.168.229.135:27018
test> rs.initiate();

执行上述初始化命令,只要看到ok:1这个字段值,说明初始化成功,接着稍等片刻,MongoDB会自动将当前连接的节点,设置为集群的主节点。当然,这个初始化方法也可以指定参数,比如这样:

JavaScript 复制代码
test> config = {
	_id : "zhuzi",
	members : [
    	{_id: 0, host: "192.168.229.135:27018"},
    	{_id: 1, host: "192.168.229.135:27021"},
    	{_id: 2, host: "192.168.229.135:27020", arbiterOnly: true}
	]
};
test> rs.initiate(config);

这是先指定集群节点,然后再初始化,后面咱们会依次添加集群节点,所以这种方式感兴趣的也可以自己试试。

⑨通过初始化的主节点,将其他节点加入到集群中,命令如下:

JavaScript 复制代码
rs.add(host, arbiterOnly);

该方法有两个参数,前者是其他节点的IP:port,后者则是仅仲裁权力(可选参数),如果置为true,当前添加的节点则只有选举权力;如果为false,则拥有数据复制、选举的权力。

添加仲裁节点还有另外一个专门的方法,如下:

JavaScript 复制代码
rs.addArb(host);

通过该方法添加的仲裁节点,只会拥有选举权力,并不参与副本集群中的数据维护工作。

通过上面的方法,将副本节点加入到集群中:

shell 复制代码
zhuzi [direct: primary] test> rs.add("192.168.229.135:27019");

上面添加副本节点没问题,当大家添加仲裁节点之前,需要更改一下默认的"写关注"级别,否则会报如下错误:

log 复制代码
MongoServerError: Reconfig attempted to install a config that would change the 
    implicit default write concern. Use the setDefaultRWConcern command to set 
    a cluster-wide write concern and try the reconfig again.

这是因为MongoDB5.x以上的版本,集群内的写关注级别(数据同步模式),会根据节点数量而发生变化,当集群内添加新节点时,就会重新配置"写关注级别",而仲裁节点并不参与数据同步,添加时就会出毛病,这时需要手动设置一下写关注级别:

JavaScript 复制代码
db.adminCommand({
  "setDefaultRWConcern" : 1,
  "defaultWriteConcern" : {
    "w" : "majority"
  }
});

执行上述命令后,再执行添加仲裁节点的命令,

shell 复制代码
zhuzi [direct: primary] test> rs.addArb("192.168.229.135:27020");

添加完成后,可以通过下述两个命令查看集群的节点配置、状态:

JavaScript 复制代码
rs.conf();
rs.status();

其实所有副本集群相关的信息,都保存在local库的system.replset集合中,大家也可以直接去查询这个集合。至此,副本集群的环境就搭建完毕,接着来看看节点之间的数据同步效果。

1.2、副本集群数据同步

①在主节点上进行读写数据测试:

shell 复制代码
zhuzi [direct: primary] test> use zhuzi;
zhuzi [direct: primary] zhuzi> db.test.insert({_id:1});
zhuzi [direct: primary] zhuzi> db.test.find({_id:1});

此时主节点没有任何异样,读写数据操作都能正常执行。

②新开另一个shell窗口,连接副本节点进行读写数据测试:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongosh 192.168.229.135:27019
zhuzi [direct: secondary] test> use zhuzi;
zhuzi [direct: secondary] zhuzi> db.test.find({_id:1});

MongoServerError: not primary and secondaryOk=false .....

此时执行数据读取命令时,又会出现新的报错,说咱们没有权限读取数据,这里需要开启一下权限:

shell 复制代码
zhuzi [direct: secondary] test> rs.secondaryOk();

如果是4.0以下的版本,则是rs.slaveOk()方法,执行该方法后,再执行find就能正常读取数据。不过需要注意,该方法只针对当前shell有效,当重启shell或打开新的shell会话,需要再次执行该方法来允许读操作。

接着再重新执行读取命令,这时会发现,从节点会将主节点上写入的数据,自动同步过来:

shell 复制代码
zhuzi [direct: secondary] zhuzi> db.test.find({_id:1});
[ { _id: 1 } ]

同时,在副本节点上执行写操作时,会出现相关报错,提示当前并非主节点,不允许写入数据:

shell 复制代码
zhuzi [direct: secondary] zhuzi> db.test.insert({_id:2});
MongoBulkWriteError: not primary

③再来连接仲裁节点看看情况:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongosh 192.168.229.135:27020
zhuzi [direct: secondary] test> rs.secondaryOk();
zhuzi [direct: secondary] test> use zhuzi;
zhuzi [direct: secondary] zhuzi> db.test.find({_id:1});

在仲裁节点上执行任何读写操作时,都会出现这个错误:

log 复制代码
MongoServerError: node is not in primary or recovering state

提示当前节点,既不是主节点,也不是从节点,所以无法执行相应命令。

④好了,到这里关于数据同步就演示完毕了,不过为了后续外部可以连接,也可以顺便开放一下端口:

shell 复制代码
[root@~]# firewall-cmd --zone=public --add-port=27018/tcp --permanent
[root@~]# firewall-cmd --zone=public --add-port=27019/tcp --permanent
[root@~]# firewall-cmd --zone=public --add-port=27020/tcp --permanent
[root@~]# firewall-cmd --reload

下面来看看MongoDB副本集群中的故障自动切主功能。

1.3、副本集群主从切换

①先通过ps命令查询所有mongodb的进程:

shell 复制代码
[root@~]# ps aux | grep mongodb

②通过kill命令强杀27018主节点,模拟主节点宕机下线:

shell 复制代码
[root@~]# kill -9 42969

③大概等待几秒钟左右,再去27019节点(原本的从)查看集群状态:

shell 复制代码
rs.status();

此时会发现,没有任何外力介入,副本集群自动完成了主从切换!

④再次重启27018节点(旧主),观察集群的变化:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongod -f /soft/mongodb/conf/replication-cluster/27018.conf

结果很显然,虽然"旧主复燃",但它却没有夺回"老大"的身份,而是变成了"新主"的从节点。

⑤将27018、27019节点,即主、从节点都干掉,再在仲裁节点上观察集群的状态:

shell 复制代码
[root@~]# kill -9 43032 47881

从截图中能十分清楚的观察到,主、从节点已经失去心跳,整个集群处于不可工作状态,因为仲裁节点上没有数据,无法继续对外提供服务。

二、副本集群原理

经过上面的内容阐述,相信诸位对副本集群有了一定认知,接着来聊聊其原理。当然,对于主从之间的数据同步原理,大家可以参考之前的《MySQL主从复制原理篇》,虽然技术栈不同,但数据复制的过程大体类似,这里不再过多叙述,重点聊聊其自动切主的原理。

但凡涉及到主从切换的地方,都离不开选举机制,毕竟采用选举投票的方式来选择新主,这是主流一致性协议中的共识,但MongoDB的副本集群与传统主从集群有些许差异,因为在所有时间内,所有的主都是选出来的!

成为主节点的依据是:获得集群内大多数节点的投票(节点数量一半以上的票数),集群初始化时,由于其他节点还未添加进集群,所以集群内只有执行初始化的这一个节点,它会给自己投一票,此时就只有它自己,所以它会理所当然的成为集群内的第一代主节点,如果集群后续期间一直正常,则不会触发新一轮的选举。

接着来说说整个集群内部的工作阶段:

  • ①故障检测:当一个节点感知到其他节点不可用时,会先发起通信向其他可用节点进行二次确认;
  • ②故障处理:如果检测到主节点不可用,从节点会将自己转换为候选人,并向其他成员宣布;
  • ③选举开始:轮次号加一,开启一轮新的选举,每个候选人节点开始向其他成员发送拉票请求;
  • ④投票开始:在一个新的选举轮次中,每个节点只能投一票,可以投给自己或者其他节点;
  • ⑤投票结束:所有节点已投票,或抵达本轮选举的时间限制后,将获得大多数投票的节点立为新主;
  • ⑥主从切换:新主会向其他节点发送"上位"消息,其他节点更新自己的配置,接受新主上位;
  • ⑦数据同步:完成主从切换后,从节点以新主为数据基准,校验自身数据是否完整,有缺失则同步;

上述每一步都是由MongoDB集群自动完成,无需任何外力介入,下面详细把每一步展开叙述。

2.1、故障检测

检测出一个集群节点是否故障,主要依靠心跳机制来完成,集群每个成员会以10s一次为频率,定期向其他成员发出心跳包,从而告知其他节点自己还活着。当某个节点不再发出心跳时,其他节点将无法收到心跳包,此时会有一或多个节点率先发现问题,将会判定这个没有心跳的节点出现故障。

为了避免网络波动、延迟、故障带来的误判,率先发现问题的节点,会向集群内的其他成员发起通讯,从其他成员那里二次确认,是否收到了"故障节点"的心跳?如果其他成员收到了,当前节点不会进行额外处理;如果其他成员也未收到,当前节点会通知所有成员,"故障节点"已经下线;如果其他节点均未回复,当前节点会认为自己网络出现了问题,或者整个集群不可用,当前节点会停止工作,尝试重连恢复。

还有一种情况,就是当从节点去主节点同步数据时,如果发现自己无法连接到主节点时,这时从节点也会试图向其他节点发起通信,二次确认主节点是否故障......(逻辑同上)。

2.2、故障处理

其余节点收到故障节点下线的通知后,集群内所有存活的节点,会判断此次下线的节点身份,如果是从节点或仲裁节点,存活节点只会修改自身的集群配置,将下线的节点从集群中剔除。如果此次下线的是主节点,集群内的所有从(副本)节点,会将自己转换为"候选人"角色,并通知其他节点自己想成为"新主"。

注意:如果集群内有仲裁节点,仲裁节点收到主节点下线的消息后,并不会将自己转变为"候选人",因为仲裁节点只有投票权,没有竞选权!

2.3、选举开始

当主节点下线、并且集群内出现候选人时,整个集群会开启新的选举轮次(term),每个轮次都会拥有一个唯一的轮次号(标识),集群内第一个成为候选人的节点,会递增轮次号,同时率先向其他节点发出选举请求,并把自己的票投给自己。

不过这里有意思的是,新一轮的选举可能由好几种原因触发:

  • ①通过心跳机制,检测到主节点不可用、并向其余节点已确认主节点下线的情况;
  • ②从节点同步数据,无法正常连接主节点、并......(同上)。
  • ③主节点优先级降低,或具有较高优先级的新节点加入集群;
  • ④开发者在主节点上手动执行rs.stepDown()命令时;

上述这几种集群,都会触发集群开启新一轮的选举过程。

2.4、投票开始

新一轮的选举开始后,当集群内的其他从节点收到拉票请求时,可以选择给其他节点投票,或者把票投给自己、向集群宣布自己也想成为新主,并向其余节点发出拉票请求。当然,如果收到拉票请求的节点,角色属于仲裁节点,它无法将票投给自己,只能选择投给其他节点。

集群内存在多个候选人时,仲裁节点投票时,会遵循先到先得的原则,先收到谁的拉票请求,就把自己的票投给对应节点。如果同时收到多个候选人拉票时,此时则会通过oplog操作日志来判断哪个节点的数据最新,该值越高的节点享有越高的竞票权。

oplog:从节点的操作日志,用于记录主节点的写操作,一个从节点的操作日志越新,说明数据和旧主越接近(类似于MySQL、Redis里的pos点)。

值得说明的一点,并不是所有从节点,在收到其他从节点拉票时,会把票投给自己、转变为新的候选人开始拉票。只有当收到的拉票请求,其数据比自己老时,才会将票投给自己!毕竟自己的数据和旧主更接近,所以更有资格成为新主。

2.5、投票结束

当集群内所有存活节点都已投票后,投票阶段将会结束,但如果集群内有节点迟迟不投票,此时选举会陷入僵局,所以每轮选举都有时间限制,如果超出了该限制还未投票的节点,将会被视为弃权。

投票阶段结束后,集群各个从节点会交换各自的票数,只有当获得大多数节点投票的从节点,才有资格成为新主,具体的数字为:集群节点数量的一半+1。为此,这里也要注意,如果集群的可用节点小于数量的一半时,整个集群将会陷入不可写入状态,只处理读请求。

好了,前面仅是最理想化的判断准则,而某些情况下,投票结束后的状况会更极端一点,比如两个从节点,获得的票数相同,此时该推选谁成为新主?此时则会比对两个从节点的oplog,也就是看谁的数据更完整,谁就会成为新主。

可是光凭oplog还是不够,再极端一点,如果两个节点的票数、oplog一模一样,此时又该谁作为新主?为了解决此问题,MongoDB给每个节点设计了priority优先级的概念,每个节点的优先级默认为1,仲裁节点的为0,表示没有竞选权(priority值可以通过rs.conf()命令查看)。

修改节点优先级的命令如下:

JavaScript 复制代码
// 获取集群节点配置
config = rs.conf();
// 根据下标修改指定节点的优先级(members是个数组对象)
config.members[要修改的节点下标].priority = 要修改为的值;
// 重新加载修改后的配置
rs.reconfig(config);

注意,修改集群内节点的优先级后,如果把从节点的优先级,调的比主节点高、或者将主节点的优先级,调的比从节点低,都会触发新一轮的选举过程。

回到开始的问题,如果两个节点的票数、oplog相同,此时会使用票数+priority值,哪个节点累加后的值更大,谁就会成为新主。不过这里再看一种特殊情况,现有A、B两个节点票数相同,但Aoplog最新,Bpriority值最高,谁会成为新的主节点?答案是A,因为如果数据落后的B成为了新主,那么数据比它更新的A节点,还需要把多出来的那部分数据删掉,显然并不合理。

最后再来看一种极端中的极端现象,如果两个节点的票数、oplog、priority完全相同,此时谁会成为新主?答案是都不会,而是会触发一轮新的选举过程,重复前面的步骤,直到选出新主为止。这也是为什么建议集群节点数量保持在奇数的原因,节点数量为奇数时,几乎不会出现票数持平的现象。

PS:如果集群从节点数量为偶数,记得加入一个仲裁节点,或者将某个节点的优先级调高,保证极端情况下,不会由于票数持平而触发重新选举。

2.6、主从切换

总之,经过前面的阶段后,任何极端情况下,都会选出一个新主,此时新主会向其他成员发送:自己成为新主,以及选举轮次号,其余节点收到后,会先对比轮次号,是不是自己投票过的那轮选举,如果不是,说明期间触发过新的选举,而自己没有参与进去,此时该节点会否定这次的新主,又会触发新的选举......

如果轮次号与自己投票的相同,收到信息的节点则会认可新主,接着变更自己的配置,将原本的旧主标为下线状态,竞选胜利的从节点标为新主。

2.7、数据同步

在主从切换完成后,新的主节点会向所有从节点发送oplog,以此来同步数据,其余从节点会拿自身的oplog与之比对,如果发现不完整,则会主动去新主上拉取缺少的数据,从而确保集群内所有节点的数据一致性。

最后说明一下,如果触发了选举过程,集群将会陷入不可用状态,只有当选举、数据同步完成后,才会恢复对外部的数据读写服务。

好了,到这里关于集群的工作流程,大致就讲明白了,其实所有涉及到主从架构的技术栈,主从切换都是这么个原理,把这个流程整明白了,Redis、MQ、Zookeeper......都大差不差,其核心就在于Paxos、Raft及其变种的一致性协议。

三、MongoDB分片集群

前面提到的副本集群,虽然可以实现读写分离、异地容灾,但由于其概念是基于主从理念之上,为此,主从集群的缺点依旧保留了下来,如经典的木桶效应,存储容量受集群内空间最小的节点限制,集群现有节点容量为128G、1TB、2TB三个节点,此时该集群的数据容量最大为128GB;又好比写的性能受到主节点限制,因为在主从集群中,从节点可以一直线性拓展,但主节点同时只能存在一个,否则会出现脑裂问题......

为了解决副本集群存在的问题,MongoDB还支持分片式集群,所谓的分片式集群,就是采用多台节点存储不同的数据,即A存一部分、B存另一部分、C存......,所有节点的数据加一块,能组成完整的数据,每个节点都支持读、写操作,这样就能实现真正意义上的高吞吐、高容量。

像分片式架构,MQ、Redis、MySQL、ES......等技术栈几乎都支持,只不过是实现方式的区别罢了,分片集群的实现思路大体有两种:

  • 中心化分片:也叫代理式分片,即所有读写请求先交给代理器,再由代理根据分片算法分发请求;
  • 去中心化分片:没有中间人,所有读写请求直接交给服务本身,由服务自身来负责分发请求;

这里举些相应的案例,前者的代表型案例有Redis-Codis、MySQL-MyCat......,后者的典型案例有Redis-Cluster、MySQL-ShardingJDBC......,当然,这里咱们不过多讨论其他技术栈,先来聊聊MongoDB的分片集群。

3.1、分片集群概述

以往的副本集会受到硬件配置的限制,如果写性能出现瓶颈,只能依靠拉高CPU、内存配置来提升性能,而分片集群无需考虑这点,毕竟整个集群都是由不同节点组合对外服务,如果性能出现瓶颈,只需要继续增加分片节点即可,相较于副本集群,分片集群的上限更高,架构图如下:

上图是《官方文档-分片章节》给出的示意图,分片集群种总共有三种角色:

  • Router路由:负责接收、分发客户端的读写请求,类似于代理中间件;
  • Shard分片:存储数据、处理读写请求的具体节点;
  • Config配置:存储路由、分片节点的元数据和配置信息;

这三种角色简单理解就是:一个负责请求调度、一个负责数据存储、一个负责配置管理,不过这里要注意,路由可以由一或多个mongos节点组成,config同理,而shard则并非单独的mongodb节点,而是一个个的replicaSet复制集(副本集群),整体结构图如下:

接下来了解一些MongoDB分片集群中的概念(其实和其他技术栈的大差不差):

  • Shard Rules:分片规则,也叫分片算法,指分发读写请求的逻辑,如随机、取模等;
  • Shard Key:分片键,也叫路由键,指基于文档中的哪个字段进行分片计算;
  • Document:文档,这个概念在前面就一直接触过,等价于MySQL中的一条数据;
  • Chunk:块,指包含一定范围内多个文档的数据段,属于集群中分割、存储数据的基本单位;
  • Shard:分片,每个分片都由一个副本集群组成,一个分片中可以存储多个Chunk数据块;
  • Cluster:集群,由多个Shard分片组成,统一对外提供读写服务。

综上所述,当一个写入请求出现时,Mongos会找到配置的分片键,再使用已配置的分片规则进行计算,从而得出本次数据要落入的分片,最后将该写入请求分发到对应的分片即可,举个例子:

JavaScript 复制代码
db.student.insert({_id:1, name:"竹子爱熊猫", address:"掘金"});

假设集群中存在两个分片:{A、B},这里使用_id作为分片键,分片规则为:根据分片数量取模:1%2=1,意味着本次写操作将落入下标为1的分片中,所以本次数据会落入到B分片中。

当出现读取这条数据的请求时,客户端最好使用/包含"分片键"来作为查询条件,如果单独使用其他字段来查询,Mongos无法根据配置好的分片规则,计算出本次读取操作具体要落入的分片,为了能查询到本次要读取的数据,只能将该请求分发到每个分片上,当每个分发查询完成后再统一返回给Mongos,由Mongos聚合所有分片上的结果,最终才能返回给客户端,这样查询的代价比较大!

最后,MongoDB分片集群对比Redis分片集群,或者传统的分库分表技术而言,还有一个无与伦比的优势,即:在集群的分片动态伸缩(数量发生变化)时,会自动触发数据迁移机制,由MongoDB内部来自动维护数据的平衡,无需任何外力介入 !像"数据均衡"这个功能,无论在Redis、还是分库分表中,都需要开发者手动完成数据迁移,维护成本特别高。

3.2、分片集群实战

前面大致对分片集群的概念有了一定了解,光说不练是假把式,为此,接下来咱们手动来搭建一个分片集群玩一玩,这里同样采用伪集群的模式搭建,理论上一个完整的分片集群,所需节点数量如下:

  • 两个Mongos路由节点、两个由副本集群组成的分片、一个由副本集群组成的配置节点

毕竟为了保证整个集群的高可用,任何一个角色,都需要部署多个节点来保障容灾性,但每一个分片、配置节点,都必须由副本集群组成,而副本集为了保持奇数特性,通常要由三个节点,粗略估算下来,所需节点数量为:2+(2*3)+(1*3)=11个!

嗯......,这个硬件成本有点高,这里咱们也选择搭建一个"弟中弟"版分片集群,把所有涉及到副本集群的角色,全都阉割成一个节点,也就是每个副本集只有主节点,不为其添加副本节点、仲裁节点,毕竟如何搭建完整的副本集群,前面已经学习过了,这样最终所需的节点数量变为了:2+2+1=5个~

确认好节点数量后,咱们规划一下每个节点的端口号:

  • 两个分片:27021、27022
  • 一个配置:27023
  • 两个路由:27024、27025

好了,规划完如何搭建分片集群后,下面依次完成每一步的搭建。

①为所有节点创建数据、日志、配置文件目录:

shell 复制代码
[root@~]# mkdir /soft/mongodb/data/sharding-cluster && 
          mkdir /soft/mongodb/log/sharding-cluster && 
          mkdir /soft/mongodb/conf/sharding-cluster && 
          mkdir /soft/mongodb/data/sharding-cluster/27021 && 
          mkdir /soft/mongodb/data/sharding-cluster/27022 && 
          mkdir /soft/mongodb/data/sharding-cluster/27023 && 
          mkdir /soft/mongodb/log/sharding-cluster/27021 && 
          mkdir /soft/mongodb/log/sharding-cluster/27022 && 
          mkdir /soft/mongodb/log/sharding-cluster/27023 && 
          mkdir /soft/mongodb/log/sharding-cluster/27024 && 
          mkdir /soft/mongodb/log/sharding-cluster/27025

②新建好相应目录后,先来编写两个分片的配置文件:

shell 复制代码
[root@~]# vi /soft/mongodb/conf/sharding-cluster/27021.conf

# MongoDB日志存储相关配置
systemLog:
    # 将所有日志写到指定文件中
    destination: file
    # 记录所有日志信息的文件路径
    path: "/soft/mongodb/log/sharding-cluster/27021/mongo.log"
    # 当服务重启时,将新日志以追加形式写到现有日志尾部
    logAppend: true
storage:
    # 指定MongoDB存储数据的目录
    dbPath: "/soft/mongodb/data/sharding-cluster/27021"
processManagement:
    # 以后台进程方式运行MongoDB服务
    fork: true
    # 指定保存mongo进程ID的文件位置
    pidFilePath: "/soft/mongodb/conf/sharding-cluster/27021.pid"
net:
    # 绑定服务实例的IP,默认是localhost,这里换成本机IP
    bindIp: 192.168.229.135
    # 绑定的端口,默认是27017
    port: 27021
replication:
    # 指定副本集群的名称
    replSetName: zhuzi-shard-1
sharding:
    # 指定当前节点在分片集群中的角色(shardsvr代表分片节点)
    clusterRole: shardsvr

与之前副本集群的配置文件对比,仅仅就最后多了一个分片角色配置,其他完全相同。

③再copy一份27021.conf配置文件,然后将批量替换掉端口号,并将副本集群名称改为zhuzi-shard-2

shell 复制代码
[root@~]# cd /soft/mongodb/conf/sharding-cluster/
[root@~]# cp 27021.conf 27022.conf
[root@~]# vi /soft/mongodb/conf/sharding-cluster/27022.conf

直接粘贴并敲下回车   :%s/27021/27022/g
直接粘贴并敲下回车   :%s/zhuzi-shard-1/zhuzi-shard-2/g

④分别启动27021、27022这两个数据分片节点:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongod -f /soft/mongodb/conf/sharding-cluster/27021.conf
[root@~]# /soft/mongodb/bin/mongod -f /soft/mongodb/conf/sharding-cluster/27022.conf

⑤通过mongosh客户端工具,分别连接27021、27022主节点,完成副本集群初始化:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongosh 192.168.229.135:27021
test> rs.initiate();
test> rs.status();
zhuzi-shard-1 [direct: primary] test> quit;

[root@~]# /soft/mongodb/bin/mongosh 192.168.229.135:27022
test> rs.initiate();
test> rs.status();
zhuzi-shard-2 [direct: primary] test> quit;

如果副本集群中拥有多个节点,在主节点这里再将副本节点、仲裁节点添加进集群即可,具体可参考"副本集群实战"阶段。

⑤编写配置节点的配置文件:

shell 复制代码
[root@~]# vi /soft/mongodb/conf/sharding-cluster/27023.conf

# MongoDB日志存储相关配置
systemLog:
    # 将所有日志写到指定文件中
    destination: file
    # 记录所有日志信息的文件路径
    path: "/soft/mongodb/log/sharding-cluster/27023/mongo.log"
    # 当服务重启时,将新日志以追加形式写到现有日志尾部
    logAppend: true
storage:
    # 指定MongoDB存储数据的目录
    dbPath: "/soft/mongodb/data/sharding-cluster/27023"
processManagement:
    # 以后台进程方式运行MongoDB服务
    fork: true
    # 指定保存mongo进程ID的文件位置
    pidFilePath: "/soft/mongodb/conf/sharding-cluster/27023.pid"
net:
    # 绑定服务实例的IP,默认是localhost,这里换成本机IP
    bindIp: 192.168.229.135
    # 绑定的端口,默认是27017
    port: 27023
replication:
    # 指定副本集群的名称
    replSetName: zhuzi-config
sharding:
    # 指定当前节点在分片集群中的角色(configsvr代表配置节点)
    clusterRole: configsvr

这里主要是更改了端口、集群名称,以及最后的节点类型,这里换成了configsvr,表示配置节点。

⑥启动27023配置节点,并通过mongosh工具连接,完成集群的初始化:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongod -f /soft/mongodb/conf/sharding-cluster/27023.conf
[root@~]# /soft/mongodb/bin/mongosh 192.168.229.135:27023
test> rs.initiate();
test> rs.status();
zhuzi-config [direct: primary] test> quit;

⑦编写路由节点的配置文件:

shell 复制代码
[root@~]# vi /soft/mongodb/conf/sharding-cluster/27024.conf

# MongoDB日志存储相关配置
systemLog:
    # 将所有日志写到指定文件中
    destination: file
    # 记录所有日志信息的文件路径
    path: "/soft/mongodb/log/sharding-cluster/27024/mongo.log"
    # 当服务重启时,将新日志以追加形式写到现有日志尾部
    logAppend: true
processManagement:
    # 以后台进程方式运行MongoDB服务
    fork: true
    # 指定保存mongo进程ID的文件位置
    pidFilePath: "/soft/mongodb/conf/sharding-cluster/27024.pid"
net:
    # 绑定服务实例的IP,默认是localhost,这里换成本机IP
    bindIp: 192.168.229.135
    # 绑定的端口,默认是27017
    port: 27024
sharding:
    # 指定配置节点的IP地址(如果是多个节点,使用逗号分隔,后面的无需集群名称作为前缀)
    configDB: zhuzi-config/192.168.229.135:27023

路由节点与前面的分片、配置节点不同,这里无需配置数据存储目录,因为路由节点不需要存储数据,只需要指定日志目录,并且在最后写明配置节点的replSetName/IP:Port即可。

⑧因为要搭建两个路由节点,所以这里咱们再选择将27024.conf文件复制一次:

shell 复制代码
[root@~]# cd /soft/mongodb/conf/sharding-cluster/
[root@~]# cp 27024.conf 27025.conf
[root@~]# vi /soft/mongodb/conf/sharding-cluster/27025.conf

直接粘贴并敲下回车   :%s/27024/27025/g

⑨分别启动27024、27025这两个路由节点:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongos -f /soft/mongodb/conf/sharding-cluster/27024.conf
[root@~]# /soft/mongodb/bin/mongos -f /soft/mongodb/conf/sharding-cluster/27025.conf

注意,这里是使用mongos脚本,而并非mongod脚本来启动!

⑩使用mongosh命令来连接任意一个路由节点:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongosh 192.168.229.135:27024
[direct: mongos] test> use zhuzi;
[direct: mongos] test> db.pandas.insertOne({_id:1, name:"肥肥"});

MongoServerError: Database zhuzi could not be created :: caused by :: No shards found

连接路由节点后尝试写入数据,这时会发现数据写入失败,给出的原因是还未进行分片配置,毕竟前面咱们只在Mongos中指定了配置节点的地址,并没有在配置节点上指定分片节点的地址,为此,接下来还要为集群配置分片节点。

⑪在路由节点上,切换到admin库,为集群添加分片节点,最后查看分片集群状态:

shell 复制代码
[direct: mongos] test> use admin;
[direct: mongos] admin> sh.addShard("zhuzi-shard-1/192.168.229.135:27021");
[direct: mongos] admin> sh.addShard("zhuzi-shard-2/192.168.229.135:27022");
[direct: mongos] admin> sh.status();

如果每个分片的副本集群有多个节点,同样在后面使用逗号分隔即可!当然,如果添加错了也可以移除分片,使用下述命令:

JavaScript 复制代码
db.runCommand({removeShard: "分片的副本集群名称"});

PS:咱们在27024路由上配置了分片节点,难道不需要在27025上也配置一次吗?答案是不需要,由于这两个路由节点,绑定了同一个配置集群,因此27024上添加的分片节点,会被同步到配置节点上,而27025会监听配置节点,当配置发生变化时,会自动拉取并更新自身配置!

3.3、分片规则配置

经过前面的步骤后,分片集群中所有节点都已经串联起来了,接着继续在路由节点上开启数据分片,以及配置分片键、分片规则,这些都是针对具体的库、集合进行配置,例如给zhuzi这个库开启分片:

JavaScript 复制代码
sh.enableSharding("zhuzi");
// 相同作用的命令:
// db.runCommand({enablesharding: "zhuzi"});

zhuzi库开启分片后,接着需要做分片规则配置,这里要指定具体的集合、字段:

JavaScript 复制代码
sh.shardCollection("zhuzi.pandas", {"name": "hashed"});
// 相同作用的命令:
// db.runCommand({shardcollection:"zhuzi.pandas", key: {"name": "hashed"}});

上述命令表示为zhuzi库的pandas集合配置分片规则,key指定了具体的分片键为name字段,而使用的分片算法为hashed哈希分片算法,这里主要说一下分片算法,MongoDB总共支持两种:

  • 范围分片:适用于数值型字段,MongoDB会自动按照一定范围对数据进行分片,指定时写1
  • 哈希分片:适用于非数值型字段,会先对字段值做哈希处理,转换为数字再取模,指定时写hashed

如果你的分片键属于数值型字段,并且该字段经常被用于范围查询,这时使用范围分片算法最佳,并且这也是MongoDB默认的分片策略。反之,如果你希望数据分散的较为均匀,或者分片键并非数值型字段,这时选用哈希分片算法才是最好的选择。

配置好分片规则后,再在mongos路由上尝试插入数据:

JavaScript 复制代码
use zhuzi;
db.pandas.insertOne({_id:1, name:"肥肥"});

此时就会发现,本次插入能正常执行!接着再使用for循环,模拟插入100条数据,以此来观察数据分片的效果:

JavaScript 复制代码
for(i=2;i<=101;i++){db.pandas.insertOne({_id:i,"name":"肥肥"+i})};

大家发现没?大多数JS原生的语法,都能在MongoDB里直接执行!接着统计一下pandas集合的数据总数:

JavaScript 复制代码
db.pandas.count();
// 27024路由节点执行结果:101
// 27025路由节点执行结果:101
// 27021分片节点执行结果:51
// 27022分片节点执行结果:50

大家会发现,此时数据被均匀分散到了两个分片节点,这里其实顺便也验证了读操作(count),会发现无论是在哪个节点,都能精准的读到对应的数据!同时,如果分片集群已经搭建成功,后续启动的顺序为:配置节点、数据节点、路由节点,不然会导致集群启动陷入阻塞。

OK,到这里就完成了分片集群的基本学习,最后再附上一些集群管理的相关命令:

  • sh.addShard():将一个分片节点添加到分片集群中。
  • sh.removeShard():从分片集群中移除一个分片节点。
  • sh.enableSharding():为一个数据库启用分片功能。
  • sh.shardCollection():为一个集合配置分片规则(算法+分片键)。
  • sh.status():查看分片集群的状态和分片信息。
  • sh.addTagRange():为数据块添加标签范围。
  • sh.removeTagRange():从数据块中移除标签范围。
  • sh.addShardTag():为分片添加标签。
  • sh.removeShardTag():从分片移除标签。
  • sh.addTagForZone():为一个区域添加标签。
  • sh.removeTagFromZone():从一个区域移除标签。
  • sh.setBalancerState():启用或停用分片集群的平衡器。
  • sh.waitForBalancer():等待分片集群的平衡器完成操作。
  • sh.enableBalancing():启用分片集群的平衡器。
  • sh.disableBalancing():停用分片集群的平衡器。
  • sh.isBalancerRunning():检查分片集群的平衡器是否正在运行。

这些命令里出现了部分未曾接触过的知识,比如标签、平衡器,关于平衡器大家无需过多关心,因为它是分片集群中用来确保各个分片上的数据量相对均衡的,主要负责自动调整和重新分布数据块,在集群初始化、数据分布不均衡、节点数量变化时都会触发其工作。

而关于标签这玩意儿,一般不建议使用,除非真有特殊需求(如数据隔离和分区需求),并且十分擅长相关知识,Tag标签使用过多、或不合理,可能会导致数据分布不均匀、查询性能下降、数据迁移困难等问题出现,这里就不再对其进行单独展开,感兴趣的可以去了解了解。

3.4、范围分片与数据块(Chunk)

上一阶段讲述了哈希分片算法,而MongoDB还支持范围分片策略,其实字符串类型的字段,也可以使用范围分片算法,它会根据"字典序"来比较并划分数据,但通常建议在数值型字段上使用范围分片算法,下面咱们来实验一下:

JavaScript 复制代码
// 在mongos节点上对zhuzi集合配置范围分片
sh.shardCollection("zhuzi.zhuzi", {"serial_number": 1});

// 通过for循环插入100条数据进行测试
for(i=1;i<=100;i++){db.zhuzi.insertOne({"serial_number":i})};

我现在搭建的分片集群中,有shard-1、shard-2两个分片节点,接着我通过下述命令,创建了一个分片规则:
sh.shardCollection("zhuzi.zhuzi", {"serial_number": 1});
接着执行了下述命令,向zhuzi集合插入了100条测试数据,可为什么所有数据都跑到shard-2分片上去了?
for(i=1;i<=100;i++){db.zhuzi.insertOne({"serial_number":i})};

此时分别在27024、27021、27022三个节点上执行db.zhuzi.count()统计语句,会发现一个现象,此时数据全都落到了27022节点上,27021节点没有一条数据,这是为啥呢?因为每个分片节点到底负责存放哪些范围内的数据,这是由MongoDB自身决定的,所以小数据量的测试,会发现数据分散不均匀,不过随着数据的增加和变动,数据分布会趋向于均匀。

那默认情况下,多久才会向另一个分片节点分发数据呢?即另一个分片节点的第一个Chunk块被填满后,Chunk的默认大小为64MB,这意味着27022节点存满64M数据后,mongos才会考虑向27021分发数据!

啥叫Chunk?在前面稍微提到过这个概念,是指包含多个文档的数据块,也是集群分割、存储数据的基本单位,这里详细解释一下。每个Chunk都有一个由最大值、最小值形成的范围(基于分片键的值来定义),每个Chunk还有一个分片标识,用于表示该Chuck所属的分片节点,mongos可以根据分片标识,来将一个请求分发到具体的分片节点上,这些信息,大家可以在mongosconfig.chunks集合中查看。

大家可以通过下述这些命令来管理Chunk数据块:

  • sh.moveChunk():手动移动一个数据块到指定的分片。
  • sh.splitAt():手动在指定分片键的值处切分一个数据块。
  • sh.splitFind():根据查询条件切分一个数据块。

但通常Chunk的划分、迁移是由MongoDBBalancer平衡器自动管理,平衡器会根据ChunkSize来划分Chunk,也会根据集群的负载和数据分布情况,自动进行数据迁移操作,以保持各分片节点的数据平衡及查询性能,而迁移的原则是:找到Chunk最多的节点,将其中某些Chunk挪动到数量最少的分片节点上

OK,关于Chunk的概念了解这些即可,最后提一嘴,如果Mongos正在迁移Chunk,这时出现读请求查询相应数据会被阻塞,直到数据迁移完成为止,数据迁移过程,大家可以通过sh.status()命令观察迁移进度。

3.5、不同版本中的分片集群

在不同版本的分片集群中,分片规则都有所限制,共存的限制如下:

  • ①分片键字段必须有索引,如果对空集合配置分片规则,会自动创建索引;如果对已有数据的集合配置分片规则,分片键字段必须手动创建匹配的索引,如使用哈希分片算法:则需手动创建哈希索引,否则会报错。
  • ②一个集群最多可以拓展1024个分片节点。
  • ③一个集合中同时只能存在一个分片键,不能、也无法指定多个。

4.4版本之前的MongoDB,还有如下限制:

  • ①分片键字段的值,大小不能超过512Bytes
  • ②哈希分片算法仅支持单字段的哈希分片键,例如{name:"hashed"}
  • insert一个文档时,必须插入分片键字段。
  • ④已经插入的文档,分片键字段的值不允许修改。

4.4版本之后,前面的限制进行了优化:

  • ①分片键字段没有数据大小限制。
  • ②支持复合哈希分片键,例如{_id:1, name:"hashed"}
  • insert一个文档时可以不插入分片键字段,不插入时会当做Null值处理。
  • ④修改一个文档时,如果分片键不是_id字段,则可以直接修改。

OK,到这里关于MongoDB分片集群的话题就此打住了,关于更多的知识,就需要大家自行在使用过程中慢慢探索~

四、MongoDB集群篇总结

本篇内容从最初的单机缺陷出发,逐步分析了不同体量下的集群方案,从这也能观察出如今存储技术的缩影,无论是MySQL,还是ES、Redis、FastDFS......,技术的发展路线总是惊人的相似,从最初的单节点服务,到后来的多副本容灾、读写分离,再到如今的分片式存储,以MySQL、Redis、MongoDB三者为例来对比:

数据库 单体 多副本 分布式
MySQL 单实例 主从集群 分库分表/TiDB
Redis 单实例 主从+哨兵集群 Redis-Cluster
MongoDB 单实例 副本集群 MongoDB-Cluster

从上表中不难发现?如今的存储产品都保持着这个趋势在发展,其实这也是后端技术的整体趋势,从单机过渡到分布式,都是迫于互联网时代井喷式增长所带来的流量压力,不断探索下达成的共识 !再以Java服务为例,从最开始的Tomcat单节点,到后来的Nginx水平集群,再到如今的SpringCloud、Dubbo分布式、微服务架构,同样遵循着前面所说的共识在发展,这究竟是为什么?

原因很简单,首先可以提升系统整体的吞吐量,提供更强的并发承载能力;其次,可以构建出高可用架构,整个系统不会由于某一服务单节点故障,从而引发系统整体不可用带来的雪崩现象;最后,还可以与云计算技术完美结合,实现灵活的弹性架构,根据实际业务发展去动态伸缩节点!

当然,这里也不得不提到一件有趣的事情,是关于MySQL的,在早几年的时间里,分库分表技术炙手可热,几乎每一个后端开发者想进阶提升时,就不免想起这块技术。反观近几年,分布式关系型数据库 ,这个名词如日方升,为什么?原因就在于分库分表的动态伸缩能力太过复杂,具体大家可参考《分库分表副作用篇-动态伸缩与流量迁移》章节,无法和云计算做到完美结合,为此,诸如TiDB、OceanBase......这类分布式关系型数据库,才成为了未来关系型数据库的新方向。

不过大家也不用太过担心,像MySQL、Oracle、PgSQL这类表现强劲的传统关系型数据库,也永远不会被淘汰,毕竟并不是每个系统都有那么大的流量,实际开发过程中,90%+都属于中小型项目,硬件成本有限,使用传统的关系型数据库足以满足开发需求。

好了,本篇文章就说到这里,咱们下一篇重点讲述Java中,如何结合SpringData来操作MongoDB数据库,毕竟前面这两篇内容,为了屏蔽语言之间的差异,都在使用MongoDB原生的语法进行阐述~

相关推荐
Ai 编码助手34 分钟前
MySQL中distinct与group by之间的性能进行比较
数据库·mysql
陈燚_重生之又为程序员1 小时前
基于梧桐数据库的实时数据分析解决方案
数据库·数据挖掘·数据分析
caridle1 小时前
教程:使用 InterBase Express 访问数据库(五):TIBTransaction
java·数据库·express
白云如幻1 小时前
MySQL排序查询
数据库·mysql
萧鼎1 小时前
Python并发编程库:Asyncio的异步编程实战
开发语言·数据库·python·异步
^velpro^1 小时前
数据库连接池的创建
java·开发语言·数据库
荒川之神1 小时前
ORACLE _11G_R2_ASM 常用命令
数据库·oracle
IT培训中心-竺老师1 小时前
Oracle 23AI创建示例库
数据库·oracle
小白学大数据1 小时前
JavaScript重定向对网络爬虫的影响及处理
开发语言·javascript·数据库·爬虫
time never ceases2 小时前
使用docker方式进行Oracle数据库的物理迁移(helowin/oracle_11g)
数据库·docker·oracle