深访收官。1.4 节说过 NoSQL 曾以"无事务"为标志,MongoDB 用 4.0(复制集多文档事务)与 4.8(分片事务)两步补齐了这块招牌。本节讲清它的能力范围、性能代价,以及更重要的工程判断:什么时候不该用事务。这是 3.4 分布式事务原理在文档模型上的直接落地。
MongoDB 事务的三个边界:范围上支持跨多文档、多集合、多数据库的操作(分片事务还能跨分片);隔离上提供快照隔离(事务读到的是开始时的一致快照,配合写冲突检测);时限上有默认执行超时(避免长事务拖垮集群),超时自动中止。
// 事务三步:开事务 → 读写 → 提交(异常自动中止) const session = db.getMongo().startSession() session.startTransaction({ readConcern: { level: "snapshot" }, // 快照读 writeConcern: { w: "majority" } // 多数派确认(呼应 4.5) }) try { const accounts = session.getDatabase("shop").accounts accounts.updateOne({ _id: "A" }, { $inc: { balance: -100 } }) accounts.updateOne({ _id: "B" }, { $inc: { balance: 100 } }) session.commitTransaction() } catch (e) { session.abortTransaction() // 任一步失败全部回滚 } finally { session.endSession() }
官方建议先反问"真的需要事务吗",背后是三笔真实的账。锁与冲突:事务持有写锁直到提交,长事务阻塞其他写入,冲突高发时互相重试、吞吐骤降。oplog 与内存:事务的中间状态写入需要暂存(内存),提交后才进 oplog,大事务对内存与 oplog 窗口(4.5)都是冲击。延迟叠加:多数派确认 + 协调开销,分片事务的延迟是普通写入的数倍起。所以事务的正确打开方式不是"凡是多步都包事务",而是能缩则缩:先看单文档原子性够不够,再看条件更新够不够,最后才考虑事务。
背景:账户集合存放余额,需求是转账 100 元从 A 到 B,要求任何时刻不出现"钱凭空消失"。
操作:路径一是事务版(上面代码):开事务、两次 $inc、提交,语义最直白,冲突低的中后台场景完全够用。路径二是设计规避版:把余额账本改为账目流水——转账不是修改两个余额,而是插入一条流水 {from: A, to: B, amount: 100, state: "PENDING"}(单文档插入天然原子);A、B 余额按流水异步汇总或由定时任务结算;PENDING 流水由重试器推进到 DONE,失败则标记 FAILED 并告警。余额视图是流水的投影,账目永不矛盾。
结果:事务版在高并发(同账户被密集读写)下吞吐明显低于流水版;流水版实现量更大但把热点写(余额行)摊成了追加写(流水),秒杀级并发下稳定。
解读:两条路径的取舍就是 3.4 的延续——事务版付锁与延迟,换来最少的业务代码;流水版用事件溯源思想把"修改"变"追加",单文档原子性覆盖正确性,以查询复杂度换并发容量。选哪条看两个量:并发强度与团队对流水模型的熟悉度。
变式:若转账还要联动库存与积分(跨集合、跨服务),MongoDB 事务也覆盖不了跨服务边界——那已经是 3.4 Saga 的领地,本地事务 + 事件 + 补偿才是答案。
第一个是事务里做外部调用:事务中调 HTTP 接口或长计算,超时中止后外部效果无法回滚——事务只管数据库内的操作,副作用一律出事务。第二个是把事务当万能补丁:先建模出"多处同时改"的脆弱结构,再用事务兜底,正确顺序是建模先行(把强一致数据聚到一个文档)。第三个是大事务:一次事务改十万文档,时限、内存、oplog 三处同时告急;大任务分批小事务,每批留断点。第四个是忽视重试:写冲突会以异常形式抛出,客户端要捕获并重试整个事务(重试天然安全——快照隔离保证语义)。
MongoDB 从 4.0 起支持复制集内的多文档事务,4.2 起支持分片集群事务。但"支持"不等于"推荐随时使用"。
| 维度 | 单文档写入 | 多文档事务 |
|---|---|---|
| 原子性 | 天然保证 | 需显式开启事务 |
| 性能 | 最优 | 明显下降(需要维护事务状态与快照) |
| 锁与冲突 | 文档级,粒度小 | 事务期间持有锁,长事务会阻塞其他操作 |
| 超时 | 无 | 默认 60 秒,超时自动中止 |
| 适用场景 | 绝大多数业务操作 | 跨文档强一致(转账、库存与订单同生共死) |
const session = client.startSession(); try { session.withTransaction(async () => { const accounts = client.db("bank").collection("accounts"); // 扣款与入账在同一事务内,要么都成功要么都回滚 const out = await accounts.updateOne( { _id: "A", balance: { $gte: 500 } }, { $inc: { balance: -500 } }, { session } ); if (out.modifiedCount !== 1) throw new Error("余额不足或账户不存在,回滚"); await accounts.updateOne( { _id: "B" }, { $inc: { balance: 500 } }, { session } ); }, { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" }, readPreference: "primary" }); } finally { await session.endSession(); }
一条判断标准:如果一个操作在关系型里会用事务,而在 MongoDB 里你打算用事务——先问一句"能不能改成单文档"。能改,性能与复杂度都会好很多;不能改(如跨账户转账),事务就是正确答案,别硬造单文档方案。
MongoDB 深访到此完整收官:模型、操作、优化、复制、分片、聚合、事务,文档门派的工程主干已经走完。下一章转场键值门派,看 Redis 如何用另一套哲学回答同样的问题。