# [译文]Fault-Tolerant Replication with Pull-Based Consensus in MongoDB

这篇文章主要介绍了 mongo 在副本集中的实现的共识算法，与大部分的 raft 不一样，它是基于 pull-base 的模式的；我在第一次解决 mongo 的副本集问题的时候一开始就进入了 push-base 的 raft 的分析，后来发现整个代码逻辑有点奇怪；mongo 对 raft 类似的实现还是进行了很多不一样的修改，从而达到它想要的目的；这文章可能很好的理解到很多 mongo 的知识，所以我决定来翻译这个文章；大家别对我的英文水平抱有任何幻想，我只是想让自己能更加明白一点，而且也不会全文一字一句的翻译，有的大概的意思就差不多，英语好的小伙伴直接原文吧，其实并不难读, [Fault-Tolerant Replication with Pull-Based Consensus in MongoDB](https://www.usenix.org/conference/nsdi21/presentation/zhou).

## 1. 摘要

在本论文中，我们将展示在 mongo 中，副本集中的强一致性是如何设计和实现的；mongo 的共识算法继承与 raft，通过这个算法可能保证 mongo 能提供线性化和容忍小于半数以上节点挂掉的能力；但是 mongo 的共识算法和主流的 raft 算法主要的区别点：**mongo 实现了一套独特的 pull-base 的数据同步模型，一个副本通过拉取另外一个副本来获得新的能力**. 这种 pull-base 的数据模型被初始化在任何一个副本中，而这种拉取的过程可能会发生在任何两个副本之间，这个是与主流的 raft 算法是很不一样的。基于 pull-base 模型提供了更加灵活可变的数据传输拓扑结构，而这些的变化能让 mongo 在性能和财务的损耗有这一定的优势，这是我们用户强烈希望的；这篇论文会描述几个方面的内容：

* 这个共识算法是如何工作的
* mongo 是如何将这套算法集成到自己的体系之内
* 为了支持更加丰富的功能，mongo 对协议进行了哪些扩展
* 评测

## 2. 介绍

mongodb 是基于文档管理的分布式数据库；在这些年我们一直致力于改进副本集的功能，让它能支持更加强的一致性语意；在前几个 paper 中，分别描述了[mongodb 是如何支持 tunable consistency](http://www.vldb.org/pvldb/vol12/p2071-schultz.pdf)和[mongodb 是如何支持 causal consistency](https://dl.acm.org/doi/pdf/10.1145/3299869.3314049)，而今天这篇论文主要准备详细介绍关于 mongo 是如何实现线性化的副本一致性。

对于实现副本集之间的数据的线性一致性的主流方式就是基于共识算法；而目前共识算法主要是 raft 和 paxos；在我们研究之下，目前不存在一个算法能直接 (不需要进行大量修改) 适配我们的场景。这个关键原因在于这些主流的算法都是基于 push-base 模型，在一个副本集中会存在一个 primary，数据是通过 primary 推向各个副本；而 mongo 想要的是基于 pull-base 模型，一个副本会主动拉去另外一个副本并且不一定是 primary 的副本;

有一些原因促使我们构建一套以拉为主的同步模型；
1. 能支持让任意两个副本进行同步数据，能构建更加灵活的数据传输的拓扑结构，在一个网络富裕的环境下，能充分利用这网络资源;有很多用户都倾向于在使用 mongo 的过程中能配置网络，用来应付他们的生产环境，比如多机房部署之类的；
2. pull-base 的同步模型给予 mongo 一种向后兼容的能力；因为之前的版本是一个基于 pull-base 的主备模型;

因为没有太合适的，所以我们基于 raft 开发了我们自己的副本同步模型；之所有选择 raft，主要原因是更加被工业界接受，比较简单易懂和更加类似于我们之前的版本的副本同步模型，当然我们也相信给予其他的，比如 Paxos，也是能正常工作的；我们的方法的原则是解耦 raft 中的数据同步的过程 (大部分就是`AppendEntriesRPC`):一个副本通过 pull 模型从另外一个副本拉去新的数据和副本之间相关报告它们自己最新的同步状态，从而能知道某一个请求是否满足了大多数的要求;

开发一个新的副本同步模型通常说的要比做的简单；我们在开发过程中遇到的最大的挑战在于对 raft 协议的细微的改动；raft 协议本身是被证明是 ok 的，但是我们每次微笑的改动都会遇到一些非预期的边缘测试通不过，从而影响了整个服务的正确性。为了验证我们设计和实现是正确的，我们花了巨大的工作量在于验证和测试这个协议本身，有`model checking by TLA+`，单元测试，集成测试，混沌测试和故障注入测试。

最终我们开发出来的协议成功的完成了可以在任意两个副本之间进行数据拉取的主要目标；和 raft 的以推模型不一样的，我们的系统支持任意的数据同步路径：从链式推送到广播; 基于我们开发的同步模型为 mongo 提供了许多优点，包含了性能上的 (比如可以减轻 primary 压力充分利用其他节点的能力等) 和管理维度上的 (比如可以管理数据传播路径等); 这篇论文主要会包含以下内容：

* 我们基于 raft 设计了一套新的共识算法，这套算法会更加符合 mongo 本身的需要，它能提供更加灵活和可定制化的数据同步路径
* 我们会描述 mongodb 在使用这套新的协议中间的设计上的各种选择； mongodb 提供一些独一无二的功能集，这给我们的设计带了额外的挑战和实现；比如，我们提供了一种兼容 mongo 的弱一致性的执行模型，但是也通过 rollback 保证了日志的线性复制。
* 我们对不同副本参数下面的 mongo 进行了评测；我们的评测结果证明了 mongodb 副本集模型的可靠和有效性;

我们将根据如下组织本论文。

* 段落 2: 背景回顾
* 段落 3: 描述共识算法的主体
* 段落 4: 我们设计中的扩展功能
* 段落 5: 评测结果的讨论
* 段落 6: 相关工作

## 2. 背景

**Mongodb 接口和架构**. mongodb 是一个存储文档性数据，支持对单文档或者多文档的 crud，并且支持一种`rich query language`的数据库; 每一个文档是一个二进制的一段数据，这个数据结构类似于 json; 文档通过唯一 id 来进行标识，并且被组织在集合 (`Collection`) 中，这个集合类似于 Sql 中的 table;

为了提供高可用，mongodb 提供了以一组副本集的模式来运行一个数据库，这组副本集通过公式算法来进行数据同步和备份. mongo 也支持按照水平维度进行扩展，将在相同的集合中的数据分布在不同的 shard 上，整个模型是 share-nothing; 每一个 shard 是按照副本集进行部署。在这篇论文中我们将聚焦于单个副本集。

**一致性和容错性**. 在之前的论文中，我们介绍了 mongodb 是如何去支持弱一致性，包括因果一致性的。在这片论文中，我们将聚焦于一种强一致性的 - 线性一致性。我们假设在一个部分的异步环境中，所谓异步环境是消息的达到时间完全没上限和没有一个靠谱或者完美的错误检测方法；对于副本集中的每一个 node，在少于半数节点都出现故障的情况下依然都能保证系统可用。而对于这种在容错系统中保证线性一致性的解决方式通常就是共识算法。共识算法有：

* Viewstamped replication
* Paxos
* raft
* zab

我们的解决方法采用了近几年比较流行的 Raft，但是最终发明了一个被大量修改之后的新的协议。

**基于 pull-base 的 mongodb 进化**. 在 10 年前的 mongo 1.0 版本，那个时候 mongo 支持 master-slave 这种模型的副本同步的方式，但是与其他的 master-slave 不一样的是，mongo 在那个时候就会选择了 pull-base 的模型的，而且 pull 的目标不一定是 primary.

对于基于 pull-base 模式的同步数据最最大的优势在于针对网络可以有能力去做更加灵活的网络传输的方案; 依赖于用户需要，数据的传输能工作在星形拓扑结构，链式结构，或者混合结构。这种对数据链路的控制是很多用户需要的，大部分原因在于性能和钱财上的消耗。举个例子：当我们被部署在 AWS 的 EC2 上，数据传输在内网是又快又便宜，但是跨机房的传输就变得又贵又慢。

基于拉模型的数据同步方式在最近几年不断的不迭代和进化中；在几年前我们推出发布版本中，我们假设是一个半同步网络，在这种网络中：要不通过手动的方式进行错误管理 (用户需要在切换 leader 的时候去提前指定 new 的 primary 在哪里) 要不就是错误检测工具会要求消息必须在 30s 之内达到；从 2015 年开始，我们基于 Raft 重新建模了同步模型;新的歇息保证了在异步环境下面的安全性和支持全自动的错误恢复的功能。和之前一样，这个模型依然是基于 pull-base 的，在下一个段落我们将开始描述这是如何实现的。

## 3. 设计

本段落将描述 mongo 的副本是如何工作的；包括整体的架构和数据结构 ($3.1)、整个副本协议的主题 ($3.2)、关于正确性的讨论 ($3.3) 和系统是如何选择数据传输路径的 ($3.4);最后会有一个附录，总结了 mongo 的共识算法与 raft 之间的区别。

### 3.1 先验知识

在 mongo 中，副本之间复制的基本单元是 oplog,oplog 是一个有序的日志项；每一个日志项表示对数据库的一次操作; 下面展示了一些 oplog 的一些例子; 这种日志项会被存储在一个叫做 oplog 的表中，oplog 的表的行为和一张普通的用户表几乎是一致的；但是当一些数据不需要的的时候，oplog 会自动删除最老的数据，并且新的数据添加到 oplog 中;

```json
{ // The oplog entry timestamp 
    "ts": Timestamp(1597904287, 12), 
    // The term of this entry 
    "t": NumberLong(40), 
    // The operation type, "i" for insert 
    "op": "i", 
    // The collection name 
    "ns": "test.collection", 
    // A unique collection identifier 
    "ui": UUID("947b54f...852f62")), 
    // The document to insert 
    "o":{ "_id": ObjectId("5f3e...b950"), "x": 1 } 
} 
```

一个 oplog 的日志项需要被同步到半数以上的服务器上才能被认为是 commit; 被 commit 的日志项即使出现了少于半数节点故障也能保证持久化下来; mongo 系统会等待请求被 commit 之后才会返回给 client. mongo 也支持弱一致性的写入操作，在一个请求还没有真正被 commit 之后就回复 client([29](http://www.vldb.org/pvldb/vol12/p2071-schultz.pdf),[34](https://dl.acm.org/doi/10.1145/3299869.3314049)); 在副本同步之后，理论上所有的服务器都应该有相同的 oplog，而 oplog 将按照相同的顺序被应用在所有的服务器上。

一个服务节点能扮演的 primary 或者 secondary 两种角色，只有 primary 才能承担写请求。当一个 secondary 转变成 primary 的过程中会出现第三种角色: candidate(候选者),mongo 的竞选规则和 raft 是类似的; 比如当一个节点在选举超时的时间内没有发现 primary，这个时候当前节点就

1. 将自己变成候选者
2. term + 1
3. 向周围节点发送选举请求;

其他的节点 (voter，选民) 有两个保证：

1. 只有候选者有相同的或者更加新的日志才能将票给它
2. voter 要保证在给定的任期的前提下，只能将投票投给一个候选者;

如果这个候选者能获得半数以上的投票 (包含自己的一票) 的话，就可以从候选者变成 primary;

在选举成功之后，一个新的 primary 将有一个独一无二的任期号 (单调递增)；当新 primary 产生新的 oplog 会写入到自己的 oplog 表中，其他的副本将通过数据同步协议 ($3.2) 进行同步；可能会出现超过一个以上的 primary 出现在集群中，但是数据同步和共识算法协议保证最多只有一个 primary 能成功的 commit 一个特定位置的日志项。

在 mongo 中，每一个 oplog 都会有 timestamp 和 primary 的任期号；timestamp 是一个单调的逻辑时钟；这两个数据索引 oplog 的位置的，这两个数据组成的数据对被叫做**OpTime**, Optime 能用来标识 oplog 的在副本集中的唯一性，提供了 oplog 的全局有序性; OpTimes 按字典顺序进行比较，比如一个 Optime 如果 term 比别的 opTime 更加大的话，那这个 Optime 就比较大；如果 term 一样的话，那么就比较 timestamp 的大小。

### 3.2 数据副本同步

如前面提到的，mongo 是按照拉模式进行副本之间的数据同步。不像 raft 或者其他的共识算法协议一样，当 primary 需要将新的日志项同步给副本的时候需要从 primary 主动推送给 secondary(比如 raft 中的`AppendEntries`这个 RPC 接口)；在 mongo 中，primary 是等待其他的副本拉取最新的 oplog 的日志项来保证数据同步。

primary 在将一个日志项添加到自己的 oplog 中，会处理从 secondary 的两种类型的 RPC 请求，分别为：

* `PullEntries`
* `UpdatePosition`

secondary 通过`PullEntries`来拉取最新的日志，而使用`UpdatePosition`来报告 secondary 本身的一些状态，primary 通过这些信息来判断 oplog 是否已经被同步到半数以上的节点，并且可以安全的 commit 这个数据；和 Raft 一样，一旦当前位置的日志项被 commit 了，那么间接的就表示之前的所有的日志项都被 commit 了;

#### 3.2.1 `PullEntries`

这里面有一个关键的设计抉择是 secondary 的`PullEntries`请求并不一定只能发送给 primary.代替的是 secondary 可以按照就近原则从周边的 server 中拉取 oplog 的数据。而这个正在拉取的 secondary 被叫做`syncing server`，它的上游即被拉取的 server 被叫做`同步源 sync-source`.

secondary 会持续不断的发送`PullEntries`请求给选择出来的同步源 ($3.4 段会对同步源有更加详细的描述)，并接受最新的日志项。这个`PullEntries`包含了当前 server 最新的 oplog 的 timestamp(`prevLogTimestamp`) 作为参数。当同步源接受到一个`PullEntries`请求之后会回复比这个 timestamp 相同或者大于的 oplog 的日志项数组，如果当同步源比当前节点的 oplog 要老的时候，会返回一个空的数组；如果`PullEntries`的请求参数和同步源的日志项是相同的时候，这个时候不会马上返回而是会等待一段时间 (默认是 5s)，主要是为了防止`PullEntries`的频繁请求。

当 secondary 收到了同步源的回复之后，它会尝试将收到的 oplog 日志与自己的 oplog 日志进行合并。在合并之前，secondary 对检查当前收到的 oplog 是否能和本地的 oplog 进行合并，特别是它会检查接受到的 oplog 的第一个日志项是否和自己的 oplog 最后一个日志项是一样的；因为只有是一样的，才能让 secondary 持续合并后期的 oplog. 假如接受到的回复的日志项不能与本地的 oplog 有交集并且接受到的 oplog 通过比较`OpTime`发现比本地更加新，这个时候 secondary 会按照自己的 oplog 的顺序往前匹配，直到找到与同步源最近的一个有交集的日志项，这个时候会清除这个日志项之后的所有 oplog 日志然后再将同步源的 oplog 日志进行同步，这样之后 secondary 的 oplog 的日志就和同步源的是一致的。而被舍弃的这些日志相较于 Raft 来说，会有更加多的工作要做；原因在于 mongo 在这边投机 (预测) 的优化。

> 正常 Raft 的协议是需要等待 secondary 都接受了当前日志项之后才会被 commit 的，而 mongo 通过 primary 往周边节点发送信息的结果来获得是否可以被 commit. 而且同步源本身可以是非 primary，是否存在可能同步错误的 oplog 呢，又或者本身已经被 commit 的日志也会被清理掉呢？

#### 3.2.2 `UpdatePosition`

当接受了`PullEntries`的回复，并将对应的 oplog 日志项合并到本地的 oplog 之后，secondary 会通过`UpdatePosition`这个接口将自己最新的 Oplog 发送给自己的同步源，而同步源在接受到这个消息之后会转发给自己的同步源，就通过这样一层一层的发送最后就到达了 primary 节点.  primary 会在内存 (非持久化) 中保存着每一个副本的最新 oplog 的位置，当接受到一个`UpdataPosition`的请求之后，primary 会将于本地的数据进行比较，如果比它新就会更新内存中对应的数据。然后 primary 会对这些内存中的统计数据进行计算，如果发现有一个 OpTime 超过半数的有相同任期和相同或者更大的时间戳的话，primary 就会更新`lastCommitted`为上面的那个 OpTime，并且将这个信息同步给其他的副本，通过的方式是在其他的消息中夹带着同步过去，比如心跳消息或者`PullEntries`的回复中，而`lastCommitted`被认为是已经被提交的位置。



#### 3.2.3 如何实现

在 mongo 中，`PullEntries`这个 rpc 请求是通过查询大于等于时间戳的 oplog 表来实现的；因为 oplog 这个表是按照 timestamp 进行排序的，所以这个查询本身非常容易被优化；使用 mongo 的游标可以让同步节点能批量的工作和支持类似于流式的方式进行工作，以便同步源在不等待新的请求的情况下将数据发送出去，从而减少本身同步带来的耗时;

```
这边的细节估计需要考究一下，如果在不等待新的请求的情况下将新的数据发送出去，接受方应该是等待或者？
```

为了避免过多的`UpdatePosition`消息转发，每一个服务器在两次转发的过程中，被动的批量接受`UpdatePosition`的包然后进行合并，仅仅保持每一个服务器上的最新的 oplog 位置。在整个流程中，每一个服务器最多只会存在一个`UpdatePosition`的消息给同步源，这个`UpdatePosition`消息只会在等待上一个消息发送完之后才会发送下一个。

在 mongo 的心跳请求和 raft 不一样，它的职责更加明确，从传统 Raft 的`AppendEntries`中解耦出来。心跳请求会发送给所有的副本，主要的作用是监控存活简单、将当前可以被 commit 的位置同步和选择合适的同步源。

### 3.3 正确性

细心的读者肯定会发现，我们的数据同步协议中仅仅只是检查日志项的`OpTimes`，但是不会检查或者比较当前的同步源的任期是否高于或者相同自己的任期；这个与 Raft 数据同步的协议是有区别的。在 raft 中的数据同步过程中，这个检查是会在`AppendEntries`的 rpc 中执行的，只有当`AppendEntries`中包含的 primary 的任期号大于或者等于本身接受节点的任期号的时候才会让这个 rpc 返回成功。

这个**关键性**的协议改变意味着在 mongo 中基于日志数据同步的行为会和 Raft 有着不同；在 Raft 中，假如一个节点已经投票给更高任期的节点之后，当前这个节点是不会再接受老的 primary 的`AppendEntries`的请求。但是在 Mongo 中，因为`PullEntries`请求是不会检查同步源的任期的，所以即使当前节点已经投票给任期更加大的节点，同步源是过期的 primary，它依然能从过期的 primary 中同步到比它新的日志项。

![20211009131944-2021-10-09](https://cdn.nomoshen.com//vscode_img/20211009131944-2021-10-09.png)

> 关于上图的一些描述:
>
> * 每一个框表示oplog的日志项,里面的数字为当前term
> * (a): server A 和Server E两个都是primary;
> * (b): raft仅仅只会允许A到B的数据复制(红色箭头)
> * (c): mongo能支持A到B,C,D的数据复制(红色箭头)
> * (d):C/D会通过`UpdatePosition`的请求来报告自己的位置，这个请求包含了自己的任期（橘色的箭头），A收到这个信息之后就强制让出primary
> * (e): 最后数据都会是serverE，那么之前(c)过程中的数据复制将被回滚掉; 

上图展示了raft在secondary的行为上的区别; 

1. 开始的时候，5个服务器都任期都是1;
2. A因为赢得了A/B/C的投票之后赢得了选举成为primary(任期号:2), 然后在A本地oplog中写了一个日志项;
3. E的任期号因为赢得了C/D/E的投票也成功选举成为primary(任期号:3),然后在E本地oplog也写了一个日志项，但是和A的不一样;
4. 在Raft中，A的这条日志项只能同步到B，C/D/E会选择拒绝;

在mongo中，B/C/D都能从A上面同步到新的日志项，即使C/D在这个时间已经投票给了任期为3的E, 也不会干扰到这个同步过程；假如A是B/C/D的同步源，那么这个三个节点将因为A中的日志项比它们三个节点的本地oplog日志项新，所以也会同步过来；现在A中的这条任期为2的日志项已经被同步到集群的大多数节点，那么这条日志项就被认为是committed，如果我们没有进一步修改Raft的规则: **一条leader committed的日志项在将来是不能进行修改**,那么最后服务器E的任期为3的日志项会重新覆盖被committed日志，这明显是违反了Raft的安全性;

为了阻止上面的情况发生，我们增加了为`UpdataPosition` rpc添加了新的参数: 为正在同步的server的任期号; 这个rpc的接收方如果发现接收到的任期号比本地任期高的话，就会更新本地的任期号；假如这个接收者是老的primary的话，那么当前节点在发现有更高任期的出现之后，在触发committed之前就会主动`step down`自己的primary，这样就避免了任何不安全的提交。在上面的例子中，A接收到了`UpdatePosition`时候，发现了任期号3之后立马就进行`step down`操作，不会更新`lastCommitted`; 所以即使日志已经被同步到了大多数节点，日志也不会被认为是committed.

到目前为止，我们假设一个secondary可以在投票给更加新的节点之后依然可以从旧的primary同步日志项. 事实上，这个时候同步方的节点可能已经被选举为新primary. 即使一个primary(或者候选者)已经在更加高的任期号投票给它自己，它依然可以从其他更加低任期号的节点中同步数据，只要没有用新的任期号构建新的日志项写入到自己本地的oplog即可(因为写入了节点的oplog就比其他的要新). **mongo会尽可能的保留未提交的日志用于failover使用，这是与Raft的最大的区别**

更正式，我们对`UpdatePosition`的修改是为了保证Raft的一个关键的特性-**leader的完整性**. 这个特性主要指:当一个任期的日志项被committed了之后，那么这个日志项将会一直存在于后期所有leader的日志中. 在mongo中，只有primary收到了超过半数的`UpdatePosition`的任期号为`T`的同步信息之后，才会认为任期为`T`的一个日志项被committed了.后期其他的leader(`U > T`)必须从半数以上节点中获得投票信息，那么必然有两个半数以上的集合会存在交集，而存在的这个交集是保证安全性的关键点. 要不投票节点在投票之前发送了`UpdatePosition`的请求，那么隐含了新的primary节点有了这条被committed的日志，要不就是在`UpdatePosition`之前发送了投票请求，那么这个时候老primary收到请求之后会立马进行退位操作，也保证了没有任何日志被提交；无论哪种情况，都能保证leader的完整性.

除了这个特性，其他的Raft特性我们都仍然保持，所以可以Raft的证明过程也证明我们的协议的正确性。此外，为了公式地验证我们的系统的正确性，我们已经用TLA+写了关于我们协议的正式的规范，并且应用于模型检查; 

### 3.4 同步源的选择

通过`Heartbeat` rpc可以知道其他的服务器的状态，包含有oplog的位置等；而选择一个同步源的标准就是选择的节点存在有比自己更新的oplog即可. 当同步源发生回滚(日志被清理和重新覆盖) 的场景下，这个条件也会在接收到`PullEntries`的回复之后进行再次check. 当然mongo用一套机制保证了不会让这个同步过程形成一个环. 一个server从一个同步源同步数据之后，它会持续从这个节点拉取数据，除非同步源出现了不可用或者更加好的同步源产生了，所以在一个稳定的环境下，一个server不大会频繁的改变自己的同步源.

## 4. 扩展

在这个段落，我们将介绍mongo的几个关键特征。

### 4.1 投机执行器和回滚

在基于Raft的系统中，副本能应用一条日志的条件必须是等到收到committed的消息才可以. mongo中有一种优化，叫做

**当一条日志项被add到oplog，就投机化的`apply`一条oplog中的日志项** `[译者: 不再等待commit，oplog有什么我就直接apply]`. 假如发生了`failover`操作的话，那么之前被投机`apply`的日志项就可能需要被删除(类似于3.2.1的例子). 在那个例子中，系统就需要回滚那些item =2 在A/B/C/D中的日志项. 在数据库领域通用的回滚策略是通过`undo or redo log`来实现的. mongo为了实现这个目标的方式是通过将存储引擎(`WiredTiger`)和本身的复制协议进行统一设计. 

WT是一个支持多版本事务的存储引擎，它能使用oplog timestamp来定位到对应的版本和数据更新. 关于WT有三个关键的功能支持了之前说的那些事情; 

1. 存储引擎支持投机化的更新，而多版本管理可以支持根据client需要的一致性要求来支持不同版本的可见性，即使很多更新都还没有被committed;
2. 存储引擎支持快速回滚的功能，可以快速回滚某一个时间戳之后的所有对应的数据更新.
3. 当接受到committed的信息之后，存储引擎会所有`<=`这个时间戳的更新的数据合并到底层磁盘上的`checkpoint`和垃圾回收掉这些不再需要被存储的version

当一个节点需要被回滚的时候，整个决策的过程是通过与它的同步源进行交互之后，获得与同步源的公共的oplog位置，比如`Tcommon`,这个节点需要截断所有在`Tcommon`的oplog日志项；而且这种操作，一定需要回滚到这些被清理掉的日志相关的投机写操作.

从mongo 4.0版本开始，WT引擎已经支持了将数据回滚到某一个指定时间的相关版本的功能. mongo会周期性的通知存储引擎目前的稳定时间戳`Tstable`, 这个时间戳来源于当前节点收到的committed信息.

为了能回滚被删除的日志项的对应的更新数据，这个正在做回滚操作的节点会将自己的版本回滚到最新的稳定时间戳的版本，然后再从同步源节点同步`[Tstable, Tcommon]`之间的oplog;

### 4.2 初始化同步

当oplog的磁盘空间达到预设的阈值之后，mongo会清理老久的oplog日志，通常默认是5%的磁盘空余就会开始. 在大多数的共识系统中，比如Chubby或者Raft，通常会在清理过期oplog之前都会去做一次对db的快照,这些快照被用于到新的节点加入的时候进行追赶使用. 而且mongo并不是依赖这种快照策略，而这个过程被叫做`initial sync(初始同步)`.

mongo不使用快照策略在初始同步的主要原因是mongo有一个可插拔的存储api，它不需要存储引擎支持快照的功能. 比如在WT之前的存储引擎是mmap，这个mmap是不支持快照的；所以mongo的决策的最大原因是在于向后兼容没有快照功能的存储引擎.

在mongo的初始同步流程如下：

1. 新的服务加入到集群中的时候，会选择一个自己的同步源，在整个初始化阶段过程中都会使用这个同步源`[译者:不出意外是不会换的，如果同步源不可用是另外的情况了]`; 一旦这个流程开始，这个新的节点会继续记录当前同步源已经被committed的oplog的位置，**以这个位置作为自己同步oplog的开始Tstart**
2. 新的server会通过扫描的方式来clone同步源的数据库; 遍历每一个db中的每一个表，用`cursor`将每一个表进行遍历过来.当然在遍历的过程中，同步源不会停止写，所以会将一些更新结果也一并同步到新的节点上来,比如有一些数据已经被更新、有一些数据已经过时了，一些数据已经被删除了等. 在最后一步会对这个数据进行修复的.
3. 新的server将接受开始点之后的所有的oplog(第一步)，然后应用到自己的数据库上. 在数据库clone之后，新的节点会再次记录同步源目前的committed的位置,这个点作为**结束点Tend**. 一旦新的服务器的应用的日志项的位置操作了这个位置，就表示当前的新节点已经处于一致性的状态，那么这个初始同步过程就结束了；

`[译者]: [Tstart, Tend]之间的oplog是会影响clone过程中的数据的一致性的;在clone完之后在回放这段时间的oplog就能保证整个数据库状态在Tend节点的一致性，后面的数据变化只需要同步oplog即可，而这个时间点之后的数据库处于不一致的，存在oplog可能生效也可能不生效的过程； 而clone和回放oplog是有先后顺序的;可以看这个文档:https://zhuanlan.zhihu.com/p/79786663 `

假如遇到了同步源不可用的时候，那么新节点会重新选择一个同步源，重新上面的这个过程.

从上面的流程中我们注意到了有的时候一些oplog在新节点上会被重放两次. 这个一个操作被应用在初始同步之后的话，那么本身copy的数据库就包含了这次操作，所以就会造成新的节点重复回放两次oplog. mongo为了避免这种情况造成的数据库不一致，通过的方式将oplog的重放便成幂等：也就是说同一条oplog被应用多次的结果是一模一样的.

对于一些改变数据库状态的操作(插入、更新、删除等)，我们在初始同步节点将这些操作的语意进行修改来保证它们的幂等. 在mongo中，insert的操作如果遇到已经存在见直接忽略；update和delete操作如果被操作的对象不存在也会被忽略。mongo支持很多丰富的操作，比如递增文档中某一个字段的value等. 这些操作都将被primary转化为`unconditional field assignments`. 比如一个递增的操作，primary会将这个字段的value取出来然后进行+1，最后在oplog中的是计算完之后的数据；从结果上来说，数据库的一致性需要同步源和新节点一起来保证.

### 4.3 保留未提交的oplog日志项

在故障恢复之后，在primary上面的没有被提交的日志会因为故障的原因而失去. 这对于其他的系统可能是一件好事情，因为这个时候客户端将会尝试这些没有返回committd回复的请求，但是对于mongo来说却是有问题的，因为mongo支持弱一致的功能，主要是写入primary成功和少于majority节点数据同步成功之后就会返回客户端已经成功. 因为这个，当故障发生之后，就会触发一大批没有被提交的日志丢失。虽然理论上来说，客户端在选择用这种方式进行写入的时候就没有保证一定是不丢失的，但是mongo依然会尽可能的将这些未提交的日志保留下来。

出于这个目的，我们将介绍在选举primary过程中的额外的步骤-`primary catchup`阶段. 新的primary在转化为primary之后并不会马上的开始承担写任务，代替的是， 它将持续从它之前的同步源中同步比它新的oplog到本地，直到没有更新的或者超时；这个超时是可以配置的，默认是1分钟。我们牺牲了快速恢复的时间来获得保存更加多的未提交的日志。

在`3.3`的介绍，这个设计是可能的. 当新的primary在没有写入任何的oplog到本地之前的，从老的primary上同步数据是可以的.

### 4.4 附加的副本角色

#### 4.4.1 仲裁节点

mongo支持一个特殊的角色叫做仲裁节点；仲裁节点是一种类似于secondary可以用来进行投票的，但是本身因为不会存储数据而不会消耗任何的存储成本和复制成本，它的作用仅仅只是用于投票.比如, 在`Primary-Secondary-Arbiter`这样的部署结构中，当primary因为故障而挂了之后，secondary能从自己和仲裁节点那边得到选票，最后变成primary来提供服务；但是这些写都只能是未提交模式，因为没有办法达到majority个数的写. 假如这个时候一个新的节点加入用于替换之前crash掉的primary的话，这个arbiter的存在可以安全的进行集群成员的变更

```
[译者]： 仲裁节点使用的最多的应该是集群夸机房部署的情况，为了防止单机房挂了之后导致的整个集群不可能，所以就必须多机房部署；但是只是两个机房部署的话，不管怎么样当主机房挂了之后，另外机房的集群肯定是不可用的，因为选择不出来primary; 三机房部署并不是每一个公司都能承担的了的，多机房的数据同步也会消耗不少的时间；而仲裁节点就是破局的点，本身仲裁节点对资源的消耗有限，所以将它部署到公有云的虚拟node上作为第三个机房，当主机房挂掉之后，依然能保证多数选票，那就能达到高可用性.
```

#### 4.4.2 无投票的角色

为了容错的需求，用户常常会部署一些副本用于分担primary的读流量或者通过访问本地的数据来降低延迟。比如，用户可能需要在一些副本上通过mongo的聚合语句来跑比较重负担的分析任务. 这些副本是不能用于容错使用的而且在比较重负债的情况下会影响本身的写入延迟. 另外一个比较明显的例子就是: 为了能让全球部署的应用能达到能访问就近的数据中心来获得低延迟，这就需要在全球部署十几个副本. 假如发生了比较罕见的同时几个服务器出现故障，不应该通过所有的副本的个数来计算majority的个数来满足容错，也不希望用这个数来确定是否commit写.

mongo的无投票角色就是用于这个目的. 无投票角色本身和正常的secondary节点是一样的，但是他们没有参与投票的权限，也不会计算在majority的个数中，与之对应的应该就是投票成员. mongo目前支持50个副本，但是最多指支持7个投票成员. 因为这样能保证即使是majority写也能尽可能快的返回.

无投票成员在推模式下面工作的非常的好，尽可能的最小化对primary的影响，也可能将一些特殊的读服务的场景迁移到这些无投票成员节点上.

### 4.5 选举优化

#### 4.5.1 选举的主动切换

在出现故障的场景中，secondary会等待一个选举超时时间之后才会进行选举，这个时间是用来确定primary不可用；而且当故障是可以被预期的 ，这个老的primary将在某一个时候进行下线之类的。这个时候mongo提供了主动切换primary的功能，这个会缩短切换的整体过程，减少不可用的时间，并且减少候选者的等待时间.

当一个primary通过admin的命令操作触发主动退让primary的命令之后，它将停止写操作和等待用户指定的时间让有资格的secondary来追oplog; 然后primary会从中选择出最好的secondary来运行选举的过程. 在大部分场景下，这个被选择出来的secondary会赢的选票变成新的primary. 这种主动退让的策略相较于等待选举超时而言缩短了failover的不可用时间；这个有点类似于在Raft中的leader转让.

这个功能已经被用在`MongoDB Atlas`上，这是mongo维护的云服务的数据库. `Atlas` 的用户可以使用滚动升级的方式来执行一些操作，比如应用一些安全补丁、扩展集群和升级最新的稳定版本的mongo.而滚动升级就会使用到primary的主动切换的功能；通过这个方式可以最小化可预期的故障恢复的不可用时间. 事实上，在Atlas的故障大部分都是可被预期的操作.

#### 4.5.2 成员的优先级

在大部分情况下，用户对哪些服务器能作为primary是有偏好的，特别在多数据中心的场景下，用户需要primary部署在离用户比较近的数据中心从而获得低延迟. mongo支持通过配置的方式来指定选举之间的优先级. 当一个secondary发现自己的优先级比当前的primary的优先级要高了之后，它将在**基于优先级的情况下的超时时间**之后进行一轮选举.这个超时时间是会因为优先级的高低有不同，更加高的优先级会有更加小超时时间. 用这种方式，优先级最新的node的将最新被选举成功，并成为新的leader. 假如因为oplog的原因没有选举成，但是它在后期依然会因为优先级的原因不断的进行选举，知道变成primary. 当优先级被设定为0的时候，当前节点就失去了选举的权利.

当一个高优先级的server出现故障的时候，选举会存在一个潜在的问题，当这台服务器起来之后会生成更加高的任期让当前的primary退让下来. 当因为高优先级的服务器重启带来的同步日志滞后，并且在重启之后加入到集群中的时候，这个问题会产生尤其大的破坏性. 直到这个高优先级的服务器能追上最新的oplog之前，它都会进行不断的选举，每次选举的会造成集群一种抖动. 虽然集群都能在最后选择出一个新的primary，但是整个集群会每隔一个选举超时就触发集群不可用；为了解决当一个节点被重新加入到集群的时候,在raft论文的$9.6章节有描述预选举,主要是说候选节点只有知道它能获得半数以上的选票的时候它的任期才能被增加(任期号也不是你想自我增加就能增加的，需要别人认可). mongo通过用`dry-run`的方式来实现预选举. 当一个具有高优先级但是过时的候选节点即使它不会赢得primary，但是它依然会进行选举. 但是这个候选人会在预选举的时候就失败，通过这个机制保护了现存的primary被这种更高任期号的干扰`[译者]按照raft中的描述，任期号递增是有限制的，也是通过限制任期号递增来限制对集群的干扰，所以理论上集群中不会存在无效的高任期号，我觉得mongo这边因为是通过这种方式限制更加高的优先级`.

值得注意的类似于网络分区等罕见的场景下，可能会发生两个节点一直不断的进行选举任务从而引起`活性`问题. 这个和优先级设计的问题不一样的，并且优先级的设计也不会使得更加糟糕. 在这些场景中，预选举并不能完全解决这些问题. 在异步网络中活性是不可能被保证的. 当然在真实世界中，这通常不是个问题.

### 4.6 只读操作

`strawman solution`通过使用将读请求当作写请求的方式来实现线性读; mongo优化了这种方式，它是通过看primary中的日志项是否已经被复制到其他的节点，如果已经复制到大多数的话，那么读这些操作就可以达到线性读. 值得注意的是这个优化与弱一致性的读是不一样的，虽然它们两个都将读操作转移到了oplog上面. 即使弱一致性读在primary和secondary节点上都能支持，但是它是不需要等待节点之间的数据同步的，但是线性读的话就只能在primary上读并且必须等到一次同步过程.



## 5. 评测

```
[译者]这部分内容是基本的评测，可以自行看论文
```



## 6. 相关工作

```
[译者]这部分内容可以自行看论文
```

 ## 7. 结论

```
[译者]这部分内容可以自行看论文
```



## 8. 译者最后

由于我本身对mongo的了解还在不断摸索中，文中有一些地方可能翻译不那么到位，而且没有很详细的看过mongo这块源码，可能在思考中有一部分只能靠自己的经验和之前看raft的相关资料来思考，如果有什么不对的，各位可以通过一下方式来找我或者自行看英文论文:

* twitter:https://twitter.com/andrew_rong

**本篇文章以 MIT 协议开源，欢迎任何人转载、引用**
