GridFS 不是 MongoDB 的独立存储引擎,而是运行在普通集合之上的一套文件分块协议。理解它,本质上是理解两张表、一个写入顺序约定、以及一套读取时的验证机制。
一、整体架构
GridFS 将每个文件分散存储在两个集合中:GridFS 还会自动创建两个关键索引。fs.files 上的 { filename: 1, uploadDate: 1 } 支持按文件名查最新版本;fs.chunks 上的 { files_id: 1, n: 1 } 唯一复合索引是整个机制的正确性基础------unique: true 防止同一块被重复写入,n 保证顺序扫描无需额外排序。
二、文件保存的完整处理逻辑
核心原则
chunks 全部写完,才写 files。 fs.files 记录的出现是文件"正式对外可见"的唯一信号,顺序不能颠倒。
写入流程### 缓冲区机制(C# 驱动核心逻辑)
C# 驱动的 Write() 不要求调用方一次传入整块数据,内部维护一个大小为 chunkSizeBytes 的 _buffer,等积满才触发一次数据库写入:
csharp
public override void Write(byte[] buffer, int offset, int count)
{
while (count > 0)
{
// 计算本次能填入内部缓冲的字节数
int available = _chunkSizeBytes - _bufferOffset;
int toCopy = Math.Min(count, available);
Array.Copy(buffer, offset, _buffer, _bufferOffset, toCopy);
_bufferOffset += toCopy;
offset += toCopy;
count -= toCopy;
// 缓冲满 → 立即落盘一个 chunk
if (_bufferOffset == _chunkSizeBytes)
FlushCurrentChunk(); // 插入 fs.chunks,重置 _bufferOffset = 0
}
}
private void FlushCurrentChunk()
{
chunksCollection.InsertOne(new BsonDocument {
{ "files_id", _fileId },
{ "n", _chunkNumber++},
{ "data", new BsonBinaryData(_buffer, 0, _bufferOffset) }
});
_bufferOffset = 0;
}
Close() 时处理尾块,再写 fs.files:
csharp
public override void Close()
{
if (_bufferOffset > 0) // 有剩余字节 → 写尾块(大小 < chunkSizeBytes)
FlushCurrentChunk();
// 若 length == 0,不创建任何 chunk
filesCollection.InsertOne(new BsonDocument {
{ "_id", _fileId },
{ "filename", _filename },
{ "length", _totalLength }, // Int64,精确字节数
{ "chunkSize", _chunkSizeBytes }, // Int32
{ "uploadDate", DateTime.UtcNow }
});
}
分块规则
文件长度:700 KB chunkSize:255 KB
├─ n=0 [━━━━━━━━━━━━━━━ 255 KB ━━━━━━━━━━━━━━━] 满块,精确等于 chunkSize
├─ n=1 [━━━━━━━━━━━━━━━ 255 KB ━━━━━━━━━━━━━━━] 满块,精确等于 chunkSize
└─ n=2 [━━━━━━━━ 190 KB ━━━━━━━━] 尾块,≤ chunkSize,不补零
总块数 = ⌈ 716800 / 261120 ⌉ = 3
- 除最后一块外,所有块必须精确等于
chunkSizeBytes - 尾块只包含实际剩余字节,不补零
- 空文件(length = 0)不创建任何 chunk 文档
孤儿 chunks 清理
上传中断会产生有 fs.chunks 记录但无 fs.files 记录的孤儿块,需定期清理:
javascript
// 删除所有孤儿 chunks
db.fs.chunks.deleteMany({
files_id: { $nin: db.fs.files.distinct("_id") }
})
三、文件获取逻辑
读取流程
读取是写入的镜像,先查元数据确认文件完整,再按索引顺序扫描分块,流式重组输出。### ProcessNextBatch() 三重验证
下载时驱动对每个 chunk 执行三层强制验证(C# 等效逻辑):
csharp
void ProcessNextBatch(List<BsonDocument> batch)
{
long lastN = CalculateTotalChunks() - 1;
foreach (var chunk in batch)
{
int n = chunk["n"].AsInt32;
byte[] data = chunk["data"].AsByteArray;
bool isLastChunk = (n == lastN);
// 验证 1:序号连续
if (n != _expectedChunkNumber)
throw new GridFSChunkException(_fileId, _expectedChunkNumber, "is missing");
// 验证 2:非末块必须精确等于 chunkSizeBytes
if (!isLastChunk && data.Length != _fileInfo.ChunkSizeBytes)
throw new GridFSChunkException(_fileId, n, "is the wrong size");
// 验证 3:末块不能超过 chunkSizeBytes
if (isLastChunk && data.Length > _fileInfo.ChunkSizeBytes)
throw new GridFSChunkException(_fileId, n, "is the wrong size");
_expectedChunkNumber++;
// 将 data 字节拷贝到调用方缓冲区...
}
}
三重验证对应写入时的三条强制规则,形成完整的写入---读取一致性闭环。驱动不做 的两件事:不重新验证 files_id(查询已过滤);不默认重算 MD5(md5 字段已 deprecated)。
范围读取(Range Read)
GridFS 原生支持 HTTP Range 请求,视频播放器拖动进度条跳转的底层实现正是基于此:
javascript
// 读取 500000 ~ 1000000 字节范围
const startChunk = Math.floor(500000 / chunkSize); // = 1
const endChunk = Math.floor(1000000 / chunkSize); // = 3
const startOffset = 500000 % chunkSize; // 块内偏移
db.fs.chunks.find({
files_id: fileId,
n: { $gte: startChunk, $lte: endChunk }
}).sort({ n: 1 });
// 对首块截取 startOffset 之后的字节,对尾块按需截断
注意:驱动每次必须将完整的 chunk 加载进内存,不支持部分块加载。频繁随机跳转场景应选更小的 chunkSizeBytes。
四、查看分块数量的方法
方法一:公式计算(首选,O(1))
fs.files 文档中已有 length 和 chunkSize,直接计算,无需碰 fs.chunks:
javascript
const file = db.fs.files.findOne({ _id: fileId });
const totalChunks = Math.ceil(file.length / file.chunkSize);
方法二:实际计数(完整性验证,O(n))
javascript
db.fs.chunks.countDocuments({ files_id: ObjectId("...") })
若计算值与实际计数不符,文件已损坏或上传中途中断。
方法三:聚合全库对比(生产巡检)
javascript
db.fs.chunks.aggregate([
{ $group: { _id: "$files_id", actualChunks: { $sum: 1 } } },
{ $lookup: {
from: "fs.files", localField: "_id",
foreignField: "_id", as: "fileInfo"
}},
{ $project: {
actualChunks: 1,
expectedChunks: {
$ceil: { $divide: [
{ $arrayElemAt: ["$fileInfo.length", 0] },
{ $arrayElemAt: ["$fileInfo.chunkSize", 0] }
]}
}
}},
// 只返回实际块数 ≠ 期望块数的异常文件
{ $match: { $expr: { $ne: ["$actualChunks", "$expectedChunks"] } } }
])
方法四:预存到 metadata(高频读取场景)
上传时一次计算写入,后续读取 O(1):
csharp
new GridFSUploadOptions {
Metadata = new BsonDocument {
{ "totalChunks", (long)Math.Ceiling((double)fileSize / chunkSize) },
{ "uploader", userId },
{ "processed", false }
}
}
| 方法 | 时间复杂度 | 适用场景 |
|---|---|---|
| 公式计算 | O(1) | 日常单文件查询 |
countDocuments |
O(n),n 为该文件块数 | 完整性验证 |
| 聚合对比 | O(N),N 为 chunks 总数 | 全库巡检 |
| metadata 预存 | O(1) | 业务高频读取 |
五、ChunkSize 大小策略
三层配置体系(C# 驱动)
规范默认值(255 KiB = 261120 字节)
↓ 可覆盖
GridFSBucketOptions.ChunkSizeBytes ← Bucket 级,影响该 Bucket 所有上传
↓ 可覆盖(优先级最高)
GridFSUploadOptions.ChunkSizeBytes ← 单次上传级,仅影响此文件
csharp
// Bucket 级默认值
var bucket = new GridFSBucket(database, new GridFSBucketOptions {
ChunkSizeBytes = 261120 // Nullable<int>,null = 使用规范默认
});
// 单次上传覆盖(仅此文件生效)
await bucket.UploadFromBytesAsync("video.mp4", bytes,
new GridFSUploadOptions { ChunkSizeBytes = 1048576 }); // 1 MB
修改 Bucket 的 chunkSize 只影响新上传的文件 ,已有文件沿用写入时的值。读取时驱动始终从 fs.files 中读取该文件实际使用的 chunkSize,与 Bucket 当前配置无关。
为什么默认是 255 KiB 而不是 256 KiB
这是精心设计的工程决策,而非随意选择:
一个 chunk 文档的 BSON 开销分析:
_id: 12 字节(ObjectId)
files_id: 12 字节(ObjectId)
n: 4 字节(Int32)
data 头: 5 字节(BinData 头部)
字段名开销: ~30 字节("files_id"=9, "data"=5, "n"=2 等)
文档结构: ~15 字节(BSON 文档头、终止符等)
────────────────────────────────
总 BSON 元数据开销 ≈ 78 字节
255 × 1024 + 78 ≈ 261198 ≈ 256 KiB(贴近内存对齐边界)
若选 256 × 1024,整个文档将略微超过 256 KiB
场景化选型策略| 场景 | 推荐值 | 核心理由 |
|---|---|---| | 流媒体大视频(4K) | 1 ~ 4 MB | 减少 chunks 文档总数,降低索引压力,顺序读取吞吐高 | | 常规文件(文档、图片) | 255 KiB(默认) | 平衡块数与 I/O,贴近 256 KiB 内存对齐,无需配置 | | 高频随机读(音频跳转) | 64 ~ 128 KB | Range Read 每次加载更少无效字节,延迟低 | | 海量小文件(≤ 16MB) | 不建议用 GridFS | 直接存 BinData 字段,避免双集合开销 | | 分片集群 | 4 MB | 减少跨分片文档,配合 files_id hashed 分片键 |
分片集群配置
在分片集群中,推荐以 files_id 的哈希值为分片键,确保同一文件所有块集中在少数分片:
javascript
sh.shardCollection("mydb.fs.chunks", { files_id: "hashed" });
// 同一 files_id 的 chunks 哈希值集中
// → 重组时不需要跨分片聚合 → 读取性能大幅提升
六、一致性保障的边界
GridFS 通过写入顺序约定实现弱一致性,而非依赖事务,理解这个边界非常重要:
GridFS 保障的:
- 读取方看到
fs.files记录,意味着所有 chunks 已写完 - 下载时三重验证能检测到序号缺失和大小异常
- 唯一复合索引防止同一块被重复写入
GridFS 不保障的:
- 上传中断不会自动回滚已写的 chunks(产生孤儿块)
- 写入非多文档事务,无法原子地同时操作两个集合
- 并发上传同名文件需要业务层控制
生产环境推荐使用 WriteConcern.Majority,降低副本集选举期间丢失 chunks 的概率:
csharp
var bucket = new GridFSBucket(database, new GridFSBucketOptions {
WriteConcern = WriteConcern.Majority,
ReadPreference = ReadPreference.Primary,
ChunkSizeBytes = 261120
});
七、总结
GridFS 的设计是一套精心权衡的工程方案,五条核心结论:
① 写入原则 :chunks 先落盘,files 最后写。fs.files 记录是文件"可见"的唯一信号。
② 读取原则 :先查 fs.files 拿 chunkSize 和 length,再按 {files_id, n} 复合索引顺序扫描 chunks,三重验证保障完整性。
③ 块数查询 :首选 ⌈ length / chunkSize ⌉ 公式计算,O(1);验证场景用 countDocuments;高频业务场景预存到 metadata。
④ ChunkSize 选型 :随机访问多用小块(64~128 KB),顺序读取用大块(1~4 MB),通用场景用默认(255 KiB)。chunkSize 是每个文件独立 的属性,写入时确定,存在 fs.files 中,与 Bucket 当前配置无关。
⑤ 弱一致性:GridFS 不是事务系统,依赖写入顺序保障弱一致性,需要业务层处理孤儿块清理和并发控制。