MongoDB复制集底层原理

复制集原理

复制集高可用

复制集选举

MongoDB的复制集选举使用Raft算法来实现,选举成功的必要条件是大多数投票节点存活。在具体的实现中,MongoDB对raft协议添加了一些自己的扩展,这包括:

  • 支持chainingAllowed链式复制,即备节点不只是从主节点上同步数据,还可以选择一个离自己最近(心跳延时最小)的节点来复制数据。
  • 增加了预投票阶段,即preVote,这主要是用来避免网络分区时产生Term(任期)值激增的问题
  • 支持投票优先级,如果备节点发现自己的优先级比主节点高,则会主动发起投票并尝试成为新的主节点。



**一个复制集最多可以有50 个成员,但只有 7 个投票成员。**这是因为一旦过多的成员参与数据复制、投票过程,将会带来更多可靠性方面的问题。

当复制集内存活的成员数量不足大多数时,整个复制集将无法选举出主节点,此时无法提供写服务,这些节点都将处于只读状态。



此外,如果希望避免平票结果的产生,最好使用奇数个节点成员,比如3个或5个。

当然,在MongoDB复制集的实现中,对于平票问题已经提供了解决方案:

  • 为选举定时器增加少量的随机时间偏差,这样避免各个节点在同一时刻发起选举,提高成功率。
  • 使用仲裁者角色,该角色不做数据复制,也不承担读写业务,仅仅用来投票。



自动故障转移

心跳机制,在复制集组建完成之后,各成员节点会开启定时器,持续向其他成员发起心跳,这里涉及的参数为heartbeatIntervalMillis,即心跳间隔时间,默认值是2s。如果心跳成功,则会持续以2s的频率继续发送心跳;如果心跳失败,则会立即重试心跳,一直到心跳恢复成功。



选举超时检测,一次心跳检测失败并不会立即触发重新选举。实际上除了心跳,成员节点还会启动一个选举超时检测定时器,该定时器默认以10s的间隔执行,具体可以通过electionTimeoutMillis参数指定:

  • 如果心跳响应成功,则取消上一次的electionTimeout调度(保证不会发起选举),并发起新一轮electionTimeout调度。
  • 如果心跳响应迟迟不能成功,那么electionTimeout任务被触发,进而导致备节点发起选举并成为新的主节点。



在MongoDB的实现中,选举超时检测的周期要略大于electionTimeoutMillis设定。该周期会加入一个随机偏移量,大约在10~11.5s,如此的设计是为了错开多个备节点主动选举的时间,提升成功率



因此,在electionTimeout任务中触发选举必须要满足以下条件:

(1)当前节点是备节点。

(2)当前节点具备选举权限。

(3)在检测周期内仍然没有与主节点心跳成功。



在复制集发生主备节点切换的情况下,会出现短暂的无主节点阶段,此时无法接受业务写操作。如果是因为主节点故障导致的切换,则对于该节点的所有读写操作都会产生超时。如果使用MongoDB 3.6及以上版本的驱动,则可以通过开启retryWrite来降低影响。

# MongoDB Drivers 启用可重试写入
mongodb://localhost/?retryWrites=true

# mongo shell
mongosh --retryWrites



如果想不丢数据重启复制集,更优雅的打开方式应该是这样的:

  1. 逐个重启复制集里所有的Secondary节点

  2. 对Primary发送rs.stepDown()命令,等待primary降级为Secondary

  3. 重启降级后的Primary



复制集数据同步机制

在复制集架构中,主节点与备节点之间是通过oplog来同步数据的,这里的oplog是一个特殊的固定集合,当主节点上的一个写操作完成后,会向oplog集合写入一条对应的日志,而备节点则通过这个oplog不断拉取到新的日志,在本地进行回放以达到数据同步的目的。

mongodb的oplog就类似于mysql的binlog

在这里插入图片描述



oplog

  • MongoDB oplog 是 Local 库下的一个集合,用来保存写操作所产生的增量日志(类似于 MySQL 中 的 Binlog)。
  • 它是一个 Capped Collection(固定集合),即超出配置的最大值后,会自动删除最老的历史数据,MongoDB 针对 oplog 的删除有特殊优化,以提升删除效率。
  • 主节点产生新的 oplog Entry,从节点通过复制 oplog 并应用来保持和主节点的状态一致;





查看oplog

use local
db.oplog.rs.find().sort({$natural:-1}).pretty()

local.system.replset:用来记录当前复制集的成员。

local.startup_log:用来记录本地数据库的启动日志信息。

local.replset.minvalid:用来记录复制集的跟踪信息,如初始化同步需要的字段。

在这里插入图片描述

op:操作类型:

​ i:插⼊操作

​ u:更新操作

​ d:删除操作

​ c:执行命令(如createDatabase,dropDatabase)

​ n:空操作,特殊用途

ns:操作针对的集合

o:操作内容

o2:操作查询条件,仅update操作包含该字段

ts: 操作时间,当前timestamp + 计数器,计数器每秒都被重置

v:oplog版本信息



