复制集对外表现为"主从加自动故障切换",内部却是一套基于 oplog 的状态机复制协议。理解 oplog 的结构、初始同步的代价、多数派提交的边界与回滚的触发条件,才能在生产事故中快速定位是复制延迟、写关注配置还是网络分区。本文从 oplog 的物理结构讲起,逐层深入到回滚与延迟诊断。
1. oplog 结构与幂等性
oplog(operation log)是复制集的心跳,它记录了主节点上所有改变数据的操作,从节点按顺序重放这些操作以保持数据一致。
1.1 oplog 文档结构
oplog 存放在 local 库的 oplog.rs 集合,每条记录是一次操作:
use local
db.oplog.rs.findOne()
{
ts: Timestamp({ t: 1728192000, i: 1 }),
t: Long("12"),
h: Long("0"),
v: 2,
op: "i",
ns: "shop.orders",
ui: UUID("8f2b1c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"),
o: { _id: ObjectId("6521a0f1c3b4e5d6a7f80001"), amount: 100, status: "paid" },
wall: ISODate("2026-10-06T07:00:00.000Z"),
lsid: { id: UUID("..."), uid: BinData(0, "...") },
txnNumber: Long("5"),
stmtId: 0
}
| 字段 | 含义 |
|---|---|
| ts | 操作时间戳,复制集的全局序 |
| op | 操作类型:i 插入、u 更新、d 删除、c 命令、n 空操作 |
| ns | 命名空间,即库.集合 |
| o | 操作内容 |
| o2 | 更新操作的查询条件 |
| ui | 集合的 UUID |
| wall | 操作发生时的墙钟时间 |
1.2 幂等性要求
从节点重放 oplog 必须幂等,否则重复应用会导致数据不一致。这决定了两条铁律:
// 错误:基于位置的更新不幂等
db.orders.updateOne({ _id: 1 }, { $set: { "items.0.qty": 5 } })
// 正确:基于标识的更新幂等
db.orders.updateOne({ _id: 1, "items.sku": "A1" }, { $set: { "items.$.qty": 5 } })
// 数组操作符重放安全
db.orders.updateOne({ _id: 1 }, { $pull: { tags: "old" } }) // 幂等
db.orders.updateOne({ _id: 1 }, { $pop: { tags: 1 } }) // 不幂等,慎用
注意:
$pop、$inc在重放时若非基于精确条件,可能产生重复效果。虽然 MongoDB 通过o2记录更新条件来保证重放语义,但应用层仍应避免依赖"相对位置"的更新。
1.3 oplog 的写入路径
主节点在完成本地写后,把操作追加到 oplog,从节点通过 tailable cursor 拉取并应用。oplog 本身是 capped collection,写满后覆盖最旧记录。
// 查看 oplog 是否为 capped 集合
db.oplog.rs.stats().capped // true
db.oplog.rs.stats().maxSize // 字节数
2. oplog 容量与大小规划
oplog 的大小决定了从节点能落后多久仍可增量同步。oplog 写满覆盖后,落后的从节点将无法增量追赶,只能做代价高昂的初始同步。
2.1 默认大小与规划原则
默认 oplog 为可用磁盘空间的 5%,最小值 990MB,最大值 50GB。规划原则:oplog 覆盖的时间窗口应能扛住一次完整备份加恢复的时间。
// 查看当前 oplog 配置与覆盖时间
rs.printReplicationInfo()
configured oplog size: 10240MB
log length start to end: 43200secs (12hrs)
oplog first event time: Thu Oct 05 2026 19:00:00 GMT+0800
oplog last event time: Thu Oct 06 2026 07:00:00 GMT+0800
now: Thu Oct 06 2026 07:00:01 GMT+0800
若 log length start to end 只有几小时,而备份恢复需要一天,则一次故障就会触发初始同步。
2.2 调整 oplog 大小
// 在线调整 oplog 为 20GB(单位 MB)
db.adminCommand({ replSetResizeOplog: 1, size: 20480 })
// 从节点上同样执行,或临时生效
db.adminCommand({ replSetResizeOplog: 1, size: 20480, temporary: true })
| 参数 | 说明 | 注意 |
|---|---|---|
| size | 新大小,单位 MB | 只能增大,不能低于已用空间 |
| minRetentionHours | 按时间保留下限 | 6.0 起支持,可保证时间窗口 |
// 6.0 起:保证 oplog 至少覆盖 24 小时
db.adminCommand({ replSetResizeOplog: 1, minRetentionHours: 24 })
决策铁律:oplog 大小不是"越大越好",而是"覆盖时间必须大于最坏恢复时间"。写入密集的集群建议按写入速率估算:所需小时数乘以每小时 oplog 生成量,再留一倍余量。
3. 初始同步三阶段
当新节点加入复制集,或落后太多的节点无法增量追赶时,会触发初始同步(initial sync)。它分为三个阶段。
3.1 三阶段流程
| 阶段 | 动作 | 耗时来源 |
|---|---|---|
| 克隆(Clone) | 复制源的所有数据,本地库除外 | 数据量、网络带宽 |
| 应用(Apply) | 应用克隆期间产生的新 oplog | 写入速率 |
| 追平(Catch-up) | 构建索引并应用剩余 oplog | 索引数量 |
// 观察初始同步进度
db.adminCommand({ replSetGetStatus: 1 }).members.forEach(m => {
print(m.name, m.stateStr, m.syncSourceHost)
})
rs-shard02:27018 STARTUP2 rs-shard01:27018
STARTUP2 即表示正在初始同步。同步期间该节点不可读(除非配置了 secondaryOk)。
3.2 选择同步源
initialSyncSourceReadPreference 决定初始同步从哪个节点拉数据。默认 primaryPreferred,可改为优先从从节点拉取,避免拖垮主节点。
// 通过副本集配置设置同步源偏好
cfg = rs.conf()
cfg.settings.initialSyncSourceReadPreference = "nearest"
rs.reconfig(cfg)
| 取值 | 含义 | 适用 |
|---|---|---|
| primaryPreferred | 优先主节点(默认) | 常规 |
| secondaryPreferred | 优先从节点 | 减轻主节点压力 |
| nearest | 最近节点 | 多机房部署 |
| primary | 强制主节点 | 强一致要求 |
3.3 加速初始同步的手段
- 用文件系统快照(如 LVM、云盘快照)拷贝数据目录,跳过克隆阶段
- 从同机房的节点同步,减少网络延迟
- 同步期间暂停非必要业务写入
# 通过文件系统快照做种子数据(示意)
mongodump --host rs-shard01:27018 --db shop --out /seed/shop
mongorestore --host rs-shard02:27018 --dir /seed/shop --oplogReplay
重要:初始同步会全量拉取数据,对源节点造成显著压力。生产环境应避免让多个节点同时做初始同步,并优先用快照种子缩短窗口。
4. 链式复制与 chainingAllowed
链式复制允许从节点从其他从节点同步,而不是全部从主节点拉取,从而减轻主节点压力。
4.1 开启与关闭
cfg = rs.conf()
cfg.settings.chainingAllowed = true // 默认开启
rs.reconfig(cfg)
关闭链式复制后,所有从节点都直接从主节点同步:
cfg.settings.chainingAllowed = false
rs.reconfig(cfg)
4.2 同步源的观察
db.adminCommand({ replSetGetStatus: 1 }).members.forEach(m => {
print(m.name, m.stateStr, "syncSource:", m.syncSourceHost || "(primary)")
})
rs-shard01:27018 PRIMARY syncSource: (primary)
rs-shard02:27018 SECONDARY syncSource: rs-shard01:27018
rs-shard03:27018 SECONDARY syncSource: rs-shard02:27018
| 拓扑 | 主节点压力 | 延迟传播 | 适用 |
|---|---|---|---|
| 全从主同步 | 高 | 短 | 节点少 |
| 链式复制 | 低 | 逐跳累加 | 节点多、跨机房 |
| 混合 | 中 | 中 | 常规生产 |
注意:链式复制会放大延迟,链越深尾节点落后越多。对延迟敏感的从节点(如读偏好 secondary 的报表节点)应显式指定从主节点同步,或关闭链式复制。
5. 写关注与多数派提交
5.1 w:majority 与多数派提交
w:majority 表示写操作被多数节点确认后才算成功。多数派提交点(majority commit point)决定哪些数据"已提交且不会回滚"。
db.orders.insertOne(
{ orderId: "A1001", amount: 100 },
{ writeConcern: { w: "majority", wtimeout: 5000 } }
)
5.2 writeConcernMajorityJournalDefault
该参数决定 w:majority 是否隐含 j:true(写入 journal 才算确认)。默认 true。
// 查看副本集配置中的该参数
rs.conf().settings.writeConcernMajorityJournalDefault // true
// 关闭时(不推荐):多数派确认不等待 journal
cfg = rs.conf()
cfg.settings.writeConcernMajorityJournalDefault = false
rs.reconfig(cfg)
| 配置 | 语义 | 风险 |
|---|---|---|
| true(默认) | 多数派写入 journal 后确认 | 延迟略高,最安全 |
| false | 多数派写入内存后确认 | 崩溃可能丢已确认写 |
// 查看当前各节点的提交点
db.adminCommand({ replSetGetStatus: 1 }).optimes
// { lastCommittedOpTime: {...}, appliedOpTime: {...}, durableOpTime: {...} }
决策铁律:涉及资金的集合一律
w:majority加j:true。w:1只保证主节点接收,主节点崩溃时未复制的写会回滚。写关注是可用性与一致性的旋钮,必须按业务等级分级配置。
5.3 读关注与写关注搭配
// 强一致读:只读多数派已提交的数据
db.orders.find({ orderId: "A1001" }).readConcern("majority")
// 线性化读:配合 w:majority 写,保证读到最新已提交
db.orders.find({ orderId: "A1001" }).readConcern("linearizable")
6. 回滚处理与复制延迟诊断
6.1 回滚的触发条件
当主节点在分区期间接受了写入,但未复制到多数派就被降级,重新加入时它上面那些"多数派没有的写"必须回滚。回滚数据被写入 dbPath 下的 rollback/ 目录。
// 查看是否发生回滚
db.adminCommand({ replSetGetStatus: 1 }).members.forEach(m => {
if (m.stateStr === "ROLLBACK") print(m.name, "ROLLBACK")
})
# 回滚目录示例
ls /var/lib/mongodb/rollback/
# shop.orders.2026-10-06T07-12-33.0.bson
# shop.orders.2026-10-06T07-12-33.0.bson
6.2 回滚数据的处理
回滚的 BSON 文件需要人工审查,决定哪些操作要重新提交:
# 用 bsondump 查看回滚内容
bsondump /var/lib/mongodb/rollback/shop.orders.2026-10-06T07-12-33.0.bson
// 审查后手工重放(示例,需谨慎)
db.orders.insertOne({ orderId: "A1002", amount: 200 }) // 从回滚文件恢复
| 场景 | 处理方式 |
|---|---|
| 回滚数据是重复写 | 丢弃 |
| 回滚数据是有效业务写 | 审查后重放 |
| 频繁回滚 | 检查网络稳定性与写关注配置 |
重要:回滚是数据丢失的信号。回滚窗口默认 30 分钟(
rollbackTimeLimitSecs),超过则节点需要重新初始同步。若频繁出现回滚,说明网络分区与写关注配置存在系统性问题。
6.3 复制延迟诊断
// 查看各从节点落后主节点的时间
rs.printSecondaryReplicationInfo()
source: rs-shard01:27018
syncedTo: Thu Oct 06 2026 07:00:00 GMT+0800
0 secs (0 hrs) behind the primary
source: rs-shard01:27018
syncedTo: Thu Oct 06 2026 06:59:42 GMT+0800
18 secs (0 hrs) behind the primary
// 深入指标:oplog 应用队列与批量情况
db.serverStatus().metrics.repl
{
executor: { counters: { ... }, queues: { networkInProgress: 0, networkInProgress: 0 } },
apply: {
batches: { num: 1284, totalMillis: 5120 },
ops: 98765,
earliestOplogEntryTime: ISODate("2026-10-06T07:00:00Z"),
latestOplogEntryTime: ISODate("2026-10-06T07:00:18Z")
},
buffer: { count: 0, maxSizeBytes: 268435456, sizeBytes: 0 },
network: { bytes: 104857600, getmores: { num: 1024, totalMillis: 320 } },
initialSync: { completed: 1, failedAttempts: 0 }
}
| 指标 | 含义 | 异常信号 |
|---|---|---|
| apply.ops | 已应用的 oplog 条数 | 增速远低于主节点写入 |
| apply.batches.totalMillis | 应用批次的耗时 | 持续偏高说明从节点 CPU 瓶颈 |
| buffer.sizeBytes | 待应用的 oplog 缓冲 | 持续增长说明应用跟不上 |
| repl.network.getmores | 拉取次数与耗时 | 耗时高说明网络瓶颈 |
// 查看从节点是否在读自己的 oplog(读偏好 secondary 时的常见问题)
db.serverStatus().oplogTruncation // 截断信息
决策铁律:复制延迟先分清是"网络拉取慢"还是"本地应用慢"。看
network.getmores.totalMillis高则是网络,看apply.batches.totalMillis高则是磁盘或 CPU。定位到环节再优化,不要盲目加索引或扩机器。
7. 总结与最佳实践
- oplog 结构:capped 集合,按 ts 排序,重放必须幂等,避免位置依赖的更新
- 容量规划:覆盖时间大于最坏恢复时间,用
minRetentionHours兜底时间窗口 - 初始同步:三阶段克隆、应用、追平,用快照种子加速,避免多节点同时同步
- 链式复制:默认开启可减轻主节点压力,但会放大尾节点延迟
- 写关注:核心数据一律
w:majority加j:true,writeConcernMajorityJournalDefault保持默认 true - 回滚处理:回滚目录的 BSON 需人工审查,频繁回滚要查网络与写关注配置
- 延迟诊断:区分网络拉取与本地应用两个环节,再针对性优化
决策铁律:复制集的一切异常最终都能落到三个指标上——oplog 覆盖时间、多数派提交点、从节点应用延迟。把这三点纳入监控大盘,比事后翻日志高效得多。写关注与读关注的组合必须与业务一致性等级一一对应,不能全站一刀切。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。