跳到主要内容

事务

版本演进​

被问「MongoDB 支持事务吗」,要分三层回答:

版本能力
一直都有单文档操作原子性(包括嵌套文档和数组的修改)
4.0副本集内的多文档 ACID 事务
4.2分片集群的分布式多文档事务
4.4事务中可以创建集合和索引

所以准确答案是:MongoDB 支持 ACID 事务,但它的设计哲学是「通过文档模型让你尽量不需要事务」。

单文档原子性​

这是最该强调的一点。把需要一起更新的数据嵌入同一个文档,更新就是原子的,不需要任何事务机制:

// 一次原子操作:扣库存 + 加销量 + 追加日志
db.products.updateOne(
{_id: pid, stock: {$gte: 1}},
{
$inc: {stock: -1, sold: 1},
$push: {logs: {time: new Date(), action: "sell"}}
}
)

这个写法还顺带实现了乐观锁 —— stock: {$gte: 1} 作为条件,库存不足时 matchedCount 为 0,天然避免超卖。和 MySQL 的 UPDATE ... WHERE num >= 1 是同一个思路。

所以建模时的一个核心考虑就是:哪些数据需要一起原子更新?把它们放进同一个文档。

多文档事务怎么用​

const session = client.startSession()
try {
await session.withTransaction(async () => {
await accounts.updateOne({_id: "A"}, {$inc: {balance: -100}}, {session})
await accounts.updateOne({_id: "B"}, {$inc: {balance: 100}}, {session})
}, {
readConcern: {level: "snapshot"},
writeConcern: {w: "majority"},
maxCommitTimeMS: 5000
})
} finally {
await session.endSession()
}

三个必须注意的点:

  1. 所有操作必须显式传 session —— 漏传的那个操作就在事务之外,这是最常见的 bug,而且不会报错
  2. 必须用 withTransaction() 而不是手动 startTransaction / commitTransaction —— 前者内置了对 TransientTransactionError 和 UnknownTransactionCommitResult 的重试逻辑
  3. readConcern 用 snapshot、writeConcern 用 majority 才是完整的 ACID 保证

隔离级别​

MongoDB 事务提供 快照隔离(Snapshot Isolation),对应 WiredTiger 的 MVCC:

  • 事务开始时获取一个快照,整个事务期间看到的是同一个一致性视图
  • 读不加锁,不会脏读、不可重复读
  • 写冲突时后提交的事务失败(乐观并发控制),而不是像 InnoDB 那样阻塞等待

和 MySQL 对照:

MongoDB 事务InnoDB
隔离级别快照隔离(≈ RR)默认 RR
并发控制乐观 —— 冲突即失败,应用重试悲观 —— 加锁阻塞等待
事务外的读readConcern 决定总是 MVCC 快照读
幻读快照隔离天然避免靠间隙锁
死锁不会(冲突直接失败)会,有死锁检测

「不会死锁」是乐观并发的好处,但代价是应用必须处理重试。 这个取舍是高频追问点。

限制与代价​

:::warning 事务的硬限制

限制值
默认最长生命周期60 秒(transactionLifetimeLimitSeconds)
单个事务的 oplog 条目16MB(4.2+ 可拆成多条,限制放宽)
事务中不能做的事不能操作 config/admin/local 库;4.4 之前不能创建集合或索引
存储引擎必须是 WiredTiger
部署形态必须是副本集或分片集群,单机 mongod 不支持

60 秒限制意味着不能用事务做批量数据处理。超时会直接 abort。 :::

性能代价:

  1. 长事务撑爆内存 —— WiredTiger 的 MVCC 版本存在内存里(不像 InnoDB 存磁盘 undo log)。长事务会导致快照无法释放、cache 被历史版本占满,进而触发应用线程参与 eviction,整个实例的延迟都会恶化
  2. 分布式事务更贵 —— 分片集群下走两阶段提交,跨分片的协调开销明显
  3. 写冲突重试放大延迟 —— 高冲突场景下重试次数上升

设计建议​

官方和社区的共识是:把多文档事务当成「逃生舱」而不是常规工具。

替代方案按优先级:

  1. 重新建模,把数据嵌入同一文档 —— 这是 MongoDB 的正道。订单和订单明细、文章和评论(数量可控时),都该嵌在一起
  2. 用单文档的条件更新实现乐观锁 —— updateOne({_id, version: v}, {$set: {...}, $inc: {version: 1}})
  3. 两阶段提交的应用层实现 —— 在文档里记状态机(pending → committed → done)+ 定时任务补偿。这是 4.0 之前的标准做法,现在仍适用于超过 60 秒的长流程
  4. 最后才用多文档事务

:::tip 这题的得分点 被问「MongoDB 事务和 MySQL 事务的区别」,不要只答「MongoDB 4.0 才支持」。真正的区别是设计取向:

  • MySQL 的事务是基础设施 —— 范式化拆表之后,跨表一致性只能靠事务,所以事务必须又快又强
  • MongoDB 的事务是补充 —— 文档模型的前提是「一个业务对象一个文档」,需要跨文档事务通常说明建模没贴合访问模式

所以在 MongoDB 里「大量使用事务」本身就是一个设计警告信号。 :::

Change Streams​

顺带一个高频相关问题:怎么感知数据变更?

watch() 基于 oplog 提供变更流,可以订阅集合 / 库 / 整个集群的变更:

const stream = db.orders.watch([
{$match: {operationType: {$in: ["insert", "update"]}}}
])
for await (const change of stream) {
console.log(change.operationType, change.documentKey, change.updateDescription)
}

要点:

  • 相当于 MySQL 的 binlog 订阅(Canal),但是官方内置能力
  • 要求副本集或分片集群(依赖 oplog)
  • 返回的事件只在被多数派确认后才推送,所以不会推送后来被回滚的变更
  • 支持 resumeToken 断点续传,服务重启不丢事件(前提是 oplog 窗口内)
  • 典型用途:缓存失效、同步到 ES / 数仓、审计、事件驱动架构

这个能力是 MongoDB 相对 MySQL 的一个实际优势 —— 不需要额外部署 Canal / Debezium。