子文档更新不直接产生磁盘碎片,但会因copy-on-write机制积累逻辑碎片和page空洞,导致file bytes available for reuse飙升及cache overflow count非零,拖慢查询。

子文档更新本身不直接产生磁盘碎片,但会在 WiredTiger 引擎下持续积累逻辑碎片和 page 内部空洞;真正拖慢查询的不是“碎片”这个词,而是 file bytes available for reuse 值飙升 + cache overflow count 非零。
为什么嵌套字段更新会悄悄加重碎片
WiredTiger 对子文档(如 {profile: {name: "a", email: "b"}})的更新默认走 copy-on-write:哪怕只改 profile.email,也可能在新位置写入整个 profile 子对象,旧版本滞留在 page 中不立即回收。高频更新同一文档的子字段(比如用户实时状态、计数器、日志数组追加)时,page 分裂更频繁,小块空闲空间散落各处。
常见错误现象包括:
- 查询响应时间变长,但
explain("executionStats")显示nReturned正常、totalDocsExamined却异常高 -
db.coll.stats().wiredTiger["block-manager"]["file bytes available for reuse"]持续增长(例如 > 100MB) -
db.coll.stats().wiredTiger["cursor"]["cache overflow count"]不为 0,且随更新量上升
避免子文档更新引发碎片的实际写法
核心原则是减少“就地膨胀式更新”,把可变字段从嵌套结构里拆出来——不是为了 schema 美观,而是让 WiredTiger 更容易复用空间。
- 把高频更新的子字段(如
last_seen,status_updated_at,retry_count)提到根层级,避免嵌套路径更新触发整块重写 - 用
$set替代$push或$addToSet操作数组——后者可能引起数组所在 page 整体迁移;若必须追加,控制数组长度上限,或改用单独集合存储事件流 - 对布尔/枚举类字段,不用
{flags: {active: true, verified: false}},改用扁平字段active: true, verified: false - 避免在子文档中存二进制 blob 或 base64 字符串——这类字段更新必然触发 copy-on-write,应移至 GridFS 或外部对象存储
compact 能不能救急?什么时候能跑
compact 可以清理子文档更新留下的物理空洞,但它不能在任何事务活跃时执行,也不能在主节点写入高峰期运行。
- 必须等所有涉及该集合的事务提交或中止后才能执行:
db.runCommand({compact: "users"}) - 在副本集上,
force: true是必需的(否则报错),且只作用于当前节点,secondary 需单独执行 - 执行前确认:
db.currentOp({secs_running: {$gt: 30}})返回空,且globalLock.currentQueue.writers == 0 - 注意:
compact不释放空间给操作系统(除非是 WiredTiger 引擎且配置了cache_size合理),它只是把空闲页标记为可复用
真正可持续的缓解方式
靠定期 compact 或重建索引掩盖问题,不如从更新模式上切断碎片源头。最容易被忽略的一点是:子文档更新的“频率”比“深度”更致命——一个每秒更新 50 次的 stats.last_ping 字段,比一个每月改一次的 5 层嵌套配置,对碎片的影响大两个数量级。











