CSDN 原文镜像
本文为作者 CSDN 博客的全文镜像,原文发布于 2026-07-07。为适配本站结构,仅补充了站内元数据与来源说明,正文主体保持原文内容。
- 原文链接:https://blog.csdn.net/m0_63309778/article/details/162658023
- 站内分区:工程实践 / 对象存储直传一致性

文件上传一致性:为什么对象存储直传不是一个简单的 Upload 接口
在很多业务系统里,文件上传看起来只是一个普通接口:前端选择文件,后端接收文件,然后保存起来。
但只要系统进入生产环境,尤其是涉及大文件、多文件并发、断点续传、对象存储、异步解析、安全扫描、知识库入库等场景,文件上传就不再是一个简单的 POST /upload 问题,而会变成一个典型的“分布式一致性治理”问题。
因为在现代文件服务架构里,文件通常不会直接存进数据库,而是会拆成两个事实源:
| 系统 | 保存内容 | 是否保存文件二进制 | 主要职责 |
|---|---|---|---|
| 数据库 | 文件元数据、业务归属、状态、上传会话、分片记录、事件记录 | 否 | 业务事实源 |
| 对象存储 | 文件内容、对象 ETag、对象元数据、multipart 临时分片 | 是 | 对象事实源 |
这也意味着:数据库事务只能保护数据库内部的一致性,无法回滚已经发生在对象存储里的外部副作用。
比如:
- 文件已经 PUT 到对象存储,但数据库状态还没更新;
- multipart 已经 complete 成功,但数据库提交失败;
- 数据库记录显示文件可用,但对象被人为删除;
- 上传初始化成功,用户关掉页面,最终留下未完成上传;
- 分片上传成功,但服务端没有记录对应 part 信息。
这些问题不是某一行代码写错了,而是对象存储直传架构天然会遇到的工程问题。
真正可靠的文件上传系统,靠的不是“强行把所有东西包进一个事务”,而是:
短数据库事务 + 明确状态机 + 幂等接口 + 对象事实校验 + 补偿机制 + 定时清理 + 周期对账。
一、为什么不建议让后端代理所有文件上传
最直观的文件上传方式是:
浏览器 -> 后端服务 -> 文件系统 / 对象存储这种方式实现简单,但在生产环境里会很快遇到瓶颈:
- 大文件会占用后端连接、线程、带宽和内存缓冲。
- 文件流量全部经过业务服务,扩容成本高。
- 上传进度、断点续传、分片重试都更复杂。
- 后端重启、网关超时、网络抖动都会影响上传稳定性。
- 多文件并发上传时,后端容易变成流量瓶颈。
因此,生产系统里更常见的方案是对象存储预签名直传。
典型流程如下:
1. 前端请求文件服务初始化上传
2. 文件服务写入数据库元数据,并生成预签名上传 URL
3. 前端直接 PUT 文件或分片到对象存储
4. 前端调用 complete 接口通知文件服务
5. 文件服务校验对象真实存在、大小正确、分片完整
6. 校验通过后,数据库状态推进为上传完成
7. 后续触发扫描、解析、入库等异步任务这个架构的本质是:
业务服务负责权限、元数据、状态和校验;文件内容流量直接走对象存储。
这样可以显著降低后端压力,也更适合大文件和高并发场景。

