打破誤解!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: majority和writeConcern: majority配置(單節點環境),部分跨分片場景需使用snapshot級別,以下是帶完整代碼的關鍵測試結果:
測試前提
所有測試均初始化基礎數據:
// 初始化數據
use test_db;
db.test.drop();
db.test.insertMany([
{ _id: 1, value: 10 },
{ _id: 2, value: 20 }
]);
事務均通過startSession()開啓,明確指定讀/寫關注級別。
1. 防止寫循環(G0):衝突即失敗,拒絕無限等待
- 測試場景:兩個事務同時更新同一文檔;
- 傳統兩階段鎖數據庫:第二個事務會等待第一個事務完成,避免衝突;
- MongoDB 測試代碼:
// 初始化數據(同上,略)
// 事務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 秒超時後執行成功:
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 測試代碼:
// 初始化數據(同上,略)
// 事務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();
- 結論:
majority或snapshot級別均能杜絕“讀取未提交數據”的異常。
3. 防止中間讀(G1b):事務內讀時間線固定
- 測試場景:T1 更新文檔並提交,T2 在 T1 提交前已啓動,後續再次讀取該文檔;
- MongoDB 測試代碼:
// 初始化數據(同上,略)
// 事務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 測試代碼:
// 初始化數據(同上,略)
// 事務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 測試代碼:
// 初始化數據(同上,略)
// 事務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 再次執行相同查詢;
// 初始化數據(同上,略)
// 事務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 批量刪除匹配文檔,觸發衝突;
// 初始化數據(同上,略)
// 事務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 測試代碼:
// 初始化數據(同上,略)
// 事務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;
// 初始化數據(同上,略)
// 事務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 再次查詢;
// 初始化數據(同上,略)
// 事務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 刪除匹配舊值的文檔,觸發衝突;
// 初始化數據(同上,略)
// 事務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 測試代碼:
// 初始化數據(同上,略)
// 事務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 測試代碼:
// 初始化數據(同上,略)
// 事務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 事務隔離級別怎麼用?
- 核心選型:單分片場景用
readConcern: majority即可滿足大部分需求,多分片場景需用snapshot保證跨分片一致性; - 衝突處理:顯式多文檔事務採用“衝突即失敗”,應用需實現重試邏輯(簡單場景可直接重試,複雜場景需處理冪等性);
- 打破誤解:MongoDB 的多文檔事務早已具備生產級穩定性,其隔離能力不遜色於傳統關係型數據庫,無需再被“NoSQL 事務不靠譜”的老觀念束縛;
- 性能平衡:通過 MVCC 和“衝突即失敗”機制,MongoDB 在保證一致性的同時,避免了讀鎖帶來的擴展性問題,適合高併發場景。
如果你的業務需要使用 MongoDB 的多文檔事務,不妨直接複製本文的測試代碼進行驗證,結合自身業務特點選擇合適的讀關注級別,同時在應用層處理好寫傾斜和反依賴循環問題,即可充分發揮其高併發+強一致性的優勢~