打破误解!MongoDB 事务隔离级别深度实测:快照隔离竟能防住 8 种异常?

打破误解!MongoDB 事务隔离级别深度实测:快照隔离竟能防住 8 种异常?

作为 NoSQL 数据库的代表,MongoDB 的事务能力一直被部分开发者"低估"------不少人还抱着"老版本有坑""NoSQL 事务不靠谱"的固有印象。但实际上,自 4.0 版本支持多文档事务后,MongoDB 已实现 ACID 兼容,其基于快照隔离(Snapshot Isolation)的设计,不仅能媲美传统关系型数据库的一致性,还通过独特的冲突处理机制兼顾了性能。本文结合完整实测代码和案例,带大家彻底搞懂 MongoDB 的事务隔离级别,理清那些流传已久的误解。

一、核心结论:MongoDB 事务隔离级别的本质

MongoDB 的多文档事务采用快照隔离(Snapshot Isolation) 机制,依托多版本并发控制(MVCC)实现强一致性,完全满足 ACID 特性。

很多开发者会下意识将其与 SQL 标准的隔离级别对标,但这种做法并不恰当------SQL 标准的隔离级别定义并未考虑 MVCC,而 MongoDB、PostgreSQL 等主流数据库均依赖 MVCC 实现并发控制。简单来说:

  • MongoDB 的事务隔离能力不逊色于传统关系型数据库;
  • 早期版本(如 4.0 刚支持多文档事务时)的部分问题已完全修复,像早期 Jepsen 报告中提到的异常场景已不复存在;
  • 其隔离级别通过readConcern(读关注级别)控制,不同级别对应不同的一致性保障和使用场景。

二、各读关注级别的特点(与 SQL 隔离级别的区别)

MongoDB 的事务隔离核心由readConcern参数控制,不同级别对应不同的一致性表现,且无法直接等同于 SQL 标准的隔离级别,具体特点如下:

1. local 级别

  • 可能读取到未提交的中间状态(这些状态后续可能因事务回滚而失效),因此有时被类比为 SQL 的"读未提交";
  • 但需注意:部分 SQL 数据库在极端情况下也会出现类似行为,却仍将其归为"读已提交"级别,可见直接对标并不严谨。

2. majority 级别

  • 仅读取已被大多数节点确认提交的数据,避免了"读未提交"问题,常被类比为 SQL 的"读已提交";
  • 关键区别:SQL 的"读已提交"是为了减少两阶段锁的锁定时长,采用"冲突等待"机制;而 MongoDB 多文档事务采用"冲突即失败"(fail-on-conflict)机制,避免长时间等待;
  • 局限:在多分片场景下,可能读取到多个不同时间点的状态,无法保证跨分片的时间线一致性。

3. snapshot 级别

  • 真正等同于"快照隔离",能提供跨分片的时间线一致性,防止的异常场景比"读已提交"更多;
  • 有趣的是:部分数据库会将其称为"可串行化",因为 SQL 标准并未考虑"写倾斜"(write skew)异常,而快照隔离本身可规避部分此类场景。

4. linearizable 级别

  • 仅适用于单文档操作,无法用于多文档事务,可类比为 SQL 的"可串行化";
  • 特点:能保证单文档操作的线性一致性,但会重新引入读锁带来的扩展性问题,这也是多数数据库不默认提供可串行化级别的原因------MVCC 的核心优势就是避免读锁。

三、实测验证:MongoDB 如何防止事务异常?

为了验证 MongoDB 事务的隔离能力,测试者遵循 Martin Kleppmann 的测试框架(原本用于 PostgreSQL),针对多文档事务场景进行了一系列实测。所有测试均采用readConcern: majoritywriteConcern: majority配置(单节点环境),部分跨分片场景需使用snapshot级别,以下是带完整代码的关键测试结果:

测试前提

所有测试均初始化基础数据:

JavaScript 复制代码
// 初始化数据
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

事务均通过startSession()开启,明确指定读/写关注级别。

1. 防止写循环(G0):冲突即失败,拒绝无限等待

  • 测试场景:两个事务同时更新同一文档;
  • 传统两阶段锁数据库:第二个事务会等待第一个事务完成,避免冲突;
  • MongoDB 测试代码:
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 两个事务同时更新同一文档
T1.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
T2.test.updateOne({ _id: 1 }, { $set: { value: 12 } });

