MongoDB爱好者
垂直技术交流平台

MongoDB复制技术内幕

复制(Replication) 是MongoDB内部的一个重要模块。复制模块将数据持续的从主节点(Primary)同步到从节点(Secondary),当主节点宕机后,从节点可以被选举成为新的主节点。这个功能对于用户是透明的,因为客户端会重试节点状态变更导致的错误。MongoDB的多个节点通过复制模块组成复制集(replica sets)

复制集是一组MongoDB节点,其中包含一个主节点和多个从节点。主节点负责处理所有的写请求,用户可以通过设置slaveOK参数读从节点,slaveOK并不是默认开启的参数。

 稳态复制(Steady State Replication) 

复制集在正常运行中的状态被称为稳态复制。这个过程中,有一个主节点和多个从节点,每个从节点从主节点同步数据,或者从另一个从节点链式同步数据。

主节点的职责(Life as a Primary)

写操作(Doing a Write)

当用户执行写操作时,主节点就像独立节点(Standalone Mode)一样对数据库执行写操作。副本集节点与独立节点写入的一个不同之处在于,副本集节点有一个OpObserver,只要对数据库执行写操作,副本集节点 就会在oplog中插入一条文档,以描述该写操作。oplog是local数据库中一个CappedCollection,名为oplog.rs,WiredTiger有专门的针对Oplog进行优化,oplog集合也是MongoDB数据库中唯一一个不包含_id字段的集合。

如果一个写操作包含多个子操作,则每个子操作都有自己的oplogEntry。例如,使用隐式集合创建的插入操作(对一个不存在的表执行插入操作)会创建两个oplog,一个用于建表,一个用于插入。

任何一条Oplog都是幂等的,因此update操作中的$inc算符会被MongoDB改写为$set算符。从节点通过拉取其他节点的Oplog并回放来进行稳态复制

写操作可以指定write concern。当一个命令包含write concern时,该操作所在的线程会一直阻塞,直到操作产生的oplogEntries被write concern指定的节点复制。主节点需要跟踪从节点的最新状态(Oplog复制延时情况)来明确该命令何时返回。write concern可以指定要等待的节点数或多数节点(majority)。如果指定了majority,则写操作还将等待该操作已经包含在已提交的快照(committed snapshot)中,以便可以通过readConcern: { level: majority } 读取。

从节点的职责 (Life as a Secondary)

一般来说,从节点仅需要选择一个要从其同步数据的节点,即它们的同步源(sync source),从同步源的Oplog中拉取数据,然后将这些Oplog应用到本地的数据库中。

从节点在同步数据过程中还不断将自己的复制进度更新给自己的同步源,主节点利用从节点的同步进度判断当前写操作的write concern是否能被满足。

Oplog拉取(Oplog Fetching)

Oplog的拉取通过OplogFetcher模块完成。OplogFetcher模块不直接应用(应用指的是将Oplog写入本地数据库中)拉取到的Oplog,该模块将OplogEntries写入一个缓冲区中(OplogBuffer),有另一个线程从OplogBuffer中取出Oplog并应用。OplogBuffer是一个纯内存的阻塞式队列。

OplogFetcher的生命周期(Oplog Fetcher Lifecycle)

OplogFetcher是由BackgroundSync线程创建的,当节点处于SECONDARY状态时,BackgroundSync线程会持续运行。BackgroundSync处于一个while循环中,在每次循环中,它首先通过SyncSourceResolver选择一个同步源,然后启动OplogFetcher。在稳态复制时,OplogFetcher会持续地接收和处理来自同步源的Oplog。

OplogFetcher在运行过程中有很多情况会导致终止,比如它从同步源收到了一个错误码,再比如进程终止前BackgroundSync模块会退出,从而终止OplogFetcher。OplogFetcher每接收到一批Oplog,会校验Oplog的合法性。根据合法性校验的返回值,OplogFetcher可能会切换同步源,或者进入ROLLBACK状态,甚至退出进程。

Oplog Fetcher实现细节(Oplog Fetcher Implementation Details)

这里我们举个例子,以节点A作为同步源,节点B为要获取数据的节点。

OplogFetcher启动后,会首先与同步源节点A建立一个连接,通过这个连接,OplogFetcher会建立一个exhaust cursor来从节点A获取日志。这意味着,当节点B开始发出find和getMore命令后,节点A就将持续向节点B发送数据,而不需要节点B再发出getMore命令了。节点B的OplogFetcher发给源节点A的find命令满足下面的条件。

db.oplog.rs.find({ts:{$gte:X}}) // X 是上一批拉取到的Oplog的最后一条Oplog的时间戳

因此,find命令发出后,节点B每次应该都要收到返回数据。如果没有,那就说明节点A的日志是落后于节点B的,而不应该再作为B的源节点。如果返回了数据,但是返回的数据与B的最新Oplog不匹配,那就有两种可能性,一种是A相对于B来说太新了,A中最老的Oplog都比B中最新的Oplog新,另一种可能是B的Oplog已经与A分叉了,B需要ROLLBACK

在收到find命令的响应时,从节点会检查响应中的元数据来判断同步源节点是否仍然符合资格,从节点主要检查两方面

  • 该节点被选为同步源后有没有出现过回滚
  • 同步源数据是否依然领先于本节点(同步源不一定是主节点,因此同步源可能比本节点落后)
OplogFetcher在拉取Oplog的游标上指定awaitData: true, tailable: true参数,以便之后的每轮请求不立即返回数据,而是在maxTimeMS到期前等待更多数据一起返回。如果在maxTimeMS到期后没有返回数据,说明OplogFetcher在本轮请求没有获得数据,将会等待下一轮数据请求批次到来。
如果OplogFetcher在尝试连接到同步源或拉取Oplog时遇到任何错误,它将使用OplogFetcherRestartDecision来检查是否有足够的重试次数来创建新的exhaust cursor。OplogFetcherRestartDecision在需要时会自动重新连接到同步源,每当OplogFetcher成功在该批次接收到数据时,它将重试次数清零。如果它连续多次出错而重试次数用光,则可能表明建立的连接或同步源发生了错误。在这种情况下,OplogFetcher将关闭并显示错误状态。
OplogFetcher会由于各种原因而关闭。当每一批次数据成功地处理之后,OplogFetcher会决定是否继续从当前同步源进行同步数据。如果OplogFetcher决定继续,它将等待下一批次到达并重复操作。否则,OplogFetcher将终止,这将导致BackgroundSync选择新的同步源。更改同步源的原因包括:
1)本节点不再处于副本集中。
2)同步源节点不再处于副本集中。
3)用户使用了replSetSyncFrom命令来更改同步源。
4)链式复制选项(chainingAllowed)被关闭,该节点当前从主节点同步数据,与原同步源的链路被禁用。
5)同步源不是主节点,并且没有自己的同步源,这也就意味着同步源节点将不会及时获取新的日志条目,最终会导致从该同步源同步数据的节点滞后。
6)同步源节点最新的OpTime比另一个成员最新的OpTime 落后30秒(maxSyncSourceLagSecs)。这说明同步源已经滞后了,此时改变同步源可以减少同步源的读IO压力,使得同步源和其他节点尽量不要相差太远。
7)该节点发现了一个网络延时(ping值)更小的同步源。该节点到新的同步源的ping比到当前同步源至少小一个changeSyncSourceThresholdMillis时间, changeSyncSourceThresholdMillis也是一个服务器参数,默认值是5ms。

同步源选择过程(Sync Source Selection)

无论何时当一个节点发起同步数据,或者创建一个新的BackgroundSync(当不再是主节点时),或者当前的OplogFetcher产生错误时,该节点都必须获得一个新的同步源。
同步源节点的选择是由SyncSourceResolver类来完成的。
SyncSourceResolver调用ReplicationCoordinator来决定同步源的候选者列表。
ReplicationCoordinator再调用TopologyCoordinator类来实现该功能。

选择一个同步源候选者(Choosing a sync source candidate)

选择新的同步源候选者之前,TopologyCoordinator首先会检查用户是否使用replSetSyncFrom命令设置了特定的同步源。在这种情况下,从节点会选择该特定主机作为同步源,并且重置其状态,不再请求同步源。
如果链式复制选项被禁用,从节点只能从主节点同步,将主节点作为唯一的同步源候选者。 其他情况下,会遍历副本集中其他所有节点,来选出最适合的一个同步源候选者。
  • 首先,从节点会检查进程内的TopologyCoordinator模块的副本集视图,以了解各节点的最新OpTime时间。如果某个节点的最新OpTime比主节点落后maxSyncSourceLagSecs秒,就不会被列为候选人。
  • 然后,从节点会循环遍历每个节点,选择满足一系列琐碎的条件的最近节点。这里的“最近”取决于ping值。
  • 如果没有节点满足这个上述条件,BackgroundSync会等待1s然后重新发起同步源选择过程。

同步源探测(Sync Source Probing)

确定了同步源候选者后,SyncSourceResolver会探测同步源候者,以确保确实能够从同步源候者的oplog中获取数据。
  • 如果同步源候选者没有Oplog或者发生错误,从节点就会将该同步源放入黑名单一段时间,重新寻找新的同步源候选者。
  • 如果同步源候选者最老的OplogTime比本节点最新的OplogTime还要新,该节点仍然将该同步源候选者放入黑名单,由于该候选者太超前了。
  • 在节点初始化、回滚或从异常关闭中恢复期间,节点会初始化一个特殊的OpTime,叫做minValid,节点在可以安全的提供读操作或者角色变为SECONDARY之前,Oplog必须应用到超过minValid。因此同步源的minValid也会被检查是否满足条件。
  • 同步源的RollbackID也会被拉取并检查。
如果从节点相对于所有的同步源候选者都落后太久,它就会进入maintenance模式从而等待人工干预(Resync)。如果没有可用的候选者,BackgroundSync模块会等待1s,重新发起同步源选举过程。当从节点找到同步源时,会调用BackgroundSync 启动OplogFetcher。

Oplog应用(Oplog Entry Application)

一个独立的线程ReplBatcher运行OplogBatcher模块,OplogBatcher模块从oplog buffer中拉取oplog记录并创建下一个要应用(前面解释过,应用指的是将oplog回放到本地数据库中的过程)的batches。这些batches称为oplog applier batches,与oplog fetcher batches不同的是,oplog fetcher batches是生产者消费者模型中的生产者,oplog applier batches 是生产者消费者模型中的消费者。oplog fetcher batches是在从节点的oplog fetching过程中,由节点的同步源发送到该节点上的。一个batch中的操作会尽可能的被并行应用,但是并不是所有的操作都可以被并行应用的,比如说,一个dropDatabase操作必须和其他操作互斥。
OplogApplier负责应用oplog的具体工作。它运行在一个while循环中,每次循环做以下事情:
  1. 获取下一批Oplog
  2. 加PBWM(Parallel Batch Writer Mode)锁。
  3. oplogTruncateAfterPoint设置为节点上一次应用的optime(在此批次之前),以帮助节点在oplog写入的过程中异常关闭时恢复启动。
  4. 将这批oplog写入local.oplog.rs。
  5. 清除oplogTruncateAfterPoint并将minValid设置为这批oplog中最后一条oplog的optime。在应用到minValid optime之前,数据库的状态相对于oplog是不一致的。
  6. 多个线程并行应用oplog, 这意味着同一批oplog不会被顺序执行。
    • 每个批次中的操作会由不同的写线程执行,同一个线程中的操作串行执行。
    • 同一个collection的oplog一定会被同一个线程处理。
    • 不同collection的oplog可能会被分配到不同的线程处理。
    • 每个线程会将相邻的insert操作合并为一个bulk执行以提高性能,其他操作单独执行。
  7. 存储引擎(wiredTiger/rocksDB) Flush WAL。
  8. 持久化”applied through” optime(一批oplog中的最后一条oplog的optime)。由于这批oplog已经应用完了,oplog和数据库数据保持一致了,因此此时可以更新minValid记录了。
  9. 通知存储引擎(mongo-wiredTiger/mongoRocks)更新oplog可见性。由于oplog是并行应用的,因此只有在完整应用完一批oplog之后,更新oplog可见性才是安全的,否则会有oplog 空洞出现。
  10. 析构这批oplog,并推进全局时间戳(和节点的lastAppliedOpTime)。
