副本集(Replica Set)是 MongoDB 高可用的基石。但很多人只知道"主挂了会自动选新主",对背后的 oplog 复制、选举协议、写关注与读偏好如何影响一致性一知半解。本文把副本集的复制链路和选举机制讲清楚,顺带讲那些导致"数据丢了"或"脑裂"的线上坑。

场景:主库切换后丢了几条写

一次主节点宕机,副本集自动选出新主,服务恢复。但事后对账发现,宕机前最后几秒确认成功的写入消失了。这不是 bug,而是你没理解 write concern 与故障切换时的 rollback 机制。要讲清楚,先看数据怎么在节点间流动。

机制一:oplog 与异步复制

副本集复制的核心是 oplog(operations log)——一个固定大小的 capped collection(local.oplog.rs),记录主节点上所有改变数据的操作,且是幂等的。

幂等很关键:oplog 不记录"$inc:1",而是记录"把这个字段设为最终值"。这样从节点无论重放多少次,结果都一致。

复制流程是拉模式(pull),不是推:

1
2
3
4
1. Secondary 连到同步源(通常是 Primary),tail oplog
2. 拉取 ts 大于自己最后应用位置的 oplog 条目
3. 批量并行 apply 到自己的数据集
4. 更新自己的 lastAppliedOpTime,并向 Primary 汇报

注意复制是异步的——主节点写完本地、写完 oplog 就可以给客户端返回(取决于 write concern),不等从节点。这就是开头丢数据的根源:确认了但还没复制出去,主就挂了。

oplog 大小决定了"从节点能落后多久还能追上"。落后超过 oplog 覆盖的时间窗,从节点会进入 RECOVERING,只能全量重新同步(initial sync)。监控 replication lag 和 oplog window 是运维必修。

机制二:选举协议(Raft 风格)

MongoDB 的选举基于 Raft 思想,引入了 term(任期)和多数派投票。每个副本集成员维护一个 term,每次选举 term 递增。

触发选举的时机:从节点在 electionTimeoutMillis(默认 10s)内没收到主节点心跳,就认为主可能挂了,把自己变成候选人,term+1,向其他成员拉票。

赢得选举的条件:

1
2
3
1. 拿到多数派(majority)的票,N 个投票成员需要 floor(N/2)+1 票
2. 候选人的 oplog 必须足够新(不能落后于投票者)
3. 一个 term 内每个成员只投一票

多数派是防脑裂的关键。假设 5 节点集群分成 3+2 的网络分区:3 节点那侧能凑齐多数派(3 > 2.5)选出主并继续服务;2 节点那侧凑不齐,只能降级为只读/不可写。这样保证任何时刻最多一个主,绝不脑裂

这也解释了为什么副本集成员数推荐用奇数。4 节点和 3 节点的容错能力一样(都只能挂 1 个还保持多数派),但 4 节点多花一份成本、还更容易在 2+2 分区时双侧都无法工作。

1
2
3
3 节点:多数派 2,可容忍 1 个故障
5 节点:多数派 3,可容忍 2 个故障
偶数节点不增加容错,反而浪费资源

Arbiter(仲裁者)的取舍

凑不齐奇数又不想多存一份数据时,可以加 arbiter——只投票、不存数据。但 arbiter 有个隐患:它不持有数据,无法满足 w:majority 中需要数据落盘的语义保障,在 2 数据节点 + 1 arbiter 的配置下,一个数据节点挂了,w:majority 写会一直 hang(因为只剩 1 个数据节点,达不到数据多数派)。生产环境慎用 arbiter。

机制三:write concern 与读偏好——一致性旋钮

Write Concern 控制写入要被多少节点确认才算成功:

1
2
3
4
// 只要主节点确认(快,但主挂了可能丢)
{ w: 1 }
// 多数派节点确认(安全,故障切换不丢)
{ w: "majority", j: true, wtimeout: 5000 }

w:"majority" 是避免开头那个"丢数据"问题的答案——只有当多数派都收到这条写,它才被确认。即使主随后宕机,新主一定在多数派里,数据不会丢。代价是延迟更高(要等网络往返)。

Read Concern / Read Preference 控制读哪里、读多新:

1
2
3
primary(默认)   :强一致,所有读走主
secondaryPreferred:读从,分摊压力,但可能读到旧数据
readConcern:majority:只读已被多数派确认、不会被回滚的数据

工程权衡一句话:w:1 + 读从 最快但最弱一致;w:majority + readConcern:majority + 读主最强一致但最慢。按业务对一致性的容忍度选,不要一刀切。

机制四:Rollback——故障切换时的数据回滚

当旧主在写入还没复制到多数派时宕机,新主选出后,旧主恢复时发现自己有"新主没有的写"。这些写会被回滚(rollback),写到回滚文件里(BSON),不会自动重新应用。这正是 w:1 下丢数据的具体机制。w:majority 能彻底避免它,因为被多数派确认过的写,新主一定有。

工程实践与命令

1
2
3
4
5
6
7
# 查看副本集状态、各节点角色与复制延迟
mongosh --eval 'rs.status().members.forEach(m =>
print(m.name, m.stateStr, "lag:",
m.optimeDate ? (Date.now()-m.optimeDate)/1000 : "-", "s"))'

# 查看 oplog 时间窗口(能容忍多久的落后)
mongosh --eval 'db.getReplicationInfo()'

priority 可以控制谁更容易当主(如让计算资源好的机房节点优先);hidden/votes:0 节点可用于专门跑备份或分析,不参与选举不接业务流量。

常见误区与踩坑

  • 误区:有副本集就不会丢数据。默认 w:1 仍可能在故障切换时丢,关键写务必 w:"majority"
  • 踩坑:oplog 太小导致从节点频繁 initial sync。大批量写入(数据迁移、批处理)前评估 oplog window,必要时扩容 oplog。
  • 踩坑:用 arbiter 凑数,w:majority 写卡死。优先用真实数据节点,arbiter 仅在极受限场景用。
  • 误区:从节点读一定是最新的。复制异步,secondaryPreferred 会读到滞后数据,强一致场景必须读主或 readConcern:majority
  • 踩坑:网络抖动触发不必要的选举electionTimeoutMillis 太小会在网络抖动时频繁切主,每次切主都有几秒不可写窗口,跨机房部署尤其要注意。

小结

副本集 = 异步 oplog 复制 + Raft 风格多数派选举。多数派同时撑起了两件事:选举不脑裂、写入不丢失。理解 write concern 和 read preference 这两个一致性旋钮,按业务调到合适档位,再配合奇数节点和合理的 oplog 大小,就能拿到 MongoDB 高可用的真正保障。记住那条铁律:要数据不丢,就 w:"majority"