说到 MongoDB 事务和 GridFS 的配合,先要说明一个事实:GridFS 无法纳入 MongoDB 事务的范畴。其底层需要在两个集合——`fs.files` 和 `fs.chunks`——之间进行跨集合写入,而事务不支持这种跨集合的流式操作。官方明确表示不提供多文档事务支持。在驱动层面,即便传入 `session` 参数,它也会被静默忽略,既不报错也不生效。这容易产生孤儿元数据,带来维护上的困扰。
因此,任何在 `session.startTransaction()` 内部调用 `bucket.openUploadStream()` 或 `bucket.uploadFromStream()` 的做法,大概率会失败或者留下孤儿文档。这不是通过配置可以解决的问题,而是设计层面的限制。
为什么 GridFSBucket 方法无法纳入事务
问题的根源在于 GridFS 的底层写入必须跨越 `fs.files` 和 `fs.chunks` 两个集合。事务要求所有操作在同一个上下文内完成,但 `GridFSBucket` 的流式上传流程是:先插入 `fs.files` 文档获取 `_id`,再用该 `_id` 分批写入 `fs.chunks`。在此过程中,驱动内部的非事务性流控制完全不受 `session` 的约束。
官方文档明确指出:`GridFS does not support multi-document transactions`。即便手动传入 `session` 参数,Node.js 驱动(v6.7 及以上)也会将其忽略——不报错,但也不起任何作用。
关键点包括:
- 调用 `bucket.openUploadStream()` 后,`fs.files` 的插入立即发生。此时事务尚未提交,但写入已经落盘。
- 后续的 chunk 写入如果失败或中断,`fs.files` 中的文档就会残留,形成“孤儿元数据”。
- 事务的 rollback 只能回滚显式写入的业务集合,对 `fs.*` 集合完全无效。
正确的配合方式:分离关注点,上传归上传,事务管关联
实际上,真正需要原子性的不是“文件存储”这一动作,而是“该文件属于某条业务记录”。将上传和绑定分离是最符合现实约束的做法。
步骤:
1. 单独调用 `bucket.openUploadStream()`,获取返回的 `uploadStream.id`(即 `fs.files._id`)。
2. 在事务中执行业务逻辑,例如 `collection.updateOne({ _id: orderId }, { $set: { attachmentId: fileId } })`。
3. 仅当事务成功提交后,才认为该文件正式归属业务实体。若事务失败,`fs.files` 和 `fs.chunks` 仍存在,但业务侧没有引用,可以后续异步清理。
需要特别提醒:不要在事务中调用任何 `bucket.*` 方法,包括 `bucket.find()` 或 `bucket.delete()`。这些操作虽然不报错,但同样游离于事务之外,无法保证一致性。
识别并清理上传中断产生的孤儿文件
网络抖动、进程崩溃等情况容易导致 `fs.files` 已写入,而 `fs.chunks` 缺失。这类文件使用 `openDownloadStreamById()` 无法读取,只能主动清理。
通过聚合管道定位孤儿文档:
db.fs.files.aggregate([
{
$lookup: {
from: "fs.chunks",
localField: "_id",
foreignField: "files_id",
as: "chunks"
}
},
{ $match: { "chunks.0": { $exists: false } } }
])
确认结果后,仅删除 `fs.files` 中的对应记录:
db.fs.files.deleteMany({ _id: { $in: [ /* 上述查出的 ObjectId 数组 */ ] } })
重要提醒:
- 切勿直接对 `fs.chunks` 执行 `deleteMany`。chunk 文档本身没有独立语义,必须依赖 `files_id` 关联判断。
- 清理任务建议作为定时作业执行(例如每天凌晨一次),避免频繁扫描影响主业务。
- 生产环境中,上传前最好生成唯一的业务标识(如 UUID)写入 `metadata` 字段,便于后续按业务维度排查。
总结
真正的难点不在于写代码,而在于接受 GridFS 与事务天然割裂的事实。所有试图绕过这一限制的做法,最终都会在异常路径上付出更高的维护成本——孤儿文件、状态不一致、人工救火。划清边界,比强行缝合更加可靠。