// 执行结果:抛出写冲突错误
// MongoServerError[WriteConflict]: Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.
  • 关键差异:隐式单文档事务(自动提交)采用"冲突等待"机制,测试中等待 60 秒超时后执行成功:
JavaScript 复制代码
const db = db.getMongo().getDB("test_db");
print(`Elapsed time: ${
  ((startTime = new Date())
  && db.test.updateOne({ _id: 1 }, { $set: { value: 12 } }))
  && (new Date() - startTime)
} ms`);

// 执行结果:Elapsed time: 72548 ms(等待约60秒超时后成功)
// 此时T1事务因超时被中止,提交时会报错:
// session1.commitTransaction();
// MongoServerError[NoSuchTransaction]: Transaction with { txnNumber: 2 } has been aborted.
  • 结论:显式多文档事务采用"冲突即失败",让应用自行处理重试逻辑,兼顾复杂事务的灵活性。

2. 防止未提交读(G1a):只认已提交数据

  • 测试场景:事务 T1 更新文档后未提交,事务 T2 读取该文档;
  • MongoDB 测试代码:
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T1更新文档但未提交
T1.test.updateOne({ _id: 1 }, { $set: { value: 101 } });

// T2读取文档,仅能看到已提交数据
T2.test.find(); 
// 执行结果:[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]

// T1回滚事务
session1.abortTransaction();

// T2再次读取,结果不变
T2.test.find(); 
// 执行结果:[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]

session2.commitTransaction();
  • 结论:majoritysnapshot级别均能杜绝"读取未提交数据"的异常。

3. 防止中间读(G1b):事务内读时间线固定

  • 测试场景:T1 更新文档并提交,T2 在 T1 提交前已启动,后续再次读取该文档;
  • MongoDB 测试代码:
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T1更新文档(未提交)
T1.test.updateOne({ _id: 1 }, { $set: { value: 101 } });

// T2读取,看不到未提交变更
T2.test.find(); 
// 执行结果:[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]

// T1修改并提交
T1.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
session1.commitTransaction();

// T2再次读取,仍看不到T1提交的新值
T2.test.find(); 
// 执行结果:[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]

session2.commitTransaction();
  • 区别于 SQL:多数 MVCC 架构的 SQL 数据库在"读已提交"级别下,会在每个语句执行前重置读时间线,因此能看到后续提交的新值,而 MongoDB 事务的读时间线固定在事务启动时,从根源避免了"中间读"。

4. 防止循环信息流转(G1c):未提交变更互不可见

  • 测试场景:T1 更新文档 1,T2 更新文档 2,两者互相读取对方更新的文档;
  • MongoDB 测试代码:
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T1更新文档1,T2更新文档2
T1.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
T2.test.updateOne({ _id: 2 }, { $set: { value: 22 } });

// T1读取文档2,看不到T2的未提交更新
T1.test.find({ _id: 2 }); 
// 执行结果:[ { _id: 2, value: 20 } ]

// T2读取文档1,看不到T1的未提交更新
T2.test.find({ _id: 1 }); 
// 执行结果:[ { _id: 1, value: 10 } ]

// 提交两个事务
session1.commitTransaction();
session2.commitTransaction();

5. 防止事务消失(OTV):多事务冲突快速失败

  • 测试场景:三个事务同时操作同一批文档,存在交叉更新;
  • MongoDB 测试代码:
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T3
const session3 = db.getMongo().startSession();
const T3 = session3.getDatabase("test_db");
session3.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T1更新两个文档
T1.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
T1.test.updateOne({ _id: 2 }, { $set: { value: 19 } });

// T2更新文档1,触发冲突
T2.test.updateOne({ _id: 1 }, { $set: { value: 12 } });

// 执行结果:抛出写冲突错误
// MongoServerError[WriteConflict]: Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.
  • 优势:相比传统数据库的"等待-超时"机制,MongoDB 的"冲突即失败"能让应用更快感知并处理冲突,提升整体吞吐量。

6. 防止多前驱谓词异常(PMP):快照一致性覆盖查询场景

  • 测试场景 1:T1 启动后执行查询(无匹配结果),T2 插入匹配该查询条件的文档并提交,T1 再次执行相同查询;
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T1查询value=30的文档,无结果
T1.test.find({ value: 30 }).toArray(); 
// 执行结果:[]

