导语
在腾讯云 MongoDB 的运营过程中, 发现较多用户对副本集主从复制流程的理解还有些偏差. 这些偏差在一定程度上影响了应用程序设计和平时的运营.
本文会聚焦下面几个问题:
写大多数节点是如何完成的?
从节点拉取 oplog 和回放 oplog 是否会有阻塞, 如何调优?
Mongo Shell 上执行 printSlaveReplicationInfo 命令看主从延迟, 系统压力不大时也在秒级, 是否正常?
printSlaveReplicationInfo 为什么不能优化到毫秒级别?
在从节点上执行 printSlaveReplicationInfo 命令, 发现从节点的数据领先主节点, 是否正常?
什么是链式复制? 哪些场景适合开启, 哪些不适合?
主从复制架构分析
主从复制大致流程
MongoDB 副本集 https://docs.mongodb.com/manual/replication/ 模式下, 用户向主节点写入数据, 并记录 oplog. 从节点通过 oplog 进行数据同步, 最终保证副本集中的各个节点的数据一致性.
客户端可以指定写入请求的一致性级别(WriteConcern), 比如对于数据一致性较高的场景, 可以设置数据复制到 "大多数" 节点才返回成功. 这样能够保证即使主节点重启后不会回滚掉之前写入的数据.
一个常见的误解: 写大多数节点模型下, 客户端需要将数据发到多个节点, 是否会增加客户端的负担?
"写大多数" 请求的流程如下, 客户端只需要向主节点写入数据即可(不需也不能向从节点直接写数据); 从节点进行 oplog 同步之后, 会将自身已经同步的 oplog 时间点通知给主节点; 主节点维护了副本集中各个从节点的 oplog 同步情况, 如果确定数据已经到了大多数节点上(包括自己), 则给客户端返回成功. 如果数据同步发生了异常, 或者同步太慢, 则可能触发超时.
同理, ReadConcern Majority 也不是客户端去读多个节点, 这里不详细讨论
副本集数据同步示意图
详细的主从同步流程如下图所示(以 1 Primary 1 Secondary 为例):
主从复制细节
主要步骤如下:
主节点接受用户的写请求, 更新用户表和 oplog 表. 如果用户设置了 writeConcern:majority, 此时由于不符合写入成功的返回条件, 处理线程会阻塞
从节点上的 "rsBackgroundSync" 后台线程通过 find/getmore 命令到主节点上获取 oplog, 并放入到 OplogBuffer 中;"replBatcher" 线程感知到 OplogBuffer 中的数据并消费, 保存到 OpQueue 中;"OplogApplier" 线程感知 OpQueue 中的新数据, 通过多个(默认 16 个)worker 线程回放 Oplog, 并更新 lastAppliedOpTime 和 lastDurableOpTime
从节点上的 "SyncSourceFeedback" 后台线程感知到有新数据写入成功, 将自身最新的 lastAppliedOpTime 和 lastDurableOpTime 等信息通过 "replSetUpdatePosition" 内部命令返回给主节点
主节点接受到各个从节点 最新的 lastAppliedOpTime 和 lastDurableOpTime(writeConcernMajorityJournalDefault 配置项决定了具体以哪个时间为准), 计算大多数节点 (包括自己) 当前的数据同步进展, 并更新 lastCommittedOpTime, 然后唤醒正在等待的请求处理线程
主节点上的用户处理线程给用户返回处理结果
常见误解说明:
误解 1: 从节点拉取 oplog 回放完之后, 才会拉取下一批 oplog
真实情况: 拉取和回放属于不同的线程, 相互不会阻塞
误解 2: 对参数 replBatchLimitBytes(默认 100MB) 和 replBatchLimitOperations(默认 5000) 存在误解, 认为回放线程必须累积到这么多 oplog 后才会批量回放
真实情况: 回放线程尽量累积大量数据才回放(批量并发执行效率高). 但是如果 oplog 比较少, 会提前返回. 但是极端情况下, 可能会有最多阻塞 1 秒的情况(具体参考 sync_tail.cpp 中的 SyncTail::tryPopAndWaitForMore 实现). 关于这一点, 下一篇文章会结合代码和例子进行详细分析
误解 3: 从节点通过心跳返回同步进度, 主节点根据心跳信息决定 writeConcern:majority 是否返回
真实情况: 从节点通过 replSetUpdatePosition 及时上报同步情况. 心跳周期太长, 默认 2 秒一次, 所以根据心跳信息显然是不合适的
性能调优建议
根据实际情况, 调整回放线程的个数, 默认 16 个. 对应 replWriterThreadCount 参数, 可在程序启动时指定.
根据实际情况, 调整批量回放的最大 oplog 条数 (默认 5000) 和最大 oplog 大小(默认 100MB). 前者对应 replBatchLimitOperations 参数, 可在程序启动时或者运行过程中指定; 后者对应 replBatchLimitBytes 参数, 在 官方文档中说明可以动态修改, 但是实测发现并不成功, 代码中也没有找到修改的接口. 如果有变更需求, 可以直接修改 sync_tail.h 中 replBatchLimitBytes 的初始化代码
主从延迟命令解析
MongoDB 管理员使用 printSlaveReplicationInfo 命令来观察主从延迟情况
printSlaveReplicationInfo 是 MongoShell 封装的 JS 命令, 可以在任意一个 MongoShell 客户端上直接执行 db.printSlaveReplicationInfo 查看 JS 源代码. 如下所示:
- function () {
- var startOptimeDate = null; // 基准 optime
- var primary = null;
- // 根据基准 optime, 打印节点的延迟情况, 精确到秒
- function getReplLag(st) {
- assert(startOptimeDate, "how could this be null (getReplLag startOptimeDate)");
- print("\tsyncedTo:" + st.toString());
- var ago = (startOptimeDate - st) / 1000;
- var hrs = Math.round(ago / 36) / 100;
- var suffix = "";
- if (primary) {
- suffix = "primary";
- } else {
- suffix = "freshest member (no primary available at the moment)";
- }
- print("\t" + Math.round(ago) + "secs (" + hrs + "hrs) behind the" + suffix);
- }
- function getMaster(members) {
- for (i in members) {
- var row = members[i];
- if (row.state === 1) {
- return row;
- }
- }
- return null;
- }
- function g(x) {
- assert(x, "how could this be null (printSlaveReplicationInfo gx)");
- print("source:" + x.host);
- if (x.syncedTo) {
- var st = new Date(DB.tsToSeconds(x.syncedTo) * 1000);
- getReplLag(st);
- } else {
- print("\tdoing initial sync");
- }
- }
- function r(x) {
- assert(x, "how could this be null (printSlaveReplicationInfo rx)");
- if (x.state == 1 || x.state == 7) { // ignore primaries (1) and arbiters (7)
- return;
- }
- print("source:" + x.name);
- if (x.optime) {
- getReplLag(x.optimeDate);
- } else {
- print("\tno replication info, yet. State:" + x.stateStr);
- }
- }
- var L = this.getSiblingDB("local");
- if (L.system.replset.count() != 0) {
- var status = this.adminCommand({'replSetGetStatus': 1}); // replSetGetStatus 命令的结果, 作为本次计算的数据源
- primary = getMaster(status.members);
- if (primary) {
- startOptimeDate = primary.optimeDate; // 如果主节点存在, 则选择主节点的 optime 为基准 optime
- }
- // no primary, find the most recent op among all members
- else {
- startOptimeDate = new Date(0, 0);
- for (i in status.members) { // 如果主节点不存在, 则选择最新的 optime 为基准 optime
- if (status.members[i].optimeDate> startOptimeDate) {
- startOptimeDate = status.members[i].optimeDate;
- }
- }
- }
- for (i in status.members) { // 对除 primary 和 arbiter 的节点, 打印延迟情况
- r(status.members[i]);
- }
- }
- }
总体来说, 根据 replSetGetStatus 命令中每个 member 的 optimeDate 来计算延迟. 分析内核代码发现, replSetGetStatus 命令通过心跳信息来获取其他节点的 optimeDate.
可以得出, printSlaveReplicationInfo 命令的结果依赖于心跳信息.
MongoDB 副本集心跳示意图
默认配置下, 节点之间的心跳间隔是 2 秒, 也就是说 printSlaveReplicationInfo 展示的可能是 "过期" 信息, 存在一定的误差.
比如有一个持续被写入的副本集, 主节点在 t1 时刻维护的还是 t0 时刻的心跳信息, 则 printSlaveReplicationInfo 命令会显示从节点比主节点落后 1 秒, 在主节点很快接受到从节点更新的信息之后, 主从延迟又会马上变为 0 秒. 出现主从延迟 "抖动" 的情况.
同理, 按照从节点的视角来看, 在 t1 时刻已经从主节点同步到了最新的数据, 但是维护的主节点心跳还是 t0 时刻的 "过期" 数据. 此时会认为主节点的 optimeDate 还是 t0, 所以在从节点上执行 printSlaveReplicationInfo 命令, 会看到从节点 "领先" 主节点 1 秒的奇怪现象.
特殊说明
心跳信息维护在 TopologyCoordinator::memberData 中
内核对主节点维护的 memberData 进行了优化: 除了正常的心跳请求会更新之外, 从节点发送过来的 replSetUpdatePosition 也会更新 memberData 中的数据. 所以在主节点上执行 printSlaveReplicationInfo 命令 相对来说 已经尽量做到准确了.
总结: 心跳信息带来的不确定性, 会导致 printSlaveReplicationInfo 的结果存在误差
延迟命令的精度问题
MongoDB 使用了 BSON 格式的 TimeStamp, 是一个 64 bit 的值:
高 32 bit 存放 UNIX 秒级时间
低 32 bit 存放 一个递增的计数器, 来区分这一秒内的多条 oplog
因此, TimeStamp 能够表示的精度只有秒级.
因此, printSlaveReplicationInfo 命令看到的秒级延迟不能说明主从延迟真的是秒级. 除了前文说到的心跳原因, TimeStamp 的精度问题也会给观测带来误差.
链式复制
什么是链式复制
在 MongoDB 副本集模式中, 从节点除了可以到主节点同步数据外, 还可以到数据较新的另外一个从节点同步数据.
如下图所示, 第 2 个从节点可以切换同步源到第 1 个从节点, 这样副本集的同步关系变成了 Primary1-->Secondary1-->Secondary2 链式结构
image.PNG
如何开启
通过 rs.reconfig() 命令将
settings.chainingAllowed
设置为 true(默认已经开启)
根据具体的使用场景, 可以在从节点上执行 命令指定同步源. 如果不手动指定, 则 MongoDB 后台线程会根据各个节点的 oplog 时间进行选择和切换.
适合开启链式复制的场景
链式复制带来的好处是: 不用所有从节点都到主节点同步数据, 可以有效减少主节点的压力.
对于写完主节点即返回, 并读主节点的业务来说, 开启链式复制能在一定程度上提升性能.
适合关闭链式复制的场景
链式复制带来的缺陷是:
数据复制的链路变长. 对于 WriteConcern 设置比较大的请求, 处理时长会变长.
读 oplog 的压力从主节点转移到了部分从节点上, 会一定程度上影响从节点的性能.
因此, 对于 {WriteConcern:majority} 的业务场景, 建议关闭链式复制; 对于写主读从的业务场景, 可以根据实际的请求量, 考虑是否关闭链式复制.
参考文档
GitHub: replication-internals
来源: https://www.qcloud.com/developer/article/1592500