MongoDB 副本集 primary 切换丢了 12 个订单的复盘:writeConcern 必须 majority

MongoDB 副本集网络抖动后丢了 12 个订单。本文讲清楚 rollback 机制、为什么默认 w:1 不安全、majority 怎么配、性能代价多少、各语言客户端代码、监控指标,以及分片集群额外的坑。附 8 条生存法则。

线上 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 条生存法则

  1. writeConcern: majority:订单/支付/库存类必备
  2. readConcern: majority:防读到脏数据
  3. journal: true:防 primary 单点崩溃丢失
  4. retryWrites: true:网络抖动自动重试
  5. wtimeout: 5000:防止无限等待
  6. 3 节点起步,5 节点最稳:奇数节点避免选举平局
  7. 分片键选业务主键:避免跨 shard
  8. 监控 replication lag:超 10 秒告警

事故复盘后,我们公司所有 MongoDB 业务代码都做了这 8 条强制改造。一年来没再出现过类似数据丢失,只剩偶尔 primary 切换时少数请求 wtimeout(应用层重试解决,无数据丢失)。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Spring @Transactional 失效的 7 种真实场景 + 修法

2026-5-19 10:50:18

技术教程

ConcurrentHashMap.computeIfAbsent 嵌套调用导致 CPU 100% 的真实事故复盘

2026-5-19 11:15:47

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索