标准 专业
多元 极客

MongoDB研究院(2)——Replication——副本集状态

同步

复制用于在多态服务器之间备份数据。MongoDB的复制功能是使用操作日志oplog实现的,操作日志包含了主节点的每次操作。oplog是位于local数据库中的一个固定集合(具体表现为oplog.rs)。副本节点通过查询这个集合就可以知道需要进行复制的操作。

首先,副本节点从当前使用的同步源中获取需要执行的操作;然后,在自己的数据集上执行这些操作;最后,副本节点再将这些操作写入自己的oplog.rs中。如果遇到某个操作失败的情况(一般只有当同步源的数据损毁或者数据与主节点不一致时才会发生),那么副本节点就会停止从当前的同步源复制数据。

如果某个副本节点挂掉了,当它重新启动后,就会自动从oplog.rs中的最后一个操作开始同步。由于复制的过程是先执行操作再写入oplog.rs,所以,副本节点可能会在已经同步过的数据上再次自行复制操作。MongoDB预见了这这一情况的发生,所以将这种情况设计为:如果一个oplog.rs中,同一个操作执行多次,那么这与执行一次的效果是一样的。

通常,oplog.rs使用空间的增长速度与系统处理写请求的速度近乎相同。凡事都有例外,如果单次请求能够影响到多个文档,oplog.rs中就会出现多条操作日志;如果单个操作会影响多个文档,那么每个受影响的文档都会对应oplog.rs中一条日志。

初始化同步

什么是初始化同步?

副本集中的成员启动之后,就会检查自身的状态,确定是否可以从某个成员那里进行同步。如果不行,它会尝试从副本集中的另一个成员那里进行完整的数据复制,这个过程就是初始化同步(initial syncing)。

初始化同步包含以下几个步骤:

  1. 同步前的预备工作:包括选择一个成员作为同步源,在local数据库中的me集合中创建一个标识符,删除已经存在的数据库。
    [ReplicationExecutor] syncing from: Sunshine:27017
    
    [rsSync] initial sync drop all databases
    
    [rsSync] dropAllDatabasesExceptLocal 1
    
  2. 克隆:将同步源的所有记录全部复制到本地。
    [rsSync] initial sync clone all databases
    
    [rsSync] initial sync data copy, starting syncup
    
  3. 进入oplog第一步,该过程的所有操作都会记录在oplog.rs中。
    [rsSync] oplog sync 1 of 3
    
  4. 进入oplog第二步,用于将第一个oplog同步中的操作记录下来。
    [rsSync] oplog sync 2 of 3
    
  5. 同步基本完成,开始创建索引。如果集合比较大,或者需要创建的索引比较多,这个过程会很漫长。
    [rsSync] initial sync building indexes
    
  6. 如果当前节点的数据仍然远远落后于同步源,那么同步过程的最后一步就是将所有操作全部同步过来。
    [rsSync] oplog sync 3 of 3
    
    [rsSync] initial sync finishing up
    
    [rsSync] set minValid=(term: -1, timestamp: Jul 18 09:11:07:1)
    
  7. 已经完成初始化同步,切换到普通同步状态,当前成员可以成为副本节点了。
    [rsSync] initial sync done
    
    [ReplicationExecutor] transition to RECOVERING
    
    [ReplicationExecutor] transition to SECONDARY
    

如果想要掌握MongoDB的运行流程,其实最好的方法就是查看服务器日志,MongoDB的风吹草动都会展现在服务器日志上。

在初始化同步过程中,经常遇到的问题是,克隆或者创建索引的时候所耗费的时间过长,导致成员与同步源的oplog严重脱节,新成员的同步速度赶不上同步源的变化速度,同步源可能会将成员所需要复制的数据覆盖掉。

这个问题没有有效的解决方法,除非在不太忙的时候执行初始化同步,或者从备份(注意不是指副本集中的副本节点)中恢复数据。如果新成员与同步源的oplog脱节,初始化同步就无法正常进行。

陈旧数据处理

如果一个副本节点远远落后于同步源当前的操作,那么这个副本节点就是陈旧的(stale)。