// T2插入匹配条件的文档并提交
T2.test.insertOne({ _id: 3, value: 30 });
session2.commitTransaction();

// T1再次查询,仍无结果
T1.test.find({ value: { $mod: [3, 0] } }).toArray(); 
// 执行结果:[]

session1.commitTransaction();
  • 测试场景 2:T1 批量更新文档,T2 批量删除匹配文档,触发冲突;
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T1批量更新所有文档
T1.test.updateMany({}, { $inc: { value: 10 } });

// T2删除value=20的文档,触发冲突
T2.test.deleteMany({ value: 20 });

// 执行结果:抛出写冲突错误
// MongoServerError[WriteConflict]: Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.
  • 结论:MongoDB 通过快照隔离和冲突检测,避免了基于过时查询结果执行写操作的异常。

7. 防止丢失更新(P4):拒绝并行更新覆盖

  • 测试场景:两个事务同时读取同一文档,基于相同的旧值执行更新;
  • MongoDB 测试代码:
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 两个事务均读取文档1的旧值
T1.test.find({ _id: 1 }); 
// 执行结果:[ { _id: 1, value: 10 } ]
T2.test.find({ _id: 1 }); 
// 执行结果:[ { _id: 1, value: 10 } ]

// 两个事务基于旧值执行更新,后执行的触发冲突
T1.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
T2.test.updateOne({ _id: 1 }, { $set: { value: 11 } });

// 执行结果:抛出写冲突错误
// MongoServerError[WriteConflict]: Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.
  • 关键:应用只需捕获该错误并重试,即可保证更新的原子性。

