MongoDB 副本集与事务

前面九篇我们都在单机环境玩 MongoDB——能跑、能学、能做练习。但生产环境里只跑单机就是定时炸弹:硬盘坏了、进程挂了、机器宕机,数据就没了,服务也断了。MongoDB 给的解决方案是副本集(replica set)——同一份数据存多份,主挂了自动切换。这一篇讲副本集架构、读写一致性控制,以及 4.0+ 的多文档事务,把 MongoDB 推向真正生产可用。

1. 为什么需要副本集

单机 MongoDB 有几个根本性风险:数据只有一份(磁盘坏 = 数据丢失)、单点故障(进程挂 = 服务中断)、无法分担读负载。副本集一箭三雕解决这三件事:

2. 副本集拓扑:Primary + Secondary + Arbiter

一个副本集由若干成员组成,角色分三种:Primary(主,唯一,所有写都走它)、Secondary(从,从主同步数据,可承担读)、Arbiter(仲裁,只投票不存数据)。最小生产配置是 3 个成员(奇数便于投票选主)。

// 副本集(replica set):同一份数据存多份,主从 + 自动故障转移
// 最小生产配置:3 个成员(奇数,便于投票选主)

//        +-----------+
//        |  Primary  |   <- 所有写都走 Primary
//        +-----------+
//         /         \
//   同步 /           \  同步
//      /              \
// +-----------+   +-----------+
// | Secondary |   | Secondary |   <- 从 Primary 同步数据,可承担读
// +-----------+   +-----------+
//
// Primary 挂了 -> 剩下的 Secondary 们自动选出一个新 Primary
// 客户端透明切换,业务几乎无感知

// 启动一个单机版副本集(本地学习用)
// 1. 启动 mongod 加 --replSet 参数
mongod --replSet rs0 --port 27017 --dbpath /data/rs0-1
mongod --replSet rs0 --port 27018 --dbpath /data/rs0-2
mongod --replSet rs0 --port 27019 --dbpath /data/rs0-3

// 2. 连上其中一个,初始化副本集
mongosh --port 27017
> rs.initiate({
    _id: "rs0",
    members: [
      { _id: 0, host: "localhost:27017" },
      { _id: 1, host: "localhost:27018" },
      { _id: 2, host: "localhost:27019" }
    ]
  })

// 3. 看状态(谁是 Primary)
> rs.status()
> rs.isMaster()

3. Arbiter:凑票数的轻量成员

当你只有 2 台机器存数据、又想有"自动故障转移"能力时,可以加一个 Arbiter(仲裁节点)——它不存数据,只在选主时投一票,凑成奇数避免平票。

// Arbiter(仲裁节点):只参与投票,不存数据
// 用途:凑奇数票,省机器(已有 2 个数据节点时加 1 个 Arbiter 凑成 3)

//        +-----------+
//        |  Primary  |
//        +-----------+
//          |
//   +------+------+
//   |             |
// +-----------+  +-----------+
// | Secondary |  |  Arbiter  |   <- 不存数据,只在选主时投票
// +-----------+  +-----------+

// 启动 Arbiter
mongod --replSet rs0 --port 27020 --dbpath /data/arb

// 加入副本集(在 Primary 上执行)
> rs.addArb("localhost:27020")

// Arbiter 优势:省存储和内存
// Arbiter 劣势:不提供读容量,不备份
// 通常:数据重要就别用 Arbiter,凑够 3 个真实数据节点

但 Arbiter 不备份数据、不分担读,只是个"凑数"角色。如果数据重要、能多花一台机器,直接凑 3 个真实数据节点更稳。

4. 选举与故障转移

Primary 挂了之后,剩下的 Secondary 们会自动触发选举(基于 Raft 协议的变种),选出新 Primary。整个选举过程通常 10~30 秒,期间写入会短暂失败(读如果允许从 Secondary 则不受影响)。客户端驱动内置了重试逻辑,业务几乎无感知。

选举的关键点:

5. oplog:复制的核心

Secondary 怎么知道 Primary 写了什么?靠 oplog——一个特殊的固定大小集合,记录 Primary 上所有写操作。Secondary 不停拉取 oplog 并重放,实现近乎实时的同步。

