gridfs覆盖更新本质是全量重写而非局部修改:每次uploadfromstream都会生成新_id、写入全部chunk、删除旧文档,即使改1字节也触发双倍写放大、索引重建和孤儿块风险。

GridFS覆盖更新必然触发全量重写,不是“更新”而是“换新”
GridFS 没有局部修改能力,uploadFromStream 覆盖同名文件时,驱动不会复用旧 fs.chunks,而是生成全新 _id、写入全部新 chunk、再删旧文档。哪怕只改 1 字节,也要重传整个文件 + 重建所有索引 + 触发两次写放大。这直接导致磁盘上新旧 chunk 交错写入,WiredTiger 页面分裂加剧,available for reuse 空间持续增长。
并发覆盖会制造大量孤儿 chunk,它们无法自动回收
多个客户端同时上传同名文件时,后完成的上传会覆盖 fs.files 中的 _id,但旧 fs.chunks 可能尚未被清理——尤其在副本集同步延迟或删除失败时。这些 orphan chunk:
- 不被任何
fs.files._id引用,却长期占用磁盘和索引空间 - 不会被
compact或后台任务自动识别和清理 - 必须靠聚合查询手动识别:
db.fs.chunks.aggregate([ { $lookup: { from: "fs.files", localField: "files_id", foreignField: "_id", as: "files" } }, { $match: { "files.0": { $exists: false } } } ])
默认 chunkSize 加剧碎片,小修改也引发大 IO
默认 chunkSizeBytes: 255 * 1024 在高频覆盖下是双刃剑:
- 设太小(如 64KB)→ 10MB 文件切出 160+ chunk → 元数据爆炸、索引压力陡增、每次覆盖都触发上百次 BSON 解析
- 设太大(如 4MB)→ 同样 10MB 文件仅 3 个 chunk → 单次覆盖仍要写满 12MB(含冗余),且 wire protocol 单包上限约 16MB,易触发分包重试
- 真正问题不在“大小”,而在于:每次覆盖都强制刷新整组 chunk,旧物理页未及时复用,WiredTiger 的 page fill rate 持续走低
删除不走 bucket.delete() 就等于埋雷
业务代码若绕过 GridFS API,直接调 deleteOne({ _id: oldId }) 或只删 fs.chunks,就会留下 fs.files 孤儿文档。这些文档虽小,但:
- 让
fs.files.filename索引持续膨胀,拖慢后续所有元数据查询 - 使
db.fs.files.countDocuments()远大于db.fs.chunks.countDocuments(),成为碎片率升高的明确信号 - 定期跑聚合清理只是补救,不能替代流程管控——只要有一个新功能又绕过
bucket.delete(),碎片就照涨
真正难的不是调参,而是接受一个事实:GridFS 的“覆盖更新”本质是版本切换,不是数据库意义上的原子更新。碎片率高从来不是配置没对,而是把 GridFS 当成了支持高频写入的通用存储抽象。