当一个副本节点陈旧之后,它会查看副本集中的其他成员,如果某个成员的oplog足够详尽,可以用处理那些落下的操作,就从这个成员处开始同步。如果任何一个成员的oplog都没有参考价值,那么这个成员上的复制操作就会中止,这个成员需要重新进行完全同步(或者从最近的备份中恢复)。

为了避免陈旧备份节点的出现,让主节点使用比较大的oplog保存足够多的操作日志是很重要的。

心跳

什么是心跳?

副本集中的每个成员都需要知道其他成员的状态,为了维护集合的最新视图,每个成员每隔两秒钟就会向其他成员发送一个心跳请求(heartbeat request),心跳请求的信息量非常小,用于检查每个成员的状态。

心跳最重要的功能之一就是让主节点知道自己是否满足集合“大多数”的条件。如果主节点不再得到“大多数”服务器的支持,它就会退位,变成备份节点。

成员状态

各个成员会通过心跳将自己的当前状态告诉其他成员,我们已经知道两种状态:主节点和副本节点,下面我们来介绍一些其他的常见状态:
STARTUP
成员刚启动时处于这个状态。在这个状态下,MongoDB会尝试加载成员的副本集配置。配置加载成功后,就进入STARTUP2状态。
STARTUP2
整个初始化同步过程都处于这个状态。
RECOVERING
这个状态表明成员运转正常,但是暂时还不能处理读取请求。启动时,成员需要做一些检查以确保自己处于有效状态,之后才可以处理读取请求。在启动过程中,成为副本节点之前,每个成员都要经历RECOVERING状态。在处理非常耗时的操作时,成员也可能是进入RECOVERING状态。当一个成员与其他成员脱节时,也会进入RECOVERING状态,
ARBITER
在正常操作中,仲裁者应该始终处于ARBITER状态。
系统出现问题时,成员将会处于下面这些状态:
DOWN
如果一个正常运行的成员变得不可达,它就处于DOWN状态。注意,如果有成员被报告为DOWN状态,他有可能处于正常运行状态,不可达的原因可能是网络问题。
UNKNOWN
如果一个成员无法到达其他成员,其他成员就无法知道它处于什么状态,会将其报告为UNKNOWN状态。通常,这表明这个未知状态的成员已经挂掉了,或者是两个成员之间存在网络访问问题。
REMOVED
当成员被移除副本集时,它就处于这个状态。如果被移除的成员又重新被添加到副本集中,它就会回到“正常”状态。
ROLLBACK
如果成员正在进行数据回滚,它就会处于ROLLBACK状态。回滚结束后,ROLLBACK状态会转变为RECOVERING,没有问题后成为副本节点。
FATAL
如果一个成员发生了不可挽回的错误,它不再或者不想恢复到正常情况,它就处于FATAL状态。应该了解成员转变为FATAL状态的原因

回滚

假设一个副本集中拥有两个数据中心,数据中心A拥有一个主节点和一个副本节点,数据中心B拥有三个副本节点。如果这两个数据中心之间出现了网络故障,其中数据中心A中主节点的最后操作是X,但是X并没有被复制到数据中心B。由于数据中心A、B无法连通,所以数据中心B会根据”大多数“的原则选举出主节点,这个新的主节点会继续执行新的操作,并将记录写入oplog.rs中。

等到网络恢复后,数据中心A中的服务器就会从其他服务器开始同步操作X之后的操作,但是在数据中心B的任意一个成员的oplog.rs中,都找不到操作X记录的存在(操作X的记录在数据中心B的oplog中找不到共同点)。

当这种情况发生时,数据中心中的两个成员会进入回滚状态,将未复制操作撤销。数据中心A中的成员会在数据中心B的成员的oplog上寻找共同点,最终定位到了X-1操作。

这时,数据中心A中的成员会查看这些没有被复制的操作,将受这些操作影响的文档写入到”../data/rollback”目录下的.bson格式文件中(如果操作X是一个更新操作,服务器会将被操作X更新的文档写入collectionName.bson文件)。然后会从当前主节点复制这份文档。

如果回滚的数据量大于300MB,或者要回滚30分钟以上的操作,回滚就会失败,对于回滚失败的节点,必须进行重新同步。