// oplog:记录 Primary 上所有写操作的"操作日志"
// 是一个固定大小的集合(local.oplog.rs)
// Secondary 不断拉取 oplog 重放,实现同步

// 看 oplog 内容
use local
db.oplog.rs.find().sort({ ts: -1 }).limit(5)
// 每条记录大概长这样:
// {
//   ts: Timestamp(...),
//   op: "i",                  // i=insert u=update d=delete
//   ns: "myblog.posts",       // namespace
//   o: { ... }                // 操作的具体内容
// }

// oplog 是固定大小的(默认磁盘 5%),会循环覆盖
// Secondary "掉队太多"(oplog 已被覆盖)就需要全量重同步
// 大量写场景要手动调大 oplog
> db.adminCommand({ replSetResizeOplog: 1, size: 8192 })   // MB

// 看 Secondary 同步延迟
> rs.printReplicationInfo()    // 看 oplog 大小/时长
> rs.printSecondaryReplicationInfo()  // 看 Secondary 落后多少

oplog 是固定大小的(默认约占磁盘 5%),会循环覆盖。如果某个 Secondary "掉队太多"(oplog 已经被覆盖到它没读的位置),就需要重新做全量同步。高写入场景建议手动调大 oplog,避免 Secondary 网络抖动后跟不上。

6. 读偏好 readPreference

副本集允许多个副本,客户端可以选择从哪个成员读——这就是读偏好。默认只从 Primary 读(强一致),但你可以分流到 Secondary 减轻主压力。

// readPreference:读偏好,告诉驱动从哪个成员读
// 五种模式:
//   primary        (默认)只从主读,强一致
//   primaryPreferred 优先主,主挂了才读从
//   secondary      只从从读(分流到 Secondary)
//   secondaryPreferred 优先从
//   nearest        选延迟最低的(不管主从)

// Node.js 驱动里设置
const client = new MongoClient("mongodb://host1:27017,host2:27017,host3:27017/?replicaSet=rs0")

// 单次查询指定读偏好
db.collection("posts").find({}).withOptions({
  readPreference: "secondaryPreferred"
})

// 读 Secondary 风险:可能有复制延迟(刚写到主的数据,从还没同步)
// 适合"能容忍最终一致"的场景:报表、统计、分析

// readConcern:控制读到多"新"的数据
//   "local"     (默认)本成员的最新数据(可能被回滚)
//   "majority"  已被多数成员确认的数据(强一致,稍慢)
//   "linearizable" 主上已确认且读之前的数据(最强一致,最慢)

读 Secondary 的代价:可能有复制延迟(刚写到主的数据,从还没同步完)。所以读偏好不是无脑选 Secondary——对"最新数据敏感"的查询(如刚下单后立刻查订单列表)仍要读 Primary;对"能容忍最终一致"的查询(如历史报表、统计)可以读 Secondary。

7. 写关注 writeConcern

写关注是反过来问的——"写到什么程度才算成功"。默认 w: 1 只要求主确认即可返回,主刚确认就挂了还没同步到从,数据会丢。改成 w: "majority" 要求多数成员都确认,持久性大幅提升。

// writeConcern:写关注,告诉服务端"写到什么程度才算成功"
// 主要参数:
//   w:       多少个成员确认才算成功
//     w=0          不等确认就返回(最快,但可能丢)
//     w=1          (默认)主确认即可
//     w="majority" 多数成员确认(强持久,生产推荐)
//   j:       是否要求写入 journal(磁盘持久化)
//     j=true       落盘才算成功(防宕机丢数据)
//   wtimeout: 等待超时(毫秒),超时不算失败但返回错误

// 单次写入指定
db.posts.insertOne(
  { title: "重要数据" },
  { writeConcern: { w: "majority", j: true, wtimeout: 5000 } }
)

// 全局默认(连接串里)
// mongodb://host1,host2,host3/?replicaSet=rs0&w=majority

// 经验:
//   普通业务日志、计数 -> w=1 足够
//   重要业务数据(订单、支付) -> w="majority" + j=true
//   追求极限吞吐的临时数据 -> w=0(自己承担丢失风险)

