1. 项目背景

业务场景:本地生活电商的双十一活动期间,DBA 发现 MongoDB 的锁等待指标飙升——locks.Global.acquireWaitCount 在一个小时内从 0 涨到 8 万。排查发现——秒杀活动中优惠券领取的事务(第 11 章)在高并发下产生了严重的写冲突。更具体的说,两个事务同时尝试更新同一个优惠券的 remainingStock 字段——WiredTiger 的 MVCC 检测到冲突——其中一个事务被重试了 3 次才成功。但事务重试的代价是等待和 CPU 开销——大量并发事务导致 CPU 100% 且锁等待队列越来越长。

还有一个更底层的问题——为什么 MongoDB 的单文档操作不需要锁?$inc 做原子库存扣减是怎么在 1000 个并发下不冲突的?答案在 WiredTiger 的 MVCC 和文档级锁实现中。

痛点:不理解锁和并发控制的内部机制——长事务为什么会阻塞其他写入、全局锁和文档锁的粒度区别、WriteConflictException 为什么会触发事务重试、WiredTiger 的 MVCC 快照隔离与 MySQL 的行锁有何本质不同——就无从诊断高并发下的性能瓶颈。

2. 项目设计

小胖(看着 Grafana 的锁等待曲线):大师!锁等待怎么飙到 8 万了?我们的操作大部分是单文档的 $inc,应该不需要锁吧?

大师:单文档操作仍然需要锁——但 MongoDB 的锁粒度很细,不是你想的那种"全局大锁"。MongoDB 的锁分层——从粗到细:Global Lock(全局锁)> Database Lock(库锁)> Collection Lock(集合锁)> Document Lock(文档锁,WiredTiger 内部实现)。大部分 CRUD 操作只需要意向锁(Intent Lock)在高层——实际的并发控制发生在 WiredTiger 的文档级 MVCC 中。

技术映射:MongoDB 的"锁"分为两个层次——① MongoDB Server 层的锁(全局/库/集合意向锁,用于操作协调);② WiredTiger 层的锁(文档级,基于 MVCC 的快照隔离)。$inc 在 MongoDB 层只需库级意向锁(IS/IX),真正的并发控制在 WiredTiger 文档级完成。

小胖:MVCC 又是啥?跟 MySQL 的 MVCC 一样吗?

大师:MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想是"读不阻塞写,写不阻塞读"。每个操作看到的是数据库的一个"快照"——在这个快照的时间点上数据是一致的。如果两个写操作同时修改同一个文档——第二个提交的会发现自己的快照版本已经"过期"了——触发 WriteConflictException——WiredTiger 自动重试后面的那个写入。

技术映射:WiredTiger 使用基于 Timestamp 的快照隔离。每个事务有一个 read timestamp(看到哪个时间点的数据)和一个 commit timestamp(提交时分配的时间戳)。冲突检测发生在 commit 时——如果事务在 read timestamp 到 commit timestamp 之间读过的数据被其他事务修改了——提交失败(WriteConflict)。

小白:那 MongoDB 的事务(multi-document transaction)的锁行为跟单文档有什么不同?

大师:多文档事务的锁粒度更粗、持有时间更长。事务期间获取的锁在 commitTransaction 之后才释放——其他需要相同锁的操作会被阻塞。这也是为什么 MongoDB 官方强烈建议"事务尽可能小、尽可能短"——一个 30 秒的事务可能阻塞数百个其他正常请求。

技术映射:事务中的写入在 WiredTiger 层生成 prepare timestamp(准备时间戳),在 commit 时才分配实际的 commit timestamp。事务期间的 Oplog 条目在事务提交时才写入——这保证了事务的原子性(要么全部可见,要么全部不可见)。

小胖:那我们秒杀活动的 WriteConflictException 是不是事务导致的?

大师:不一定。高并发下,即便不用事务——纯粹的 $inc 也会触发 WriteConflict。每次 $inc 在 WiredTiger 中是一个 read-modify-write 操作——读取当前值 → 自增 → 写回。如果两个线程同时执行——第二个在写回时发现文档已经被第一个改了(版本号变了)——WiredTiger 自动重试第二个的整个操作。这是正常行为,但如果并发度极高,重试次数多了就表现为性能下降。