副本集连接

等待写入复制

如果希望不管发生什么,都将写入操作保存到副本集中,那么必须要确保写入操作被同步到了大多数的副本集。

我们使用“getLastError()”命令检查写入是否成功,也可以使用这个命令确保写入操作被复制到副本节点。

参数“w”会强制要求getLastError等待,一直到给定数量的成员都执行完了最后的写入操作。

MongoDB有一个特殊的关键字可以传递给参数“w”:majority。

sunshine:PRIMARY> db.runCommand({"getLastError" : 1, "w" : "majority"})

只有当使用了”w”参数并且最后的操作被复制到多个服务器时才会有”writtenTo”这个属性。

假设在执行这个命令的时候,只有一个主节点和一个仲裁者节点可用,那么主节点就无法将这个写操作复制到副本集中的任何成员。getLastError命令不知道应该等待多久才会完成所有的复制写入,所以它会一直等待下去,这个时候应该为”wtimeout”参数指定值,它的值是getLastError命令的超时时间,如果超过这个时间结果集还没有返回,就会返回MongoDB无法再指定时间内将写入操作复制到”w”个成员。

getLastError命令在指定了”w”参数后,可能会由于多种原因导致失败。

比如说,副本集中的部分成员挂掉,或者落后于主节点,或者由于网络问题没有心跳。

如果该命令无法在”wtimeout”设置时间内返回结果集,并不代表没能在指定时间内复制到”w”参数指定数量的成员。

写操作可能已经被复制到了一些成员中,而且会尽快地复制到其他成员。

“majority”并不是唯一一个可以传递给getLastError的”w”参数的值,还可以传入指定 的任意整数,比如:

sunshine:PRIMARY> db.runCommand({"getLastError" : 1, "w" : 2, "wtimeout" : 1000})

这个命令在1000ms内等待,直到写操作被复制到两个成员。

注意,”w”参数的值包含了主节点。如果希望写入操作被复制到n个副本节点,应该将”w”参数的值指定为n+1,将”w”设置为1相当于没有传入”w”选项,因为MongoDB默认会检查主节点是否执行了写操作。

使用常量数值的弊端在于,如果副本集的配置发生了变化,就需要修改程序代码来匹配之前的平衡。

自定义复制保证规则

副本集允许创建自己的规则,并且可以传递给getLastError,以保证写操作被复制到所需的服务器上。

不同数据中心之间,极有可能发生心跳不通的情况。在执行写入操作后,确认成功之前,保证写入操作复制到每一个数据中心的至少一台服务器上,这样,万一某个数据中心挂掉,其他的数据中心也会有一份最新的本地数据副本。要实现这种机制,首先按照数据中心对成员分类,可以在副本集配置中添加一个”tags”字段,tags字段是一个对象,每个成员可以拥有多个标签。

sunshine:PRIMARY> var config = rs.config()
sunshine:PRIMARY> config.members[0].tags = {"data_center" : "center"}
sunshine:PRIMARY> config.members[0].tags = {"data_center" : "left"}
sunshine:PRIMARY> config.members[0].tags = {"data_center" : "right"}

接下来创建自己的规则,可以通过在副本集配置中创建”getLastErrorMode”字段实现。

每条规则的形式都是”name” : {“key” : “value”}。

“name”字段就是规则的名称,”key”字段就是tags的键,number的意思是保证写操作复制到number个分组,每个分组内至少一台服务器上,这很好的解决了刚刚在上面谈到的”w”参数指定任意整数耦合度较高的问题。

sunshine:PRIMARY> db.runCommand({"getLastError" : 1, "w" : {"perDataCenter" : {"data_center" : 2}}})

这个命令代表着,写入操作必须复制到2个分组,每个分组内至少一台服务器上,也就是说,除了Sunshine:27017主节点外,Sunshine:27018和Sunshine:27019两个节点至少有一台拥有最新的写入操作。
通常,隐藏节点在某种程度上是高级兵种:发生故障的时候,不会转移到隐藏节点,读操作也不能路由到隐藏节点。
假设我们拥有五个成员,hide0到hide4,其中,hide4是隐藏节点,如果我们希望确保写操作会被复制到非隐藏节点的大多数,也就是hide0到hide3中的至少三个成员,我们要这样创建规则,首先,为各个成员设置标签:

sunshine:PRIMARY> var config = rs.congig()
sunshine:PRIMARY> config.members[0].tags = [{"show" : "1"}]
sunshine:PRIMARY> config.members[1].tags = [{"show" : "2"}]
sunshine:PRIMARY> config.members[3].tags = [{"show" : "3"}]
sunshine:PRIMARY> config.members[4].tags = [{"show" : "4"}]

然后添加并使用如下规则:

sunshine:PRIMARY> config.settings.getLastErrorModes = [{"showMajority" : {"show" : 3}}]
sunshine:PRIMARY> rs.reconfig(config)
sunshine:PRIMARY> db.runCommand({"getLastError" : 1, "w" : "showMajority", "wtimeout" : 1000})

自定义规则可以无限制的创建。归纳总结创建自定义规则的经验,具体有两个步骤:

  1. 使用键值对设置成员的“tags”字段。这里的键用于描述分组,值表示分配给这个服务器的所属分组。
  2. 基于刚刚创建的分组创建规则。规则总是形如{“name” : {“key” : number}}的形式,表示写操作返回成功之前需要复制到至少number个分组,每个分组内的一台服务器上。
    规则是一种非常强大的副本集配置方式,虽然它理解和设置起来都有些复杂。除非有特殊的强需求,否则使用”w”:1或者”w”:”majority”就已经非常安全了。

将读请求路由至副本节点

默认情况下,各种语言使用的连接驱动默认会将所有的请求都路由到主节点,但是可以通过设置连接驱动的首选项来配置将查询路由到服务器的类型,下面会通过分析来权衡路由到副本节点的利弊。

出于一致性的考虑

  1. 对一致性要求非常高的应用程序,不应该从备份节点读取数据。
    副本节点通常会落后主节点几毫秒,但是,不能保证一定是这样的。由于加载问题,配置问题,网络问题等原因,副本节点可能会落后于主节点几分钟,几小时。所以如果将读请求路由到一个远远落后于主节点的副本节点,客户端不会感觉到任何问题,这就可能造成了读取到未能及时更新的数据。
  2. 应用程序需要读取它自己的写操作。例如,先插入一个文档,紧接着再查询它。如果路由到副本节点,可能这个写操作还没有来得及复制到副本节点。这种情况在Java连接驱动中很容易出现,因为mongo-java-driver默认写操作的参数”w”:1,也就是说写操作的规则仅仅是写入主节点,副本节点需要通过同步才能将这个插入的文档收纳进来,但是客户端的请求速度可能会比副本节点的同步速度快,所以可能会读取不到相应的文档,造成不必要的错误。但是这种情况可以避免,就是通过使用”w”参数,在写操作返回之前,复制到所有的节点,但是这会损耗一部分的性能。

出于负载的考虑

用户之所以会将读请求路由到副本节点,其实就是想实现分布式负载。

如果你的主节点只能支持每秒10000次的查询操作,而现有的业务场景需要每秒进行30000次的查询操作,那么一个主节点加上两个副本节点在浅显层面上可以解决你的问题。

但是,这种饱和状态很容易导致系统意外过载,一旦出现这个问题,很难恢复。

如果是一个主节点和三个副本节点,当其中一个节点挂掉,副本集中的成员依然可以正常工作。看似解决了问题,但是如果这个挂掉的节点恢复后,势必会从副本集中的其中一个成员处进行数据复制,这就会导致其他成员的过载。

过载会导致副本性能降低,然后会导致剩余的副本节点远远落后于主节点,这会引起一连串的连锁反应,进而整个副本集崩溃掉。

这种情况的解决方法是,及时并明确每台服务器的负载情况,以及整个系统需要的负载情况,根据走势进行分析,及时扩容或者解决问题。另一种解决办法就是尝试使用分片作分布式负载。

赞(1) 投币

评论 抢沙发

慕勋的实验室慕勋的研究院

码字不容易,路过请投币

支付宝扫一扫

微信扫一扫