59、MongoDB事务:会话(session)、多文档事务、ACID保证、重试逻辑
说实话,MongoDB 的事务功能来得有点晚。我记得 4.0 版本刚推出多文档事务时,很多关系型数据库的老手都嗤之以鼻——「NoSQL 搞什么事务?」但用过之后你会发现,MongoDB 的事务设计其实很务实,尤其适合那些「需要原子性操作,但又不想放弃文档模型灵活性」的场景。
今天咱们就聊聊 MongoDB 事务的核心:会话、多文档事务、ACID 保证,还有重试逻辑。嗯,这部分内容我踩过不少坑,希望能帮你少走弯路。
1. 会话(Session)——事务的载体
在 MongoDB 里,事务不能独立存在。它必须依附于一个会话(Session)。你可以把会话理解成「事务的容器」。
我个人习惯这样类比:会话就像一次电话通话,事务就是通话过程中说的每一句话。通话开始(开启会话),说话(执行事务),挂断(结束会话)。
核心要点:所有事务操作都必须在同一个会话内完成。跨会话的操作无法保证原子性。
创建会话的代码很简单:
// 获取会话
var session = db.getMongo().startSession();
// 在会话中开启事务
session.startTransaction();
try {
// 执行事务操作
var collection = session.getDatabase("shop").getCollection("orders");
collection.insertOne({ orderId: 1001, amount: 299 });
// 提交事务
session.commitTransaction();
} catch (error) {
// 回滚事务
session.abortTransaction();
print("事务失败: " + error);
} finally {
session.endSession();
}
这里有个细节要注意:事务内的所有读写操作,都必须使用会话对象来获取数据库和集合。直接用 db.collection 操作是不受事务保护的。我曾经在项目里犯过这个错,查了半天才发现是会话没传进去。
2. 多文档事务——到底能做什么?
MongoDB 的多文档事务支持跨多个文档、多个集合、甚至多个数据库的原子操作。说白了,就是让你能在 NoSQL 里玩出关系型数据库的 ACID 感觉。
来看一个典型的转账场景:
function transferMoney(fromAccount, toAccount, amount) {
var session = db.getMongo().startSession();
session.startTransaction({
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" }
});
try {
var accounts = session.getDatabase("bank").getCollection("accounts");
// 扣钱
accounts.updateOne(
{ accountId: fromAccount, balance: { $gte: amount } },
{ $inc: { balance: -amount } }
);
// 加钱
accounts.updateOne(
{ accountId: toAccount },
{ $inc: { balance: amount } }
);
session.commitTransaction();
return "转账成功";
} catch (e) {
session.abortTransaction();
return "转账失败: " + e;
} finally {
session.endSession();
}
}
你想想看,如果没有事务,扣钱成功但加钱失败怎么办?那可就出大事了。多文档事务保证了这两个操作要么都成功,要么都失败。
我的经验:事务内的操作尽量精简。别在一个事务里塞几十个操作,那样锁冲突的概率会急剧上升。我一般控制在 5-10 个操作以内。
3. ACID 保证——MongoDB 做到了什么程度?
很多人问:MongoDB 的事务真的能像 MySQL 那样保证 ACID 吗?答案是:在副本集环境下,基本可以。
| ACID 属性 | MongoDB 实现方式 | 注意事项 |
|---|---|---|
| 原子性(Atomicity) | 事务日志 + 两阶段提交 | 事务内所有操作要么全做,要么全不做 |
| 一致性(Consistency) | 写关注 + 读关注 | 需要显式设置 readConcern 和 writeConcern |
| 隔离性(Isolation) | 快照隔离(Snapshot Isolation) | 默认级别,可防止脏读、不可重复读 |
| 持久性(Durability) | WiredTiger 日志 + 多数派确认 | 设置 w: "majority" 确保数据不丢 |
这里我要特别说一下隔离性。MongoDB 默认使用快照隔离,这意味着事务开始时,它会看到数据库的一个「快照」。其他事务的修改不会影响当前事务的读取。这能防止脏读和不可重复读,但无法防止幻读。
避坑指南:我曾经在分片集群上跑事务,结果发现跨分片事务的性能下降得厉害。后来查文档才知道,MongoDB 4.2 才支持分片集群的多文档事务,而且性能开销比副本集大得多。如果你的数据需要频繁跨分片事务,建议重新考虑分片键的设计。
4. 重试逻辑——让事务更健壮
事务在执行过程中可能会因为各种原因失败:网络抖动、主节点切换、写冲突等等。这时候就需要重试逻辑。
MongoDB 官方推荐的做法是使用可重试写入和事务重试机制。但注意,不是所有错误都适合重试。比如文档校验失败,重试一万次也没用。
我一般这样写重试逻辑:
function executeWithRetry(transactionFunc, maxRetries = 3) {
var session = db.getMongo().startSession();
for (var attempt = 1; attempt <= maxRetries; attempt++) {
try {
session.startTransaction({
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" }
});
transactionFunc(session);
session.commitTransaction();
return "成功";
} catch (error) {
session.abortTransaction();
// 判断是否可重试
if (isRetryableError(error) && attempt < maxRetries) {
print("第 " + attempt + " 次尝试失败,准备重试...");
// 指数退避等待
sleep(Math.pow(2, attempt) * 100);
continue;
} else {
throw error;
}
}
} finally {
session.endSession();
}
}
function isRetryableError(error) {
// 可重试的错误码列表
var retryableCodes = [
112, // WriteConflict
245, // LockTimeout
246, // LockBusy
262 // SnapshotUnavailable
];
return retryableCodes.includes(error.code);
}
这里有个关键点:重试时要重新开启会话和事务。不能复用之前已经 abort 的会话。我见过有人犯这个错,结果事务状态混乱,数据都写乱了。
我的建议:重试次数别设太多,3 次足够了。每次重试之间加个指数退避,比如第一次等 200ms,第二次等 400ms,第三次等 800ms。这样既能避免频繁重试加重系统负担,又能给系统恢复的时间。
5. 知识体系总览
下面这张图总结了 MongoDB 事务的核心脉络,你可以对照着理解:
这张图把今天讲的内容串起来了。你写代码的时候,就按这个流程来:先开会话,再启事务,配好 ACID 参数,最后加上重试逻辑。顺序别搞反了。
6. 实战中的几个坑
最后分享几个我踩过的坑,希望能帮你省点时间:
- 事务超时:MongoDB 默认事务超时是 60 秒。如果你的事务执行时间太长,会被强制 abort。我建议在 startTransaction 时显式设置 maxCommitTimeMS。
- 写冲突:多个事务同时修改同一个文档时,会产生写冲突。这时候其中一个事务会被 abort,需要重试。所以高并发场景下,尽量让事务操作不同的文档。
- 不要在事务里做耗时操作:比如网络请求、文件读写。事务期间会持有锁,耗时操作会阻塞其他事务。
- 事务大小限制:单个事务最多操作 1000 个文档(4.0 版本限制)。虽然 4.2 以后放宽了,但为了性能,还是别搞太大。
一句话总结:MongoDB 事务不是银弹。它能解决原子性问题,但会带来性能开销。该用时别犹豫,不该用时别硬上。我见过有人把 MongoDB 当 Oracle 用,结果性能惨不忍睹。