1. 项目背景
业务场景:本地生活电商上线了"限时抢优惠券"功能。用户在活动页点击领取,后端需要在同一个原子操作中完成三件事:① 校验优惠券库存是否充足并扣减 1;② 在用户领取记录中插入一条;③ 更新用户的优惠券列表。起初开发认为 MongoDB 的单文档原子性足以应付——把优惠券、领取记录、用户优惠券列表全嵌在一个文档里。结果用户反馈"领券成功了但优惠券列表没更新"、"库存扣了但没发券"。更严重的是,一次系统异常重启导致部分领取记录丢失,财务对账时发现优惠券总核销量比领取量少了一大批,索赔无从追溯。
痛点:不懂 MongoDB 事务边界的团队,常用以下错误方式"保证一致性":把跨文档操作塞进一个超大的嵌入文档——文档越来越臃肿,更新放大、性能下降;在应用层手动实现"先 A 后 B,失败则回滚 A"的补偿逻辑——代码复杂且补偿失败的概率不低;误解单文档原子性可以覆盖所有场景——当业务存在跨文档的因果写入时,必须借助多文档事务。
2. 项目设计
小胖(举着手机满屏报错截图):大师!领券功能上线后天天被投诉——用户说券领了但库存没减,有人说减了库存但没收到券。我查了下代码,是先更新库存再插领取记录,但中间如果出错了,第一个操作根本不回滚!
大师:这是典型的跨文档一致性需求。MongoDB 4.0 开始支持多文档事务(Multi-Document Transactions),专门解决你这个问题。你可以把库存扣减和领取记录写入包在一个事务里——要么同时成功,要么同时失败。
小胖:但我听同事说 MongoDB 的事务很弱,跟 MySQL 的 ACID 没法比,最好别用。
大师:这是一个流传很广的误解。早期 MongoDB 确实不支持多文档事务,但从 4.0 开始,它在技术上已经具备完整的 ACID 事务能力。关键区别在于设计哲学——MySQL 鼓励用事务保护所有关联操作,MongoDB 鼓励通过文档设计减少跨文档事务的需求。事务是最后的手段,不是首选。
技术映射:MongoDB 的事务基于 WiredTiger 的 MVCC(多版本并发控制)实现,提供快照隔离级别。事务生命周期通过 session(会话)管理——先 startSession(),再在会话中 startTransaction()。
小白:那事务的核心 API 是什么?跟 SQL 的 BEGIN/COMMIT/ROLLBACK 一样吗?
大师:结构非常相似,改个名字而已:
| SQL 事务 | MongoDB 事务 |
|---|---|
BEGIN |
session.startTransaction() |
| 执行 INSERT/UPDATE/DELETE | 执行正常的 CRUD 操作 |
COMMIT |
session.commitTransaction() |
ROLLBACK |
session.abortTransaction() |
小胖:那 readConcern、writeConcern、readPreference 这三个是什么?我在同事代码里经常看到。
大师:这是 MongoDB 事务和普通操作都涉及的三个一致性控制维度,但很多人在完全搞懂之前就开始乱配。先说结论:如果你是写业务代码的开发,90% 的场景默认设置就够用。但在下结论之前,我们得知道每个选项的含义:
| 控制维度 | 含义 | 关键选项 |
|---|---|---|
writeConcern |
写操作需要多少个节点确认才算成功 | { w: "majority" } 多数节点确认;{ w: 1 } 主节点确认即可 |
readConcern |
读操作能看到哪个时间点的数据 | "local" 最新但可能回滚;"majority" 多数确认过的数据;"snapshot" 事务专用快照 |
readPreference |
读操作从哪个节点读取 | "primary" 只读主节点;"secondary" 从库读;"nearest" 最低延迟节点 |
小白:那下单后从从库查不到订单,跟 readConcern 有什么关系?
大师:这个问题背后的场景是——你在 Primary 上写了一条订单,writeConcern 是 { w: 1 }(主节点确认就返回),然后立刻在 Secondary 上查这条订单。writeConcern 只要求主节点写入,但主节点还没来得及把数据复制到 Secondary,所以从库查不到。
技术映射:这就是"读己之写"问题的根源。解决方式有两类:① 读取时指定 readConcern: "majority",等数据复制到多数节点后再返回——增加延迟但保证一致性;② 读操作仍从 Primary 走(readPreference: "primary"),写入成功后直接能从主节点读到最新数据。
小胖:那事务重试呢?如果网络抖动导致事务提交失败,代码应该自动重试吗?
大师:对,而且重试时必须注意幂等性——事务里包含的 insertOne 如果生成了 ObjectId 并且被重试执行两次,可能导致插入两条记录(因为 ObjectId 不同)。所以正确的做法是:使用业务唯一键(如 couponId + userId)配合唯一索引,让重试的插入因为唯一约束冲突而优雅失败,而非产生重复数据。
大师(总结):事务重试 + 幂等唯一键 + 适度的一致性配置,是 MongoDB 事务的铁三角。另外记住一个原则——不要把事务当锁用。事务期间持有的锁会阻塞其他请求,长事务是生产性能的头号杀手(超过 60 秒的事务会被自动中止)。
3. 项目实战
3.1 环境准备
沿用 Docker 环境。MongoDB 8.0 事务默认支持(复制集环境),单节点也支持事务(无高可用保证)。
docker compose -f mongodb-lab/docker-compose.yml ps
3.2 分步实现
步骤一:准备数据并测试单文档原子性
目标:验证 insertOne 的单文档原子写入。
use local_life
// 优惠券库存
db.coupons_txn.drop()
db.coupons_txn.insertOne({
_id: "COUPON_NEWYEAR",
name: "新年满减券",
totalStock: 100,
remainingStock: 100,
faceValue: NumberDecimal("50.00"),
minOrderAmount: NumberDecimal("200.00"),
validFrom: new Date("2026-01-01"),
validTo: new Date("2026-06-30"),
status: "active"
})
// 用户优惠券钱包
db.user_coupons_txn.drop()
db.user_coupons_txn.insertOne({
userId: "USER_001",
couponList: [], // 用户持有的优惠券列表
stats: { totalReceived: 0, totalUsed: 0 }
})
// 领取记录(独立集合,用于审计和对账)
db.coupon_records_txn.drop()
db.coupon_records_txn.createIndex({ userId: 1, couponId: 1 }, { unique: true })
db.coupon_records_txn.createIndex({ createdAt: -1 })
print("数据准备完成")
步骤二:多文档事务——领取优惠券
目标:在事务中原子完成库存扣减 + 记录写入 + 用户钱包更新。
// 领取优惠券的事务函数
function claimCoupon(userId, couponId) {
const session = db.getMongo().startSession()
try {
session.startTransaction({
readConcern: { level: "snapshot" }, // 事务内快照读
writeConcern: { w: "majority" } // 多数节点确认
})
const couponsColl = session.getDatabase("local_life").coupons_txn
const recordsColl = session.getDatabase("local_life").coupon_records_txn
const userColl = session.getDatabase("local_life").user_coupons_txn
// 步骤 1:原子扣减库存(条件检查:库存 > 0)
const deductResult = couponsColl.updateOne(
{ _id: couponId, remainingStock: { $gt: 0 } },
{ $inc: { remainingStock: -1 } },
{ session }
)
if (deductResult.modifiedCount === 0) {
throw new Error("库存不足或优惠券不存在")
}
// 步骤 2:插入领取记录(唯一索引防重复)
const record = {
userId: userId,
couponId: couponId,
status: "received",
claimedAt: new Date()
}
recordsColl.insertOne(record, { session })
// 步骤 3:更新用户钱包
userColl.updateOne(
{ userId: userId },
{
$push: {
couponList: {
couponId: couponId,
claimedAt: new Date(),
status: "unused"
}
},
$inc: { "stats.totalReceived": 1 }
},
{ session }
)
// 提交事务
session.commitTransaction()
print("领取成功:", userId, "→", couponId)
return { success: true }
} catch (e) {
// 手动回滚
session.abortTransaction()
print("领取失败,事务已回滚:", e.message)
return { success: false, error: e.message }
} finally {
session.endSession()
}
}
// 测试:正常领取
claimCoupon("USER_001", "COUPON_NEWYEAR")
// 验证结果
const coupon = db.coupons_txn.findOne({ _id: "COUPON_NEWYEAR" })
const user = db.user_coupons_txn.findOne({ userId: "USER_001" })
const records = db.coupon_records_txn.find({ userId: "USER_001" }).toArray()
print("剩余库存:", coupon.remainingStock)
print("用户持有券数:", user.couponList.length)
print("领取记录数:", records.length)
步骤三:模拟事务失败与自动回滚
目标:验证当中间步骤失败时,事务整体回滚。
// 模拟库存耗尽的场景
function claimCouponWithRetry(userId, couponId, maxRetries = 3) {
let retryCount = 0
while (retryCount < maxRetries) {
const session = db.getMongo().startSession()
try {
session.startTransaction({
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" }
})
const couponsColl = session.getDatabase("local_life").coupons_txn
const recordsColl = session.getDatabase("local_life").coupon_records_txn
const userColl = session.getDatabase("local_life").user_coupons_txn
// 扣库存
const deduct = couponsColl.updateOne(
{ _id: couponId, remainingStock: { $gt: 0 } },
{ $inc: { remainingStock: -1 } },
{ session }
)
if (deduct.modifiedCount === 0) throw new Error("库存不足")
// 插记录——如果已存在(幂等唯一索引冲突),回滚
recordsColl.insertOne({
userId, couponId, status: "received", claimedAt: new Date()
}, { session })
// 更新用户
userColl.updateOne(
{ userId },
{ $push: { couponList: { couponId, claimedAt: new Date(), status: "unused" } },
$inc: { "stats.totalReceived": 1 } },
{ session }
)
session.commitTransaction()
return { success: true, retries: retryCount }
} catch (e) {
session.abortTransaction()
retryCount++
if (e.message.includes("库存不足")) {
return { success: false, error: "库存不足", retries: retryCount }
}
// 其他错误(如网络抖动),重试
if (retryCount >= maxRetries) {
return { success: false, error: e.message, retries: retryCount }
}
print(`重试 ${retryCount}...`)
} finally {
session.endSession()
}
}
}
// 先把库存减到 1,然后两个用户同时抢(第二个抢到失败)
db.coupons_txn.updateOne({ _id: "COUPON_NEWYEAR" }, { $set: { remainingStock: 1 } })
const r1 = claimCouponWithRetry("USER_002", "COUPON_NEWYEAR")
const r2 = claimCouponWithRetry("USER_003", "COUPON_NEWYEAR")
print("用户002:", JSON.stringify(r1))
print("用户003:", JSON.stringify(r2))
// 期望:一个成功,一个"库存不足"
// 验证数据一致性
const finalCoupon = db.coupons_txn.findOne({ _id: "COUPON_NEWYEAR" })
const finalRecords = db.coupon_records_txn.countDocuments({ couponId: "COUPON_NEWYEAR" })
print("最终库存:", finalCoupon.remainingStock, "| 领取记录:", finalRecords)
步骤四:事务重试与幂等写入
目标:演示幂等键如何让重试安全。
// 重置测试数据
db.coupons_txn.updateOne({ _id: "COUPON_NEWYEAR" }, { $set: { remainingStock: 5 } })
db.coupon_records_txn.deleteMany({})
db.user_coupons_txn.updateOne(
{ userId: "USER_001" },
{ $set: { couponList: [], "stats.totalReceived": 0 } }
)
// 模拟事务执行成功但网络回包丢失的场景:
// 服务端提交成功,但客户端没收到确认,触发重试
// 重试时 insertOne 因为 userId+couponId 唯一索引冲突而失败
// 但前提是重试逻辑里正确处理了唯一冲突
function claimCouponSafe(userId, couponId) {
const session = db.getMongo().startSession()
try {
session.startTransaction()
const c = session.getDatabase("local_life").coupons_txn
const r = session.getDatabase("local_life").coupon_records_txn
const u = session.getDatabase("local_life").user_coupons_txn
c.updateOne(
{ _id: couponId, remainingStock: { $gt: 0 } },
{ $inc: { remainingStock: -1 } },
{ session }
)
try {
r.insertOne({ userId, couponId, status: "received", claimedAt: new Date() }, { session })
} catch (e) {
if (e.code === 11000) { // DuplicateKey
print("幂等拦截:重复领取,视为成功")
// 库存在前一步已扣,视为重试生效,不插入新记录
} else {
throw e
}
}
u.updateOne(
{ userId },
{ $addToSet: { couponList: { couponId, claimedAt: new Date(), status: "unused" } },
$inc: { "stats.totalReceived": 1 } },
{ session }
)
session.commitTransaction()
return { success: true }
} catch (e) {
session.abortTransaction()
return { success: false, error: e.message }
} finally {
session.endSession()
}
}
// 模拟双重请求(同一用户同一券)
const res1 = claimCouponSafe("USER_001", "COUPON_NEWYEAR")
print("第1次:", JSON.stringify(res1))
const res2 = claimCouponSafe("USER_001", "COUPON_NEWYEAR")
print("第2次:", JSON.stringify(res2))
// 第2次库存扣减成功(= 0),但因唯一索引冲突无需再插记录
// 最终用户只持有 1 张券
const finalCheck = db.user_coupons_txn.findOne({ userId: "USER_001" })
print("用户持券数(应为1):", finalCheck.couponList.length)
步骤五:长事务警告与 abort
目标:演示过大事务导致的问题。
const longSession = db.getMongo().startSession()
longSession.startTransaction()
// 模拟在事务中做大量操作
const coll = longSession.getDatabase("local_life").coupons_txn
// 在事务中更新时间来模拟长操作
coll.updateOne({ _id: "COUPON_NEWYEAR" }, { $set: { status: "locked" } }, { session: longSession })
print("事务已开启,持有锁...")
// 在另一个 mongosh 窗口中尝试查询:
// use local_life
// db.coupons_txn.findOne({ _id: "COUPON_NEWYEAR" })
// 发现查询可以返回(MongoDB 默认使用快照读,不会阻塞读),
// 但另一个事务尝试更新同一文档会被阻塞直到锁释放或超时
// MongoDB 默认事务超时 60 秒
// maxTransactionLockRequestTimeoutMillis 默认 5ms(锁等待超时)
// 释放事务
longSession.commitTransaction()
longSession.endSession()
print("事务已释放")
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/scripts/ch11-prepare-data.js |
准备优惠券测试数据 |
mongodb-lab/scripts/ch11-transaction-demo.js |
多文档事务演示 |
mongodb-lab/scripts/ch11-retry-idempotent.js |
事务重试与幂等 |
mongodb-lab/scripts/ch11-long-transaction.js |
长事务模拟 |
3.4 测试验证
use local_life
// 1. 验证正常事务:库存、记录、钱包 三个集合数据一致
const coupon = db.coupons_txn.findOne({ _id: "COUPON_NEWYEAR" })
const totalRecorded = db.coupon_records_txn.countDocuments({ couponId: "COUPON_NEWYEAR" })
const deducted = 100 - coupon.remainingStock
print("库存已扣:", deducted, "| 记录数:", totalRecorded,
deducted >= totalRecorded ? "PASS (记录 ≤ 扣减)" : "FAIL")
// 2. 验证幂等:同一用户同一券只能有一条领取记录
const count = db.coupon_records_txn.find({
userId: "USER_001", couponId: "COUPON_NEWYEAR"
}).count()
print("USER_001 + COUPON_NEWYEAR 记录数:", count, count <= 1 ? "PASS" : "FAIL")
// 3. 验证原子回滚:库存为 0 时事务不应该产生任何副作用
db.coupons_txn.updateOne({ _id: "COUPON_NEWYEAR" }, { $set: { remainingStock: 0 } })
const beforeRecord = db.coupon_records_txn.countDocuments({})
const result = claimCoupon("USER_999", "COUPON_NEWYEAR")
const afterRecord = db.coupon_records_txn.countDocuments({})
print("库存 0 时, 记录数不变:", beforeRecord === afterRecord ? "PASS" : "FAIL")
// 4. 恢复测试数据
db.coupons_txn.updateOne({ _id: "COUPON_NEWYEAR" }, { $set: { remainingStock: 100, status: "active" } })
print("\n=== 全部验证通过 ===")
4. 项目总结
4.1 MongoDB 事务 vs MySQL 事务
| 维度 | MongoDB | MySQL (InnoDB) | 说明 |
|---|---|---|---|
| 多文档事务 | 支持(4.0+) | 天生支持 | MongoDB 起步晚但已成熟 |
| 隔离级别 | 快照隔离(Snapshot) | 四种隔离级别 | MongoDB 只有快照隔离一种 |
| 锁粒度 | 文档级(WiredTiger) | 行级/表级 | MongoDB 细粒度更优 |
| 事务大小 | 有限制(不建议过大) | 理论上无上限 | MongoDB 大事务影响 Oplog |
| 长事务 | 不推荐(> 60s 自动 abort) | 可配置但也不推荐 | MongoDB 更严格 |
| 分布式事务 | 跨分片事务支持(4.2+) | 需 XA 协议 | MongoDB 分片事务性能逊于单分片 |
4.2 适用场景
MongoDB 事务适用:
- 优惠券/积分/库存的因果写入——领取、扣减、记录、通知需原子完成。
- 订单创建——订单主表 + 订单明细 + 库存预扣 + 优惠券核销。
- 账户余额变更——转账(转出方扣款 + 转入方加款)。
- 数据迁移的多表同步写入——保证新旧数据不完全错位。
- 配置变更的多集合原子更新——多个配置集合需同时生效。
不适用场景:
- 高频秒杀——事务锁等待会成为瓶颈,应优先用原子操作符 + 单文档更新。
- 批量数据处理——如全量迁移需要处理百万级数据,事务范围过大,用批量写入 + 补偿逻辑更合理。
- 报表统计——统计查询不需要事务,用
readConcern: "majority"保证数据一致性即可。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
| 事务默认 60 秒超时 | 超过自动 abort,通过 transactionLifetimeLimitSeconds 调整 |
| 不要在事务中做 IO | HTTP 调用、文件读写放在事务外,缩短事务持锁时间 |
| 幂等键必加唯一索引 | 配合事务重试,唯一索引是实现幂等的最可靠方式 |
writeConcern: majority |
写操作需多数节点确认后才能提交,确保不因主节点宕机丢失 |
| 分片集群事务开销 | 跨分片事务比单分片慢 3-5 倍,分片键设计尽量让事务内所有操作落在同一分片 |
4.4 常见踩坑经验
故障案例一:事务内空更新导致静默失败
某抢券系统在事务内执行 updateOne({_id: couponId, stock: {$gt: 0}}, {$inc: {stock: -1}}),当库存为 0 时 modifiedCount === 0,但被判断为"更新成功"——因为 MongoDB 的 updateOne 在不匹配时返回 { matchedCount: 0, modifiedCount: 0 } 而非报错。事务继续执行了后续的插入记录和发券,产生了无库存却发券的 bug。根因:必须显式检查 modifiedCount > 0,否则主动 throw 回滚事务。
故障案例二:重试时忘记重建 session
某团队事务重试代码写为 while(true) { try { commit() } catch(e) { retry++ } },但 commit 失败后没有调用 session.abortTransaction() 就重试——导致 TransientTransactionError 叠加脏 session 状态,重试永远失败。根因:每次重试前必须 abortTransaction() 清理 session 状态,或 endSession() 后重建 session。
故障案例三:Oplog 写满导致从库延迟
某电商在促销期间大量使用跨文档事务,每个事务在 Oplog 中产生多条记录。Oplog 窗口从正常的 2 小时压缩到 15 分钟,导致一个执行慢的从库追不上——触发 Initial Sync(全量重新同步),影响线上服务。根因:Oplog 大小未根据事务量调整。解决:增加 Oplog 大小至 24 小时窗口;减少事务中的操作数量,尽量单文档原子化。
4.5 思考题
- 如果在事务内部,先 insert 再 update 同一文档,然后在 commit 前发生 WriteConflictException,会重试整个事务还是只重试冲突的操作?
- 为什么 MongoDB 不支持 READ UNCOMMITTED 隔离级别?快照隔离(Snapshot Isolation)和 MySQL 的 REPEATABLE READ 有何本质区别?
(答案将在第 12 章末尾揭晓)
上一章思考题答案:
计算近 7 天 GMV 增长最快的 10 个类目:需两个时间窗口——本周(近 7 天)和上周(7-14 天),分别按类目聚合计 GMV,然后通过
$lookup或应用层计算增长率。聚合管道方案:$facet中两个子管道分别统计本周和上周 GMV →$unwind并$project计算增长率 →$sort→$limit。若数据量非常大,建议用预计算写入宽表,避免实时对比两个大时间窗口。
$lookuppipeline 版本优势:① 支持对关联子结果做$sort+$limit(如只取最新一条评论),传统写法无法限制数量;② 支持对关联子结果做$match过滤(如只看五星评论),传统写法只能全量拉回;③ 支持关联子结果做字段转换和计算($addFields),灵活度远超传统写法。同时 pipeline 版本在 MongoDB 5.0+ 中可以把$match和$sort下推到外表索引,与传统写法的性能差距缩小。
延伸阅读与资源
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号