线上 MongoDB 副本集机房网络抖动 8 秒,自动故障切换,新 primary 上线。第二天客服反馈,有 12 个订单"创建成功"但用户看不到 —— 数据真的丢了。这种丢失在 MongoDB 副本集里叫 rollback,默认配置下随时可能发生。本文讲清楚 write concern、rollback 机制和怎么彻底防止。
故障还原
// 应用代码:看起来很正常的写入
db.orders.insertOne({
_id: 'ord_12345',
uid: 1001,
amount: 199.00,
created_at: new Date()
});
// 客户端拿到 ack,继续走业务
// 但其实数据只写了 primary,还没复制到 secondary
// 此时 primary 网络中断
// 故障切换:secondary 选出新 primary
// 老 primary 恢复后,发现自己有些数据 secondary 没有
// 这些"多出来的数据"会被 rollback(回滚)到本地文件,不再回到集群
问题根源:MongoDB 默认的 write concern 是 w: 1,意思是"只要 primary 写入就 ack"。primary 还没把数据复制给 secondary 就挂了,故障切换之后这些数据丢失。
原理:MongoDB 副本集复制流程
正确配置:write concern
// 错:默认 w:1,只等 primary 确认
db.orders.insertOne({...});
// 对:w:majority,等大多数副本确认才 ack
db.orders.insertOne(
{_id: 'ord_12345', uid: 1001, amount: 199.00},
{writeConcern: {w: 'majority', wtimeout: 5000, j: true}}
);
// 参数解释:
// w: 'majority' 等大多数副本(3 副本集需要 2 个)
// j: true 等数据写入 journal(磁盘持久化)
// wtimeout: 5000 超时 5 秒,不会无限等
用 w:majority 之后,只有数据真的被复制到大多数节点才 ack。primary 挂了也不会丢,因为新选出的 primary 一定包含这些数据。
各语言客户端配置
// Java
MongoClientSettings settings = MongoClientSettings.builder()
.applyConnectionString(new ConnectionString("mongodb://node1,node2,node3/?replicaSet=rs0"))
.writeConcern(WriteConcern.MAJORITY.withWTimeout(5, TimeUnit.SECONDS).withJournal(true))
.readConcern(ReadConcern.MAJORITY) // 读也要 majority,避免读到将被 rollback 的脏数据
.readPreference(ReadPreference.primaryPreferred())
.retryWrites(true) // 自动重试可重试的写入
.build();
# Python
from pymongo import MongoClient, WriteConcern, ReadPreference
from pymongo.read_concern import ReadConcern
client = MongoClient(
'mongodb://node1,node2,node3/?replicaSet=rs0',
w='majority',
wtimeoutms=5000,
journal=True,
read_preference=ReadPreference.PRIMARY_PREFERRED,
read_concern=ReadConcern('majority'),
retry_writes=True,
)
# 也可以在 collection 层面覆盖
orders = client['mydb'].get_collection('orders',
write_concern=WriteConcern(w='majority', wtimeout=5000, j=True),
read_concern=ReadConcern('majority'),
)
orders.insert_one({...})
// Go
import "go.mongodb.org/mongo-driver/mongo"
import "go.mongodb.org/mongo-driver/mongo/options"
import "go.mongodb.org/mongo-driver/mongo/writeconcern"
import "go.mongodb.org/mongo-driver/mongo/readconcern"
wc := writeconcern.Majority()
wc.WTimeout = 5 * time.Second
opts := options.Client().
ApplyURI("mongodb://node1,node2,node3/?replicaSet=rs0").
SetWriteConcern(wc).
SetReadConcern(readconcern.Majority()).
SetRetryWrites(true)
client, _ := mongo.Connect(context.TODO(), opts)
性能代价
w:1 改成 w:majority 写延迟会增加。基准测试(3 节点本地副本集):
写并发 1000 QPS,1KB 文档:
w:1 P50: 1.2ms P99: 5ms
w:majority P50: 3.8ms P99: 12ms
w:majority + j:true P50: 6.1ms P99: 18ms
大约慢 3-5 倍。但对一致性敏感的场景(订单 / 支付 / 库存),这点性能换数据安全完全值。
对真正高 QPS / 不敏感的场景(日志收集 / 监控数据),可以保留 w:1,但要在业务设计上能容忍丢失。
读 concern 也要关注
// 错:默认 local,可能读到将被 rollback 的脏数据
db.orders.find({uid: 1001});
// 对:majority,只读已经被大多数副本确认的数据
db.orders.find({uid: 1001}).readConcern('majority');
// 更严格:linearizable(线性一致)
// 但性能代价巨大,仅限金融账户余额这种场景
db.account.find({uid: 1001}).readConcern('linearizable');
secondary 节点延迟监控
// 在 primary 上看复制状态
rs.printSecondaryReplicationInfo();
// source: secondary1.example.com:27017
// syncedTo: Mon Nov 25 2024 10:00:00 GMT
// 2 secs (0.00 hrs) behind the primary
// source: secondary2.example.com:27017
// syncedTo: Mon Nov 25 2024 10:00:01 GMT
// 1 secs (0.00 hrs) behind the primary
// 程序里查
db.adminCommand({replSetGetStatus: 1});
// {
// members: [
// {name: 'p1', state: 1, stateStr: 'PRIMARY', optimeDate: ...},
// {name: 's1', state: 2, stateStr: 'SECONDARY', optimeDate: ...},
// ...
// ]
// }
Prometheus 告警:
- alert: MongoReplicaLagHigh
expr: mongodb_mongod_replset_member_replication_lag > 10
for: 2m
labels: { severity: warning }
annotations:
summary: 'MongoDB secondary 复制延迟 > 10 秒'
description: '高延迟会让 majority write 等更久,极端时 wtimeout 写失败'
- alert: MongoElectionFrequent
expr: rate(mongodb_mongod_replset_member_election_date[5m]) > 0.05
annotations:
summary: 'Mongo 副本集频繁选举,可能网络不稳'
怎么检测有没有发生过 rollback
# SSH 到老 primary,检查 rollback 目录
ls -la /var/lib/mongo/rollback/
# 如果有文件,说明发生过 rollback,这些是丢失的数据
# 用 bsondump 把 rollback 文件转 JSON 查看
bsondump /var/lib/mongo/rollback/rollback-orders.2024-11-25T03:45:12.bson | jq
# 输出格式:
# {"_id":"ord_12345","uid":1001,"amount":199.00,"created_at":"2024-11-25T03:45:10Z"}
# 这就是丢失的订单数据,业务需要人工补回
我们后来在所有 MongoDB 节点配了一个监控,rollback 目录非空就立即告警。事故复盘时把这些数据手动补回业务库。
多文档事务
MongoDB 4.0+ 支持多文档 ACID 事务,跨 collection / 跨数据库都可以。但事务开销很大,不要滥用。
// 显式事务
const session = client.startSession();
session.startTransaction({
readConcern: {level: 'majority'},
writeConcern: {w: 'majority', wtimeout: 5000}
});
try {
const orders = client.db('shop').collection('orders');
const inventory = client.db('shop').collection('inventory');
await orders.insertOne({_id: 'ord_1', uid: 1001, items: [{sku: 'A1', qty: 2}]}, {session});
const inv = await inventory.findOneAndUpdate(
{sku: 'A1', stock: {$gte: 2}},
{$inc: {stock: -2}},
{session, returnDocument: 'after'}
);
if (!inv) throw new Error('out_of_stock');
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
throw e;
} finally {
await session.endSession();
}
事务限制:
- 分片集群上事务需要 4.2+
- 事务有时间上限,默认 60 秒
- 事务内不能有 DDL(createIndex / dropCollection 等)
- 事务期间持有锁,影响并发
分片集群额外的坑
副本集是单 shard 内的高可用。分片集群是水平扩展的 shard 集合。跨 shard 写不能保证原子,即使 w:majority 也不行。
// 分片键选择不当 → 跨 shard 操作 → 性能差 + 一致性差
// 比如订单按 _id 哈希分片,但业务总按 uid 查询
// 大多数查询需要 scatter-gather 所有 shard
// 正确:按主要查询模式选分片键
sh.shardCollection('shop.orders', {uid: 1, _id: 1});
// 现在按 uid 查询只命中一个 shard,事务也只涉及一个 shard
// 范围分片 vs 哈希分片
sh.shardCollection('shop.logs', {created_at: 1}); // 范围分片:时序数据
sh.shardCollection('shop.users', {uid: 'hashed'}); // 哈希分片:均匀分布
MongoDB 副本集 8 条生存法则
- writeConcern: majority:订单/支付/库存类必备
- readConcern: majority:防读到脏数据
- journal: true:防 primary 单点崩溃丢失
- retryWrites: true:网络抖动自动重试
- wtimeout: 5000:防止无限等待
- 3 节点起步,5 节点最稳:奇数节点避免选举平局
- 分片键选业务主键:避免跨 shard
- 监控 replication lag:超 10 秒告警
事故复盘后,我们公司所有 MongoDB 业务代码都做了这 8 条强制改造。一年来没再出现过类似数据丢失,只剩偶尔 primary 切换时少数请求 wtimeout(应用层重试解决,无数据丢失)。
—— 别看了 · 2026