程序员阿沛
发布于 2026-06-27 / 0 阅读
0
0

深入学习mongodb五mongodb副本集的复制同步和选举机制

深入学习mongodb(五) mongodb副本集的复制、同步和选举机制

本节我们介绍mongodb的复制功能,复制功能指mongodb服务将部署到多个节点(一般来说一个节点会单独部署到一条机器)上,这些节点包含一个主节点和多个从节点,主节点接收客户端的写请求,从节点则复制和同步主节点的数据,从而使得每个节点都有完整数据集的所有数据,即使一个或多个节点出错崩溃,也可以保证应用程序正常运行和数据安全。

在MongoDB中,创建一个副本集之后就可以使用复制功能了。

副本集是一组服务,其中有一个主服务(primary),用于处理客户端请求(主节点);

还有多个备份服务(secondary),用于保存主服务的数据副本(从节点)。如果主服务崩溃了,备份服务会自动将其中一个成员升级为新的主服务。

一、实操创建一个mongodb副本集(单机)

下面正式实操一个mongodb的副本集例子(需要以/data/db为数据的存储路径,请确保该路径存在却权限为777)。

通过执行下面的命令就可以创建一个副本集,由于使用的是ReplSetTest测试类,因此该副本集的所有节点都在同一台机器上。

PS:ReplSetTest是用于测试mongodb的复制和副本功能的类,上线时创建副本集并不适用此命令。这些设置不适用于生产环境,但是可以让你熟悉复制功能以及相关的各种配置。

replicaSet  =  new  ReplSetTest({"nodes"  :  3})

这行代码可以创建一个包含三个mongodb服务的副本集:一个主节点和两个备份节点。

执行下面两个命令之后mongod服务才会真正启动:

replicaSet.startSet()       //  启动3个mongod进程replicaSet.initiate()       //  配置复制功能

现在已经有了3个mongod进程,分别运行在2000、20001和20002端口。其中,主节点2000接收写请求,其他两个节点会同步主节点2000的数据,因此整个副本集会保持有3份独立完整的数据。

这3个进程都会把各自的日志输出到当前shell中,这会让人很混乱。所以要开启一个新的shell用于工作。

在第二个shell中连接到运行在20000端口的mongod:

>  conn1  =  new  Mongo("localhost:31000")connection  to  localhost:31000>  primaryDB  =  conn1.getDB("test")        // 从conn1这个连接的节点中获取test这个库。当前shell连接的其实还是27017端口的那个mongod服务,此时db变量其实也还是27017的库。primaryDB才是20000服务下的库test

可以看到副本集的状态:

>  primaryDB.isMaster(){  "topologyVersion" : {          "processId" : ObjectId("604968c1f3cc49361376e293"),          "counter" : NumberLong(8)  },  "hosts" : [          "VM-0-13-centos:20000",          "VM-0-13-centos:20001",          "VM-0-13-centos:20002"  ],  "setName" : "__unknown_name__",  "setVersion" : 3,  "ismaster" : true,  "secondary" : false,  "primary" : "VM-0-13-centos:20000",  "me" : "VM-0-13-centos:20000",  "electionId" : ObjectId("7fffffff0000000000000001"),  "lastWrite" : {          "opTime" : {                  "ts" : Timestamp(1615423820, 1),                  "t" : NumberLong(1)          },          "lastWriteDate" : ISODate("2021-03-11T00:50:20Z"),          "majorityOpTime" : {                  "ts" : Timestamp(1615423820, 1),                  "t" : NumberLong(1)          },          "majorityWriteDate" : ISODate("2021-03-11T00:50:20Z")  },  "maxBsonObjectSize" : 16777216,  "maxMessageSizeBytes" : 48000000,  "maxWriteBatchSize" : 100000,  "localTime" : ISODate("2021-03-11T00:55:54.169Z"),  "logicalSessionTimeoutMinutes" : 30,  "connectionId" : 22,  "minWireVersion" : 0,  "maxWireVersion" : 9,  "readOnly" : false,  "ok" : 1,  "$clusterTime" : {          "clusterTime" : Timestamp(1615423820, 1),          "signature" : {                  "hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),                  "keyId" : NumberLong(0)          }  },  "operationTime" : Timestamp(1615423820, 1)}

“ismaster” : true指明了这是一个主节点,副本集中还有一个hosts 列表。

可以从"primary" 字段获知主节点是哪一个,然后重新连接到主节点所在的主机/端口就可以了。

既然已经连接到主节点,就做一些写入操作看看会有什么发生吧!首先,插入1000个文档:

> primarydb.createCollection("zbp")> zbp = primarydb.zbp> for(i=0;i<1000;i++){zbp.insert({count:i})}> zbp.count()           // 1000

此时再连接到20001这个服务,并查看信息:

conn2 = new Mongo("localhost:20001")secondarydb = conn2.getDB("test")secondarydb.isMaster()      // ismaster为false,是从节点zbp2 = secondarydb.zbpzbp2.find()          // 尝试在从节点查询刚刚在主节点插入的数据

结果返回报错:

error:  {      "$err"  :  "not  master  and  slaveok=false",      "code"  :  13435  }

报错的原因是:为防止备份节点(的数据)可能会落后于主节点,从而导致应用程序意外拿到过期的数据,备份节点在默认情况下会拒绝读取请求。

如果希望从备份节点读取数据,需要设置“从备份节点读取数据没有问题”标识,如下所示:

> conn2.setSecondaryOk()

slaveOk是对连接(例子中是conn2)设置的,不是对数据库(secondaryDB)设置的,因此如果你用一个新的连接读取数据时需要再调用一次setSecondaryOk()方法。
现在就可以从这个备份节点中读取数据了。

现在试着在conn2上执行写入操作:

secondaryDB.coll.insert({"count"  :  1001})

结果报错:

WriteCommandError({    "topologyVersion" : {            "processId" : ObjectId("604968c176e0e68c94537666"),            "counter" : NumberLong(4)    },    "operationTime" : Timestamp(1615424461, 107),    "ok" : 0,    "errmsg" : "not master",    "code" : 10107,    "codeName" : "NotWritablePrimary",    "$clusterTime" : {            "clusterTime" : Timestamp(1615424461, 107),            "signature" : {                    "hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),                    "keyId" : NumberLong(0)            }    }})

可以看到,不能对备份节点执行写操作。 备份节点只通过复制功能写入数据,不接受客户端的写入请求。

mongodb的副本集能够自动故障转移,如果主节点挂了,其中一个备份节点会自动选举为主节点。 接下来我们模拟下这个过程:

primarydb.adminCommand({"shutdown":1})      // 关闭20000的主节点>  secondaryDB.isMaster()   // 此时20001晋升主节点

第一个检测到主节点挂了的备份节点会成为新的主节点。

现在回到第一个shell,执行下面的命令关闭副本集(这个shell中现在应该充满了大量的副本集成员输出日志,不过敲几次Enter键之后就可以看到命令提示符了)

> replicaSet.stopSet()

有几个关键的概念需要注意。

客户端在任意一个节点上可以执行的请求,都可以发送到主节点执行(如读、写、执行命令、创建索引等)。

客户端不能在备份节点上执行写操作。

默认情况下,客户端不能从备份节点中读取数据。在备份节点上显式地执行setSecondaryOk之后,客户端就可以从备份节点中读取数据了。

二、实操创建一个mongodb副本集(多机)

上面的例子是在一台机器上部署多个副本集,下面我们会在多台机器上建立复制功能。

只有一台机器的朋友可以在一个机器上开启多个mongd进程来模拟多机器的mongd副本集,但是多个mongd进程需要绑定不同的接口(27017,27018,…),而且指定不同的数据目录(/data/db1,/data/db2,…),否则在启动服务时会报错。同一机器上两个端口不同的mongd进程不能共用一个数据目录。

假设你有一个运行在server-1:27017上的单个mongod实例(或者没有也可以),使用–
replSetname选项重启server-1的mongodb服务。

# mongod --port "YOUR_PORT" --dbpath "YOUR_DB_DATA_PATH" --replSet "YOUR_REPLICA_SET_INSTANCE_NAME" -f  mongod.conf  --forkmongod --replSet zbpset --fork --logpath=/var/log/mongodb/mongod.log

通过上述命令,我们在server-1这台机器上启动了一个名为 zbpset 的副本集,当然这条命令只启动了副本集的一个mongodb节点。

其中 --fork 表示后台运行(用–fork的时候必须使用–logpath指定日志的位置),当然是用 nohup &也可以后台运行。 --relSet
后接自定义的集合实例名称。mongodb的配置文件路径在yum安装下一般是/etc/mongod.conf。

使用同样的replSet和集合实例名称启动另1个服务器(下面会把它称作机器server-2)作为副本集中的其他成员。

如果不想在命令行添加那么多选项,可以将这些选项写在配置文件中。

# /root/mongdb.conflogpath=/var/log/mongodb/mongod.loglogappend=trueport=27017fork=truedbpath=/data/dbreplSet=zbpsetauth=true       
# 要进行密码权限验证keyFile=/root/keyfile       
# 用于密码和权限验证的证书,可以通过openssl rand -base64 90 -out /root/keyfile生成,设置权限为600bind_ip=0.0.0.0         
# 允许其他ip远程连接(否认节点与节点之间同步会失败),不过一定要设置新用户的密码和权限验证,否则可能被攻击

此时只需在启动时指定配置文件即可加载所有配置项即可。

mongod --config=/root/mongdb.conf

现在应该有2个分别运行在不同服务器上的mongod实例了。但是,每个mongod都不知道有其他mongod存在,而且也没有区分谁是主节点谁是从节点。

为了让每个mongod能够知道彼此的存在,需要创建一个用于同步的配置文件,在配置文件中列出每一个成员,并且将配置文件发送给server-1,然后server-1会负责将配置文件传播给其他成员。(此时server-1和server-2有相同的keyfile,因此二者同步的时候会自动校验权限。即使不设置新用户二者也可以成功同步,但是不设置新用户且bind_ip=0.0.0.0的话,就很可能被攻击)。

首先创建这个用于同步的副本集配置文件。 在server-1的mongo shell中,创建一个如下所示的副本集配置:

config  =  {  "_id"  :  "zbpset",     // 副本集名称  "members"  :  [       // 下面server-1和server-2请替换为自己的机器ip      {"_id"  :  0,  "host"  :  "server-1:27017"},      {"_id"  :  1,  "host"  :  "server-2:27017"},  ]}
rs.initiate(config)     // 对副本集进行初始化rs.status()             // 查看副本集的状态rs.config()             // 查看副本集的配置

server-1会解析这个配置对象,然后向其他成员发送消息,提醒它们使用新的配置。
所有成员都配置完成之后,它们会自动选出一个主节点,然后就可以正常处理读写请求了。

如何区分哪个服务是主节点呢?只要看到命令行的标头就行。

zbpset:SECONDARY>     从节点zbpset:PRIMARY>       主节点

从节点不能进行写操作。而且想要在从节点进行读取,必须先执行rs.secondaryOk(),否则从节点也是不能读取的。

副本集所有的成员都可以接受读操作。但是,默认情况下,读请求都是会将读操作直接定向至主节点。

几个注意点:

1.在启动副本集的mongd服务之前,先起一个普通的(非副本集内的)mongd服务,然后设置新用户用于权限验证(如果已经有可以用于权限验证的用户可以忽略这条)。

然后再执行mongd
–config=/root/mongdb.conf启动副本集的mongd服务,并用db.auth()进行登录。另外登录的时候,需要在你创建用户的库登录,例如如果你是在admin这个库创建的用户A,那么下次进入命令行并希望读写test库的内容时,必须先切到admin这个库用用户A登录(在test库登录会失败,因为test库没有A这个用户),再切回test库进行操作才能成功。

2.启动mongd后,请用ps和netstat命令查看mongd是否正常启动,有时候用ps命令看到mongd进程已经起来了,但是用netstat查看27017并没有被占用,那么其实mongd还是没有正常开启。如果mongd没有正常开启请查看错误原因,一般错误原因errmsg会直接在命令行显示,但是如果使用了
–fork选项,就只能在日志文件(该日志文件的位置是自定义的)查看。

3.必须使用mongo shell来配置副本集。没有其他方法可以基于文件对副本集进行配置。

4.如果多个节点所在的机器都设置了keyfile和bind_ip=0.0.0.0的情况下没有通信成功,此时可能是各自mongd进程的端口没有对外开放,只需开放mongd进程的端口的防火墙即可。

rs辅助函数

注意上面的rs.initiate()命令中的rs。rs是一个全局变量,其中包含与复制功能相关的辅助函数(可以执行rs.help()查看可用的辅助函数)。

副本集内的每个成员都必须能够连接到其他所有成员(包括自身)。

另外,副本集的配置中不应该使用localhost作为主机名而应该使用ip作为主机名。如果所有副本集成员都运行在同一台机器上,那么localhost可以被正确解析,但是运行在一台机器上的副本集意义不大;如果副本集是运行在多台机器上的,那么localhost就无法被解析为正确的主机名。

修改副本集配置

可以随时修改副本集的配置:可以添加或者删除成员,也可以修改已有的成员。

rs.add("server-4:27017")        // 添加节点rs.remove("server-1:27017")     // 删除节点,该操作可能会导致整个副本集短暂的无法连接或写入

也可以以文档的形式为新成员指定更复杂的配置

rs.add({"_id"  :  5,  "host"  :  "spock:27017",  "priority"  :  0,  "hidden"  :  true})

删除成员时(或者是除添加成员之外的其他改变副本集配置的行为),会在shell中得到很多无法连接数据库的错误信息。这是正常的,这实际上说明配置修改成功了。重新配置副本集时,作为重新配置过程的最后一步,主节点会关闭所有连接。因此,shell中的连接会短暂断开,然后重新自动建立连接。

重新配置副本集时,主节点需要先退化为普通的备份节点,以便接受新的配置,然后会恢复。要注意,重新配置副本集之后,副本集中会暂时没有主节点,之后会一切恢复正常。

可以在shell中执行rs.config()来查看配置修改是否成功。

>  rs.config(){  "_id"  :  "testReplSet",        "version"  :  2,  "members"  :  [      {              "_id"  :  1,              "host"  :  "server-2:27017"      },      {              "_id"  :  2,              "host"  :  "server-3:27017"      },      {                        "_id"  :  3,              "host"  :  "server-4:27017"      }  ]}

每次修改副本集配置时,"version"字段都会自增,它的初始值为1。

除了对副本集添加或者删除成员,也可以修改现有的成员。为了修改副本集成员,可以在shell中创建新的配置文档,然后调用rs.reconfig。假设有如下所示的配置:

>  rs.config(){"_id"  :  "testReplSet",        "version"  :  2,"members"  :  [  {      "_id"  :  0,      "host"  :  "server-1:27017"  },  {      "_id"  :  1,      "host"  :  "10.1.1.123:27017"  },  {                              "_id"  :  2,      "host"  :  "server-3:27017"  }]}

其中"_id"为1的成员地址用IP而不是主机名表示,需要将其改为主机名表示的地址。首先在shell中得到当前使用的配置,然后修改相应的字段:

>  var config  =  rs.config()>  config.members[1].host  =  "server-2:27017">  rs.reconfig(config)      // 需要使用rs.reconfig辅助函数将新的配置文件发送给数据库

对于复杂的数据集配置修改,rs.reconfig通常比rs.add和rs.remove更有用,比如修改成员配置或者是一次性添加或者删除多个成员(add和remove只能增加和删除1个节点,不能是多个)。

可以使用rs.reconfig命令做任何合法的副本集配置修改:只需创建想要的配文档然后对其调用rs.reconfig。

三、选举机制

当一个备份节点无法与主节点连通时,它就会联系并请求其他的副本集成员将自己选举为主节点。其他成员会做几项理性的检查:自身是否能够与主节点连通?希望被选举为主节点的备份节点的数据是否最新?有没有其他更高优先级的成员可以被选举为主节点?

如果要求被选举为主节点的成员能够得到副本集中“大多数”成员的投票,它就会成为主节点。即使“大多数”成员中只有一个否决了本次选举,选举就会取消。如果成员发现任何原因,表明当前希望成为主节点的成员不应该成为主节点,那么它就会否决此次选举。

在日志中可以看到得票数为比较大的负数的情况,因为一张否决票相当于扣掉10000张赞成票。如果某个成员投赞成票,另一个成员投否决票,那么就可以在消息中看到选举结果为-9999或者是比较相近的负数值。

每个成员都只能要求自己被选举为主节点。简单起见,不能推荐其他成员被选举为主节点,只能为申请成为主节点的候选人投票。

成员配置选项

有时我们并不希望每个成员都完全一样。你可能希望让某个成员拥有优先成为主节点的权力,或者是让某个成员对客户端不可见,这样便不会有读写请求发送给它。在副本集配置的子文档中可以为每个成员指定这些选项。

选举仲裁者

一般而言,副本集中的节点数会在3及以上,2个节点的副本集在选举上会有一定的缺陷。如果我们的应用对复制的要求不高,真的只用2台节点存储数据的话,我们可以设置一个“仲裁者”节点来帮助选举。仲裁者的唯一作用就是参与选举。仲裁者并不保存数据,也不会为客户端提供服务,所以可以将仲裁者作为轻量级进程,运行在配置比较差的服务器上。

启动仲裁者与启动普通mongod的方式相同,使用"–replSet
副本集名称"和空的数据目录。再使用rs.addArb()辅助函数将仲裁者添加到副本集中:

rs.addArb("server-5:27017")

也可以不用rs.addArb(),而是用rs.add()并指定arbiterOnly选项,这与上面的效果是一样的:

rs.add({"_id"  :  4,  "host"  :  "server-5:27017",  "arbiterOnly"  :  true})

成员一旦以仲裁者的身份添加到副本集中,它就永远只能是仲裁者:无法将仲裁者重新配置为非仲裁者,反之亦然。

使用仲裁者的另一个好处是:如果你拥有的节点数是偶数,那么可能会出现一半节点投票给A,但是另一半成员投票给B的情况。仲裁者这时就可以投出决定胜负的关键一票。

使用仲裁者的注意点:

只有在mongodb的节点数为偶数是才需要设置仲裁者,因为仲裁者的设置目的就是为了帮助选举,而奇数数量的节点不会有选举困难,因此不需要仲裁者节点。仲裁者的目的应该是避免出现平票,而不是导致出现平票。

而且最多只能使用一个仲裁者。

一个副本集最多可以有50个成员,但仅能有7个可投票成员。如果一个副本集的成员个数大于6的情况下为偶数其实也不会造成选举困难,因为如果成员数为偶数,可投票成员也会只有7个。

作者的建议是:尽量避免使用仲裁者,而是使用奇数的副本集。

优先级

优先级用于表示一个成员渴望成为主节点的程度。

优先级的取值范围可以是0~100,默认是1。

将优先级设为0有特殊含义:优先级为0的成员永远不能够成为主节点只能作为备份节点或从节点。这样的成员称为被动成员(passive member)。

拥有最高优先级的成员会优先选举为主节点(但前提是它的数据是最新的,如果一个优先级低的成员比一个优先级高的成员的数据新那么这个优先级低的成员会成为主节点)。

另外,只有主节点down掉的时候从节点才会开始选举新的主节点(但是有时候可能是主节点没down掉,但是由于网络原因从节点无法及时和主节点通信,从节点也会认为主节点down掉而开始选举)。

假如在副本集中添加了一个优先级为1.5的成员:

>  rs.add({"_id"  :  4,  "host"  :  "server-4:27017",  "priority"  :  1.5})

假设其他成员的优先级都是1,只要server-4拥有最新的数据,那么当前的主节点就会自动退位,server-4会被选举为新的主节点。

如果server-4的数据不够新,那么当前主节点就会保持不变。设置优先级并不会导致副本集中选不出主节点,也不会使数据不够新的成员成为主节点。

隐藏成员

很多人会将不够强大的服务器或者备份服务器隐藏起来成为隐藏成员,隐藏成员无法接收读请求,但是可以同步数据作为备份服务。客户端不会向隐藏成员发送请求,隐藏成员也不会作为复制源(尽管当其他复制源不可用时隐藏成员也会被使用)。

假设有一副本集如下所示:

>  rs.isMaster(){  "hosts"  :  [      "server-1:27107",      "server-2:27017",      "server-3:27017"     ] }

为了隐藏server-3,可以在它的配置中指定hidden : true。只有优先级为0的成员才能被隐藏(不能将主节点隐藏):

>  var  config  =  rs.config()>  config.members[2].hidden  =  0>  config.members[2].priority  =  0>  rs.reconfig(config)

现在,执行isMaster() 可以看到:

>  rs.isMaster(){  "hosts"  :  [      "server-1:27107",      "server-2:27017"  ],}

使用rs.status()和rs.config()能够看到隐藏成员,隐藏成员只对isMaster()不可见。

客户端连接到副本集时,会调用isMaster()来查看可用成员。因此,隐藏成员不会收到客户端的读请求(隐藏成员只能作为备份节点而不接收读请求)。

要将隐藏成员设为非隐藏,只需将配置中的hidden设为false就可以了,或者删除hidden选项。

延迟备份节点

数据可能会因为人为错误而遭受毁灭性的破坏:例如可能有人不小心删除了主数据库(此时从节点因为同步,也会删掉数据)。

为了防止这类问题,可以使用slaveDelay设置一个延迟的备份节点。

延迟备份节点的数据会比主节点延迟指定的时间(单位是秒),这是有意为之。这样,如果有人不小心摧毁了你的主集合,还可以将数据从先前的备份中恢复过来。

slaveDelay

slaveDelay要求成员的优先级是0。而且最好应该将延迟节点用hidden隐藏掉,否则当读请求被路由到延迟节点时就会查到延迟的数据而导致数据不一致问题。

备份节点不创建索引

有时,备份节点并不需要与主节点拥有相同的索引,甚至可以没有索引。

如果某个备份节点的用途仅仅是处理数据备份或者是离线的批量任务,那么你可能希望在它的成员配置中指定"buildIndexs" :
false。这个选项可以阻止备份节点创建索引。

这是一个永久选项,指定了"buildIndexes" :
false的成员永远无法恢复为可以创建索引的“正常”成员。如果确实需要将不创建索引的成员修改为可以创建索引的成员,那么必须将这个成员从副本集中移除,再删除它的所有数据,最后再将它重新添加到副本集中,让它重新进行数据同步。

另外,这个选项也要求成员的优先级为0。


评论