二、数据库和对象存储分别负责什么
在文件服务里,数据库和对象存储不应该互相替代。
数据库适合保存业务事实,例如:
- 文件 ID;
- 原始文件名;
- 文件大小;
- 文件类型;
- 对象存储 key;
- 上传状态;
- 业务归属;
- 上传模式;
- 分片上传会话;
- 分片完成记录;
- SHA256 / ETag;
- 扫描状态;
- 解析状态;
- 异步事件状态。
对象存储适合保存对象事实,例如:
- 文件二进制内容;
- 对象 ETag;
- Content-Type;
- 对象元数据;
- multipart 临时分片;
- multipart complete 后的最终对象。
这两个系统之间通常通过 objectKey 或类似字段建立关联。
一个比较通用的对象 key 设计可以是:
namespace/{bizType}/{yyyy}/{MM}/{dd}/{fileId}.{ext}这里要注意,objectKey 最好具备一定隔离能力,但不要暴露敏感业务信息。它既要方便治理和清理,也要避免把真实业务名称、客户名称、用户信息直接写进对象路径。
三、直传模式的核心问题
小文件一般可以走 direct upload,也就是单对象直传。
流程大致如下:
前端 -> 文件服务:初始化上传
文件服务 -> 数据库:写入文件元数据,状态为 INIT
文件服务 -> 对象存储:生成预签名 PUT URL
文件服务 -> 前端:返回 fileId、uploadUrl、requiredHeaders
前端 -> 对象存储:PUT 文件
前端 -> 文件服务:调用 complete
文件服务 -> 对象存储:statObject 校验对象
文件服务 -> 数据库:状态推进为 UPLOADED / SAFE这里有一个非常关键的点:
前端 PUT 成功,不等于业务上传成功。
PUT 成功只说明对象存储收到了文件,但业务服务还没有确认:
- 这个对象是不是属于当前上传任务;
- 大小是否正确;
- 类型是否正确;
- ETag 是否一致;
- SHA256 是否一致;
- 是否允许进入后续业务流程。
所以,只有 complete 接口成功后,业务上才应该认为文件上传完成。
直传模式下常见的不一致是:
数据库:文件状态仍是 INIT / UPLOADING
对象存储:文件对象已经存在这种情况通常发生在:
- 前端 PUT 成功后没有调用 complete;
- 用户关闭页面;
- 网络断开;
- complete 请求超时;
- 服务重启;
- 网关异常。
这不是数据库事务能解决的问题,因为文件对象已经在对象存储里了。
正确的治理方式是:
- complete 接口可重试;
- 上传状态可查询;
- 超时未完成上传定时清理;
- 必要时做对象存储对账;
- 对确认无业务价值的对象进行删除。
四、multipart 分片上传为什么更复杂
大文件通常需要走 multipart upload。
它的流程比直传复杂很多:
1. 初始化 multipart 上传
2. 对象存储创建 uploadId
3. 数据库记录上传会话
4. 前端逐个上传 part
5. 每个 part 上传成功后,前端上报 partNumber 和 ETag
6. 服务端记录分片完成状态
7. complete 时,服务端校验数据库 part 记录
8. 服务端调用对象存储 listParts 对账
9. 对账一致后,调用 completeMultipartUpload
10. 对象存储合并最终对象
11. 数据库状态推进为上传完成multipart 的核心难点在于:它不是一个请求完成的,而是跨多个请求、多个分片、多个状态。
因此服务端不能盲信客户端传回来的 parts 列表。
更稳的做法是:
- 每个 part 上传成功后,客户端必须调用服务端接口记录 part 完成。
- 服务端保存
partNumber + ETag + size。 - complete 前,服务端查询本地 part 记录。
- 服务端调用对象存储
listParts。 - 将数据库记录和对象存储记录做双边对账。
- 只有数量、partNumber、ETag 都一致时,才允许 complete。
也就是说:
客户端只是触发 complete,真正判断上传是否完整的是服务端。
五、事务边界:数据库能回滚,对象存储不能回滚
很多文件服务都会在初始化上传、完成上传、取消上传、记录分片等操作上加数据库事务。
这当然是必要的。
数据库事务可以保护:
- 文件元数据插入;
- 上传状态更新;
- 分片记录插入;
- 上传会话记录;
- outbox 事件记录;
- 本地状态清理。
但是它不能保护对象存储操作。
例如:
- PUT object;
- createMultipartUpload;
- completeMultipartUpload;
- abortMultipartUpload;
- removeObject;
- statObject;
- listParts。
原因很简单:对象存储不是数据库事务参与者。
当你调用了对象存储的 complete multipart,一旦对象存储合并成功,即使后面的数据库事务失败,最终对象也不会因为数据库回滚而自动消失。
所以在 MySQL + MinIO/S3 这类架构里,不要幻想通过一个 @Transactional 解决所有一致性问题。
更准确的理解应该是:
数据库事务负责本地一致性,对象存储副作用依赖补偿、重试和对账治理。
六、为什么不建议做分布式事务
有人会想到:能不能让数据库和对象存储一起提交、一起回滚?
实际工程中通常不这么做,原因包括:
- 对象存储通常不是传统 XA 事务资源。
- 文件上传可能持续几秒到几小时,不适合占用长事务。
- 预签名直传下,文件内容绕过后端,服务端无法把整个上传过程包进事务。
- multipart 本身就是跨多次请求的协议,不适合短事务模型。
- 分布式事务复杂度高,故障恢复和运维成本大。
- 即使用了分布式锁,也不能替代事务、幂等和对账。
所以更实际的方案不是“强行事务化对象存储”,而是:
状态机 + 幂等接口 + 对象校验 + 本地事务 + Outbox + 补偿任务 + 生命周期规则 + 周期对账这套方案更符合对象存储系统的工程特性。
七、complete 接口是业务承诺点
文件上传链路里,complete 是非常关键的接口。
它不是一个简单的“告诉后端我传完了”。
它真正代表的是:
服务端基于对象存储事实校验后,正式承认这个文件成为业务可用资产。
所以 complete 阶段至少应该做这些校验:
- 对象是否存在;
- 对象大小是否等于初始化声明的大小;
- Content-Type 是否符合预期;
- ETag 是否一致;
- SHA256 是否一致;
- multipart part 是否完整;
- 数据库状态是否允许推进;
- 当前用户或业务上下文是否有权限完成该文件。
对于大文件,还需要注意性能问题。
如果 complete 阶段为了校验 SHA256,把一个 1GB 文件从对象存储重新读回服务端计算一遍,性能会很差。
更好的做法是:
- 初始化时让客户端提供 SHA256;
- 上传时把 SHA256 写入对象存储 metadata;
- complete 时通过 statObject 读取 metadata;
- metadata 缺失时,再使用流式计算作为兜底。
这样既保证完整性,又避免大文件 complete 阶段反复读取对象内容。
八、幂等是文件上传系统的必选项
文件上传链路里,complete、part complete、cancel、resume 都非常容易被重复调用。
比如:
- 浏览器请求超时;
- 网关返回 504;
- 用户刷新页面;
- 前端不知道请求是否成功;
- 服务端成功处理但响应丢失;
- 网络抖动导致客户端重试。
所以接口必须尽量幂等。
对于分片完成接口,可以这样设计:
- 如果同一个 fileId、uploadId、partNumber 已经记录过;
- 并且 ETag、size 一致;
- 那么重复提交直接返回成功;
- 如果 partNumber 相同但 ETag 或 size 不一致,则返回冲突。
对于 direct complete,可以这样设计:
- 如果文件已经是完成状态,再次 complete 时重新校验对象;
- 校验通过则直接返回当前文件信息;
- 不重复推进状态,不重复发事件。
对于 multipart complete,会更复杂。
因为 multipart 一旦 complete 成功,uploadId 通常不能再重复 complete,也不能再 listParts。
所以 multipart complete 的幂等能力通常需要配合状态机和对账任务一起做。
九、最值得引入的中间状态:COMPLETING
multipart complete 最大的风险点在这里:
1. 服务端校验本地 part 记录
2. 服务端调用对象存储 completeMultipartUpload
3. 对象存储合并最终对象成功
4. 服务端准备更新数据库状态
5. 数据库提交失败或服务崩溃此时对象存储里已经有最终文件,但数据库可能仍然认为它处于 UPLOADING。
这就是典型的不一致。
为了解决这个问题,可以在状态机里增加一个中间状态:
INIT -> UPLOADING -> COMPLETING -> UPLOADED更稳的流程是:
1. complete 请求进入
2. 数据库先把文件状态标记为 COMPLETING
3. 调用对象存储 completeMultipartUpload
4. stat 最终对象
5. 校验对象大小、类型、SHA256
6. 数据库状态推进为 UPLOADED
7. 写入上传完成事件这样做的好处是:
如果服务在对象存储 complete 成功后崩溃,后台对账任务可以看到这个文件处于 COMPLETING 状态。
然后它可以:
- stat 最终对象;
- 校验大小、类型和摘要;
- 校验通过则补偿为 UPLOADED;
- 校验失败则标记为异常状态;
- 必要时触发告警或人工处理。
COMPLETING 状态的价值在于:它让系统知道这个文件处在“对象存储可能已经完成,但数据库还没确认”的危险区间。
没有这个状态,恢复逻辑会模糊很多。
十、Outbox:解决数据库状态和消息投递的一致性
文件上传完成后,通常还会触发后续流程:
- 文件安全扫描;
- 文档解析;
- OCR;
- 表格抽取;
- 向量化;
- 知识库入库;
- 业务回调;
- 审计记录。
这些后续动作一般不应该同步阻塞在上传接口里,而是通过消息队列异步处理。
但是这里又会出现一个一致性问题:
数据库状态更新成功了,但消息没发出去怎么办?
消息发出去了,但数据库事务回滚了怎么办?比较稳妥的方式是使用 Outbox 模式。
也就是:
- 在同一个数据库事务里更新文件状态;
- 同时写入一条待投递事件到 outbox 表;
- 事务提交后,由后台 dispatcher 扫描 outbox;
- dispatcher 再把事件投递到 Kafka、RabbitMQ 或其他消息系统;
- 投递成功后更新 outbox 状态;
- 投递失败则保留记录,后续重试。
这样可以保证:
文件状态提交成功 <=> 待投递事件也落库成功消息系统不再直接参与业务事务,而是由 outbox 做可靠缓冲。
在多实例部署时,outbox dispatcher 还需要考虑 claim 机制,避免多个节点同时投递同一批事件。
常见做法是:
- 给事件增加 PROCESSING 状态;
- 增加 lockedBy;
- 增加 lockedUntil;
- dispatcher 先抢占事件,再投递;
- 下游消费端仍然要按 eventId 做幂等。
十一、定时清理和对象对账都很重要
文件上传系统不能只依赖实时请求。
因为用户可能:
- 上传到一半关页面;
- 断网;
- 浏览器崩溃;
- 重复上传;
- 上传成功后 complete 失败;
- 业务服务重启;
- 对象存储短暂不可用。
所以后台治理任务是必须的。
常见任务包括:
| 任务 | 作用 |
|---|---|
| 清理超时 INIT / UPLOADING 文件 | 处理长期未完成上传 |
| abort 过期 multipart upload | 释放对象存储临时分片 |
| 清理软删除对象 | 延迟物理删除,支持恢复窗口 |
| 对账已完成文件 | 发现数据库认为存在但对象缺失的问题 |
| 对账 COMPLETING 文件 | 修复 multipart complete 后数据库未确认的问题 |
| 重试 outbox 事件 | 保证后续异步流程可靠触发 |
这里要区分 cleanup 和 reconciliation。
cleanup 更偏删除和成本控制。
reconciliation 更偏状态修复和一致性恢复。
例如:
对于长期停留在 UPLOADING 的文件,可以:
- 查询数据库记录;
- stat 对象存储;
- 如果对象不存在,软删元数据;
- 如果对象存在但校验失败,删除对象并记录失败原因;
- 如果对象存在且校验通过,可以根据业务策略补偿为完成状态。
对于 COMPLETING 文件,可以:
- stat 最终对象;
- 校验 size、Content-Type、SHA256;
- 校验通过则推进为 UPLOADED;
- 校验失败则标记为异常。
对于已经完成的文件,可以周期性抽样检查:
- stat objectKey;
- 如果对象不存在,标记 STORAGE_MISSING;
- 如果大小或摘要不一致,标记 STORAGE_CORRUPTED;
- 触发告警,而不是静默删除数据库记录。
业务元数据通常具有审计价值,不建议因为对象缺失就直接删除数据库记录。
十二、对象存储生命周期规则是最后一道兜底
有一类异常,服务侧可能完全看不见。
比如:
对象存储 createMultipartUpload 成功
服务进程还没来得及写数据库就崩溃这时数据库里没有 uploadId,后端定时任务也不知道这个 multipart upload 存在,自然无法主动 abort。
这类问题最适合交给对象存储自己的 lifecycle 规则兜底。
例如:
- 自动 abort incomplete multipart upload;
- 临时上传目录设置过期规则;
- 测试环境 bucket 设置更短保留时间;
- 低价值临时对象自动清理。
服务侧 cleanup 和对象存储 lifecycle 不是二选一,而是互补关系:
服务侧 cleanup:基于业务元数据做精细治理
对象存储 lifecycle:清理服务侧不可见的存储残留两者结合,才能覆盖更多异常场景。
十三、前端也要参与一致性治理
文件上传一致性不是后端一个人的事情。
前端如果处理不当,也会制造大量悬挂状态。
前端至少要遵守几个原则。
第一,PUT 成功不等于上传完成。
页面上不能在 PUT 成功后直接展示“上传成功”,必须等 complete 成功。
第二,预签名 URL 不要乱带业务鉴权头。
上传到对象存储时,只带服务端要求的 headers。不要把业务服务里的 Authorization、租户 ID、项目 ID 等请求头原样带到对象存储请求里,否则可能导致 CORS 或签名校验失败。
第三,multipart 每个 part 成功后要及时上报。
对象存储收到了 part,不代表服务端知道这个 part 已完成。服务端 complete 时通常依赖本地 part 记录和对象存储 listParts 对账。
第四,前端应该保存恢复信息。
大文件上传建议把这些信息保存到 IndexedDB:
- fileId;
- uploadId;
- chunkSize;
- totalChunks;
- sha256;
- 已完成 partNumber;
- 每个 part 的 ETag。
这样用户刷新页面后,可以继续恢复上传,而不是从头开始。
第五,complete 超时后不要立刻重新初始化。
更好的处理顺序是:
- 查询文件状态;
- 如果已经完成,直接展示成功;
- 如果仍在上传中,查询 resume 信息;
- 缺哪些 part 就补哪些 part;
- 对象已上传但状态未完成时,重试 complete。
第六,CORS 必须暴露 ETag。
如果浏览器拿不到 ETag,multipart 上传就无法可靠上报 part 信息。
十四、多文件并发上传不一定需要 Kafka 或 Redis 锁
很多人会问:多文件同时上传,是不是要用 Kafka 排队?是不是要用 Redis 或 Redisson 加锁?
不一定。
在对象存储直传架构下,文件内容主流量是:
前端 -> 对象存储而不是:
前端 -> 后端 -> 对象存储所以多文件并发上传的核心控制点通常在前端和对象存储,而不是后端队列。
比较推荐的方式是:
- 每个文件独立 init;
- 前端控制同时上传文件数,比如 3 到 5 个;
- 大文件内部控制 part 并发数;
- 后端只负责元数据、预签名 URL、状态推进和 complete 校验;
- Kafka 只负责上传完成后的异步事件;
- Redis / Redisson 只作为限流、调度互斥或热点资源保护的补充工具。
数据库唯一键、状态机、幂等接口,往往比应用层分布式锁更接近数据事实。
例如:
- 同一个上传会话不能重复创建;
- 同一个 partNumber 不能插入两条冲突记录;
- 同一个 eventId 不能重复投递;
- 状态推进必须满足条件更新。
这些约束应该优先交给数据库兜底。
Redis 锁可以用,但不要把它当成核心一致性方案。
十五、哪些不一致场景最常见
下面是一些真实系统里很常见的异常场景:
| 场景 | 可能留下的状态 | 治理方式 |
|---|---|---|
| 直传 PUT 成功但没有 complete | 数据库未完成,对象已存在 | 超时清理或对账补偿 |
| complete 校验失败 | 对象存在,数据库不推进 | 记录失败原因,允许重试或清理 |
| complete 校验成功但数据库提交失败 | 对象存在,数据库仍未完成 | complete 幂等重试,对账修复 |
| multipart 创建成功但数据库记录失败 | 对象存储有 uploadId,数据库无记录 | 立即 abort,生命周期兜底 |
| part PUT 成功但 part complete 失败 | 对象存储有 part,数据库无 part 记录 | 前端重试 part complete 或重传 part |
| multipart complete 成功但数据库失败 | 最终对象已生成,数据库仍在上传中 | COMPLETING 状态 + 对账修复 |
| 软删除后物理删除失败 | 数据库已删除,对象仍存在 | purge 任务重试 |
| 数据库记录存在但对象被删 | 数据库认为可用,对象 404 | 标记 STORAGE_MISSING 并告警 |
这张表说明了一个事实:
孤儿对象和悬挂状态不是单点 bug,而是外部副作用和本地事务边界共同导致的架构问题。
十六、推荐的演进路线
如果要从一个基础文件上传服务,逐步演进到生产可用的文件服务,可以分几个阶段。
第一阶段:基础直传能力
- 支持预签名 URL;
- 支持 direct upload;
- 支持基本元数据;
- 支持 complete 校验;
- 支持上传状态查询。
第二阶段:支持大文件 multipart
- 创建 multipart upload;
- 分片预签名 URL;
- part complete 记录;
- listParts 对账;
- completeMultipartUpload;
- 前端断点续传。
第三阶段:补齐幂等和状态机
- part complete 幂等;
- direct complete 幂等;
- multipart complete 幂等;
- 增加 COMPLETING 状态;
- 状态推进增加条件更新或乐观锁。
第四阶段:引入异步事件
- 上传完成写 outbox;
- dispatcher 投递 Kafka / MQ;
- 下游扫描、解析、入库异步执行;
- 消费端按 eventId 幂等。
第五阶段:增加清理和对账
- 清理超时上传;
- abort 过期 multipart;
- purge 软删除对象;
- 对账 COMPLETING 文件;
- 对账已完成文件;
- 增加 STORAGE_MISSING / STORAGE_CORRUPTED 状态。
第六阶段:增强多实例能力
- outbox claim;
- 定时任务 leader lock;
- 热点 fileId 条件更新;
- 租户级上传限流;
- 分布式锁只作为协调补充。
第七阶段:对象操作 outbox 化
当文件量、审计要求和可靠性要求进一步提高后,可以把对象删除、abort multipart、对象校验等外部副作用也抽象成 operation outbox。
每个对象操作都有:
- 操作类型;
- 操作目标;
- 状态;
- 重试次数;
- 错误原因;
- 下次重试时间;
- 审计日志。
这会增加系统复杂度,不建议一开始就做,但在高可靠文件平台里很有价值。
十七、一个实用设计原则
文件上传一致性可以用一句话概括:
数据库记录业务承诺,对象存储保存实际内容;业务承诺必须由对象事实校验后产生,外部副作用必须能被补偿或对账。
落到工程实践里,就是:
- 初始化只创建可恢复的上传意图;
- 文件内容尽量直传对象存储;
- complete 是业务承诺产生点;
- complete 必须校验对象事实;
- 分片完成要幂等;
- multipart complete 要考虑崩溃恢复;
- 数据库事务只负责本地一致性;
- 对象存储副作用要靠补偿和对账;
- 长时间未完成上传要清理;
- 已完成资产对象缺失要告警;
- Kafka 用于异步解耦,不用于传文件内容;
- Redis 锁用于协调,不替代数据库约束;
- 生命周期规则用于清理服务侧不可见的存储残留。
总结
文件上传系统真正难的地方,不是把文件传上去,而是处理各种“传了一半”“传完但没确认”“确认了但消息没发出去”“对象有了但数据库没更新”“数据库有记录但对象没了”的异常状态。
在对象存储直传架构下,MySQL 和 MinIO/S3 本来就不是一个事务系统里的两个表,而是两个不同的事实源。
所以更成熟的文件上传架构,不应该追求不现实的强分布式事务,而应该围绕以下几个关键词设计:
状态机
幂等
校验
补偿
Outbox
清理
对账
生命周期
可观测只要这些能力逐步补齐,文件上传服务就会从“能用”,走向“生产可用”,再走向“可恢复、可治理、可观测”的工程级文件平台。
写这篇文章的目的,不是为了证明某个技术有多先进,而是希望把一个真实问题拆开,讲清楚它背后的设计逻辑、工程取舍和落地路径。
AI 应用开发正在从“会调用模型”进入“会设计系统”的阶段。
模型只是起点,真正决定效果的,往往是数据、流程、工具、上下文、检索、评估、工程架构,以及持续迭代的能力。
我会继续围绕这些方向做系统化分享:
Agent、RAG、LLM 工程化、企业 AI 应用、私有化部署、系统架构与真实项目复盘。
希望这里不仅是一个技术博客,也能逐渐成为一个聚集 AI 应用开发者、产品实践者和工程落地者的交流空间。
dd-y 的技术博客:把想法落地,把技术讲透。
欢迎关注,一起把 AI 从 Demo 做到真正可用。
如果你也在关注 AI 应用落地、Agent 开发、RAG 系统、LLM 工程化、企业知识库、私有化部署 等方向,欢迎扫码加入我的技术交流群。
这里不会只聊概念,更希望一起交流真实项目中的问题、方案、踩坑经验和落地思路。
备注:如果二维码过期,可以私信我拉你进群。