mongodb事务仅支持replica set或sharded cluster,不支持standalone模式;必须用session.withtransaction()封装,配合条件更新和snapshot隔离,且需避免事务内耗时操作。

事务必须在 replica set 或 sharded cluster 上启用
MongoDB 的事务不支持单节点 standalone 模式,哪怕只是本地开发测试,也得启动带 replSet 的副本集。否则调用 session.startTransaction() 会直接报错:Transaction numbers are only allowed on a replica set member or mongos。常见踩坑是用 mongod --port 27017 直接起服务,然后死活进不了事务——不是代码问题,是部署模式错了。
实操建议:
- 本地快速验证:用
mongod --replSet rs0 --port 27017 --dbpath /data/db启动,再连上执行rs.initiate() - 应用连接字符串里必须包含
?replicaSet=rs0,否则驱动无法识别事务上下文 - Sharded cluster 下事务只能跨分片操作同个数据库内的集合,不能跨库
转账必须用 session.withTransaction() 封装,不能手动 start/commit
直接调用 session.startTransaction() + session.commitTransaction() 看似可行,但一旦中间抛异常(比如网络中断、文档校验失败),commit 不会自动回滚,残留的未提交事务可能卡住写锁或引发数据不一致。MongoDB 官方明确推荐用 session.withTransaction() ——它内置重试逻辑和自动回滚,且对可重试错误(如 TransientTransactionError)默认重试一次。
关键点:
-
withTransaction()的回调函数必须返回一个 Promise(Node.js)或 await 所有 DB 操作,否则事务提前结束 - 回调内若 throw 错误,驱动自动 abort;若 return 值(非 Promise),也视为成功并 commit
- 不要在回调里捕获并吞掉所有错误,否则事务可能误判为成功
双向扣款需先查后更新,且必须加写锁防止并发覆盖
金融场景下,“A 扣 100,B 加 100”不是两个独立 update。如果 A 查余额是 500,B 查余额是 300,但两人同时发起转账,可能都通过余额校验,最终 A 扣成 400、B 加成 400,而不是预期的 A=400/B=400 → 实际变成 A=400/B=400(没错,但这是巧合);更危险的是,若 B 的加操作被 A 的扣操作覆盖,就彻底丢钱。
正确做法是用原子性操作+条件更新:
- 用
findOneAndUpdate()带filter(例如{ balance: { $gte: 100 } })确保扣款前余额充足 - 更新时用
$inc: { balance: -100 },避免读-改-写竞态 - 检查返回结果的
value字段是否非 null,为 null 说明条件不满足(余额不足或文档不存在),整个事务应失败
示例片段(Node.js + MongoDB Driver):
await session.withTransaction(async () => {
const a = await db.collection('accounts').findOneAndUpdate(
{ _id: 'A', balance: { $gte: 100 } },
{ $inc: { balance: -100 } },
{ session, returnDocument: 'after' }
);
if (!a.value) throw new Error('A balance insufficient');
const b = await db.collection('accounts').findOneAndUpdate(
{ _id: 'B' },
{ $inc: { balance: 100 } },
{ session, returnDocument: 'after' }
);
if (!b.value) throw new Error('B account not found');
});
超时与隔离级别:默认 snapshot 隔离够用,但得设合理 transactionLifetimeLimitSeconds
MongoDB 事务默认使用 snapshot 隔离,能防止脏读、不可重复读、幻读,对转账类场景足够。但要注意:事务生命周期受 transactionLifetimeLimitSeconds 限制(默认 60 秒),超时自动 abort。如果转账逻辑里混了外部 HTTP 调用、文件读写等耗时操作,极易触发超时。
应对方式:
- 把所有 DB 操作集中到事务回调内,外部依赖(如风控接口)必须在事务外完成并缓存结果
- 必要时在 replica set 配置中调大
transactionLifetimeLimitSeconds,但别超过 oplog 保留窗口(否则可能因 oplog 截断导致回滚失败) - 不要依赖事务内的时间戳做业务判断,snapshot 隔离下看到的是事务开始时刻的数据快照,不是实时值
真正容易被忽略的是:事务不是银弹。它解决的是单次操作的原子性,但不保证最终一致性——比如转账成功后消息队列投递失败,下游记账没跟上,这得靠补偿或 saga 模式,不是加个 withTransaction() 就万事大吉。