大师(总结):三层并发控制——MongoDB Server 层的意向锁(低开销),WiredTiger 的文档级 MVCC(高并发),多文档事务的锁持有期(慎用长事务)。WriteConflict 是 MVCC 的正常机制,它保证了并发安全性而无需加锁——代价是高冲突时消耗 CPU 重试。

3. 项目实战

3.1 环境准备

沿用 Docker MongoDB 环境(复制集以支持事务)。

3.2 分步实现

步骤一:观察锁统计指标

use admin
var s = db.serverStatus()
var locks = s.locks

print("=== 锁统计 ===")
print("Global:")
print("  获取(R):", locks.Global.acquireCount?.r || 0)
print("  获取(W):", locks.Global.acquireCount?.w || 0)
print("  等待(R):", locks.Global.acquireWaitCount?.r || 0)
print("  等待(W):", locks.Global.acquireWaitCount?.w || 0)
print("  等待时间(W ms):", locks.Global.timeAcquiringMicros?.w / 1000 || 0)

print("\nDatabase (local_life):")
if (locks.Database) {
  var dbLocks = locks.Database
  var dbKey = Object.keys(dbLocks).find(function(k) { return k.includes("local_life") })
  if (dbKey) {
    print("  等待(R):", dbLocks[dbKey].acquireWaitCount?.r || 0)
    print("  等待(W):", dbLocks[dbKey].acquireWaitCount?.w || 0)
  }
}

// 解释:Global lock 的 W 等待不为 0 → 有全局写锁争用(通常是元数据变更或索引创建)
// Database lock 的 W 等待不为 0 → 该库上有操作在排队等锁

// 查看 WiredTiger 的事务冲突统计
var wt = s.wiredTiger
print("\nWiredTiger 事务冲突:")
print("  冲突总数:", wt.transaction?.["transaction conflicts"] || 0)
print("  写冲突:", wt.transaction?.["write conflicts"] || 0)
// write conflicts > 0 意味着 MVCC 检测到并发写冲突——WiredTiger 自动重试

步骤二:构造高并发写冲突场景

目标:观察 WriteConflictException 在并发下的触发频率。

// 准备:重置一个计数文档
use local_life
db.conflict_test.drop()
db.conflict_test.insertOne({ _id: "counter", value: 0 })

// 模拟高并发下的 $inc 操作(单文档原子更新)
// 在 mongosh 中无法真实并发,这里模拟多次快速的 $inc 来理解概念
var conflictCount = 0
for (var i = 0; i < 1000; i++) {
  try {
    db.conflict_test.updateOne(
      { _id: "counter" },
      { $inc: { value: 1 } }
    )
  } catch(e) {
    if (e.code === 112) {  // WriteConflict 错误码
      conflictCount++
    }
  }
}
print("单线程模拟——写冲突通常不可见(WiredTiger 内部重试)")
print("最终 value:", db.conflict_test.findOne({ _id: "counter" }).value)

// 真正的并发写冲突需要多客户端压测——用线程/进程并发 updateOne
// 压测后通过 serverStatus 查看 wiredTiger.transaction.write conflicts

步骤三:长事务的锁持有影响

目标:演示长事务如何阻塞其他写入。

// 终端 1:开启一个长事务,持有文档"inventory"的锁
var session1 = db.getMongo().startSession()
session1.startTransaction()
session1.getDatabase("local_life").txn_lock_test.updateOne(
  { _id: "inventory" },
  { $set: { status: "locked" } }
)
print("事务 1 开始,持有 inventory 的锁。在终端 2 尝试更新同一个文档...")
// 不要提交!让事务保持活跃

// 终端 2:尝试更新同一个文档——会被阻塞
var session2 = db.getMongo().startSession()
session2.startTransaction()
var startTime = Date.now()
session2.getDatabase("local_life").txn_lock_test.updateOne(
  { _id: "inventory" },
  { $set: { status: "unlocked" } }
)
// 这里会阻塞直到事务 1 提交或超时
print("等待时间:", (Date.now() - startTime) / 1000, "s")

// 终端 1:提交事务
session1.commitTransaction()
session1.endSession()

// 终端 2 的操作现在才能继续
session2.commitTransaction()
session2.endSession()
print("事务 2 在事务 1 提交后继续执行")

步骤四:查看当前活跃的锁等待

