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 适用场景
并发控制知识适用:
- 高并发秒杀——理解
$inc的 MVCC 重试和如何避免长事务。 - 事务死锁排查——通过锁等待和 currentOp 定位阻塞源。
- 性能退化根因分析——锁等待飙升、写冲突频繁是信号。
- 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 思考题
- WiredTiger 的 MVCC 实现中,一个文档的不同版本是如何存储的?更新操作是原地覆盖还是追加新版本?
- 如果两个事务同时开始——事务 A 读取文档 X 后更新文档 Y,事务 B 读取文档 Y 后更新文档 X。这会形成死锁吗?MongoDB 如何处理?
(答案将在第 39 章末尾揭晓)
上一章思考题答案:
Config Server 3 节点中 2 个宕机只剩 1 个——多数(2/3)不可达,剩余的 1 个 Config Server 无法选举 Primary 或确认自身仍是 Primary。mongos 无法从 Config Server 读取 Chunk 路由表(因为读也需要 majority 确认)→ 路由全失效,读写全停。Config Server 是分片集群中比任何一个 Shard 都更重要的组件。
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 实战修炼与源码剖析

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