复制和拓扑协调器(Replication and Topology Coordinators)
ReplicationCoordinator是复制模块向其他模块提供的公共api,它负责协调复制模块与系统其他部分的交互。
ReplicationCoordinator通过ReplicationCoordinatorExternalState与存储层及其他节点通信。ExternalState模块拥有和管理所有复制模块的所有线程。
TopologyCoordinator负责维护集群的拓扑状态。在集群拓扑状态发生变化(任何导致isMaster命令结果变化)时,TopologyCoordinator更新其TopologyVersion。isMaster命令在返回之前会等待TopologyVersion的更改。在进程退出时,如果是从节点,将进入静默模式:TopologyVersion自增1,并对isMaster请求响应一个ShutdownInProgress错误,以便客户端停止下发新操作给该节点。
TopologyCoordinator是非阻塞的,处理大部分复制命令的请求和响应。
通信(Communication)
每个节点在ReplicationCoordinator中都有一个ReplicaSetConfig的副本,它列出了副本集中的所有节点,每个节点通过ReplicaSetConfig和其他节点通讯。
每个节点使用内部客户端(src/mongo/client目录下的legacy c++驱动代码)相互通信。节点之间,使用一系列内部命令以及外部命令(用户命令)通信。节点之间的内部通信和用户访问mongo走的是同一个tcp端口(默认27017)。出于安全考虑,节点间使用keyfile进行相互认证。复制命令需要使用系统用户权限执行,因此向其他节点下发远程命令时,节点需鉴定当前用户为系统用户。
各节点定期与其他所有节点进行通讯,实现:
  • 检测其他节点是否存活(心跳)
  • 保持和主节点的同步(Oplog拉取)
  • 向自己的同步源更新同步进度(replSetUpdatePosition 命令)
每条oplog有一个唯一的OpTime用于描述操作何时发生,其他节点通过对比自己和别人的最新的oplog的OpTime得知各自的同步进度。
OpTime包括一个64位逻辑时间戳和一个term字段。Term字段表示自副本集启动以来发生了多少次选举。
选举协议,protocol version 1 or PV1, ,基于Raft协议,因此可以保证在同一个term不会选出两个主节点,这有助于区分在网络分区情况下,在同一时间段发生但又来自不同主节点的操作。

Oplog Fetcher返回值处理(Oplog Fetcher Responses)

OplogFetcher只是向同步源发出find和getMore命令,它的上游节点(同步源)是不会从请求中获取任何信息的。但是,下游节点,即向其同步源发出find命令的节点,会从同步源获得元数据,并使用该元数据来更新自己的副本集视图。
元数据分为两种:ReplSetMetadataOplogQueryMetadata。(OplogQueryMetadata是4.0之后新加的数据结构,为了向后兼容,结构中存在一些临时冗余变量)

ReplSetMetadata

所有复制相关的命令都会包含ReplSetMetadata,并且都会对ReplSetMetadata进行类似的处理。ReplSetMetadata包括:
(1)上游节点的last committed OpTime(这里的committed说的是被raft majority提交,不是本地事务提交)
(2)当前term
(3)ReplicaSetConfig的版本和term(用于判断是否上游节点发生了reconfig,但下游节点尚未执行)
(4) 副本集ID
(5)上游节点是否为主节点
如果其他节点的term大于本节点的term,就会将本节点的term更新,如果本节点是一个主节点,本节点会stepDown。
my.term = max(my.term, other.term)

OplogQueryMetadata

OplogQueryMetadata只来源于上游节点对OplogFetcher的响应。它包含:
(1)上游节点的last committed OpTime。该值的推进,会更新readConcern: majority读到的快照。
(2)上游节点最后一次写入的oplogTime(last applied OpTime)。
(3)上游节点认为的主节点的编号(通过ReplicaSetConfig)
(4)上游节点的同步源的编号
如果OplogQueryMetadata表明主节点依然存在,下游节点将重置其选举超时时间。下游节点用上游节点的last committed OpTime更新自己的,同时通知存储引擎层(mongo-wiredTiger/mongoRocks)推进oldestTimestamp以释放旧的快照。
在发送下一个getMore之前,下游节点使用元数据来检查它是否应该更改同步源。

心跳(Heartbeats)

HeartbeatInterval,在默认情况下被设置为2秒,每个节点使用replSetHeartbeat命令向其它节点发送心跳。这意味着心跳的数量与节点数量的平方成正比,这就是副本集中限制为最多50个成员的原因。每次心跳包含的数据RplSetHeartbeatArgsV1由以下组成:
(1)ReplicaSetConfig 的版本
(2)ReplicaSetConfig 的term
(3)ReplSetConfig的发送方的ID
(4)Term
(5)副本集名称
(6)发送方主机地址
远端节点收到心跳后,首先处理心跳数据,然后返回响应。首先,远程节点确保心跳中的replica set name与自己的replica set name相同,否则,它将发送一个错误。接收方节点的TopologyCoordinator更新MemberHeartbeatData ,重置最后一次收到心跳的时间。
如果发送节点的config比接收节点的config新(通过版本号比较),则接收节点会调度一个心跳来获取配置。接收节点的ReplicationCoordinator还会顺便更新SlaveInfo以保持和发送节点的SlaveInfo同步。有关通过心跳进行配置传播(Config Propagation)的详细信息,请参阅Reconfiguration一节。
接收节点会返回一个ReplSetHeartbeatResponse对象。这包括:
(1) 副本集名称
(2)接收节点的选举时间
(3)接收节点最后一个applied OpTime
(4) 接收节点的最后一个durable OpTime
(5) 接收节点的term
(6) 接收节点的状态
(7)接收节点的同步源
(8)接收节点的ReplicaSetConfig的版本号和term值
(9)接收节点是否为主节点
当发送节点接收到心跳响应时,像前面一样,它首先处理ReplSetMetadata。如果发送节点发现主节点,它将推迟选举时间。TopologyCoordinator更新自身HeartbeatData,标记接收节点是否依然存活。然后,发送节点的TopologyCoordinator查看响应,并决定采取下一步操作:
  • 无行为
  • 优先级变更导致再选主
  • reconfig
ReplicationCoordinator使用最近获得的OpTimes来更新接收节点的SlaveInfo。最后,TopologyCoordinator会调度下一次心跳,然后执行其设置的下一次动作。
如果因优先级变更导致再选主,那么本节点将所有节点按照优先级排序,并给自己设置一个选举定时器,选举定时器的超时时间和本节点的优先级有关。超时结束后,会再次检查本节点是否满足被选举的条件,如果满足,就进行一次选举。选举定时器的超时时间的计算公式如下
(election timeout) * (priority rank + 1)

提交点传播(Commit Point Propagation)

