事务
版本演进
被问「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()
}
三个必须注意的点:
- 所有操作必须显式传
session—— 漏传的那个操作就在事务之外,这是最常见的 bug,而且不会报错 - 必须用
withTransaction()而不是手动startTransaction/commitTransaction—— 前者内置了对TransientTransactionError和UnknownTransactionCommitResult的重试逻辑 - 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。 :::
性能代价:
- 长事务撑爆内存 —— WiredTiger 的 MVCC 版本存在内存里(不像 InnoDB 存磁盘 undo log)。长事务会导致快照无法释放、cache 被历史版本占满,进而触发应用线程参与 eviction,整个实例的延迟都会恶化
- 分布式事务更贵 —— 分片集群下走两阶段提交,跨分片的协调开销明显
- 写冲突重试放大延迟 —— 高冲突场景下重试次数上升
设计建议
官方和社区的共识是:把多文档事务当成「逃生舱」而不是常规工具。
替代方案按优先级:
- 重新建模,把数据嵌入同一文档 —— 这是 MongoDB 的正道。订单和订单明细、文章和评论(数量可控时),都该嵌在一起
- 用单文档的条件更新实现乐观锁 ——
updateOne({_id, version: v}, {$set: {...}, $inc: {version: 1}}) - 两阶段提交的应用层实现 —— 在文档里记状态机(
pending→committed→done)+ 定时任务补偿。这是 4.0 之前的标准做法,现在仍适用于超过 60 秒的长流程 - 最后才用多文档事务
:::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。