上一节已经在课程开始时创建的单节点副本集上完成了事务。单节点仍然只有一份数据:进程停止时,没有第二个成员可以接替服务。本节另建一个三成员副本集,并观察一次真正的选举。
副本集是一组保存同一数据集的 mongod 进程。正常情况下,一个成员是 Primary,负责接收写入;其余成员是 Secondary,通过复制 oplog 中的操作追赶 Primary。Primary 不可用时,满足条件的成员会发起选举,产生新的 Primary。
先把三个概念分开:
副本集的目的不是让故障消失,而是让系统能够识别故障、重新选主,并让驱动把请求送往新的 Primary。应用仍要设置合理的超时、处理暂时性错误,并让可重试操作保持幂等。
写入
应用与驱动 ──────────────────> Primary
│
oplog │ 复制
┌──────┴──────┐
▼ ▼
Secondary SecondaryMongoDB 官方的复制说明把高可用部署建立在副本集之上。生产环境通常把有投票权的成员分散到不同故障域,避免一次主机或机房故障同时带走多数成员。
“三台机器”不是魔法数字。选举需要多数票,三个有投票权的成员可以容忍一个成员不可用;如果三个进程全在同一个承载节点上,它们仍会一起受该节点故障影响。
paperboat-mongo 从第 2 节起就运行在 rs0 中,第 11 节借助它完成了事务。与其直接看一大段 rs.status(),我们先提取最有用的字段:
set 是副本集名称,连接字符串中的 replicaSet 必须与它一致。myState 用数字表示当前成员状态,1 是 Primary,2 是 Secondary。members[].stateStr 更适合人阅读。term 会随着成功选举递增,可以帮助判断是否发生过换主。下面的命令只读取状态,不改变配置:
docker exec paperboat-mongo mongosh --quiet --eval '
const s = rs.status();
printjson({
set: s.set,
term: s.term,
myState: s.myState,
members: s.members.map(m => ({
name: m.name,
state: m.stateStr,
health: m.health
}))
})
'单节点副本集会得到类似结果:
{
set: 'rs0',
term: Long('1'),
myState: 1,
members: [
{
name: 'paperboat-mongo:27017',
state: 'PRIMARY',
health: 1
}
]
}health: 1 表示该成员可达,PRIMARY 表示它能处理写入。这里只有一个成员,因此它一旦停止,副本集就失去多数票,也不会出现替代者。这个状态能运行事务,但不具备故障转移能力。
再看驱动握手时会读取的摘要:
docker exec paperboat-mongo mongosh --quiet --eval '
const h = db.hello();
printjson({
setName: h.setName,
isWritablePrimary: h.isWritablePrimary,
primary: h.primary
})
'{
setName: 'rs0',
isWritablePrimary: true,
primary: 'paperboat-mongo:27017'
}现代驱动正是根据这类拓扑信息发现成员和 Primary。旧资料中的 isMaster、master/slave 等术语已经不适合作为新项目的教学入口。
我们另建一组实验副本集,不改动前面章节一直使用的 paperboat-mongo。三个成员都使用 MongoDB 8.0 主版本,并通过同一个容器网络互相解析 mongo1、mongo2、mongo3。
副本集配置里保存的是成员彼此可达的地址。驱动连接到任意种子节点后,会读取这份地址表继续发现其他成员。因此,配置中的主机名必须能被实际客户端解析。下面让所有诊断命令都在 Compose 网络中执行;发布到其他网络时,应改用公司 DNS 中可解析的正式名称。
先创建并进入本节专用目录,后续的启动、诊断与清理命令都在这里执行:
mkdir paperboat-replica-lab
cd paperboat-replica-lab在目录中创建 compose.yaml。三个数据卷让容器重建后仍能保留各自的数据;端口 27117 到 27119 只绑定回环地址,用于单节点诊断:
services:
mongo1:
image: mongo:8.0
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
ports:
- "127.0.0.1:27117:27017"
volumes:
- mongo1-data:/data/db
mongo2:
image: mongo:8.0
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
启动时显式指定项目名,后面的清理命令就能准确找到这一组对象:
docker compose -p bookstore-rs up -d[+] Running 7/7
✔ Network bookstore-rs_default Created
✔ Volume bookstore-rs_mongo1-data Created
✔ Volume bookstore-rs_mongo2-data Created
✔ Volume bookstore-rs_mongo3-data Created
✔ Container bookstore-rs-mongo1-1 Started
✔ Container bookstore-rs-mongo2-1 Started
✔ Container bookstore-rs-mongo3-1 Started容器启动只代表三个 mongod 进程已经运行,副本集还没有配置。接着在 mongo1 中提交成员表:
docker compose -p bookstore-rs exec mongo1 mongosh --quiet --eval '
rs.initiate({
_id: "rs0",
members: [
{ _id: 0, host: "mongo1:27017", priority: 2 },
{ _id: 1, host: "mongo2:27017", priority: 1 },
{ _id: 2, host: "mongo3:27017", priority: 1 }
]
})
'{ ok: 1 }priority: 2 让 mongo1 更倾向于成为 Primary,但它不是永久领导者。网络可达性、成员状态、多数票和选举时间都会影响最终结果。
选举和初始同步需要一点时间。下面的只读循环会等待,直到三名成员都进入 PRIMARY 或 SECONDARY:
docker compose -p bookstore-rs exec mongo1 mongosh --quiet --eval '
assert.soon(
() => rs.status().members.every(m =>
["PRIMARY", "SECONDARY"].includes(m.stateStr)
),
"副本集未在预期时间内就绪"
);
printjson(rs.status().members.map(m => ({
name: m.name,
state: m.stateStr
})))
'[
{ name: 'mongo1:27017', state: 'PRIMARY' },
{ name: 'mongo2:27017', state: 'SECONDARY' },
{ name: 'mongo3:27017', state: 'SECONDARY' }
]如果 Primary 是另一个成员,并不表示失败。成功标准是:恰好一个 PRIMARY,另外两个是 SECONDARY。
同一容器网络中的应用可以使用种子列表:
mongodb://mongo1:27017,mongo2:27017,mongo3:27017/bookstore?replicaSet=rs0“种子列表”不要求把每个成员永久写死在应用中;它只是给驱动几个初始入口。驱动连接成功后会持续维护拓扑视图。
Primary 停止后,Secondary 不会在同一瞬间完成接管。成员要先判断 Primary 不可达,再由多数派完成选举,驱动还要刷新拓扑并重试合适的操作。这段窗口内出现短暂错误是正常现象。
我们先确认 mongo1 当前确实是 Primary,再停止它。若前一步显示 Primary 不是 mongo1,请把下面的服务名换成实际 Primary。
docker compose -p bookstore-rs exec mongo1 mongosh --quiet --eval '
print(db.hello().isWritablePrimary)
'true现在停止该成员:
docker compose -p bookstore-rs stop mongo1[+] Stopping 1/1
✔ Container bookstore-rs-mongo1-1 Stopped等待几秒后,从 mongo2 查看当前 Primary:
docker compose -p bookstore-rs exec mongo2 mongosh --quiet --eval '
const h = db.hello();
printjson({
me: h.me,
isWritablePrimary: h.isWritablePrimary,
primary: h.primary
})
'可能看到 mongo2 当选:
{
me: 'mongo2:27017',
isWritablePrimary: true,
primary: 'mongo2:27017'
}也可能由 mongo3 当选。成员名称不是验收重点;真正要确认的是剩余两个成员仍构成多数派,并出现一个新的 Primary。
把 mongo1 启动回来:
docker compose -p bookstore-rs start mongo1[+] Running 1/1
✔ Container bookstore-rs-mongo1-1 Started再次读取状态,重启的成员通常先以 Secondary 身份追赶:
docker compose -p bookstore-rs exec mongo2 mongosh --quiet --eval '
assert.soon(
() => rs.status().members.length === 3 &&
rs.status().members.every(m => m.health === 1),
"成员没有全部恢复"
);
printjson(rs.status().members.map(m => ({
name: m.name,
state: m.stateStr,
health: m.health
})))
'[
{ name: 'mongo1:27017', state: 'SECONDARY', health: 1 },
{ name: 'mongo2:27017', state: 'PRIMARY', health: 1 },
{ name: 'mongo3:27017', state: 'SECONDARY', health: 1 }
]由于 mongo1 的优先级较高,稍后也可能再次发生选举。不要让应用依赖固定机器永远是 Primary;应让驱动根据副本集协议选择可写成员。
writeConcern 描述服务器在什么时候向客户端确认写入:
w: 1:Primary 接受写入后即可确认。w: "majority":多数有投票权且承载数据的成员确认后才返回。j: true:要求相应成员把操作提交到日志。wtimeout:达不到确认条件时最多等待多久;超时不等于操作一定没有发生,应用必须根据错误和业务幂等性继续判断。纸舟书店的订单创建和库存扣减属于关键写入,适合使用 majority。浏览计数等可重建数据可以根据成本选择不同级别。
不要把写入命令绑死在某个成员上。下面虽然从 mongo2 容器启动 mongosh,但连接串提供三个种子地址并声明 replicaSet=rs0;客户端会发现当时真正的 Primary,即使 mongo1 已因较高优先级重新接管也能正确路由写入。
docker compose -p bookstore-rs exec mongo2 mongosh --quiet \
'mongodb://mongo1:27017,mongo2:27017,mongo3:27017/?replicaSet=rs0' \
--eval '
const result = db.getSiblingDB("bookstore").events.insertOne(
{
_id: "replica-check-001",
type: "replica_set_verified",
createdAt: new Date()
},
{
writeConcern: {
w: "majority",
j: true,
wtimeout: 5000
}
}
);
printjson(result)
'{
acknowledged: true,
insertedId: 'replica-check-001'
}acknowledged: true 表示命令满足了指定的确认条件。它不表示三份数据永远不会丢,也不代表备份已经完成;它只描述这次写入返回时的复制确认状态。
不要把 w: 0 当成普通性能开关。未确认写入不会返回服务器错误,应用很难知道唯一索引冲突、验证失败或网络中断是否发生。订单、支付和库存等关键路径更不应这样做。
readPreference 决定“去哪个成员读”,常见值有 primary、primaryPreferred、secondary、secondaryPreferred 和 nearest。readConcern 决定“允许看到什么级别的数据”。
二者不能混为一谈。把查询发给 Secondary 可以分担某些只读工作,但 Secondary 可能存在复制延迟;使用 majority 读关注则强调读取多数提交的数据。对“刚下单立刻查看订单”这类读己之写场景,默认从 Primary 读取通常更容易推理。
Node.js 驱动会把这些策略放在客户端、数据库、集合或单次操作的选项中。越靠近单次操作,意图越清楚;不要为了“读写分离”给所有查询统一改成 Secondary。
Secondary 从 oplog 拉取并应用操作。磁盘变慢、长时间操作、网络拥塞或资源不足都可能扩大延迟。只看成员“健康”还不够,还要看它落后 Primary 多久。
docker compose -p bookstore-rs exec mongo2 mongosh --quiet --eval '
const s = rs.status();
printjson(s.members.map(m => ({
name: m.name,
state: m.stateStr,
lastApplied: m.optimeDate
})))
'[
{
name: 'mongo1:27017',
state: 'SECONDARY',
lastApplied: ISODate('2026-07-15T08:12:31.000Z')
},
{
name: 'mongo2:27017',
state: 'PRIMARY',
lastApplied: ISODate('2026-07-15T08:12:31.000Z')
},
{
name: 'mongo3:27017',
state: 'SECONDARY',
lastApplied: ISODate('2026-07-15T08:12:31.000Z'
时间值会随执行时刻变化。空闲实验中三者通常接近;实际系统要把延迟转换成业务语言,例如“报表最多落后几秒”或“故障时可能丢失哪些未被多数确认的写入”,再据此设置告警。
如果应用执行了 deleteMany({}),这条删除也会进入 oplog 并复制到所有 Secondary。复制解决硬件和进程故障,备份则用于回到过去的某个可用状态。两者必须同时存在。
同理,隐藏成员、延迟成员和只读节点都有明确的运维成本,不能代替经过恢复演练的备份方案。第 14 节会把 mongodump 生成的归档恢复到另一个数据库,并验证文档数量和索引。
这组三节点副本集只服务于本节。清理前先确认终端位于保存 compose.yaml 的目录。下面的 down -v 会删除该 Compose 项目的容器、默认网络和三个命名卷,但不会删除一直使用的 paperboat-mongo:
docker compose -p bookstore-rs down -v[+] Running 7/7
✔ Container bookstore-rs-mongo1-1 Removed
✔ Container bookstore-rs-mongo2-1 Removed
✔ Container bookstore-rs-mongo3-1 Removed
✔ Volume bookstore-rs_mongo1-data Removed
✔ Volume bookstore-rs_mongo2-data Removed
✔ Volume bookstore-rs_mongo3-data Removed
✔ Network bookstore-rs_default Removeddocker compose -p bookstore-rs ps -aNAME IMAGE COMMAND SERVICE CREATED STATUS PORTS标题行下面没有服务,说明这组实验对象已经删除。纸舟书店的主课程容器仍可继续用于后面的安全、备份和诊断练习。
Compose 对象已经删除后,移除只包含本节配置的目录:
cd ..
rm -f paperboat-replica-lab/compose.yaml
rmdir paperboat-replica-labrmdir 只会删除空目录;如果它拒绝执行,先检查目录里留下了什么,不要改用宽泛的递归删除。
现在你已经看过一次完整链路:成员复制数据,Primary 停止,多数派选出新 Primary,旧成员恢复后重新追赶。高可用不是一个开关,而是拓扑、确认级别、驱动行为和故障演练共同形成的能力。