复制的多数commit point是指这样一个OpTime,所有OpTime早于或等于这个OpTime的Oplog已经被复制到副本集中的大多数节点。该commit point会受lastAppliedOptimelastDurableOpTime的影响。
在主节点上,我们通过检查大多数节点上lastAppliedOptimelastDurableOpTime的最大值来推进提交点。这个OpTime必须大于当前commit point,以便主节点可以推进它。阻塞在writeConcern上的任何线程都会被唤醒,以检查它们现在是否满足了它们请求的writeConcern。
当getWriteConcernMajorityShouldJournal配置为true时,_lastCommittedOpTime由lastDurableOpTime`推进,意味着只有大多数已经被WAL持久化的Oplog才被认为是安全的。否则就使用lastAppliedOpTime推进_lastCommittedOpTime,表示只要写入大多数节点内存的Oplog就认为是安全的。
从节点通过心跳,检查commit point是否和自己的lastAppliedOpTime有相同的term,这是为了保证Oplog历史不会分叉。

更新位置命令(Update Position Commands)

副本集节点之间定期通信的最后一种方式是使用replSetUpdatePosition命令。ReplicationCoordinatorExternalState在启动时创建一个SyncSourceFeedback对象,负责发送replSetUpdatePosition命令。
SyncSourceFeedback 发起一个循环。在每次迭代中,每当ReplicationCoordinator发现副本集中的某些节点复制了更多操作并变得更新时,就会通知一个条件变量。在继续下一步之前,检查节点是否处于PRIMARY或STARTUP状态。然后,它获取节点的同步源,并创建一个Reporter,由该Reporter向同步源发送replSetUpdatePosition命令。此命令每隔keepAliveInterval毫秒(electionTimeout / 2)发送一次,以维护副本集中节点的存活信息。replSetUpdatePosition命令是跟踪副本集节点存活状态的主要手段。因此,如果主节点无法直接与每个节点进行通信,但可以通过其他节点与每个节点间接通信,那么它仍然会保持主节点状态。
通过上述描述,我们可以看到,复制关系构成了一棵生成树(spanning tree),同步位置的更新不直接发给主节点,而是通过同步源一层层上报。即使主节点和从节点不联通,只要它们在生成树中有边可以到达,就依然是联通的。
replSetUpdatePosition命令包含以下信息:
  1. 包含每个副本集成员对象的optimes数组。该信息是来自ReplicationCoordinator的SlaveInfo信息。处于down状态的节点是不被包含在内的。每个节点包括:
    1. Last durable OpTime
    2. Last applied OpTime
    3. memberId
    4. ReplicaSetConfig版本
  2. ReplSetMetadata。通常,它只出现在响应中,但是这里它也出现在请求中。
当一个节点接收到一个replSetUpdatePosition命令时,它要做的第一件事就是让ReplicationCoordinator像之前那样处理ReplSetMetadata。
对于optimes数组中的每个节点的OpTime,会被更新到本地的副本集视图中,这会刷新optimes列表中每个节点的liveness信息。如果接收节点是主节点,会基于optimes数组推进commit point。
如果本地的副本集视图发生了更新,并且接收节点有一个同步源,那么它会将新信息推送给自己的同步源。除非出现错误,例如ReplSetConfig不匹配,否则replSetUpdatePosition命令响应中是不会包含任何信息的。
Read Concern
MongoDB中的所有读操作都是在某个时间点的数据快照上执行的。但是,对于除“快照隔离”以外的所有readConcern,如果存储引擎在执行读取时发生yield(yield指的是mongodb的QueryStage在批处理时定期释放快照和锁,让出资源的行为),则读取可能会在较新的快照上继续进行。因此,不能保证读取操作能够返回快照数据。这意味着,如果某些文档进行了更新,则在读取时可能跳过这些文档,并且自读取开始以来发生的任何更新都可能会被看到,也可能不会被看到。
Read concern可以随任何读命令一起执行,用来指定读操作在哪个一致性级别上应该得到满足。Read concern有5个:
(1)Local
(2)Majority
(3)Linearizable
(4)Snapshot
(5)Available
Local只返回节点上的最新数据。在主节点上,它通过读取存储引擎的最新快照来实现这一点。在从节点上,它在lastApplied执行时间戳读取,这样它就不会看到当前正在应用的Oplog产生的变更。有关Local ReadConcern在多文档事务中如何工作的信息,请参阅事务中的ReadConcern行为这一章节。
Majority从stable timestamp读(在代码中也称为last committed snapshot,名称出于历史遗留原因)。读取的数据仅表示已复制到副本集中大多数节点的oplog记录。在Majority读取中看到的任何数据都不会被回滚。因此,Majority的读阻止了脏读,然而Majority读一般会读到旧数据。
ReadConcern的majority读通常和Local读返回速度一样快,但有时也会阻塞。Majority读不会等待任何提交,它只是使用来自Local读的不同的快照。但是当节点元数据(catalog cache)与已提交的快照不同时,它们会阻塞。例如,索引生成或删除、集合创建或删除、数据库删除或collmod可能导致Majority读阻塞。如果主节点接收到createIndex命令,则后续的Majority读操作将被阻塞,直到在大多数节点上完成索引构建。Majority读操作在启动或回滚之后也会立即阻塞,此时还没有一个已提交的快照。有关Majority ReadConcern如何在多文档事务中工作的信息,请参阅事务中的ReadConcern行为部分。
Linearizable ReadConcern实际上会阻塞一段时间。Linearizable保证,如果一个线程执行了一个被确认的写操作,并且告诉了另一个线程该写操作,那么第二个线程应该看到该写操作。如果一个副本集暂时有两个主节点(一个尚未stepDown),并且从节点从旧主节点中读取数据,由于新的主节点可能具有更新的数据,因此客户端可能会读到旧数据。
为了防止从旧主节点读取,在读取完数据后,读取会先阻塞一段时期,来保证当前节点仍然是primary。这是如何做好的呢?原来节点在完成读操作之后,再将一个noop操作写入oplog,并等待它被复制到大多数节点。因此,在等待将noop写复制到大多数节点时,Linearizable读满足了majority读操作的相同保证。Linearizable读仅在主节点完成,由于linearizability仅被定义为单个对象上的一个属性,因此只适用于单文档事务。在多文档事务中,不允许Linearizable ReadConcern。
Snapshot ReadConcern 仅被允许在多文档事务中执行。请参阅事务中的ReadConcern行为部分
afterOpTime是仅在MongoDB内部使用的另一个ReadConcern 选项,仅用于config severs副本集。Read after optime的含义是,读操作会一直阻塞,直到该节点复制到了某个OpTime的数据。这意味着如果指定了Local读,它将一直等待直到本地快照超过指定的OpTime。如果指定了Majority读,则一直等待,直到committed snapshot 超过指定的OpTime。
afterClusterTime是用于支持causal consistency的ReadConcern 选项。

 enableMajorityReadConcern Flag 

readConcern: majority在WT引擎中默认打开,如果副本集中部署“arbiters”节点,这会带来一个问题。比如在Secondary 节点故障的场景下,此时系统可以处理写操作,但是却无法推进“majority commit point多数提交点”,这会增加WT缓存压力,进而导出性能下降或者写Stalling。
因此,建议部署带有arbitor的副本集时,将enableMajorityReadConcern设置为false(eMRC=false),此时存储引擎将不再需要维护“majority commit point多数提交点”之前历史版本。副本集会将stableTimestamp设置为最新的all_durable时间戳 ,这意味着可以对稳定版本进行checkpoint,而不必等待“majority committed”后再做checkpoint。
在副本集中,这个开关还有一些其他的影响,比如分布式事务和回滚等。希望了解更多,请参考Query Architecture Guide章节。
eMRC=false and Rollback
尽管在eMRC设置为false的时,已经支持创建稳定版本的checkpoint,但还是必须通过rollbackViaRefetch算法进行回滚,而不是采用RTT(Recover to A Timestamp)算法回滚。如前面提到的,eMRC设置为false时,副本集会将stableTimestamp设置为最新的all_durable时间戳,这让我们可以不用等待多数提交,就可以创建checkpoint。因此,stableTimestamp 会超过majority 提交时间点,这不满足恢复到Recover to A Timestamp算法的要求。

rollbackViaRefetch

当节点从同步源节点拉取的第一批oplog的时间戳小于或者等于本节点最新oplog的时间戳时,节点则需要进行回滚。回滚过程节点状态会变为ROLLBACK,此时节点不可读,在进入回滚状态时同时会释放所有快照。
启动回滚的节点首先与同步源找到回滚点,然后逆向遍历oplog中所有的操作直到回滚点,并针对每个操作进行对应的undo方法。
只通过这样简单的逆操作有时候是无法完成回退的。比如,存在一个删除操作,它在oplog中没有保存完整的文档。这种情况,节点会从源节点重新获取整个文档或者整个集合(在必要的时候,比如undo drop操作)。此外,还有一些其他的操作需要进行特殊的处理,比如,需要回滚的操作中包含dropDatabase则回滚失败。
回滚的节点先准备好需要获取和删除的文档、集合和索引的列表,实际执行此列表中的undo操作之前,会把这些操作中可以抵消的操作合并,然后,从同步源获取这些数据,并且替换掉本地版本的数据。
回滚节点会从同步源获取到 最新的Oplog时间戳和RollbackID,进行检查源节点是否出现了回滚情况,如果RollbackID不一致,说明同步源节点存在回滚,则本次回滚失败并退出。如果检查结果一致,则将同步源的oplog时间戳更新到minValid并继续从同步源节点拉取oplog直到达到minValid,然后,将节点状态从RECOVERING状态切换到SECONDARY 状态。
这个过程与初始同步过程和异常重启后发现节点数据超过主节点时的处理类似,即Oplog操作可能被重复回放到数据库中,回滚,异常重启等操作依然Oplog的幂等性,也有类似的索引约束条件。
虽然在 MongoDB 4.0及以后的版本主要使用Recover To A Timestam方法进行回滚 ,但是rollbackViaRefetch,仍需要用于支持eMRC=false场景保留了下来。
eMRC=false and Single Replica Set Transactions
单分片多文档事务要么不带时间戳读,要么从all_durable时间戳读(当设置readConcern:snapshot时),因此不依赖引擎提供从majority commit snapshot读的特性。所以,副本集的事务在readConcern: majority为false的情况下仍然可以正常工作。
eMRC=false and Cross Shard Transactions
使用Arbitor节点或者设置主节点eMRC=false在跨分片的事务中是不被允许的。如果存在Arbitor节点则会导致持续写入但是无法被raft提交的情况,这会影响分布式事务。比如我们尝试提交一个跨分片事务,但是由于Arbitor节点的存在,shard上可能不存在大多数可写入数据的节点,因此事务就无法进入prepare状态。
此外,rollbackViaRefetch算法不支持prepare的oplog,所以如果某个shard的eMRC=false,无法进行跨分片事务操作。这种场景下prepareTransaction会自动失败,从而阻止在eMRC=false的shard上进程prepare操作,由于事务还未prepare,因此此时终止是安全的。
补充一点,对于显式失败的prepareTransaction命令显示失败,在eMRC=false的节点上,启动恢复过程中回放prepare的oplog时,节点会崩溃。直接崩溃是最直接的处理方式,避免了我们基于一个不稳定的checkpoint回放prepare oplogEntry需要面对的复杂度。这种场景下唯一的方法是修改eMRC=true,然后重新执行恢复流程。

事务(Transactions) 

MongoDB引入多文档事务来提供对于多文档原子性读写操作,这些操作可以在同一个集合上或者是在多个集合上。在事务中提到的原子性描述的是一种“all-or-nothing”原则,这意味着一个事务提交,不会只提交其中的一部分操作,而回滚掉剩余的部分。同样,在事务中止时,所有的操作和与之相关的数据的操作都会回退。
多文档事务概览(Life of a Multi-Document Transaction)
在任何时候,事务都必须与一个Session关联,一个事务也只能关联一个Session。事务的状态通过TransactionParticipant类进行管理,这个类是Session的一个装饰器。任何线程尝试修改事务状态前,都必须使用正确的会话ID check out对应的Session。修改事务状态的操作包含“提交事务”,”中止事务”,和在事务中“增加新的更新操作”。在同一时间点只能有一个操作进行check out,使用这个Session的其他操作,必须等待前面的操作将Session check in后,才能重新check out。

开启一个事务(Starting a Transaction)

在服务端,如果一个操作包含了startTransaction: true参数,则会自动启动一个事务。同一个事务中的所有操作包含lsid(标识一个session的唯一ID),txnNumberautocommit:false参数。同一个Session上的txnNumber必须比之前事务的txnNumber要大,否则会抛出一个TransactionTooOld的错误。
当我们启动一个新的事务,在这个Session上的前一个未提交的事务(如果存在的话)会被隐式的中止,同时分配一个的txnNumber给新的事务。下一步将事务状态txnState更新为kInProgress,通过维护txnState可以让我们对事务状态的合法性进行判断。最后,我们将前一个事务内存中的事务状态和其他的信息都进行重置。
当在一个节点上启动一个事务,需要获得全局的意向排他锁(因此RSTL的IX锁也会加上),直到事务结束后才释放。唯一的例外是在事务进行prepare后会释放全局意向锁,然后在事务提交或者中止时,重新获取该锁。启动事务会通过在RecoveryUnit上打开一个WriteUnitOfWork启动一个存储引擎层的事务。RecoveryUnit类负责保证数据的持久化,所有对存储引擎的数据更新必须走RecoveryUnit对象。同一个事务内,未提交的数据可以被本事务自身读到,但是无法被其他事务读到。

在事务中添加操作(Adding Operations to a Transaction)

当用户开启事务后,用户对该Session的操作都会在Session当前的事务上执行,这些操作暂留在内存中,一旦写操作在主节点上完成,会更新config.transactions表中对应的sessionTxnRecord记录,包括lsid,txnNumber, txnState字段会被更新。config.transactions表是在支持retryable write特性时引入的,用来跟踪一个session上的retryable write和事务状态。当一个session被check out后,通过该表恢复事务的状态。阅读Recovering Prepared Transactions章节的内容,了解该表在事务恢复过程中如何工作的。

单分片事务的提交流程(Committing a Single Replica Set Transaction)

当决定提交一个事务时,我们会将所有操作的内容通过一个applyOps命令写入oplog中。由于一个applyOps大小上限为16M,当它大于16MB时,事务则需要将oplog拆分成多个applyOps批次。
如果我们正在提交一个只读的事务,只读事务意味着没有修改任何数据,事务必须等到它所读的数据都被 majority committed,而不需要考虑readConcern的取值。
一旦我们在oplog中记录 这个事务,那么必须通过OperationContext在引擎层提交事务。这是通过在WUOW中调用commit实现的。一旦在WUOW中调用了commit,事务生命周期中所有的写操作都会被提交到存储引擎中。
最后,更新事务表config.transactions ,将txnState设置为kCommitted,记录事务相关的其他指标,然后释放事务相关资源。

单分片事务的终止流程(Aborting a Single Replica Set Transaction)

由于未提交的事务对其他事务来说是不可见的,所以中止多文档事务的过程要比提交事务简单一些。直接中止掉存储引擎层的事务,更新seesionTxnRecord并且在Oplog中插入一条中止事务的oplogEntry。最后将txnState状态设置为kAbortedWithoutPrepare. 同时,将更新事务相关的统计指标并且重置TransactionParticipant内存中保存的状态。
值得注意的是,除了通过abortTransaction命令以外还有一些情况会导致事务被中止,比如,non-prepared事务遇到写冲突或者节点状态变更(主备倒换)。
跨分片事务和Prepare状态(Cross-Shard Transactions and the Prepared State)
在4.2版本中,我们支持了跨分片的事务,所以需要引入支持两阶段提交协议来实现跨分片事务的原子性。其中很重要的一部分就是要保证在事务提交前,所有事务的参与者都处于Prepared state,或者叫做一种保证可以提交的状态。用来避免事务在部分分片上提交成功,而在其他分片上中止。一旦节点将事务设置为Prepare状态就意味着当决定提交这个分布式事务时,节点上一定是可以提交成功的。
另外一个关键的部分是在两阶段提交协议中的协调者TransactionCoorinator,也就是启动事务候收到第一个操作的shard节点。TransactionCoorinator会协调所有参与此事务的shard最终提交或者终止这个事务。
TransactionCoordinator在被要求提交一个事务时,它必须保证所有参与事务的shard节点在提交前都处于prepared状态。协调者会给每个参与事务的shard发送一个内部命令prepareTransaction来确保这点。
在协调者TransactionCoordinator发出commitTransaction命令前,每个参与者shard执行prepareTransaction命令的oplogEntry必须是多数提交的(这样来确保prepare 操作不会被回滚)。如果有一个shard在prepare时失败了,则协调者TransactionCoordinator会通过发送abortTransaction给所有参与的shard,通知这些shard事务中止,不用考虑这些shard是否已经prepared。
事务prepared状态的持久化通过复制系统来管理,而两阶段提交协议是通过分片系统来管理的。
带Prepare操作的事务的生命周期(Lifetime of a Prepared Transaction)
在分布式事务中prepareTransaction命令执行之前,事务执行的过程与副本集事务是一样的。但是一旦事务成为Prepared状态,则不允许在事务中增加新的操作。中止一个事务的Prepared状态只能通过commitTransaction或者abortTransaction命令。这意味着Prepared状态的事务必须 不会被宕机和主从切换影响。
此外,还有一些需要注意的点。比如,Prepared状态的事务不能被杀死或者超时,Prepared状态的事务所在的session也不允许被kill掉,手工更新事务表中的状态也是被禁止的,并且prepare oplogEntry不能被oplog的rotating冲掉。

在主节点上Prepare一个事务(Preparing a Transaction on the Primary)

当主节点收到prepareTransaction命令,它会将对应的txnState更新为kPrepared。然后,构造一条prepareTransaction的oplog(拥有唯一的opTime),prepareTransaction的oplog会包含事务中的所有操作,这意味着当大于16M时,需要更多条oplog。这里的opTime会作为prepareTimestamp使用。
节点会通过RecoveryUnit设置prepareTimestamp,并且设置对应的存储引擎的事务为prepared状态。prepared状态的事务会阻塞其他相关(读/写相同的记录)事务的读写,直到事务提交或者回滚。
然后,节点创建一条prepareTransaction的oplog记录并写入到oplog表中。这条记录包含 事务中所有的操作,并以一个oplog记录的形式存储起来(大事务需要多条oplog)。节点还需要把事务的Oplog的optime更新到config.transaction表中,如果存在多个oplog条目,会使用第一条oplog的optime。还会将事务状态kPrepared更新到事务表中。这些信息在节点需要恢复或者倒换时会起到关键的作用。
在尝试Prepare一个事务时,如果上述的步骤出现失败,则节点会中止事务。如果这种情况发生,节点会给TransactionCoordinator返回失败的响应消息。这会触发TransactionCoordinator通知其他参与者的shard节点中止事务来保证事务的原子性。所以,这种情况发生时,可以重试整个事务。
最后,节点更新各种统计结果,释放RSTL锁来允许运行prepared状态的事务的主节点可以降备 ,然后,将prepareTimestamp一并在响应消息中返回给TransactionCoordinator

Prepare冲突(Prepare Conflicts)

当一个操作尝试读取某条被活跃的prepared状态的事务更新的文档时,由于事务处于prepared状态,我们不知道这个事务会被提交还是中止。所以,prepared事务的更新的内容在事务完成之前,在事务外部都是不可见的。
基于不同的readConcern的取值,在这种情况下会有不同的表现。local读,avaliable读或者Majority读(无因果一致性)不会导致prepare冲突,而是返回prepared之前的数据。如果使用快照(snapshot)读,线性性(Linearizable)读,和指定时间戳读(readAfterClusterTime)则会阻塞直到事务提交或者中止。
如果尝试修改一个已经被prepared事务更改过的文档,那么,这个写操作会被阻塞,并等待prepared的事务提交或者终止后,再继续执行。

提交一个Prepared状态的事务(Committing a Prepared Transaction)

提交一个处于prepared状态的事务与提交一个单分片事务的过程是非常相似的。其中最主要的区别是commit操作对应的oplog条目中不包含任何操作,因为,这些已经包含在prepare操作的oplog条目(可能是多条)中。
对于跨分区的事务来说,如果每个参与事务的shard都已经多数提交prepareTransactions后,TransactionCoordinator会向所有参与事务的shard发送commitTransaction命令。commitTransaction必须包含提交时间戳commitTimestamp,这样才能让所有参与事务的shard在同一时间提交。这个值就是影响事务是否可见的时间戳。
当节点的事务处于prepared状态下,此时收到commitTransaction命令,节点会再次获取RSTL锁来阻止事务执行过程中,发生主备倒换。然后,它会分配一条oplog,以commitTimestamp时间戳提交事务,在oplog表中写入commitTransaction的oplogEntry,更新config.transactions表,将记录状态改为kCommitted,记录统计信息,清理事务资源。

终止一个Prepared状态的事务(Aborting a Prepared Transaction)

中止一个prepared事务与中止一个非prepared事务是非常相似的。唯一的差异在于在中止之前,需要再次获得RSTL锁来阻止中止过程中发生状态迁移。非prepared事务不需要这一步的原因是在此场景下,节点还未释放RSTL锁。
主备倒换与事务的关系(State Transitions and Failovers with Transactions)
 
主备倒换与单分片事务的关系(State Transitions and Failovers with Single Replica Set Transactions)
 
对于单分片事务来说,如果commitTransaction命令没有被大多数节点提交,在宕机或主从切换后就有可能丢失。
对于分布式事务,未prepared的事务在降备会中止。在降备过程中,事务仍然持有RSTL锁,与降备过程会存在互斥。因此在降备过程中,节点会中止所有未提交事务直到重新获取到RSTL锁。
在升主过程中进行中(未prepared状态)的事务,会被中止。当一个事务在升主过程中存在,说明之前该节点是从节点状态,而且这个事务还需要其他oplog条目来决定是要commit还是prepare或者abort。在升主之前,这些事务都会被终止。
如果节点经过了重启,在启动恢复过程不会恢复任何未prepare的事务。

降备与Prepared状态事务的关系(Stepdown with a Prepared Transaction)

上面说过,在降备过程中,未prepare的事务会被终止,然而prepared状态的事务必须保证降备后仍然有效,这是由于参与事务的分片依赖prepareTransaction的多数提交。在事务prepare后,节点会释放RSTL锁,所以不会与降备冲突。在拿到RSTL锁之前降备只会中止未prepare的事务。一旦降备完成,节点会释放所有prepared事务的锁,从节点上的事务不会持有锁。

升主与Prepared状态事务的关系(Step Up with a Prepared Transaction)

如果从节点在升主的过程中有一个prepared事务,它需要去获取所有prepared事务相关的锁(不止是RSTL锁),因为主节点需要持有这些所去防止有冲突的操作。

Prepared状态事务的恢复(Recovering Prepared Transactions)

Prepared状态的事务必须不受降备/升主/宕机/重启的影响。在任何情况下,prepared状态的事务都必须可以被恢复。如果prepared状态事务的内存状态丢失了,它可以从prepare oplog 记录中恢复。
启动恢复,回滚和初始化同步都使用相同的算法去重建Prepared状态的事务。
在Oplog被应用的过程中,每当遇到prepareTransaction的oplog,本节点就会更新config.transactions表中事务的状态。在遇到commitTransaction的oplog记录之前,事务不会被恢复到内存中。在遇到commitTransaction的oplog记录时,本节点会通过TransactionHistoryIterator类,反向遍历oplog找到事务对应的prepareTransaction的oplog条目,恢复出内存状态,一次性提交。
恢复或者初始化同步应用oplog的过程结束后,节点去遍历config.transactions表中所有的记录去查询哪些事务处于Prepared态。这时,节点会通过TransactionHistoryIterator 找到和这些事务关联的prepareTransactionoplog条目。然后checkout与事务关联的session,并应用保存在oplog条目中的所有操作,最后prepare这个事务。
ReadConcern行为与事务的关系(Read Concern Behavior Within Transactions)
事务内的所有操作的ReadConcern应该在启动事务时指定,如果没有指定的话,默认使用local ReadConcern。
由于存在一种投机行为,在多文档事务中与不在多文档事务中的读表现是不同的。这种投机行为是什么呢?
只要事务是以writeConcern=Majority的方式运行的,事务就可以把等待ReadConcern被满足推迟到提交时进行。事务在未提交时,不去关心读到的数据是否会被回滚,在提交时,只要该事务保证执行过程中读到的数据都被大多数提交了,就可以了。因为这种”投机”因为的存在,所以以writeConcern=Majority方式运行的事务只能提供Majority的ReadConcern。
如果事务进行了一次写操作,那么等待写操作完成就足以确保事务内读到的数据被大多数提交。如果事务是只读的,那么节点会写一个noop的oplog并等待大多数提交,来提供同样的保证。

Local and Majority Read Concerns

使用Local和Majority ReadConcern的事务没有功能上的区别。在这两种情况下,节点都会执行非时间戳读。当事务启动,它会选择最新的快照,这样它就可以读到最新的数据。
之所以非时间戳读会选择最新的快照,而不是all_durable时间戳对应的快照,是因为all_durable时间戳往往比较滞后,事务执行中会遇到更多的写冲突,从而导致事务被终止。
理论上,在事务提交时,Local ReadConcern的事务增加一个noop写并等待多数提交是不必要的。但是,我们还是准备让Majority读作为事务的默认读策略,因为我们发现,所有的MongoDB的写操作都可以有投机性的Majority Read/Write行为(比如findAndModify)。除非哪一天MongoDB事务的默认ReadConcern不再是Majority了,在这之前Local和Majority ReadConcern的表现都应该一致,这很重要。

Snapshot Read Concern

Snapshot的ReadConcern 会将所有事务的读请求绑定到某个快照上,如果指定了atClusterTime参数,则该参数会被用作读时间戳,如果该参数未被指定,则使用事务启动时的  all_durable 时间戳作为读时间戳,这个时间戳对应的数据不会有oplog空洞。
事务Oplog记录的应用(Transaction Oplog Application)
一旦主节点对事务执行了prepare或者commit操作,事务的oplog记录就会被从节点复制。从节点通过OplogApplier来应用oplog记录。参考oplog entry application章节的内容获取更多从点应用oplog的细节。
在从节点应用事务相关的oplog记录之前,先把会导致config.transactions表状态变更的记录找到,并基于这些oplog记录更新config.transactions表(sessionTxnRecord)。比如,prepareTransaction,commitTransaction和abortTransaction的oplog记录都会导致表的txnState字段变化。

Unprepared Transactions Oplog Application

Unprepared事务由applyOps的oplog条目组成,如果这些applyOps小于16MB时,只会写一条oplogOps,因此从节点不必须等待任何额外的Oplog就可以直接应用。
当事务的总大小大于16MB时,会使用prevOpTime字段记录的前一个oplog记录的opTime,通过这样的方式把事务的多条oplog串联起来。使用partialTxn: true来标识事务还不完整,不能立即应用。因为partialTxn字段 不是所有的oplog都需要的,所以,把它作为‘o’字段的一个子字段。从节点在应用oplog之前必须等待收到收到最后一条applyOps的oplog,即prevOpTime非空,并且无partialTxn字段。这保证在应用oplog之前,节点上有这个事务的所有oplog。
对于不带prepare操作的事务,如果从节点看到applyOps的oplog记录,直接把CRUD操作解析出来,并用一个写线程池应用这些操作,对于大事务,当从节点接收到所有oplog记录之后(通过partialTxn字段),反向遍历oplog找到所有的事务内的操作,并执行同样的过程。
当我们得到一unprepared 事务的oplog时,我们会讲CRUD操作通过写线程池并行执行他们。对于大事务来说,一旦从节点获取到事务的所有oplog,它会发现遍历oplog链来找到事务所有的操作,并执行这些操作。
需要提到的一点是,对于非分布式事务来说checkout session是不必要的,因为这种事务对于从节点来说只是一系列的CRUD操作。磁盘上数据的原子性由恢复过程来保证,内存中的可见数据的原子性由我们如何推进从节点上的lastApplied来保证。

Prepared Transactions Oplog Application

分布式事务同样通过applyOps命令写入所有的oplog记录,只不过分布式事务在事务prepare之后才这么做。当事务小于16MB只写一条oplog,大于16MB则会写多条。
我们通过prepare: true字段来标识oplog记录是对应prepared 事务的。对于大事务,这个字段会出现在事务的oplog链中的最后一条,表示从节点必须prepare这个事务。这个oplog中的时间戳将作为prepareTimestamp
prepareTransaction的所有oplog记录会在同一个WUOW中执行。当应用处于prepared状态的事务中的oplog时,写线程必须先将事务关联的Session checkout并unstash(把txnResources从Session中拿出来的流程)事务相关的资源txnResources。这里的资源txnResources指的是事务的意向锁状态和存储状态。当我们unstash这些资源后,会把txnResources的管理责任交给OperationContext。应用线程会把prepare的操作添加到存储引擎的事务中,最后释放事务用到的锁。这意味着prepared事务在stash(把txnResources从OperationContext中解绑,归还给Session的流程)时只需要stash RecoveryUnit而不需要stash意向锁。如果这里stash意向锁的话,会导致oplog的复制流程和prepared状态的事务有冲突。这些意向锁资源在下一次unstash txnResources操作(只有可能是commitTransaction或者abortTransaction)时被恢复。
Prepared事务会单独写一条commitTransaction或者abortTransaction的oplog。commitTransaction的oplog不需要包含事务所涉及的操作,因为我们已经把他们保存在prepare操作的oplog记录中了。这些记录同样会以类似的顺序被执行:checkout相关的session,释放事务资源 并提交或者中止存储引擎上的事务。
需要注意的是,从节点可以立即应用prepare的oplog,但是节点恢复过程(recoverying状态)则必须等待恢复完成或者遇到一个commitTransaction的oplog。
Transaction Errors

PreparedTransactionInProgress Errors

在同一个Session上当存在一个进行中的事务时再启动一个新事务,则会隐式的中止现存的事务。然而,Prepared状态的事务不能被隐式中止,Prepared状态的事务只接受TransactionCoordinatorcommitTransaction或者abortTransaction命令。因此在一个有Prepared状态的事务的Session上建新事务会报PreparedTransactionInProgress
补充一点,Prepared状态的事务只能接受prepareTransaction,abortTransaction, 和commitTransaction命令。除此之外的其他命令都会失败并返回PreparedTransactionInProgress错误。
事务执行过程中的命令如果失败,并且错误码是PreparedTransactionInProgress,则会在响应消息中有一个TransientTransactionError标签。

NoSuchTransaction Errors

NoSuchTransaction发生在尝试 Continue,Prepare,Commit或者Abort一个不在执行中的事务时。这个错误常见的两个原因有:启动了一个新的事务或者事务已经被中止。
除了commitTransaction以外,其他任何事务中的命令在返回NoSuchTransaction失败时,在响应消息中都会有一个TransientTransactionError标签。如果commitTransaction命令失败返回NoSuchTransaction时没有一个write concern的错误,那么,它会在响应消息中附带一个TransientTransactionError标签。如果返回write concern错误,那么把整个事务重试一遍是不安全的,因为这个事务可能在某个节点已经提交了。

TransactionTooOld Errors

如果尝试启动一个比当前活动的事务或者最新提交的事务还老的事务,那么这个操作会失败并返回TransactionTooOld错误。

TransientTransactionError Label

事务会失败并返回上述的错误码或者其他的错误码,这些错误会导致事务中止并且不带来额外的影响。在这种场景下服务端会在响应消息中额外附带一个TransientTransactionError标签(响应消息中还是会有原始的错误码的),这样调用者就知道,他们可以安全的重试整个事务。

并发控制(Concurrency Control)

Parallel Batch Writer Mode

pbwm(Parallel Batch Writer Mode)锁是一个全局资源,用来协调从节点在回放一批oplog操作的并发操作,因为从节点的oplog回放是并发操作的,所以节点需要持有PBWM锁来等待整个批量回放的完成。对于从节点,为了能够实现可以读取一个一致状态的数据, 如果不想持有PBWM锁, 那么就需要读取这个节点的lastapplied时间戳,这个时间戳是当一批oplog回放完成后设置的,是一批回放操作的边界值,不存在空洞。但是对于初始化同步来说,backgroundIndex可能会导致数据在lastapplied 时间戳后还有变化。如果一个节点观察到在lastapplied时间戳之后数据还有变化,那就需要持有PBWM锁来保证读取数据的时候没有批量回放的数据,并且不带时间戳读(不带时间戳读默认读到最新的数据,包括那些晚于lastApplied的操作)。

复制状态变更锁(Replication State Transition Lock)
当一个节点发生状态变化时候, 需要协调状态变化和其他的一些并发操作, 比如说当一个节点在降备的时候,不能接受写操作, 直到这个节点重新被选举为主节点,这个是通过RSTL(Replication State Transition Lock)完成的,该锁是一个全局资源, 用来协调控制节点状态的变更。
需要持有这个锁来做如下状态的变更:降备,升主,回滚等。对于那些需要保证命令执行时不可以发生状态变化的操作,也需要持有该锁,比如当对一个事务执行了preapare的时候, 或者对一个已经处于Prepared状态的事务执行commit/abort操作的时候,或者检查/设置一个节点是否可以接受读写操作的时候。
全局锁上锁顺序(Global Lock Acquisition Ordering)
PBWM和RSTL 都是全局资源, 在加全局锁之前,必须先获得PBWM和RSTL锁资源。节点需要首先获取PBWM IS锁, 然后需要获取RSTL IX锁,之后才能按照自己期望的方式申请全局锁。

选举(Elections)  

升级(Step Up)
节点在以下几种情况下会发生选举:
  • 如果节点在选举超时时间(默认为10s)内没有看到主节点。
  • 如果节点发现自己的优先级高于主节点的优先级时,节点将会等待然后发起选举(称之为优先级接管)。节点在发起选举前所等待的时间与节点优先级跟其他节点优先级的大小排序有直接的关系(也就是说,更高优先级的节点将会比低优先级的节点更早的发起选举动作)。优先级接管功能允许用户指定一个节点更容易成为主节点。
  • 在副本集中, 新选举出来的主节点会尝试追赶到 latest applied Optime,在主节点追赶的过程中,新主节点不接受任何写入操作。如果某个从节点发现的数据比主节点更新, 并且主节点追赶操作花费的时间超过了catchUpTakeoverDelayMillis(默认30s), 那么这个从节点将会发起选举.  这种行为被称之为追赶接管。当主节点追赶操作花费过长时间的时候, 追赶接管操作可以让副本集更快的接受写入,因为拥有最新数据的节点不会在追赶上花费太长时间(或者不花时间)。参考”Transitioning toPRIMARY“章节,以获得主节点追赶操作的更多细节。
  • replSetStepUp命令,被用来运行在某个合适的节点上,可以使该节点立即发起选举。我们不期望用户使用该命令,该命令被用于副本集内部选举切换和测试。
  • 当一个节点被replSetStepDown命令降级时,如果enableElectionHandoff参数是true(默认值),我们会尽可能的选择一个符合条件的从节点来执行replSetStepUp命令。这种行为被称作选举交接.这样做可以缩短副本集故障时间,因为跳过了等待选举超时的时间。如果replSetStepDown 带参数force: true,或者当节点降备的时候enableElectionHandoff参数为false,那么副本集的节点将会在选举超时时间过后才会发起选举操作。

候选者角度(Candidate Perspective)

候选节点会首先运行dry-run选举,在dry-run 选举时,节点先发起一个VoteRequester,使用ScatterGatherRunner向每个节点发送replSetRequestVotes 命令,去询问是否投票选自己。候选节点在dry-run阶段不会增加term,因为如果primary看到term比自己持有的term高的时候,就会降级。通过首先运行dry-run选举,我们让不可能选举成功的节点不会增加term, 防止不必要的主节点降级。如果节点在dry-run选举中失败,它会继续自己的复制工作(还当从节点的意思)。如果节点在dry-run选举中获胜,则开始真正的选举。如果选举节点被选举交接选中, 它将跳过dry-run,并立即调用真正选举。在真正的选举时, 节点首先增加自己的term和并给自己投票。然后节点遵循和dry-run过程一样,也就是开启一个VoteRequester来向每个节点发送replSetRequestVotes 命令。每个节点接下来决定投赞成票或反对票,并将结果返回给投票发起者。候选节点的Oplog必须至少与大多数投票成员一样新,才能够被选举。如果候选节点收到了大多数节点包括自己的投票,则该候选者获胜。

投票者角度(Voter Perspective)

当节点收到了replSetRequestVotes命令时,它首先检查term是否是最新的, 更新自己的term。然后,ReplicationCoordinator将询问TopologyCoordinator是否应该投票。否决投票的条件是:
  1. 来自一个更旧的term。
  2. 配置不匹配(细节请参阅配置排序和选举)。
  3. 副本集name不匹配。
  4. 投票请求中的 last applied OpTime 比 投票者的 last applied OpTime 更早。
  5. 如果不是dry-run选举并且投票者已经在本次term投过票了。
  6. 如果投票者是仲裁者,并且它能看到一个更大或者相等的优先级的健康的主节点。这是为了防止primary flapping:两个节点互相不能通讯,而仲裁者可以和二者通讯。
当节点投票给自己或者其他节点的时候,它就会将”LastVote”信息持久化记录到local.replset.election集合中。此信息会在启动时被读入内存,并被用于后续的选举。这就确保了即使是节点重启,节点也不会在相同的term任期投票给2个节点。

过渡到PRIMARY状态(Transitioning toPRIMARY)

当候选者获胜,它必将变为主节点。首先,它要重置 sync source, 并通过一轮心跳将自己赢得选举的信息通知给所有节点。然后,这个节点检查是否需要追赶原主节点的数据。因为新主节点的选举可以不需要原主节点的投票, 所以选举出来的新主节点仍然会尝试从有效的复制源同步剩余的oplog. 虽然这些数据不保证被提交,但是这样做可以尽可能的减少回滚内容。
新主节点从心跳的响应去获得其他节点的last applied OpTime。如果新主节点的last applied opTime小于看到的最新的 last applied OpTime, 新主节点会将这个最新的 last applied OpTime 作为目标用与追赶. 在追赶开始,新主节点会设置一个定时器用于追赶超时. 如果超时或者节点追上了目标 OpTime,则该节点结束了追赶阶段。该节点会重置sync source,并停止OplogFetcher。
在新主节点的追赶阶段,我们将忽略是否链式复制开启,以便于新primary可以找到一个同步源. 需要注意的是新主节点不一定非得从数据最新的节点开始同步,它的同步源也可以从更新数据的节点复制数据。这就意味着 新primary 仍将可以追赶到目标OpTime。由于追赶是不保证一定成功的,所以在该节点可能在追赶到目标的OpTime之前就已经超时了.即便如此,该新主节点也不会降级。
在这点上, 无论追赶是否成功, 节点都将会进入 “drain mode”. 这时节点已经在日志中输出了”transition to PRIMARY”, 但是还没有将oplog buffer中的oplog 条目应用完毕.replSetGetStatus会将节点显示为PRIMARY状态. Oplog 应用仍将继续运行,当应用完oplog buffer数据后,会发信号给ReplicationCoordinator去完成升级过程。
新主节点标记了自己可以开始接受写入。依据Raft协议, 我们不能使用上一个term的oplog去更新commit point, commit point只能被当前term的oplog更新. 新主节点会写一个 “new primary” 空 oplog, 这样新主节点可以尽快的raft提交旧的写入操作。(额外的解释, 发文章的时候删掉(可以作为译者注释): 旧的写入操作是指上一个term的写入操作,还未被raft提交了的. 这里的commit指的是raft commit,不是指的写操作落盘到oplog的操作.) 一旦 commit point 被新的 “new primay” oplog entry更新, 所有的旧的写操作都会作为提交点的一部分,因为这些操作发生在 term 切换之前。最终,新主节点会删除临时集合,恢复所有prepared transactions的locks,终止所有进行中的transactions,和记录”transition to primary complete”。此时,新的写入将被新主节点接受。
降级(Step Down)

有条件的降级(Conditional)

replSetStepDown命令是一种让节点放弃primary地位的方式。这是一个有条件的降级,因为如果以下条件不满足,就可能降级失败:
  • force是 true 并且 已经超过了waitUntil的deadline,也就是降级之前等的时间(如果 force = true,则只有这个条件需要满足)
  • primary的lastApplied必须已经被复制了大多数节点。
  • 至少有一个最新数据的从节点是可选举的。
replSetStepDown命令运行时,节点就开始检查自己是否可以降级。首先,节点企图获得RSTL。为此,它必须停止所有冲突的用户/系统操作,并终止未prepared的事务。
接着,节点会一直尝试降级. 如果force=false,它将持续检查是否大多数节点都达到了lastAppliedoptime,也就是它们都可以追赶上来。该节点也会检查至少一个节点是可选举的。如果froce=true,在到达waitUntildeadline后,它将不会等待这些条件,并且立即降级.
降级成功后,因为节点已经是从节点了, 节点将会会释放prepared transactions所持有的锁。最终,我们会记录一些统计信息,并更新成员状态为SECONDARY

无条件降级(Unconditional)

降级发生也可以由以下原因导致:
  • 当 primary 看到了更高的 term。
  • 超时: 如果主节点无法与大多数节点通讯,也会导致降级。主节点不需要能够和大多数节点直接通讯。如果主节点A不能和B节点通讯,但是A可以和C通讯,C可以和B通讯,这也是可以的。如果你考虑一个cluster中最小生成树(minimum spanning tree),边是从节点到它同步源的连接,那么只要主节点能和大多数节点在树上连通,它就依然可以作为副本集的主节点。
  • 使用replSetReconfig命令,且force = true
  • 通过心跳重新配置 :如果节点通过心跳发现一个新版本的副本集设置,也会调度一个副本集配置变更。
在无条件降级情况下,在降级前我们不会检查预先条件。和有条件降级相似的是,我们仍会kill掉任何冲突的用户/系统操作去获取RSTL,成功降级后释放prepared transactions的锁。

并发降级尝试(Concurrent Stepdown Attempts)

无条件降级和有条件降级是有可能同时发生的。在这种情况下,无条件降级将会取代有条件降级,也会引起有条件降级的失败。
因为并发无条件降级 会引起有条件降级失败,一旦确认可以降级,我们就停止写入。这样,如果降级失败,我们也可以释放RSTL并允许从节点去追赶主节点,当前主节点上没有新的写入进来。
通过在TopologyCoordinator中设置_leaderMode到 kSteppingDown, 来防止并发有条件降级的发生。通过跟踪当前降级状态,我们可以防止另一个有条件降级操作在降级时启动,但是仍允许无条件降级来取代有条件降级。

回滚(Rollback) 

回滚是指当一个节点和它的同步源产生分叉后,该节点重新回到同步源历史分支上的某一个一致时间的过程.当前支持两种回滚算法,一种是RTT(Recover To A Timestamp), 一种是通过重新拉取来回滚(RollbackViaRefetch),该节主要讨论RTT方法。
网络分区的情况下会导致回滚. 考虑如下场景,当从节点不再收到主节点的数据,从节点会随后发起一次选举。此时集群中出现了两个主节点都可以接受写操作,这将导致产生了两个不同的历史分支(其中的一个主节点可以很快的检测到该场景并降备)。这段时间内,对于那个较小的网络分区下的主节点收到的写操作,这些写操作是未被提交的。那些带有未提交的写操作的节点将会回滚掉这些写操作, 并向前回滚到可以匹配同步源。回滚不是必须的, 如果一个节点不包含任何未提交的写。
从4.0版本开始,Replication支持恢复到某个时间戳算法(RTT). 该算法下,节点恢复到一致的时间点, 并应用同步源的操作,直到赶上同步源的历史分支。RTT使用WiredTiger存储引擎恢复到一个stable_timestamp,该时间戳是存储引擎检查可以获取检查点的最大时间戳。这可以被认为是一致的、大多数节点已经提交的时间点。
当节点最后的获得的OpTime 大于复制源的last applied OpTime,并且该节点的term比较低, 这个节点进入回滚状态。这种情况下,oplogfetch就会返回空数据并且报OplogStartMissing的错误。
回滚的过程中, 节点首先转为ROLLBACK状态,然后kill掉当前节点的所有用户操作,确保我们能成功的获取RSTL.回滚状态下,节点不支持读操作。
回滚的时候,在找一个 common point 前,需要等待后台索引创建完成。common point是一个OpTime,位于这个OpTIme之后的 oplog 在本节点和同步源是有差异的。在查找 common point 时, 我们持续追踪那些被回滚的操作直到common point,和更新必要的数据结构。这些包括我们rollback 文件的元数据和rollback集合fast-count数据。然后我们推进了Rollback ID(RBID)的值, 这是一个单调增加的数字,当每次回滚发生的时候我们都会增加这个ID。我们可以使用RBID来检查我们的同步源是否发生了回滚。
现在我们进入了回滚算法修改数据的过程. 该过程会终止prepared 事务, 并且在结束的时候会重建他们,如果该过程中有任何失败,我们就会终止整个回滚过程,因为我们不能保证安全的恢复。
在我们实际恢复到stableTimestamp之前,我们必须中止所有的prepare事务,通过这种方式,来释放那些事务持有的资源,并使内存中记录的状态失效。
createRollbackDataFiles = true(默认设置),则开始为已回滚的文档生成回滚文件。重要的是,写回滚文件安排在 abort prepared transaction之后,这样做,当我们尝试读取那些被prepare transaction修改过的文档时, 可以避免不必要的 prepare conflicts,不管怎样,这些事务必须中止才可以进行回滚。最后,如果我们回滚了任何操作,我们将该节点上的所有会话设置为无效。
到这一步,我们可以通知存储引擎去恢复到最后的stable_timestamp。一旦成功后,存储引擎会重建数据库数据到stable_timestamp时的数据。但这个操作不不会回退oplog。为了回退oplog, 回滚需要删除所有common point 以后的Oplog记录。这个节点被称为截断点,并会被写入到oplogTruncateAfterpoint记录,到这一步,回滚流程就知道从哪里去截断oplog。
在数据修改的最后部分,我们清理DropPendingCollectionReaper的状态,该类管理了那些因为两阶段drop算法而处于drop-pending 状态的集合,上述操作完成后,则可以执行oplog恢复过程,此过程会截断common point后的oplog, 然后应用该点 之后的所有同步源上的oplog。参考启动恢复来获取截取oplog和应用oplog的细节信息。
数据修改过程中,我们需要做的最后一步是reconstruct prepared transactions。我们必须恢复 prepare transactions 在回滚前的那个内存中的状态, 以便完成prepared事务的持久化保证。
这些都完成后,last applied 或者 durable OpTImes 仍就指向了错误历史分支,所以我们需要更新这些时间点到oplog的起始位置(last applied OpTime), 即common point。
到这一步,我们可以触发 回滚OpObserver, 然后通知外部系统,回滚已经发生了. 例如,config server必须要更新他的shard registry,以便确保不再持有那些被回滚的数据。最后,我们向日志记录整个回滚摘要信息,然后变更到SECONDARY状态,如果我们已经进入到rollback状态, 那么这个状态变更一定要成功,否则节点会被关闭。

 初始化同步(Initial Sync)

副本集添加新成员时,会触发初始化同步流程(Initial Sync). 初始化同步被ReplicationCoordinator发起,在InitialSyncer结束。当节点开始执行初始化同步时,它的状态为STARTUP2。STARTUP是新增节点加载副本集配置时的状态。
初始化同步流程大致上分为两个阶段:数据克隆阶段以及oplog 应用阶段。数据克隆阶段, 节点会拷贝其他节点上所有的数据. 数据克隆阶段结束后, 节点会进入 oplog application 阶段, apply 所有数据拷贝阶段中同步到的数据。最后恢复所有prepared状态的事务。
在数据克隆开始前,节点需要做如下几件事情:
  1. 设置初始同步的flag以初始化同步进行中,并将此flag持久化。如果节点启动时发现此flag已经被设置了,节点将会重启初始化同步无论当前节点是否已经有了数据,这是因为这个标记表明上次的初始化同步操作没有执行完毕。同时读取oplog的时候也会检查此标记位,防止在初始化同步过程中读取oplog。
  2. 寻找一个同步源。
  3. 删掉所有除local库外的数据,重新创建新的oplog。
  4. 拿到同步源的Rollback ID (RBID),用来确保数据同步过程中,同步源没有发生过rollback。
  5. 获取同步源节点的 latest OpTime 作为defaultBeginFetchingOpTime,如果同步源节点没有正在进行的事务时,这个时间戳即为beginFetchingTimestamp,也就是后面 fetching oplog 的开始时间戳。
  6. 获取同步源节点所有活跃事务中最早的开始的OpTime。如果这个时间戳存在(意味着同步源上有一个活跃的事务), 这个时间戳会作为beginFetchingTimestamp. 如果这个时间戳不存在, 节点会用defaultBeginFetchingOpTime来代替。这样可以确保即使在同步源节点上确定了最老的活跃事务时间戳之后,同步源节点又开始了新事务,新加节点也能确保会拥有与同步过来的活跃事务相关联的完整的oplog。
  7. 查询其同步源节点oplog获取最新的OpTime作为beginApplyingTimestamp,即数据克隆阶段结束后立即开始apply oplog的时间戳。如果同步源上没有活跃事务,则beginFetchingTimestampbeginApplyingTimestamp相同。
  8. 创建一个OplogFetcher,并开始从同步源提取和缓存oplog条目,以便后面apply。这些操作被缓存到一个集合中,这样可以不受可用内存不足的约束。
数据拷贝阶段(Data clone phase)
接着就是新加节点的同步数据阶段。InitialSyncer会构造一个AllDatabaseCloner用于克隆同步源节点上的所有数据库。AllDatabaseCloner会从同步源节点获取到所有的数据库list,然后为每个数据库创一个DatabaseCloner来克隆该数据库。每个DatabaseCloner又会从同步源节点获取其所有集合的list,并为每个集合创一个CollectionCloner来克隆该集合。 CollectionCloner通过调用listIndexes枚举Index并创建一个CollectionBulkLoader,以并行方式创建所有索引。CollectionCloner接着对每个collection使用exhaust cursor通过在同步源运行find请求,插入查到的文档,直到获取所有文档。与显式地需要在一个打开的游标上运行getMore来获取数据不同, exhaust cursor 在find没有耗尽游标的情况下,同步源会持续发送数据,直到没有剩余数据。
cloners对瞬时错误具有一定的容错性。如果克隆过程中遇到error_codes.yml中标记为RetriableError的错误时,cloners会重试它正在进行的网络操作。重试持续时长由参数initialSyncTransientErrorRetryPeriodSeconds决定,此参数为可配参数。重试结束时候如果还是失败,则认为是永久失败。永久失败后, cloners将选择一个新的同步源,并重试所有初始同步,最多重试参数numInitialSyncAttempts设置的次数。一个值得注意的例外是,对于真实地查询集合数据的操作,我们并不重试整个操作。对于查询操作,有一个称为resume tokens的特性。在查询中设置一个标记:$_requestResumeToken,这样我们从同步源接收的每个批处理都包含一个不透明的令牌,用来指示我们在集合中的当前位置。在存储完一批数据之后,我们将最新的resume tokens存储在CollectionCloner的成员变量中。然后在重试时,在查询操作中提供resume token,这样可以避免重新获取已经存储的那部分集合。
initialSyncTransientErrorRetryPeriodSeconds参数也用于控制初始化同步时,数据克隆开始之后, oplog fetcher和所有网络操作的重试。
Oplog应用阶段(Oplog application phase)
初始化同步数据克隆完成之后,接着就是oplog应用阶段。新节点会去获取同步源节点的last applied OpTime ,保存为stopTimestamp,在成为副本集从节点并和其他成员保持一致前,必须进行apply oplog。如果beginFetchingTimestampstopTimestamp相同,说明没有oplog记录需要被落盘且没有操作需要被apply。在这种情况下,新节点将使用同步源的最后一条oplog作为后续起始同步点,并完成初始同步。
如果beginFetchingTimestampstopTimestamp不同,则新节点会持续拉取主节点的Oplog,将它们写入自己的oplog,如果它们的时间戳在beginApplyingTimestamp之后,则apply这些oplog使得数据落盘。这种情况下会继续不断的获取Oplog数据,并添加到OplogBuffer中。
一个需要注意的的例外是,新加节点不会应用prepareTransaction涉及的oplog数据。与startup和rollback recovery中重构 prepared transactions类似,每次看到prepareTransactionoplog条目时,都会更新事务表。因为新加节点会将从源节点beginFetchingTimestamp开始的所有oplog条目写入本地oplog,所以新加节点在oplog应用阶段完成后,最终会有一套完整的用于重建prepared transaction事务的oplog。
幂等性考虑(Idempotency concerns)
应用Oplog阶段中的一些操作,有可能有些已经应用在源节点被克隆的数据中了,因为上面流程实际上在克隆阶段开始之前,就已经开始缓冲oplog了。考虑以下情况:
  1. 开始缓存oplog数据
  2. 在表foo中插入{a: 1, b: 1}
  3. 在表foo中插入{a: 1, b: 2}
  4. 删除表foo
  5. 重新创建集合foo
  6. 在集合fooa字段上创建唯一索引
  7. 克隆foo
  8. 开始apply oplog,apply oplog会尝试插入{a: 1, b: 1}{a: 1, b: 2}
由上面例子可见,apply oplog 插入的数据会和克隆的数据唯一索引出现冲突。目前采取的办法是会此阶段忽略很多error(比如上例中的DuplicateKeyerrors ),并假定这些错误最终会自洽。
结束初始化同步(Finishing initial sync)
当加节点apply 的oplog到达stopTimestamp时,apply oplog阶段就结束了。新加节点会检查其同步源节点的RollbackID,看看初始化同步期间是否发生了回滚,如果是,则重新启动初始同步,如果没有回滚, 就开始析构InitialSyncer
接着,初始化同步流程会在存储引擎注册该新加节点的lastAppliedOpTime,以确保查询oplog时所有之前的oplog都是可见的。在此之后,会重构所有的prepared transaction。都做完后,新加节点会清除initial sync 标志,并告诉存储引擎initialDataTimestamp是该节点的last applied OpTime。最后,InitialSyncer关闭,ReplicationCoordinator开始工作,负责后续的正常运行期间的同步。

 更改副本集配置(Reconfiguration)

MongoDB副本集包括了一组成员,一个成员就是副本集一个参与者, 每个成员由主机名和端口标识。我们将一个副本集中的mongod进程称为节点, 这对应着某个具体副本集的成员。副本集配置由副本集中的成员列表,一些特定配置,以及副本集的全局设置组成。为了简便起见,我们简称副本集的配置为config,配置中的每个成员都有一个成员ID,成员ID是个唯一的整数, 配置schema在ReplSetConfig类中定义,这个类可以被序列化为BSON对象,并被存储在每个副本集节点上的local.system.replset集合中。
配置初始化(Initiation)
首次启动副本集成员的mongod进程时,该进程没有任何配置,并且节点之间不会有任何通信,或复制任何数据。在未初始化的副本集任意节点都可以运行该命令进行初始化,必须通过replSetInitiate命令来初始config,以便节点知道副本集中的其他成员。然后,它建立和副本集的其他节点的连接,并开始向其他节点发送心跳。副本集的配置通过心跳在节点之间传播,这是副本集中的节点接收和初始配置的方式。
重配置的行为(Reconfiguration Behavior)
客户端可以执行replSetReconfig命令用来更新当前的配置。Reconfigurations可以运行在safemode 或者forcemode. 为简便起见,我们将重新配置称为reconfigs. Safe Reconfig只能在主节点上运行,并保证对于大多数提交的写操作都不会回滚。Force Reconfig 可以在主节点或者从节点上运行, 而且该操作可能会导致rollback大多数已经提交的写操作. 尽管Force Reconfig是不安全的,但这种使用方式可以使用户能够挽救或修复那些不可被操作或无法访问的副本集节点。

安全的重配置协议(Safe Reconfig Protocol)

在MongoDB中实现的安全配置协议与Raft PhD论文的第4节中介绍的“单服务器”重新配置方法在概念上有某些相似之处,但在设计时有所不同,以便更加简单的与心跳的reconfig协议集成。
请注意,在静态配置中,Raft协议的安全性取决于以下事实:副本集的任何两组大多数成员之间都具有至少一个共同的成员,即它们满足大多数重叠属性。但是,对于任意两个reconfig,情况并非如此。因此,在如何安全的reconfig方面设置了额外的限制。首先,所有安全的reconfig都必须满足单节点变更条件(single nodechange condition),这要求在一次reconfig中添加或删除的投票节点不得超过一个(非投票节点不受此约束)。此约束可确保任意两次相邻的reconfig之前都满足大多数重叠属性。您可以在上面引用的Raft论文部分中看到为什么如此的理由。
在主节点可以初始化新配置之前,必须满足额外两个附加约束来保证正确性。
  1. ConfigReplication: 当前的config内容c,必须已经在大多数节点投票节点被初始化成功了。
  2. OplogCommitment:上一个配置c0的所有的oplog操作记录,必须已经被复制到了当前config c1的大多数投票节点.
条件1保证任何早于C的配置无法形成”大多数”,因此无法独立的完成选举或者(被raft)提交写。
条件2保证了旧的配置中的已经(被raft)提交的写在当前配置中也已经被提交了,这一点保证了任何后续配置中选出的新leader一定包含这些已提交的操作,当这两个条件满足了,我们称当前配置已提交。
在初始化新配置之前,我们等待这两个条件在replSetReconfig命令的开始前都得到满足。在新配置生效前,满足这些条件是重新配置协议安全性的基础。在这些条件满足了,且初始化新配置之后,我们还要等待条件1在reconfig命令结束时得到满足,重新配置返回成功之前,新配置被发送到大多数节点上,但是这并不是保证安全性的严格必要条件。如果失败,将返回一个错误,但是新的配置将已经生效,并且可以开始传播。在随后的重新配置中,在应用下一个配置之前,我们仍将确保满足这两个安全条件。然而,通过在reconfig命令结束时等待配置复制,除了确保新应用的配置将出现在随后的主配置文件中之外,还可以缩短下一次reconfig开始时的等待时间。
注意,Reconfig时的force: true参数绕过了条件1和条件2的所有检查,并且它们不强制满足单节点变更条件

副本集配置的顺序与选举(Config Ordering and Elections)

如上所述,配置通过心跳在节点之间传播。为了正确地完成这个任务,节点必须有一些方法来确定一个配置是否比另一个配置”更新”。每个配置都包含一个term和版本字段,配置按照(version,term)来排序,其中首先用term来比较排序,然后用version来比较,类似于optime比较的规则。配置的term是当前主节点的term,版本是个单调递增的数字。执行reconfig时,new config的版本必须大于current config的版本。如果节点A的(version,term)大于节点B的,则认为A比配置B“更新”。如果一个节点通过来自另一个节点的心跳获取到一个更新的配置,它将发送一个心跳来获取该配置并应用
注意,force reconfigs时候,新的config的term设置为未初始化的值。当我们比较两个配置的时候,如果其中一个有未初始化的term,那么我们只对配置的版本字段进行比较。force reconfig 会给当前配置的version加一个大随机数。这使得强制设置的配置比复制集中其他节点的配置更新。
配置排序也影响选举行为。如果副本集节点在config(vc,tc)中是候选节点,那么config(v,t)的预期投票者只会在(vc,tc)=(v,t)时投票给该候选节点。理论上来说,候选人在(vc,tc)>=(v,t)时投票是可以接受的,但目前的实现并没有这样去做。有关完整投票行为的说明,请参阅选举章节。

正式的规范(Formal Specification)

关于安全reconfig协议的细节和行为, 可以更多参考TLA+ specification. 这里定位了协议的两个主要的变量,ElectionSafetyNeverRollbackCommitted,确保在同一个term中不会同时有两个节点被选为主,而且大多数已经提交的写不会被roll back。

 启动恢复流程(Startup Recovery)

启动恢复是节点在启动过程中或者状态处于STARTUP的时候,将数据和oplog两者恢复一致的过程。启动时,如果当前节点没有数据或者不存在oplog,或者节点有初始化同步的flag, 则跳过该启动恢复阶段,直接开始初始化同步流程。如果节点有数据,则会进入启动恢复流程
首先会从存储引擎获取recovery timestamp,该时间戳反映着启动过程中的数据变化(该时间戳,后续也会用于设置initialDataTimestamp时间戳),时间戳recovery timestamp 后续会作为stable_timestamp,表示该节点恢复到了一个stable checkpoint,stable checkpoint对应了一个特定时间点的持久化数据视图。需要注意的是,因为journaling特性,oplog和local库下的很多集合是个例外,它们在启动的时候,数据始终是最新的,而非对应到recovery timestamp 点的数据状态(备注,对于大部分表而言,数据都是checkpoint + 增量oplog恢复的,然而oplog表本身是不走checkpoint恢复的,wiredTiger会为oplog记录journal,对于普通的业务表,wiredTIger不记录journal。这是因为,如果业务表既有oplog又有journal,就重复记录了,是对IO的浪费)。
节点在异常关闭的时候,可能正在并行写入。每一个写操作,都会对应一个oplog entry。主节点并行写入,从节点并行应用oplog。因为操作都是并行执行的,这个会导致在oplog中存在临时的gap,gap的来源是那些还没有写入的oplog entry。称之为oplog空洞。因此,节点crash的时候,可能会造成磁盘数据存在oplog空洞
启动的时候,节点无法知道 oplog entry是否已经持久化,也无法知道哪些oplog entry未提交而导致遗失。主节点可能会丢失一些oplog,但这些oplog在从节点上可能已经应用了,或者从节点上也会存在以为已经同步但却因为异常而丢失oplog的场景。这些都会导致异常关闭的节点和副本集其他节点数据不一致。为了解决这个问题,获取recovery timestamp 后,该节点会截取oplog,只还原到一个没有oplog空洞的点, 这是用oplogTruncateAfterPoint来控制的。oplogTruncateAfterPoint信息会持久化下来并且和时间戳无关,能反应比latest stable checkpoint更多信息,即便是在节点异常的情况下。
第一种场景,在oplog应用过程中,在开始写入一批oplog entries之前,会用lastApplied时间戳 设置oplogTruncateAfterPoint。如果在写的过程中,节点异常崩溃,那么在重启恢复的时候,就恢复到写入前保存的时间点。即:oplogTruncateAfterPoint(lastApplied), 如果成功写完了,oplogTruncateAfterPoint会重设为null。即表示没有oplog空洞,或者说没有需要truncate的oplog。
第二种场景,主节点来设置oplogTruncateAfterPoint. 主节点发现一旦某个oplog前面没有空洞了,就立刻允许从节点去复制它。从节点的复制动作不会等待oplog落到主节点的磁盘,也不会等待主节点磁盘上没有oplog空洞之后.  因此,有些数据已经同步到从节点, 但因为主节点异常了,没有持久化存储到磁盘上,这些数据在主上就不存在了。主节点会持续地更新oplogTruncateAfterPoint以便跟踪和推进一个没有空洞的点(磁盘数据层面),以防类似这种节点异常挂掉的场景。所以,启动恢复流程需要考虑该异常场景下,依据oplogTruncateAfterPoint信息,与其他副本集节点的数据进行一致性协调。
在截取oplog后,节点会判断截取的oplog时间戳和recovery时间戳是否不同,如果不同,则必须应用oplog直到数据时间戳跟oplog时间戳一致。节点apply了从recovery时间戳到截取oplog时间戳之间的所有操作。有个例外场景。即:不会应用prepareTransaction对应的oplog entries。对prepareTransaction对应的oplog entries的处理,和 初始化流程或者回滚流程中的如何重建 prepared transaction 处理相似,每次遇到 prepareTransaction oplog entry,都会更新事务表。一旦节点完成所有oplog的应用,节点将会重建所有prepared 状态的事务。
最后,节点加载副本集配置,并设置lastAppliedlastDurable两个时间戳为最后一条oplog的时间戳。最后开启同步过程。

 删除库和表(Dropping Collections and Databases)

在3.6及之后的版本,replication 模块为 collection 和 database的删除引入了两阶段删除算法(Two Phase Drop Algorithm)。这让支持删除操作的回滚变的容易。在4.2版本中,collection删除的实现下沉到了存储引擎中。本节主要描述删除在replication 模块的实现,目前配置了enableMajorityReadConcern=false的节点均使用此逻辑。
删除表(Dropping Collections)
如果一张表不需要被复制到从节点(比如local库下的表),dropCollection会导致其直接被删除。否则,就会使用两阶段删除。在第一阶段,如果此节点是主节点,它将写入一个”dropCollection”的oplog操作。集合会被标记为删除然后将此集合添加到DropPendingCollectionReaper(会带着OpTime一起)的列表中,但是此时存储引擎还不会删除集合数据。每次ReplicationCoordinator向前推进commit point,节点都会检查任何涉及到drop操作的OpTime是否已被majority commit point覆盖。如果已经覆盖,那么这些相关的drop操作就可以推进到二阶段了,此时DropPendingCollectionReaper会通知存储引擎删除集合。通过等待“dropCollection”对应的oplog被 majority committed point覆盖,可以保证阶段1中的删除可以被回滚。因为存储引擎没有删集合的数据,在发生回滚时,可以轻松地还原集合。
删除库(Dropping Databases)
当一个节点收到dropDatabase命令时,它将对相关数据库中的每个集合进行如上所述的两阶段删除。一旦所有集合的删除操作都复制到大多数节点,节点将删除已经为空的数据库,并写一条dropDatabase的oplog。

 复制相关时间戳词汇表(Replication Timestamp Glossary)

这个章节,我们如果提到transaction,一般指的是存储层的transaction。复制层的transaction,我们称做 multi-document transaction 或者 prepared transactions。
all_durable: 在该时间戳之前的事务,都是已经被提交了的事务。这个点的oplog不存在oplog gap。该时间戳,是在执行相关写操作之前,就已经获取了的。该时间戳,用于维护oplog的可见性点。所有小于或者等于该时间戳的操作,已经提交并且持久化。正因为如此,才可以无gap地进行oplog的同步。
commit oplog entry timestamp:指的是 prepared transaction 中,commitTransaction这一步的oplog entry的时间戳,或者 non-prepared transaction 中 applyOps 这一步的oplog entry的时间戳。在一个跨shard的分布式事务中,每个shard都有各自的commit oplog entry timestamp,并且彼此可能不同。这个时间戳,一定是大于preparedTimestamp的。
commitTimestamp:这个是提交多文档事务的时间点。是 prepared transaction 中 commitTransaction 的oplog entry中的commitTimestamp 或者  non-prepared transaction中 applyOps 这一步的oplog entry的时间戳在一个跨shard的分布式事务中,每个shard上的这个时间戳,是一样的。事务的可见性依赖于这个时间戳。对于 non-preapred transaction而言,commitTimestampcommit oplog entry timestamp是一样的。因为,事务提交前,都不会写oplog。对于 preapred transaction而言,遵循这样的规则:prepareTimestamp<=commitTimestamp<=commit oplog entry timestamp
currentCommittedSnapshot:维护在ReplicationCoordinator中的optime,用于适配 majority reads,并且始终小于等于 lastCommittedOpTime。如果eMRC=true,该时间戳被临时设为stable opTime,stable optime可以确保oplog已经在从上同步。此后,每次计算stableTime,都会更新该时间戳。如果eMRC=false,这时间戳被设为lastCommittedOpTimelastCommittedOpTime不保证oplog已经在从上,可能在从节点的oplog中不存在。stable_timestamp不允许大于all_durable。所以,除非eMRC=false,否则该时间戳不应该小于all_durable
initialDataTimestamp:用于标记时间戳的最初开始点,当节点完成了初始化流程,会记录该时间戳。此时,该时间戳就是对应的节点的lastApplied时间戳。通过将该时间戳设为0,即通知存储引擎获取一个不稳定的checkpoint。稳定的checkpoint可以看作是一个基于时间戳的读 并且 读的数据是已经持久化了的。不稳定的checkpoint就只是简单地打开DB,读所有的数据包括当前未结束的事务提交的数据。此时的读快照,不关联任何特定的时间戳。
lastApplied内存中,最大的已经applied的oplog的时间戳。可能会晚于存储引擎可见的最大oplog时间戳。因为事务提交后,才会更新后者的值。
lastCommittedOpTime:节点本地的最大的majority commit的时间戳。每次更新该时间戳的时候,会重新计算stable_timestamp。需要注意的是,如果 还没有同步最新的majority commit的oplog entry 的话,lastCommittedOpTime可能会大于lastApplied。要了解lastCommittedOpTime是如何更新的,请参考章节(Commit Point Propagation)
lastDurable:从节点上的最新的oplog 或者 主上的写入journal的没有oplog空洞的最大oplog。后续写入操作持久化时候,存储引擎会异步地更新该时间戳。journal日志刷新的频率默认是100ms.
minValid:节点至少必须应用到该时间戳,才能达到数据一致性的点。在ReplicationConsistencyMarkers模块中,将该时间戳记录在minValid文档中。该时间戳会持久化,不受重启影响。
oldest_timestamp:存储引擎保留的最早的时间戳。新事物的开启,不能早于该时间戳。推进stable_timestamp时间戳时,也会推进oldest_timestamp。并且,oldest_timestamp 始终 小于等于stable_timestamp
oplogTruncateAfterPoint:记录最大的没有oplog空洞的点。主上,在存储引擎刷盘journal日志之前,会更新该时间戳。在oplog批量应用之前设置该时间戳,在应用之后清除。启动恢复流程,会用oplogTruncateAfterPoint时间戳把oplog截取到一个和其他副本集成员一致的数据点。对应的异常场景是:源节点crash之前的一些内存数据,可能已经同步到从节点上,但源节点因为crash而丢掉或者找不到这些数据了。
prepareTimestamp:对应prepared 事务中,prepare这一步的oplog entry的时间戳。这个是事务提交最早的时间戳。这个时间戳被存储引擎用来阻塞对prepared 数据的读操作,直到prepare的事务被提交或者放弃。
readConcernMajorityOpTime:指的是replSetGetStatus命令中的readConcernMajorityOpTime字段,来源于ReplicationCoordinator模块中 的currentCommittedSnapshot时间戳。
stable_timestamp:存储引擎允许打快照(table checkpoint)的最大的时间戳,该时间戳对应数据的一致性快照点。用于在同步过程中告知存储引擎,在哪(指时间戳的点)可以安全打读快照。这个时间戳,是有majority写保证的。RTT回滚过程中,会用到。如果是eMRC=false的场景,该时间戳,可能不具备majority写保证。这也是为什么会用到Refetch rollback algorithm进行回滚的原因。除了eMRC=false的场景,时间戳要求是单调递增的。eMRC=false时,在回滚的某些特殊场景下,stableTimestamp可能会被回退。
除了eMRC=false的场景,时间戳要求是单调递增的。eMRC=false时,在回滚的某些特殊场景下,stableTimestamp可能会被回退。
作者:华为云DDS内核组,华为云DDS内核组致力于提供高性能的云原生文档数据库。
校审人: 高强/崔鑫
文章参考自:
https://github.com/mongodb/mongo/blob/master/src/mongo/db/repl/README.md
赞(14)
未经允许不得转载:MongoDB中文社区 » MongoDB复制技术内幕

评论 3

评论前必须登录!

 

  1. #1

    这篇真心长,得分天看

    loveaishengtt5年前 (2020-06-19)
  2. #2

    图解下就更好了,这样看有点不太好理解

    tengxiaojun4年前 (2020-08-17)
  3. #3

    oplogTruncateAfterPoint应该是设置成这批 oplog 的开始时间点!

    _hust974年前 (2021-05-28)