ts字段描述了oplog产生的时间戳,可称之为optime。optime是备节点实现增量日志同步的关键,它保证了oplog是节点有序的,其由两部分组成:

  • 当前的系统时间,即UNIX时间至现在的秒数,32位。
  • 整数计时器,不同时间值会将计数器进行重置,32位。



optime属于BSON的Timestamp类型,这个类型一般在MongoDB内部使用。既然oplog保证了节点级有序,那么备节点便可以通过轮询的方式进行拉取,这里会用到可持续追踪的游标(tailable cursor)技术。

在这里插入图片描述



**每个备节点都分别维护了自己的一个offset,也就是从主节点拉取的最后一条日志的optime,在执行同步时就通过这个optime向主节点的oplog集合发起查询。**为了避免不停地发起新的查询链接,在启动第一次查询后可以将cursor挂住(通过将cursor设置为tailable)。这样只要oplog中产生了新的记录,备节点就能使用同样的请求通道获得这些数据。tailable cursor只有在查询的集合为固定集合时才允许开启。



oplog集合的大小

oplog集合的大小可以通过参数replication.oplogSizeMB设置,对于64位系统来说,oplog的默认值为:

oplogSizeMB = min(磁盘可用空间*5%,50GB)

对于大多数业务场景来说,很难在一开始评估出一个合适的oplogSize,所幸的是MongoDB在4.0版本之后提供了replSetResizeOplog命令,可以实现动态修改oplogSize而不需要重启服务器。

# 将复制集成员的oplog大小修改为60g  
db.adminCommand({replSetResizeOplog: 1, size: 60000})
# 查看oplog大小
use local
db.oplog.rs.stats().maxSize



幂等性

每一条oplog记录都描述了一次数据的原子性变更,对于oplog来说,必须保证是幂等性的。

也就是说,对于同一个oplog,无论进行多少次回放操作,数据的最终状态都会保持不变。某文档x字段当前值为100,用户向Primary发送一条{KaTeX parse error: Expected 'EOF', got '}' at position 12: inc: {x: 1}}̲,记录oplog时会转化为一条…set: {x: 101}的操作,才能保证幂等性。



幂等性的代价

简单元素的操作,$inc 转化为 $set并没有什么影响,执行开销上也差不多,但当遇到数组元素操作时,情况就不一样了。

测试,先新创建一个集合

db.coll.insert({_id:1,x:[1,2,3]})

在数组尾部push 2个元素,查看oplog发现 p u s h 操作被转换为了 push操作被转换为了 push操作被转换为了set操作(设置数组指定位置的元素为某个值)

rs0 [direct: primary] test> db.coll.update({_id: 1}, {$push: {x: {$each: [4, 5]}}})
DeprecationWarning: Collection.update() is deprecated. Use updateOne, updateMany, or bulkWrite.
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}

rs0 [direct: primary] test> db.coll.find()
[ { _id: 1, x: [ 1, 2, 3, 4, 5 ] } ]
rs0 [direct: primary] test> use local
switched to db local
rs0 [direct: primary] local> db.oplog.rs.find({ns:"test.coll"}).sort({$natural:-1}).pretty()
[
  {
    lsid: {
      id: new UUID("18c3308f-4c51-4ac9-b7d5-1f9cfa40783d"),
      uid: Binary(Buffer.from("e42772f81684de7b08e5b49ff4199884ecc26c24cda7e3cca9fb5587e492ab4c", "hex"), 0)
    },
    txnNumber: Long("2"),
    op: 'u',
    ns: 'test.coll',
    ui: new UUID("110160e6-261e-45af-9133-6e9712e46aff"),
    o: { '$v': 2, diff: { sx: { a: true, u3: 4, u4: 5 } } },
    o2: { _id: 1 },
    stmtId: 0,
    ts: Timestamp({ t: 1722235794, i: 1 }),
    t: Long("4"),
    v: Long("2"),
    wall: ISODate("2024-07-29T06:49:54.746Z"),
    prevOpTime: { ts: Timestamp({ t: 0, i: 0 }), t: Long("-1") }
  }
]



$push转换为带具体位置的$set开销上也差不多,但接下来再看看往数组的头部添加2个元素

rs0 [direct: primary] local> use test
switched to db test

rs0 [direct: primary] test> db.coll.update({_id: 1}, {$push: {x: {$each: [4, 5], $position: 0}}})
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}

rs0 [direct: primary] test> db.coll.find()
[
  {
    _id: 1,
    x: [
      4, 5, 1, 2,
      3, 4, 5
    ]
  }
]
rs0 [direct: primary] test> use local
switched to db local