8. 防止读倾斜(G-single):事务内数据一致性

  • 测试场景 1:T1 启动后读取文档 2,T2 更新文档 1 和 2 并提交,T1 再次读取文档 2;
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({

  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T1读取文档1和2
T1.test.find({ _id: 1 }); 
// 执行结果:[ { _id: 1, value: 10 } ]
T2.test.find({ _id: 2 }); 
// 执行结果:[ { _id: 2, value: 20 } ]

// T2更新两个文档并提交
T2.test.updateOne({ _id: 1 }, { $set: { value: 12 } });
T2.test.updateOne({ _id: 2 }, { $set: { value: 18 } });
session2.commitTransaction();

// T1再次读取文档2,结果不变
T1.test.find({ _id: 2 }); 
// 执行结果:[ { _id: 2, value: 20 } ]

session1.commitTransaction();
  • 测试场景 2:T1 查询匹配条件的文档,T2 更新文档使其满足新条件并提交,T1 再次查询;
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T1查询value能被5整除的文档
T1.test.findOne({ value: { $mod: [5, 0] } }); 
// 执行结果:{ _id: 1, value: 10 }

// T2更新文档1使其能被3整除并提交
T2.test.updateOne({ value: 10 }, { $set: { value: 12 } });
session2.commitTransaction();

// T1查询value能被3整除的文档,无结果
T1.test.find({ value: { $mod: [3, 0] } }).toArray(); 
// 执行结果:[]

session1.commitTransaction();
  • 测试场景 3:T2 更新文档后提交,T1 删除匹配旧值的文档,触发冲突;
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T1读取文档1
T1.test.find({ _id: 1 }); 
// 执行结果:[ { _id: 1, value: 10 } ]

// T2更新两个文档并提交
T2.test.updateOne({ _id: 1 }, { $set: { value: 12 } });
T2.test.updateOne({ _id: 2 }, { $set: { value: 18 } });
session2.commitTransaction();

// T1删除value=20的文档,触发冲突
T1.test.deleteMany({ value: 20 });

// 执行结果:抛出写冲突错误
// MongoServerError[WriteConflict]: Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.
  • 对比 SQL:SQL 的"读已提交"级别可能出现"读倾斜",即同一事务内两次读取同一文档得到不同结果,而 MongoDB 的快照隔离完全规避了这一问题。

四、需要应用层处理的事务异常

MongoDB 的快照隔离并非万能,以下两种异常无法通过数据库层面自动防止,需要应用层介入处理:

1. 写倾斜(G2-item):读依赖导致的逻辑冲突

  • 场景:两个事务同时读取同一批文档,基于读取结果执行更新,更新后的数据可能违反业务规则(如两个事务均判断"库存充足"并扣减,最终导致库存为负);
  • MongoDB 测试代码:
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

// 两个事务均读取文档1和2
T1.test.find({ _id: { $in: [1, 2] } });
// 执行结果:[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]
T2.test.find({ _id: { $in: [1, 2] } });
// 执行结果:[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]

// 两个事务分别更新文档
T2.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
T2.test.updateOne({ _id: 2 }, { $set: { value: 21 } });

// 两个事务均提交成功,未触发冲突
session1.commitTransaction();
session2.commitTransaction();
  • 原因:MongoDB 不会在读取时加锁,也无法感知"基于读取结果的写依赖";
  • 解决方案:应用层可通过"更新读集文档"的方式,将读依赖转化为写冲突(如读取文档时更新一个"版本号"字段),迫使冲突事务失败重试。

2. 反依赖循环(G2):跨事务读写依赖冲突

  • 场景:两个事务均基于对方已更新的文档执行写操作,导致最终结果违反预期;
  • MongoDB 测试代码:
JavaScript 复制代码
// 初始化数据(同上,略)

// 事务T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

// 事务T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

// 两个事务均查询value能被3整除的文档,无结果
T1.test.find({ value: { $mod: [3, 0] } }).toArray(); 
// 执行结果:[]
T2.test.find({ value: { $mod: [3, 0] } }).toArray(); 
// 执行结果:[]

// T1插入两个能被3整除的文档并提交
T1.test.insertOne({ _id: 3, value: 30 });
T1.test.insertOne({ _id: 4, value: 42 });
session1.commitTransaction();

// T2提交(无更新操作)
session2.commitTransaction();

// 最终查询,能看到T1插入的文档
T1.test.find({ value: { $mod: [3, 0] } }).toArray();
// 执行结果:[ { _id: 3, value: 30 }, { _id: 4, value: 42 } ]
  • 原因:MongoDB 不会在读写操作之间加锁,无法检测此类跨事务的读依赖冲突;
  • 解决方案:若事务的写操作依赖于之前的读取结果,应用层需显式更新读取过的文档(如添加"已处理"标记),触发 MongoDB 的写冲突检测,避免异常。

五、总结:MongoDB 事务隔离级别怎么用?

  1. 核心选型:单分片场景用readConcern: majority即可满足大部分需求,多分片场景需用snapshot保证跨分片一致性;
  2. 冲突处理:显式多文档事务采用"冲突即失败",应用需实现重试逻辑(简单场景可直接重试,复杂场景需处理幂等性);
  3. 打破误解:MongoDB 的多文档事务早已具备生产级稳定性,其隔离能力不逊色于传统关系型数据库,无需再被"NoSQL 事务不靠谱"的老观念束缚;
  4. 性能平衡:通过 MVCC 和"冲突即失败"机制,MongoDB 在保证一致性的同时,避免了读锁带来的扩展性问题,适合高并发场景。

如果你的业务需要使用 MongoDB 的多文档事务,不妨直接复制本文的测试代码进行验证,结合自身业务特点选择合适的读关注级别,同时在应用层处理好写倾斜和反依赖循环问题,即可充分发挥其高并发+强一致性的优势~

相关推荐
wei_shuo2 小时前
全场景自动化 Replay 技术:金仓 KReplay 如何攻克数据库迁移 “难验证“ 难题
数据库·自动化·king base
Gold Steps.2 小时前
数据库正常运行但是端口变成了0?
数据库·mysql
杂亿稿2 小时前
增删改查操作
数据库
Code_Geo2 小时前
在postgres数据库中Postgres FDW 全面详解
数据库·fdw
QT 小鲜肉2 小时前
【个人成长笔记】将Try Ubuntu里面配置好的文件系统克隆在U盘上(创建一个带有持久化功能的Ubuntu Live USB系统)
linux·开发语言·数据库·笔记·ubuntu
LWy6104262 小时前
数据库库、表的创建及处理
数据库
Jay_Franklin2 小时前
Python中使用sqlite3模块和panel完成SQLite数据库中PDF的写入和读取
数据库·笔记·python·pycharm·sqlite·pdf·py
小锅巴1233 小时前
百度测开面经(分类版)
数据库·分类·数据挖掘
芒果要切3 小时前
Redis 使用场景
数据库·redis·缓存