// 查看正在等待锁的操作
var waitingOps = db.currentOp({
  waitingForLock: true,
  active: true
})
print("等待锁的操作:", waitingOps.inprog.length)
waitingOps.inprog.forEach(function(op) {
  print("  opid:", op.opid, "| 操作:", op.op, "| 等待时间:", op.secs_running, "s")
  print("  锁信息:", JSON.stringify(op.locks || {}))
})

// 查看当前最慢的操作(可能正在持有锁)
var longRunning = db.currentOp({
  active: true,
  secs_running: { $gt: 5 }
})
print("\n长时间运行的操作 (>5s):", longRunning.inprog.length)
longRunning.inprog.forEach(function(op) {
  print("  opid:", op.opid, "| 时间:", op.secs_running, "s |", op.ns || "N/A")
})

步骤五:源码追踪——MVCC 的快照隔离

// 源码关键路径(GDB 断点)

// 1. 事务开始——分配 read timestamp
// 文件:src/mongo/db/storage/wiredtiger/wiredtiger_begin_transaction_block.cpp
// WiredTigerBeginTxnBlock::WiredTigerBeginTxnBlock()
// 作用:开始 WiredTiger 事务,分配快照

// 2. 文档写入——WiredTiger insert/update
// 文件:src/mongo/db/storage/wiredtiger/wiredtiger_record_store.cpp
// 函数:WiredTigerRecordStore::insertRecord() / updateRecord()
// 作用:对 WiredTiger 表执行写入(WT_SESSION::insert / WT_CURSOR::update)

// 3. 事务提交——冲突检测
// 文件:src/mongo/db/storage/wiredtiger/wiredtiger_util.cpp
// 函数:WT_SESSION::commit_transaction()
// 作用:提交时 WiredTiger 检测冲突 → WriteConflict 或成功

// 4. MongoDB 层的事务协调
// 文件:src/mongo/db/transaction/transaction_participant.cpp
// 函数:TransactionParticipant::commitTransaction()
// 作用:MongoDB 层的事务提交逻辑(Oplog 写入、锁释放)

// GDB 断点:
// (gdb) break WiredTigerRecordStore::insertRecord
// (gdb) break TransactionParticipant::commitTransaction

步骤六:对比不同锁粒度的实际影响

// 对比四种操作的锁级别
// 以下为概念演示——实际锁开销通过 serverStatus 的 locks 统计判断

print("=== 不同操作的锁粒度 ===")
print("单文档 $inc:   Global(IX) + Database(IX) + Collection(IX) → 几乎无等待")
print("多文档事务:    Global(IX) + Database(IX) + Collection(IX) → 事务提交前锁不释放")
print("createIndex:   Global(IX) + Database(IX) + Collection(X,短暂) → 集合排他锁")
print("dropCollection: Global(IX) + Database(IX) + Collection(X) → 集合排他锁")
print("listCollections: Global(IS) → 仅意向共享锁,不阻塞写入")

// IX = Intent Exclusive(意向排他锁)——允许其他并发 IX 操作(不冲突)
// X = Exclusive(排他锁)——阻塞其他任何操作
// IS = Intent Shared(意向共享锁)——允许并发 IX 和 IS(不冲突)

3.3 完整代码清单

文件 用途
mongodb-lab/scripts/ch38-lock-stats.js 锁统计指标读取
mongodb-lab/scripts/ch38-write-conflict.js 写冲突构造
mongodb-lab/scripts/ch38-long-txn-lock.js 长事务锁持有演示
debug-scripts/txn-trace.gdb 事务提交链路 GDB 断点

3.4 测试验证

use admin

// 1. 验证锁统计指标可读
var s = db.serverStatus()
print("锁统计:", s.locks ? "PASS" : "FAIL")
print("WT事务:", s.wiredTiger?.transaction ? "PASS" : "FAIL")

// 2. 验证 WiredTiger 事务冲突自动重试
// 通过 serverStatus 的 wiredTiger.transaction.write conflicts 判断

// 3. 验证长事务可被检测
var longTxn = db.currentOp({ active: true, "transaction": { $exists: true } })
print("活跃事务:", longTxn.inprog.length, "个")

// 4. 验证 waitForLock 过滤工作
var waiting = db.currentOp({ waitingForLock: true })
print("等待锁的操作:", waiting.inprog.length >= 0 ? "PASS" : "FAIL")

print("\n=== 并发控制验证完成 ===")

