4.8 事务支持:多文档 ACID


4.8 事务支持:多文档 ACID

深访收官。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(); }

五条事务使用准则

  1. 能不用就不用。 单文档原子性覆盖的场景(如把余额与流水放在同一文档内),性能远好于事务。建模阶段优先想办法把"需要一起变的数据"放进一个文档;
  2. 事务要短。 事务内的操作应控制在毫秒级,绝不在事务里做网络调用、等待用户输入或跑批量。长事务会持有锁并累积大量快照,拖慢整个实例;
  3. 务必处理重试。 事务可能因瞬时冲突(写冲突、分片迁移)被中止,驱动通常有自动重试机制,但业务代码要能识别 TransientTransactionError 并重试整个事务;
  4. 显式指定写关注。 生产环境用 w:majority,否则事务提交后仍可能因主节点切换而丢失;
  5. 先建集合再开事务。 在事务里隐式创建集合在部分版本上不支持,提前建好能避免一类隐蔽报错。

一条判断标准:如果一个操作在关系型里会用事务,而在 MongoDB 里你打算用事务——先问一句"能不能改成单文档"。能改,性能与复杂度都会好很多;不能改(如跨账户转账),事务就是正确答案,别硬造单文档方案。

本节要点回顾

  • 事务范围:多文档/集合/库/分片,快照隔离 + 多数派写关注,默认时限防长事务。
  • 三笔代价:锁冲突、内存暂存、延迟叠加——能不用就不用,官方原话。
  • 决策阶梯:单文档原子性 → 条件更新 → 事务 →(跨服务时)Saga
  • 设计规避的代表:账目流水 + 状态机,用追加写替热点修改。
  • 事务纪律:无外部副作用、控制体积、冲突重试、大任务分批。

MongoDB 深访到此完整收官:模型、操作、优化、复制、分片、聚合、事务,文档门派的工程主干已经走完。下一章转场键值门派,看 Redis 如何用另一套哲学回答同样的问题。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U