MongoDB 单文档上限是 16 MB。这个限制来自文档本身的设计约束(BSON 文档大小上限),不是存储引擎的能力上限。于是当业务需要存视频、音频、设计稿、归档包这类超过 16 MB 的二进制对象时,就需要一套把大文件切分存储的机制——这就是 GridFS。
GridFS 不是一个独立的存储引擎,而是建立在普通集合之上的一套约定:用两个集合分别保存文件元数据与文件内容分块。正因为它是约定而非引擎,用得好能省去引入额外存储组件的成本,用得不好会带来一致性、性能与备份上的麻烦。本文讲清它的结构、适用边界与工程细节。
1. GridFS 的结构
1.1 两个集合
GridFS 使用两个集合:
| 集合 | 存什么 | 关键字段 |
|---|---|---|
fs.files | 文件元数据,每个文件一条文档 | _id、filename、length、chunkSize、uploadDate、md5、metadata |
fs.chunks | 文件内容分块,每个分块一条文档 | _id、files_id、n、data |
一个 100 MB 的文件在 fs.files 里只有一条元数据文档,但在 fs.chunks 里会有约 400 条分块文档(按默认 255 KB 分块)。这种"元数据与内容分离"的设计让元数据查询(按文件名、按上传者)非常轻量,而内容读取则走顺序 I/O。
1.2 分块规则
- 默认分块大小 255 KB(
chunkSize,单位字节)。这是历史默认值,可按需调整。 fs.chunks中n从 0 开始递增,表示第几块。fs.chunks上有唯一索引{ files_id: 1, n: 1 },保证同一文件的分块顺序唯一、可定位。- 每个分块的
data字段是 BSON 的BinData类型,本身也要受 16 MB 文档上限约束,因此chunkSize不能超过约 16 MB。
分块数量的计算很简单:
分块数 = ceil(length / chunkSize)
这个数字直接决定了读取一个文件需要多少次查询,是评估 GridFS 性能的第一要素。
1.3 读取流程
读取一个文件的流程是:先按 _id 或 filename 在 fs.files 里查到元数据,拿到 length 与 chunkSize,再按 { files_id: fid } 排序 n 依次取出分块拼接。因为分块顺序由索引保证,读取是顺序 I/O,性能可控。
// 查看某个文件的分块数量与总大小
db.fs.files.findOne({ filename: "demo.mp4" })
db.fs.chunks.countDocuments({ files_id: ObjectId("...") })
// 校验分块完整性:分块数 × chunkSize 应 ≥ length
值得注意的是,读取并不需要一次性把全部分块加载进内存——驱动用游标逐个取分块,边取边输出,因此内存占用是恒定的(约一个分块大小)。
分块读取也受益于驱动的批量获取(batch size):游标一次向数据库取回一批分块,减少网络往返。默认批大小对 GridFS 已经合适,通常不需要调整;但若单文件极大且网络延迟高,适当增大批大小能提升吞吐。
与"每文档独立存取"的普通集合相比,GridFS 的读取模式更接近顺序流式访问:它把"一个大对象"摊成了"一串小文档",用文档的存储能力换取了突破 16 MB 上限的可能。理解了这一点,后面关于分块大小、索引与分片的取舍就都有了解释。
2. 何时用 GridFS,何时用对象存储
2.1 对比表
这是选型时最重要的问题。GridFS 与对象存储(S3/MinIO/OSS)各有明确适用面:
| 维度 | GridFS | 对象存储(S3 等) |
|---|---|---|
| 存储位置 | 与业务数据同库,可跨文档事务 | 独立系统 |
| 一致性 | 可参与副本集事务 | 通常最终一致(部分强一致) |
| 访问方式 | 驱动 API / mongofiles | HTTP / SDK / 预签名 URL |
| 大文件成本 | 占用数据库存储与 IO | 单位成本更低 |
| 断点续传 | 需自行实现(Range 分块) | 原生支持 Range |
| CDN 加速 | 无 | 原生对接 CDN |
| 备份 | 随数据库备份一起 | 独立生命周期策略 |
| 适合场景 | 小规模文件、需与业务强一致、需事务 | 海量文件、大文件、需 CDN |
2.2 选择依据
选择 GridFS 的典型理由:文件与业务文档必须强一致(比如订单附件与订单一起提交),或文件量不大但希望少维护一套存储系统。典型例子是"用户上传的凭证图片"——它需要与用户文档一起在事务里更新。
选择对象存储的典型理由:文件数量达到百万级、单文件达到 GB 级、需要 CDN 分发、需要独立的生命周期与归档策略。图片类文件的这种分层做法在 对象存储图片方案 中有具体案例。
2.3 16 MB 的中间方案
还有一个常被忽略的中间方案:如果文件小于 16 MB,直接存成文档里的 BinData 字段即可,不必用 GridFS。GridFS 的价值在于突破 16 MB 上限并支持流式读写,对小文件反而是过度设计——一个小文件会产生 2 条文档(元数据 + 分块)与一次额外查询。
三种存储方式的适用边界:
| 文件大小 | 推荐方式 |
|---|---|
| < 1 MB | 直接存 BinData 字段 |
| 1 MB ~ 16 MB | 存 BinData,或视一致性需求用 GridFS |
| > 16 MB | GridFS 或对象存储 |
| > 100 MB 且海量 | 对象存储 |
2.4 迁移路径
选型不是一成不变的。常见演进路径是:起步用 BinData → 文件变大改用 GridFS → 文件海量后迁到对象存储。
迁移时最需要留意的是引用的一致性:业务文档里存的是 GridFS 的 _id,迁移到对象存储后这个引用要变成对象 key。稳妥的做法是双写过渡:新文件写对象存储,旧文件保留在 GridFS,读取时先查对象存储、未命中再回退 GridFS,等旧文件访问量降到阈值后统一迁移。这个过程与通用的数据迁移方法论一致:先保证双写不丢数据,再按访问热度分批迁移,最后摘除旧路径。
3. 写入与读取
3.1 命令行工具 mongofiles
mongofiles 适合运维与调试:
# 上传
mongofiles -d filesdb put ./report.pdf
# 下载
mongofiles -d filesdb get report.pdf
# 列出所有文件
mongofiles -d filesdb list
# 按文件名搜索
mongofiles -d filesdb search "report"
mongofiles 底层调用的就是 GridFS 驱动 API,因此用它上传的文件与应用程序上传的完全兼容。
3.2 Node.js 流式 API
驱动流式 API 才是应用里的正道:
import { GridFSBucket } from "mongodb";
import fs from "fs";
const bucket = new GridFSBucket(db, { bucketName: "attachments" });
// 流式上传:适合大文件,内存占用恒定
fs.createReadStream("./video.mp4")
.pipe(bucket.openUploadStream("video.mp4", {
chunkSizeBytes: 1024 * 1024, // 1 MB
metadata: { ownerId: 42, contentType: "video/mp4" },
}))
.on("finish", () => console.log("uploaded"));
// 流式下载
bucket.openDownloadStreamByName("video.mp4")
.pipe(fs.createWriteStream("./out.mp4"));
务必用流式 API 而非 uploadFromBuffer:后者会把整个文件读进内存,一个大文件上传就能把进程内存打满。流式 API 的内存占用恒定在一个分块大小左右。
3.3 范围读取与断点续传
openDownloadStream 的 start / end 参数让 GridFS 支持部分下载(partial download),这是实现视频拖动、断点续传的基础:
bucket.openDownloadStreamByName("video.mp4", {
start: 0,
end: 1024 * 1024 - 1, // 只读第一个 1 MB
}).pipe(fs.createWriteStream("./part1.bin"));
注意 end 是排他边界(不含),且底层按分块对齐读取,因此实际读取的字节数可能略多于请求范围,需要在应用层截断。配合 HTTP 的 Range 请求头,即可实现标准的断点续传:
app.get("/files/:name", async (req, res) => {
const range = req.headers.range; // e.g. "bytes=0-1048575"
const [start, end] = range.replace(/bytes=/, "").split("-").map(Number);
res.status(206);
bucket.openDownloadStreamByName(req.params.name, { start, end: end + 1 })
.pipe(res);
});
3.4 Go 驱动
Go 驱动下对应 gridfs.NewBucket 与 UploadFromStream / DownloadToStream:
bucket, _ := gridfs.NewBucket(db)
uploadStream, _ := bucket.OpenUploadStream("video.mp4")
io.Copy(uploadStream, fileReader)
uploadStream.Close()
用法风格与 Node.js 类似,驱动的整体接入方式见 https://plumephp.com/mongodb-go-driver/。
3.5 批量与并发上传
批量上传大文件时,两个参数决定吞吐:
- 并发上传数:同时上传的文件数。受数据库写入能力与网络带宽限制,通常设 4~8,过高会让
fs.chunks的写入互相争抢。 - 单文件分块大小:分块越大,单文件的写入请求越少,吞吐越高;但并发上传大分块会放大瞬时写入压力。
一个常见的误区是把上传并发开到几十上百,结果 fs.chunks 的插入成为瓶颈,反而比低并发更慢。合理的做法是先压测出单连接写入吞吐,再按 并发数 × 分块大小 / 单请求耗时 反推上限。
此外,上传是写密集操作,应与读业务错峰。若上传流量与业务查询共享同一副本集,可考虑把上传导向从节点(仅当写关注允许)或使用独立集群。
4. 索引、分块与分片
4.1 索引
GridFS 默认在 fs.chunks 上建 { files_id: 1, n: 1 } 唯一索引,这是读取性能的关键。此外通常还会在 fs.files 上按 filename 建索引以支持按名查找,避免每次下载都做全集合扫描。
如果业务按自定义字段(如 metadata.ownerId)检索文件,需要额外建索引:
db.fs.files.createIndex({ "metadata.ownerId": 1, uploadDate: -1 })
注意 metadata 是自由字段,查询它会走这个索引,但不要对 metadata 整体建索引(太大且无选择性)。
4.2 分块大小选择
分块大小(chunkSize)是一道权衡题:
| chunkSize | 优点 | 缺点 |
|---|---|---|
| 255 KB(默认) | 分块小,范围读取粒度细 | 分块数多,元数据开销大 |
| 1 MB ~ 4 MB | 减少文档数,写入吞吐更高 | 小范围读取会读入多余数据 |
| 接近 16 MB | 文档数最少 | 单块写入压力大,不利流式 |
经验规则:以文件大小的 1/1000 到 1/100 为起点。几百 MB 的视频用 1~4 MB 分块合适;几十 KB 的小文件用小分块。分块大小在 fs.files 中按文件记录,因此同一 bucket 内不同文件可以用不同分块大小,读取时按各自记录的 chunkSize 拼接。
一个具体的估算例子:1 万个平均 20 MB 的文件,按默认 255 KB 分块,分块总数约为 10000 × 20MB / 255KB ≈ 80 万;改成 2 MB 分块后降到约 10 万条,元数据与索引开销减少约 87%。这就是分块大小对存储效率的直接影响。
4.3 分片键
当 GridFS 数据量巨大时,fs.chunks 可以分片。分片键的选择很关键——因为读取总是按 files_id 过滤并顺序读取 n:
| 分片键 | 效果 |
|---|---|
{ files_id: 1, n: 1 } | 同一文件的分块共置,读取不散射(推荐) |
{ files_id: "hashed" } | 写入分散,但同一文件仍在同一分片 |
{ _id: 1 } | 同一文件分块散落各分片,读取打散(不推荐) |
用 { files_id: 1, n: 1 } 作为分片键能让同一文件的分块落在同一分片,既避免跨分片散射查询,又让写入分散。分片键设计的一般方法见 https://plumephp.com/mongodb-sharding-key-design/。
5. 常见陷阱
5.1 孤儿分块
fs.files 与 fs.chunks 的删除不一致。 删除文件必须同时删两个集合。驱动提供的 delete 方法会处理,但如果用 deleteOne 手工删 fs.files 而忘了 fs.chunks,就会留下孤儿分块,长期积累浪费大量空间。检测孤儿分块:
// 找出没有对应 fs.files 记录的孤儿分块
db.fs.chunks.aggregate([
{ $group: { _id: "$files_id" } },
{ $lookup: { from: "fs.files", localField: "_id", foreignField: "_id", as: "f" } },
{ $match: { f: { $size: 0 } } },
{ $count: "orphans" }
])
清理时务必分批删除,避免长事务与锁竞争。
5.2 md5 字段已废弃
老版本 GridFS 会计算并存储 md5,MongoDB 4.0 之后官方已不再默认计算(出于性能与合规考虑)。不要依赖 md5 做完整性校验,应改用 metadata 里自存的 sha256。
5.3 备份体积
GridFS 数据随数据库一起备份,一个 500 GB 的 GridFS bucket 会让整个备份集膨胀。规划容量时要把这部分算进去,容量评估方法见 https://plumephp.com/mongodb-capacity-planning-benchmark/。
5.4 上传中断
流式上传如果中途失败,fs.files 里可能已经写入元数据,但 fs.chunks 不完整。应用层应在上传完成后校验分块数,或在 metadata 里标记 status: "uploading",完成后改为 complete,由后台任务清理超时未完成的记录:
// 清理超过 1 小时仍未完成的残留上传
db.fs.files.deleteMany({
"metadata.status": "uploading",
uploadDate: { $lt: new Date(Date.now() - 3600_000) }
})
// 记得同时清理对应的 fs.chunks
5.5 随机读不友好
不要把 GridFS 当作高并发随机读存储。 GridFS 的读取是"元数据查询 + 顺序分块读",对随机读并不友好。高并发随机读场景(如热门图片)应加缓存或改用对象存储 + CDN。
5.6 文件名不唯一
filename 字段没有唯一约束,同一个名字可以有多个文件(不同版本)。如果业务假设"文件名唯一",就会出现"下载到旧版本"的诡异问题。两种处理方式:
- 在
fs.files上建{ filename: 1, uploadDate: -1 }索引,读取时取最新一条。 - 用
_id作为唯一标识,filename只作展示用途。
// 取同名文件的最新版本
db.fs.files.find({ filename: "report.pdf" }).sort({ uploadDate: -1 }).limit(1)
推荐后者:用 _id 做唯一键,避免依赖文件名的唯一性。
6. 与业务数据的集成模式
6.1 引用式存储
最常见的集成方式是"引用式":业务文档里只存文件的 _id,文件内容放 GridFS。
// 业务文档
{ _id: "order-1001", userId: 42, invoiceFileId: ObjectId("...") }
// 读取时按需加载
const fileId = order.invoiceFileId;
bucket.openDownloadStream(fileId).pipe(res);
好处是业务查询不会因为文件内容而变重,坏处是需要两次查询(一次业务文档、一次文件)。
6.2 元数据索引
把常用检索维度写进 metadata,并在 fs.files 上建对应索引,可以避免"先查业务文档再查文件"的双跳:
db.fs.files.createIndex({ "metadata.tenantId": 1, "metadata.category": 1 })
6.3 生命周期
文件的删除应与业务数据同步。当业务文档被删除时,其关联文件也应清理——可以用应用层级联删除,或用 Change Streams 监听业务集合的删除事件来触发文件清理,做到最终一致。
6.4 与缓存的配合
GridFS 的读取是"元数据查询 + 顺序分块读",一次完整读取会命中数据库多次。对读多写少、且被反复访问的文件(如公共图片、文档模板),在应用层或 CDN 加一层缓存能显著降低数据库压力:
- 应用层缓存:把热点文件缓存在 Redis 或本地内存,按
_id或filename做键。适合文件较小、访问集中的场景。 - CDN 缓存:把 GridFS 文件通过一个薄代理层暴露成 HTTP 接口,前面挂 CDN。适合图片、视频这类大流量静态资源。
判断是否值得加缓存的依据是缓存命中率:若同一文件被反复访问的比例低于 20%,加缓存的收益有限,不如直接优化查询。文件访问的统计可以结合 $indexStats 与访问日志来做。
7. 实践建议
- 小于 16 MB 的文件直接存
BinData,不要用 GridFS。 - 海量或超大文件优先对象存储,MongoDB 只存引用;GridFS 留给需要强一致或事务的场景。
- 分块大小按文件量级选,视频类 1~4 MB,小文件用默认值。
fs.chunks分片用{ files_id: 1, n: 1 },避免跨分片散射。- 始终用驱动 API 删除文件,并定期清理孤儿分块。
- 自己算 sha256 存进
metadata,不要依赖已废弃的md5。 - 用流式 API 而非 buffer API,保证上传下载的内存占用恒定。
- 规划好迁移路径,从
BinData到 GridFS 再到对象存储,每一步都保证引用一致。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。