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 adminvars=db.serverStatus()varlocks=s.locksprint("=== 锁统计 ===")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){vardbLocks=locks.DatabasevardbKey=Object.keys(dbLocks).find(function(k){returnk.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 的事务冲突统计varwt=s.wiredTigerprint("\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 来理解概念varconflictCount=0for(vari=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"的锁varsession1=db.getMongo().startSession()session1.startTransaction()session1.getDatabase("local_life").txn_lock_test.updateOne({_id:"inventory"},{$set:{status:"locked"}})print("事务 1 开始,持有 inventory 的锁。在终端 2 尝试更新同一个文档...")// 不要提交!让事务保持活跃// 终端 2:尝试更新同一个文档——会被阻塞varsession2=db.getMongo().startSession()session2.startTransaction()varstartTime=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 提交后继续执行")步骤四:查看当前活跃的锁等待
// 查看正在等待锁的操作varwaitingOps=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||{}))})// 查看当前最慢的操作(可能正在持有锁)varlongRunning=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. 验证锁统计指标可读vars=db.serverStatus()print("锁统计:",s.locks?"PASS":"FAIL")print("WT事务:",s.wiredTiger?.transaction?"PASS":"FAIL")// 2. 验证 WiredTiger 事务冲突自动重试// 通过 serverStatus 的 wiredTiger.transaction.write conflicts 判断// 3. 验证长事务可被检测varlongTxn=db.currentOp({active:true,"transaction":{$exists:true}})print("活跃事务:",longTxn.inprog.length,"个")// 4. 验证 waitForLock 过滤工作varwaiting=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(热分片)上。