$set修改超10万元素内嵌数组会触发集合级锁,因WiredTiger判定重写成本过高而主动降级;$push同样无法规避,反因全文档重写和索引插入加剧锁争抢;有效方案是拆分集合或严格限长。

为什么$set修改内嵌数组字段会触发集合级锁
不是所有数组更新都会升级锁粒度,但当 $set 操作命中一个已存在、且元素数量超过约 10 万的内嵌数组时,WiredTiger 很可能放弃文档级锁,直接升级为集合级排他锁(x lock)。这不是 bug,而是引擎对“重写成本过高”的主动降级策略:它判断逐字节 diff 和 patch 整个大文档风险远高于短时锁定整个集合。
- 文档越大,WiredTiger 在事务中做 MVCC 版本快照和写前日志(WAL)的开销越不可控
- 数组字段若建有索引,
$set修改任意元素都会导致该数组所有索引键被标记为“待重建”,引擎倾向于一次性锁住集合来避免索引状态撕裂 - 使用
$[]或$[<identifier>]</identifier>批量更新数组元素时,即使只改其中几个,MongoDB 仍需遍历并校验全部元素——这会让锁持有时间从毫秒级拉长到数百毫秒
哪些数组操作最容易暴露锁升级问题
用 explain("executionStats") 查看实际执行时,如果发现以下组合,基本可判定锁已在集合级别生效:
-
executionStages.stage是UPDATE,但executionStages.nReturned为 0,而executionStages.docsExamined高达数万 → 表明引擎在扫描整个集合找匹配文档,而非靠索引精准定位 -
lockStats字段缺失,或locks下出现Collection级别的acquireWaitCount显著上升 → 直接说明锁粒度已脱离文档级 - 同一集合上其他无关写操作(如
users.updateOne({ _id: ... }, { $inc: { loginCount: 1 } }))也突然变慢或超时 → 典型的集合锁阻塞扩散现象
用 $push 替代 $set 并不能绕过锁问题
很多人误以为追加比覆盖“轻量”,但在大数组场景下,$push 反而更危险:
-
$push必须重写整个文档(BSON 不支持原地追加),文档体积越大,I/O 延迟越高,锁持有时间越长 - 若数组字段有索引,每次
$push都要插入一条新索引键;10 万元素 ≈ 10 万次 B-tree 插入,极易触发 WiredTiger 内部锁争抢 -
$push+$slice组合看似可控,但如果$slice是负数(如$slice: -5000),引擎仍需先读取全部现有元素再截断——读放大直接拖慢锁释放
真正有效的规避方式只有两个方向
别在单文档里存海量状态。要么拆,要么限。
- 把高频更新的数组内容独立成集合,用
user_id做外键 + 查询时$lookup,让锁控制在子集合粒度(如user_events) - 若必须保留在文档内,用
$addToSet替代$push减少重复写入,并严格限制数组长度:{ $push: { tags: { $each: [newTag], $slice: -5000 } } } - 绝对不要在事务中做任何涉及大数组的更新;这类操作应剥离到事务外,用最终一致性+幂等补偿(例如先写事件日志,再异步聚合进主文档)
user.profile.tags,却卡住了 user.sessions 的更新——因为它们在同一集合里。

