4. 项目总结

4.1 MongoDB 锁与并发控制速查

层次 机制 锁粒度 特点
MongoDB Server 意向锁(Intent Lock) Global / DB / Collection 轻量,仅协调操作类型
WiredTiger MVCC 快照隔离 文档级 读不阻塞写、写不阻塞读
多文档事务 事务锁 参与事务的文档 锁持有至 commit 才释放
写冲突 WriteConflictException 单文档级别 WiredTiger 自动重试

4.2 适用场景

并发控制知识适用

  1. 高并发秒杀——理解 $inc 的 MVCC 重试和如何避免长事务。
  2. 事务死锁排查——通过锁等待和 currentOp 定位阻塞源。
  3. 性能退化根因分析——锁等待飙升、写冲突频繁是信号。
  4. MongoDB vs MySQL 的并发模型对比——选型时做技术评估。

4.3 注意事项

注意事项 说明
事务不要超过 60 秒 默认 transactionLifetimeLimitSeconds=60,超时自动 abort
事务大小影响 Oplog 大事务产生的 Oplog 条目集中写入——增大从库延迟
$inc 在高冲突时的重试是纯 CPU 开销 如果写冲突数持续过高——考虑优化写入模式或降级并发度
不要在生产环境设置 maxTransactionLockRequestTimeoutMillis 过大 默认 5ms 的锁等待快速失败是好的——避免长队列

4.4 常见踩坑经验

故障案例一:迁移脚本的事务过大导致 Oplog 爆满

某团队用一个大事务包裹了 10 万条文档的更新——事务提交时一次性写入 10 万条 Oplog 条目,Oplog 窗口从 48 小时压缩到 15 分钟——三个从库全部追不上触发 Initial Sync。解决:将迁移拆成每 1000 条一个事务,批次间 200ms 间隔,给 Oplog 和从库消化时间。

故障案例二:频繁的 WriteConflict 导致 CPU 100%

某计数器服务对所有请求执行 $inc: {count: 1} 在同一个文档上。QPS 1 万时 WiredTiger 的 write conflicts 达到每秒 3000 次——CPU 全用在内核的重试循环中。解决:改用 $inc 批量累计或换用 Redis 做计数器;MongoDB 单文档不适合做极端高频的全局计数器。

故障案例三:dropCollection 阻塞所有写入

某运维在业务高峰期执行 db.temp_logs.drop() 清理临时表。dropCollection 持有 Collection X 锁——该库上所有其他集合的写入全部排队等待。如果日志量大——Index(索引)也大——drop 耗时数秒。解决:用 TTL 索引让 MongoDB 后台自动清理过期数据,避免手动 drop;或在业务低峰期(凌晨 3-4 点)执行 DDL 操作。

4.5 思考题

  1. WiredTiger 的 MVCC 实现中,一个文档的不同版本是如何存储的?更新操作是原地覆盖还是追加新版本?
  2. 如果两个事务同时开始——事务 A 读取文档 X 后更新文档 Y,事务 B 读取文档 Y 后更新文档 X。这会形成死锁吗?MongoDB 如何处理?

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

上一章思考题答案

  1. Config Server 3 节点中 2 个宕机只剩 1 个——多数(2/3)不可达,剩余的 1 个 Config Server 无法选举 Primary 或确认自身仍是 Primary。mongos 无法从 Config Server 读取 Chunk 路由表(因为读也需要 majority 确认)→ 路由全失效,读写全停。Config Server 是分片集群中比任何一个 Shard 都更重要的组件。

  2. hashed 分片键的 Chunk 分裂由 MongoDB 自动完成——因为哈希键的值均匀分布在哈希环上,相邻的哈希值之间没有业务语义的聚集,自动分裂的 Chunk 大小自然均衡。ranged 分片键(如 city 或 date)的值可能有强烈的数据倾斜(深圳的文档数远超其他城市),自动分裂只在单个 Chunk 超 128MB 时才触发——如果提前知道某个 city 会写入大量数据,手动预分裂可以避免初始写入全挤在同一个 Chunk(热分片)上。

延伸阅读与资源

NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化)
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Redis 实战修炼与原理进阶
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
MongoDB 实战进阶与内核修炼
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

posted on 2026-08-24 10:30  一天不进步,就是退步  阅读(5)  评论(0)    收藏  举报