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 事务的核心脉络,你可以对照着理解:

MongoDB 事务核心知识体系 MongoDB 事务 会话 (Session) 多文档事务 ACID 保证 重试逻辑 startSession() endSession() startTransaction() commitTransaction() 原子性 一致性 可重试写入 指数退避 事务 = 会话 + 多文档操作 + ACID 配置 + 重试机制 核心原则:要么全做,要么全不做

这张图把今天讲的内容串起来了。你写代码的时候,就按这个流程来:先开会话,再启事务,配好 ACID 参数,最后加上重试逻辑。顺序别搞反了。

6. 实战中的几个坑

最后分享几个我踩过的坑,希望能帮你省点时间:

  • 事务超时:MongoDB 默认事务超时是 60 秒。如果你的事务执行时间太长,会被强制 abort。我建议在 startTransaction 时显式设置 maxCommitTimeMS。
  • 写冲突:多个事务同时修改同一个文档时,会产生写冲突。这时候其中一个事务会被 abort,需要重试。所以高并发场景下,尽量让事务操作不同的文档。
  • 不要在事务里做耗时操作:比如网络请求、文件读写。事务期间会持有锁,耗时操作会阻塞其他事务。
  • 事务大小限制:单个事务最多操作 1000 个文档(4.0 版本限制)。虽然 4.2 以后放宽了,但为了性能,还是别搞太大。

一句话总结:MongoDB 事务不是银弹。它能解决原子性问题,但会带来性能开销。该用时别犹豫,不该用时别硬上。我见过有人把 MongoDB 当 Oracle 用,结果性能惨不忍睹。


公众号:蓝海资料掘金营,微信deep3321