经验法则:

注意 w: "majority"w: 1 慢(要等同步),用与不用是持久性 vs 性能的权衡。

8. 多文档事务(4.0+)

MongoDB 4.0 起支持跨文档 / 跨集合的 ACID 事务(必须在副本集上)。涉及"扣库存 + 加订单"、"转账户 A 扣钱 + B 加钱"这种必须同时成功或失败的场景,终于不用在应用层手动补偿了。

// 多文档事务(4.0+):跨文档 / 跨集合的 ACID
// 必须在副本集(或分片集群)上才能用
// 单机 mongod 不支持事务

// Node.js 驱动里用 session 包起来
const client = new MongoClient("mongodb://.../?replicaSet=rs0")
await client.connect()

const session = client.startSession()
try {
  session.startTransaction()

  // 转账:从 A 扣 100,给 B 加 100,必须同时成功或失败
  const db = client.db("bank")
  await db.collection("accounts").updateOne(
    { _id: "A" }, { $inc: { balance: -100 } }, { session }
  )
  await db.collection("accounts").updateOne(
    { _id: "B" }, { $inc: { balance: 100 } }, { session }
  )

  await session.commitTransaction()    // 提交
  print("转账成功")
} catch (e) {
  await session.abortTransaction()      // 出错回滚
  print("转账失败,已回滚: " + e.message)
} finally {
  await session.endSession()
}

// mongosh 里也支持(语法略有差异,用 try/catch)
const session = db.getMongo().startSession()
session.startTransaction()
try {
  // ... 操作
  session.commitTransaction()
} catch (e) {
  session.abortTransaction()
}

事务的代价:

所以 MongoDB 的哲学仍是"能用单文档原子操作就别用事务"——把相关的"必须同时变更"的数据放进同一个文档,享受天然的原子性(见上一篇模式设计)。事务是为那些实在无法这样设计的场景兜底。

9. 分片(sharding)简介

副本集解决"高可用",分片解决"容量和吞吐"——当数据量大到单机存不下(几 TB 以上),或写入吞吐高到单 Primary 扛不住,就用分片把数据水平拆到多台机器。这是 MongoDB 的横向扩展方案。

// 分片(sharding):数据量大到单机存不下时,水平拆到多台机器
// 与副本集正交:副本集解决"高可用",分片解决"容量与吞吐"

// 分片集群结构:
//   mongos        路由进程,客户端连它(对外看起来像一个 mongod)
//   config server 存集群元数据(分片在哪)
//   shard         每个分片是一个副本集,存一部分数据

// 分片键(shard key):决定文档落到哪个分片
// 例: 按 userId 哈希分散,或按 createdAt 范围分

// 启用分片(在 mongos 上执行)
sh.enableSharding("myblog")
sh.shardCollection("myblog.posts", { userId: "hashed" })

// 选分片键非常关键:
//   - 高基数(取值多,能均匀分散)
//   - 写分布均匀(避免热点)
//   - 查询常用(分片键上的查询只命中一个分片,快)
//   - 不可变更(4.2 起有限变更,但麻烦)

// 一般项目用不到分片:数据到 TB 级别再考虑
// 优先用副本集 + 索引优化,分片是最后手段

分片的核心是分片键——决定每条文档落到哪个分片。选错分片键会造成数据倾斜(某分片爆炸)或写热点(全往一个分片写),且分片键基本不可变。所以分片是"最后手段"——大多数项目一辈子用不到分片,优化好副本集 + 索引就足够了。

10. 生产部署建议

小结

这是 MongoDB 系列的最后一篇。你了解了副本集如何通过 Primary/Secondary/Arbiter 实现高可用、oplog 如何驱动复制、readPreference/writeConcern 如何在一致性与性能间权衡、4.0+ 多文档事务怎么用。恭喜走完整个系列——从"MongoDB 是什么"到生产部署,你已经具备了独立用 MongoDB 做完整后端应用的能力。下一步建议:动手做一个真实小项目(比如博客系统或待办应用),把 CRUD、聚合、索引、关联全部用一遍,知识才真正内化。

← 上一篇 MongoDB 关联查询

返回 MongoDB 教程目录

✈️💬