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 事务适用

  1. 优惠券/积分/库存的因果写入——领取、扣减、记录、通知需原子完成。
  2. 订单创建——订单主表 + 订单明细 + 库存预扣 + 优惠券核销。
  3. 账户余额变更——转账(转出方扣款 + 转入方加款)。
  4. 数据迁移的多表同步写入——保证新旧数据不完全错位。
  5. 配置变更的多集合原子更新——多个配置集合需同时生效。

不适用场景

  1. 高频秒杀——事务锁等待会成为瓶颈,应优先用原子操作符 + 单文档更新。
  2. 批量数据处理——如全量迁移需要处理百万级数据,事务范围过大,用批量写入 + 补偿逻辑更合理。
  3. 报表统计——统计查询不需要事务,用 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 思考题

  1. 如果在事务内部,先 insert 再 update 同一文档,然后在 commit 前发生 WriteConflictException,会重试整个事务还是只重试冲突的操作?
  2. 为什么 MongoDB 不支持 READ UNCOMMITTED 隔离级别?快照隔离(Snapshot Isolation)和 MySQL 的 REPEATABLE READ 有何本质区别?

(答案将在第 12 章末尾揭晓)


上一章思考题答案

  1. 计算近 7 天 GMV 增长最快的 10 个类目:需两个时间窗口——本周(近 7 天)和上周(7-14 天),分别按类目聚合计 GMV,然后通过 $lookup 或应用层计算增长率。聚合管道方案:$facet 中两个子管道分别统计本周和上周 GMV → $unwind$project 计算增长率 → $sort$limit。若数据量非常大,建议用预计算写入宽表,避免实时对比两个大时间窗口。

  2. $lookup pipeline 版本优势:① 支持对关联子结果做 $sort + $limit(如只取最新一条评论),传统写法无法限制数量;② 支持对关联子结果做 $match 过滤(如只看五星评论),传统写法只能全量拉回;③ 支持关联子结果做字段转换和计算($addFields),灵活度远超传统写法。同时 pipeline 版本在 MongoDB 5.0+ 中可以把 $match$sort 下推到外表索引,与传统写法的性能差距缩小。

延伸阅读与资源

MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

大型语言模型(LLM) vLLM 高性能推理落地实战

Agent开发之LlamaIndex 实战修炼与源码进阶

大语言模型Transformers 实战修炼与源码剖析

posted on 2026-07-24 13:09  一天不进步,就是退步  阅读(7)  评论(0)    收藏  举报