1. 项目背景
业务场景:本地生活电商的运维在半夜被告警叫醒——"MongoDB 发生主从切换,持续时间 18 秒"。18 秒的选举期间,订单写入全部失败。事后复盘发现——选举耗时长是因为当时的 Primary 所在的机器发生了网络半断(能 ping 通但 TCP 连接间歇性超时),其他节点等了 10 秒心跳超时才发现 Primary 不可达,又花了 8 秒完成选举。运维想知道:能不能把心跳超时调短一点加快选举?调短了会不会导致网络抖动时频繁误选举?更关键的是——选举期间 Oplog 的一致性是怎么保证的?会不会出现两个节点同时认为自己是 Primary 的"脑裂"?
痛点:复制集是 MongoDB 高可用的基石,但多数团队只知其表——会用 rs.status() 看状态,不知道心跳协议怎么工作、term 的真正含义、Oplog 的写入和拉取流程、writeConcern: majority 在源码层面到底做了什么。一旦出现复制延迟、选举抖动、Oplog 窗口不足等深层问题,就只能凭感觉调参数。
2. 项目设计
小胖(拿着监控曲线图):大师!昨晚 Primary 选举花了 18 秒,写入全断了。能不能把心跳改成 2 秒?这样 Primary 一挂马上就选新的?
大师:心跳超时默认是 10 秒(heartbeatTimeoutSecs),不能调太低。因为 MongoDB 的选举不是"等心跳超时→立即选举"这么简单。实际上还有一个"选举超时"(electionTimeoutMillis,默认 10 秒)和"心跳间隔"(heartbeatIntervalMillis,默认 2 秒)——这三个参数组成了一套防止误选举和脑裂的安全网。
小胖:心跳、选举超时……这两个不是一回事吗?
大师:不一样。心跳是节点之间定期 ping-pong——每 2 秒发一次,如果 10 秒都没收到回复,就把对方标记为"不可达"。选举超时是指——从发现 Primary 不可达开始,等待 10 秒才开始选举。所以总故障检测时间 = 心跳超时(10 秒)+ 选举超时(10 秒)≈ 最多 20 秒。
技术映射:节点通过心跳交换 replSetHeartbeat 命令——核心信息包括自己的 term、最新的 optime、是否认为自己是 Primary。当多数节点认为 Primary 失联时,它们各自等待一段随机时间(electionTimeout + random offset)后发起选举——这个随机偏移防止同时竞选导致分裂投票。
小胖:那 term 是什么?Raft 协议里的任期号?
大师:精确。Term(任期号)是选举的核心概念,是一个单调递增的整数。每次选举 term+1——节点只有在自己的 term 大于等于当前集群 term 时才有资格成为 Primary。这保证了"在一个 term 里只能有一个 Primary"——这是防止脑裂的第一道防线。
技术映射:Term 在源码中存储为 long long,存储在 Oplog 的每条记录中(oplog.rs 中每条文档的 t 字段就是 term)。当两个节点都声称自己是 Primary 时,term 更高的那个胜出,另一个发现自己的 term 低后会立即降级。
小白(追问):Oplog 的写入和拉取是怎么协调的?从库是怎么追主库的?
大师:Oplog 的复制流程分两条线——Primary 侧的写入线和 Secondary 侧的拉取线:
Primary 侧:每次写操作(insert/update/delete)执行完后,ReplicationCoordinator 会生成一条 Oplog 条目,格式如下:
{
"ts": Timestamp(1711000000, 1), // 时间戳(全局唯一)
"t": 5, // term(任期号)
"h": NumberLong("..."), // 哈希(用于幂等检测)
"v": 2, // Oplog 版本
"op": "i", // 操作类型 i=insert u=update d=delete
"ns": "life_mall.orders", // 命名空间
"o": { "_id": ..., "status": "待支付" } // 操作内容
}
Secondary 侧:通过 OplogFetcher 持续向 Primary(或另一个 Secondary)发送 find 命令拉取 Oplog——每次拉取从上次的 optime 往后取一批(默认 batchSize=5000 条),然后在本地依次应用(apply)。
技术映射:Oplog 拉取是异步的——Secondary 从 Primary 拉 Oplog 的延迟就是"复制延迟"。延迟的来源:网络传输延迟 + 批量缓冲延迟 + 本地 apply 开销。apply 是单线程的(MongoDB 4.0 前),4.0+ 支持并行 apply(每个库一个线程)。
小胖:那 Rollback 呢?如果 Primary 在 Oplog 还没传到从库时就挂了,新 Primary 的 Oplog 比旧 Primary 少——旧 Primary 重新加入时多出来的 Oplog 怎么处理?
大师:这就是 Rollback 的场景。旧 Primary 恢复后发现自己的 Oplog 中有新 Primary 没有的条目——这些条目被"回滚"。回滚的过程是:
- 找到旧 Primary 和新 Primary 的 Oplog 分叉点(common point)。
- 将分叉点之后旧 Primary 独有的写操作——记录到一个回滚文件中(
rollback目录下的 BSON 文件)。 - 删除旧 Primary 的这些 Oplog 条目和对应的数据。
- 旧 Primary 重新从新 Primary 同步。
技术映射:Rollback 是 MongoDB 自动处理的,但被回滚的写入对客户端来说已经"成功返回"——因为旧 Primary 当时的 writeConcern 是 w:1,客户端收到确认后数据被回滚,造成了"已确认的写入丢失"。这就是 writeConcern: majority 如此重要的原因——majority 确认的数据不会被回滚。
大师(总结):复制协议的三个核心——心跳+选举保证高可用(约 10-20 秒切换时间),Oplog 拉取+应用保证数据同步,Rollback 处理 Primary 降级后的不一致。源码层面,ReplicationCoordinatorImpl 是这一切的中央控制者。
3. 项目实战
3.1 环境准备
需要 3 节点复制集(第 17 章配置),以及 MongoDB 源码调试环境(第 32 章配置)。
3.2 分步实现
步骤一:观察 Oplog 的结构与内容
目标:直接读取 Oplog 集合,理解每条 Oplog 条目的含义。
// 连接 Primary
use local
// 查看 Oplog 基本信息
var stats = db.oplog.rs.stats()
print("Oplog 条目数:", stats.count)
print("Oplog 大小:", (stats.size / 1024 / 1024).toFixed(1), "MB")
print("Oplog 平均对象大小:", stats.avgObjSize, "bytes")
// 查看最近的 5 条 Oplog 条目
db.oplog.rs.find().sort({ $natural: -1 }).limit(5).forEach(function(op) {
print("---")
print("时间戳:", op.ts)
print("Term:", op.t)
print("操作:", op.op, "→", op.ns)
print("内容摘要:", JSON.stringify(op.o).slice(0, 120))
if (op.o2) print("查询条件:", JSON.stringify(op.o2).slice(0, 80)) // update的filter
})
// 查看 Oplog 窗口大小
var first = db.oplog.rs.find().sort({ $natural: 1 }).limit(1).next()
var last = db.oplog.rs.find().sort({ $natural: -1 }).limit(1).next()
if (first && last) {
var firstSec = first.ts.getHighBits()
var lastSec = last.ts.getHighBits()
var windowHours = (lastSec - firstSec) / 3600
print("Oplog 窗口:", windowHours.toFixed(1), "小时")
}
步骤二:观察心跳协议
目标:通过 mongod 日志观察节点之间的心跳交互。
# 在 mongod 日志中过滤心跳相关记录
docker exec mongo-rs1 cat /var/log/mongodb/mongod.log | grep "heartbeat" | tail -20
# 预期看到:
# - "Received heartbeat from ..."(收到心跳)
# - "Heartbeat to ... failed"(心跳失败)
# - 心跳中包含的 replSetName、term、optime 信息
// 通过 replSetGetStatus 查看心跳状态
var status = rs.status()
status.members.forEach(function(m) {
print(m.name)
print(" 状态:", m.stateStr)
print(" 健康:", m.health === 1 ? "正常" : "异常")
print(" 最后心跳:", new Date(m.lastHeartbeat))
print(" 心跳间隔(ms):", m.pingMs)
print(" optime:", m.optime ? m.optime.ts : "N/A")
print(" 同步源:", m.syncSourceHost || "自己或者Primary")
print("---")
})
步骤三:观察选举过程
目标:手动触发 stepDown,观察选举日志。
# 终端 1:实时查看 mongod 日志(所有节点)
docker exec mongo-rs1 tail -f /var/log/mongodb/mongod.log | grep -E "election|stepDown|PRIMARY|SECONDARY"
// 在 Primary 上执行 stepDown
rs.stepDown(30)
// Primary 降级,10-20 秒后另一个节点被选为新 Primary
# 日志中应能看到完整的选举流程:
# "step down" → 当前 Primary 降级
# "Starting an election" → 某节点发起选举
# "election succeeded" → 当选成功
# "transition to PRIMARY" → 角色切换
步骤四:模拟和观察 Rollback
目标:制造 Primary 孤立然后恢复的场景,观察回滚行为。
# 1. 在 Primary 上写入一条数据(writeConcern: w:1)
# 2. 立即断开 Primary 的网络(docker network disconnect)
# 3. Primary 降级,新 Primary 当选
# 4. 在新 Primary 上继续写入(覆盖 Oplog 分叉点后的部分)
# 5. 恢复旧 Primary 的网络
# 6. 旧 Primary 发现自己的 Oplog 分叉 → 触发 Rollback
# 7. 查看旧 Primary 的 mongod.log,搜索 "rollback"
步骤五:源码追踪——Oplog 写入路径
// 源码关键路径(GDB 断点)
// 文件:src/mongo/db/repl/replication_coordinator_impl.cpp
// 1. Oplog 写入入口
// 函数:ReplicationCoordinatorImpl::_logOp()
// 作用:操作执行后将操作序列化写入 Oplog
// 2. Oplog 拉取
// 文件:src/mongo/db/repl/oplog_fetcher.cpp
// 函数:OplogFetcher::_doNextBatch()
// 作用:Secondary 向 Sync Source 请求下一批 Oplog
// 3. Oplog 应用
// 文件:src/mongo/db/repl/oplog_applier.cpp
// 函数:OplogApplier::applyOplogBatch()
// 作用:将拉取到的 Oplog 条目应用到本地
// 4. 选举发起
// 文件:src/mongo/db/repl/topology_coordinator.cpp
// 函数:TopologyCoordinator::checkShouldElect()
// 作用:判断自身是否有资格发起选举
步骤六:writeConcern majority 的源码链路
// majority commit point 的概念:
// 每条 Oplog 条目有一个 "optime"(ts 字段)
// Primary 持续追踪每个 Secondary 的最后一条 optime(通过心跳)
// "majority commit point" = 大多数节点都复制的最高 optime
// 源码路径:
// ReplicationCoordinatorImpl::_updateCommittedSnapshot()
// → 计算新的 majority commit point
// → 所有 readConcern: majority 的读只在这个 commit point 之前返回
// → 所有 writeConcern: majority 的写等待 commit point 追上自己的 optime
// GDB 断点:
// (gdb) break mongo::ReplicationCoordinatorImpl::_updateCommittedSnapshot
// 观察 commit point 的推进逻辑
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/replicaset/oplog-inspect.js |
Oplog 结构与窗口分析 |
mongodb-lab/replicaset/heartbeat-monitor.js |
心跳状态观察 |
mongodb-lab/replicaset/rollback-simulate.sh |
Rollback 模拟脚本 |
debug-scripts/repl-trace.gdb |
复制链路 GDB 断点脚本 |
3.4 测试验证
// 连接 Primary
// 1. 验证 Oplog 写入:每次 insert 对应一条 Oplog
use local_life
db.repl_test.insertOne({ test: "oplog_write", ts: new Date() })
var oplogEntry = db.getSiblingDB("local").oplog.rs.find({
"ns": "local_life.repl_test"
}).sort({ $natural: -1 }).limit(1).next()
print("Oplog 写入:", oplogEntry ? "PASS" : "FAIL")
// 2. 验证心跳可达
var status = rs.status()
var allHealthy = status.members.every(m => m.health === 1)
print("所有节点健康:", allHealthy ? "PASS" : "FAIL")
// 3. 验证 stepDown
var prePrimary = rs.isMaster().primary
rs.stepDown(30, 10)
sleep(15000)
var postPrimary = rs.isMaster().primary
print("Primary 已切换:", prePrimary !== postPrimary ? "PASS" : "FAIL")
print("\n=== 复制协议验证完成 ===")
4. 项目总结
4.1 复制协议核心概念速查
| 概念 | 源码关键位置 | 作用 |
|---|---|---|
| Term(任期号) | Oplog 条目 t 字段 |
防止脑裂——同一 term 只能有一个 Primary |
| Oplog 写入 | ReplicationCoordinatorImpl::_logOp() |
每次写操作生成 Oplog 条目 |
| Oplog 拉取 | OplogFetcher::_doNextBatch() |
Secondary 向 Sync Source 拉 Oplog |
| Oplog 应用 | OplogApplier::applyOplogBatch() |
Secondary 在本地应用 Oplog |
| 心跳协议 | TopologyCoordinator |
每 2s 交换心跳,10s 超时判定不可达 |
| 选举 | TopologyCoordinator::checkShouldElect() |
多数节点同意 → 当选 Primary |
| Majority Commit | _updateCommittedSnapshot() |
计算多数节点复制的最高 optime |
| Rollback | ReplicationCoordinatorImpl |
回滚分叉的 Oplog |
4.2 适用场景
复制协议知识适用:
- 选举耗时异常的根因分析——心跳超时、网络分区、term 冲突。
- Oplog 窗口不足的容量规划——根据写入速率计算需要的 Oplog 大小。
- Rollback 事件的排查——被回滚的数据恢复与原因追溯。
- 多机房复制延迟优化——心跳间隔与选举超时的跨机房适配。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
heartbeatTimeoutSecs 不能低于 5 秒 |
网络偶尔抖动可能触发频繁选举 |
| Oplog 是 Capped Collection | 固定大小环形缓冲区,写入达到大小上限后覆盖最旧数据 |
| Rollback 文件需要手动清理 | 回滚产生的 rollback 目录中的文件不会自动删除 |
writeConcern: majority 在 2 节点中等于 w: 2 |
任意一个节点宕机写入就卡住 |
4.4 常见踩坑经验
故障案例一:多机房复制延迟导致上海从库永远追不上
某团队把 Primary 部署在北京,Secondary 部署在上海。网络 RTT 40ms,Oplog 拉取延迟持续 8-10 秒。根因:Secondary 的 Oplog 拉取是单线程操作(每批次等返回后再拉下一批),RTT 直接叠加到延迟中。解决:增大 OplogFetcher 的 batchSize(通过 replBatchLimitOperations 参数),将批次从默认 5000 条调大到 20000 条;或启用 chaining(Secondary 可以从另一个 Secondary 拉 Oplog,选择更近的节点)。
故障案例二:心跳超时误判导致的频繁选举
某团队把 MongoDB 部署在共享 K8s Node 上,CPU 资源竞争激烈——心跳包偶尔被 cgroup throttle 迟滞超过 10 秒,其他节点误以为 Primary 挂了触发选举。一个月内发生 47 次不必要的主从切换。解决:heartbeatTimeoutSecs 调大到 20 秒;给 MongoDB Pod 设置 CPU request(保障最低 CPU);开启 electionTimeoutMillis: 15000 增加选举等待时间。
故障案例三:Rollback 后数据"消失"导致的订单对账异常
某次 Primary 宕机 + Rollback 后,财务系统报告有 127 笔订单在 MongoDB 中找不到(实际被 Rollback 了)。这些订单的客户端实际已收到"下单成功"——因为 writeConcern: w:1,Primary 返回了成功但数据被回滚。解决:关键业务写入统一用 writeConcern: majority;Rollback 事件告警 + 回滚文件自动解析入库做人工对账。
4.5 思考题
- 如果网络发生分区——3 节点复制集中,Primary 和 1 个 Secondary 被分到一侧(2 票),另一个 Secondary 单独在另一侧(1 票)。哪一侧能选举出 Primary?为什么?
- Oplog 条目中的
h哈希字段的作用是什么?MongoDB 如何利用它实现幂等的 Oplog 应用?
(答案将在第 37 章末尾揭晓)
上一章思考题答案:
重复元素
["a","a","b"]——Multikey Index 只会为"a"创建1 个索引键(而非 2 个)。MongoDB 在索引数组时会自动去重每个文档的数组元素,避免重复的索引键造成空间浪费和查询时的重复结果。但这不是跨文档去重——不同文档中相同的"a"各自占一个索引条目。复合唯一索引
{city:1, orderNo:1}中——缺失字段被视为 null。如果两个文档都缺失 city 和 orderNo,它们在这个索引中的复合键都是(null, null)——第二个文档插入时会触发 DuplicateKey 错误。如果需要允许缺失字段共存,使用sparse: true——缺失字段的文档完全不入索引,不受唯一约束限制。
延伸阅读与资源
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化)
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Redis 实战修炼与原理进阶
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
MongoDB 实战进阶与内核修炼
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号