rs0 [direct: primary] local> db.oplog.rs.find({ns:"test.coll"}).sort({$natural:-1}).pretty()
[
  {
    lsid: {
      id: new UUID("18c3308f-4c51-4ac9-b7d5-1f9cfa40783d"),
      uid: Binary(Buffer.from("e42772f81684de7b08e5b49ff4199884ecc26c24cda7e3cca9fb5587e492ab4c", "hex"), 0)
    },
    txnNumber: Long("3"),
    op: 'u',
    ns: 'test.coll',
    ui: new UUID("110160e6-261e-45af-9133-6e9712e46aff"),
    o: {
      '$v': 2,
      diff: {
        u: {
          x: [
            4, 5, 1, 2,
            3, 4, 5
          ]
        }
      }
    },
    o2: { _id: 1 },
    stmtId: 0,
    ts: Timestamp({ t: 1722236066, i: 1 }),
    t: Long("4"),
    v: Long("2"),
    wall: ISODate("2024-07-29T06:54:26.141Z"),
    prevOpTime: { ts: Timestamp({ t: 0, i: 0 }), t: Long("-1") }
  }
]



可以发现,当向数组的头部添加元素时,oplog里的$set操作不再是设置数组某个位置的值(因为基本所有的元素位置都调整了),而是$set数组最终的结果,即整个数组的内容都要写入oplog



当push操作指定了$slice或者$sort参数时,oplog的记录方式也是一样的,会将整个数组的内容作为$set的参数

$pull, $addToSet等更新操作符也是类似,更新数组后,oplog里会转换成$set数组的最终内容,才能保证幂等性。



oplog的写入被放大,导致同步追不上——大数组更新

当数组非常大时,对数组的一个小更新,可能就需要把整个数组的内容记录到oplog里,我遇到一个实际的生产环境案例,用户的文档内包含一个很大的数组字段,1000个元素总大小在64KB左右,这个数组里的元素按时间反序存储,新插入的元素会放到数组的最前面( p o s i t i o n : 0 ) ,然后保留数组的前 1000 个元素( position: 0),然后保留数组的前1000个元素( position:0),然后保留数组的前1000个元素(slice: 1000)。

上述场景导致,Primary上的每次往数组里插入一个新元素(请求大概几百字节),oplog里就要记录整个数组的内容,Secondary同步时会拉取oplog并重放,Primary到Secondary同步oplog的流量是客户端到Primary网络流量的上百倍,导致主备间网卡流量跑满,而且由于oplog的量太大,旧的内容很快被删除掉,最终导致Secondary追不上,转换为RECOVERING状态。



在文档里使用数组时,一定得注意上述问题,避免数组的更新导致同步开销被无限放大的问题。使用数组时,尽量注意:

  1. 数组的元素个数不要太多,总的大小也不要太大
  2. 尽量避免对数组进行更新操作
  3. 如果一定要更新,尽量只在尾部插入元素,复杂的逻辑可以考虑在业务层面上来支持



复制延迟

由于oplog集合是有固定大小的,因此存放在里面的oplog随时可能会被新的记录冲掉。如果备节点的复制不够快,就无法跟上主节点的步伐,从而产生复制延迟(replication lag)问题。这是不容忽视的,一旦备节点的延迟过大,则随时会发生复制断裂的风险,这意味着备节点的optime(最新一条同步记录)已经被主节点老化掉,于是备节点将无法继续进行数据同步。



为了尽量避免复制延迟带来的风险,我们可以采取一些措施,比如:

  • 增加oplog的容量大小,并保持对复制窗口的监视。
  • 通过一些扩展手段降低主节点的写入速度。
  • 优化主备节点之间的网络。
  • 避免字段使用太大的数组(可能导致oplog膨胀)。



数据回滚

由于复制延迟是不可避免的,这意味着主备节点之间的数据无法保持绝对的同步。当复制集中的主节点宕机时,备节点会重新选举成为新的主节点。那么,当旧的主节点重新加入时,必须回滚掉之前的一些“脏日志数据”,以保证数据集与新的主节点一致。主备复制集合的差距越大,发生大量数据回滚的风险就越高。



**对于写入的业务数据来说,如果已经被复制到了复制集的大多数节点,则可以避免被回滚的风险。**应用上可以通过设定更高的写入级别(writeConcern:majority)来保证数据的持久性。这些由旧主节点回滚的数据会被写到单独的rollback目录下,必要的情况下仍然可以恢复这些数据。



当rollback发生时,MongoDB将把rollback的数据以BSON格式存放到dbpath路径下rollback文件夹中,BSON文件的命名格式如下:

<database>.<collection>.<timestamp>.bson
mongorestore --host 192.168.75.100:27018 --db test --collection emp -h ushang -p 123456
--authenticationDatabase=admin rollback/emp_rollback.bson



同步源选择

MongoDB是允许通过备节点进行复制的,这会发生在以下的情况:

  • settings.chainingAllowed开启的情况下,备节点自动选择一个最近的节点(ping命令时延最小)进行同步。

    settings.chainingAllowed选项默认是开启的,也就是说默认情况下备节点并不一定会选择主节点进行同步,这个副作用就是会带来延迟的增加,你可以通过下面的操作进行关闭:

    cfg = rs.config()
    cfg.settings.chainingAllowed = false
    rs.reconfig(cfg)
    
  • 使用replSetSyncFrom命令临时更改当前节点的同步源,比如在初始化同步时将同步源指向备节点来降低对主节点的影响。

    db.adminCommand( { replSetSyncFrom: "hostname:port" })
    
  • 12
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值