文件上传是后台系统里很常见的功能。
如果只是上传几 MB 的图片、文档,直接用 MultipartFile 接收就可以:
@PostMapping("/upload")
public String upload(MultipartFile file) {
// 保存文件
return "success";
}但如果业务中需要上传:
大视频
压缩包
安装包
大型 Excel
设计文件
几百 MB 甚至几个 GB 的文件
普通上传方式很快就会暴露问题:
文件太大,上传时间长
网络中断后需要重新上传
同一个文件被多人重复上传
服务端带宽和磁盘压力很大
多实例部署时分片容易散落在不同机器
上传成功后还涉及权限、安全扫描和下载鉴权所以,大文件上传通常会设计成:
秒传 + 分片上传 + 断点续传分成两部分:
第一部分:Java 后端接收分片并合并文件
第二部分:生产环境推荐的对象存储 Multipart Upload 方案第一种方案适合理解原理,也适合内部系统、小文件低并发场景。
第二种方案更适合真实生产环境,因为大文件流量不经过 Java 应用服务器,稳定性和扩展性更好。
一、核心概念
1. 秒传
秒传不是真的瞬间上传文件。
它的本质是:
上传前先计算文件指纹,如果服务端已经存在相同文件,并且这个文件已经处于可用状态,就直接完成用户文件绑定。
常见文件指纹可以使用:
文件 Hash + 文件大小例如:
fileHash = abc123
fileSize = 524288000普通业务可以使用:
MD5 + fileSize安全要求更高的场景可以使用:
SHA-256 + fileSize需要注意:
不要只用文件名判断秒传,因为同名文件内容可能完全不同。
在生产环境中,秒传还有一个重要前提:在生产环境中,秒传不能只判断“服务器里有没有这个文件”。
更准确地说,只有这个文件已经完整上传、合并成功、Hash 校验通过,并且安全扫描通过后,才允许秒传。
也就是说:
只有 file_storage.status = AVAILABLE 的文件,才允许被秒传复用。
如果文件还处于下面这些状态,就不能秒传:
INIT 刚创建记录,还没有上传完成
MERGING 正在合并分片
UPLOADED 已上传完成,但还没安全扫描
SCANNING 正在安全扫描
BLOCKED 被安全策略拦截
FAILED 处理失败原因很简单:
秒传复用的是已经存在的物理文件,如果这个物理文件本身还没有确认安全、完整、可用,就不能直接让其他用户复用。
举个例子:
用户 A 上传了一个视频,系统已经收到了所有分片,也完成了合并。
此时文件状态是:
UPLOADED但它还没有经过安全扫描。
如果用户 B 上传了同一个视频,系统发现文件 Hash 一样,就直接让 B 秒传成功,那么 B 就可能拿到一个还没有经过安全检查的文件。
所以更安全的做法是:
上传完成 ≠ 文件可用
安全扫描通过后,文件才可用只有当文件状态变成:
AVAILABLE才允许后续用户通过秒传直接复用。
2. 分片上传
分片上传是把一个大文件拆成多个小块上传。
例如一个 500 MB 文件:
500 MB 文件
拆成 100 个分片
每个分片 5 MB如果是 Java 后端合并版,可以把分片编号叫做:
chunkIndex一般从 0 开始:
0, 1, 2, ... 99
如果是对象存储 Multipart Upload,一般使用:
partNumber
通常从 1 开始:
1, 2, 3, ... 100这两个概念不要混用。
最终版里统一约定:
Java 后端合并版:uploadedChunks,从 0 开始
对象存储 Multipart Upload:uploadedParts,从 1 开始
3. 断点续传
断点续传解决的是:
上传过程中断后,下次不需要从头上传,只上传缺失的分片。
例如:
100 个分片
已经上传 60 个
网络断了下次继续上传时,只需要上传剩下的分片。
断点续传的核心是:
服务端必须知道哪些分片已经上传成功。所以生产环境不能只依赖前端缓存。
更稳妥的做法是:
前端保存上传结果
+
后端记录分片状态
+
必要时调用对象存储 ListParts 兜底校验二、生产方案整体流程
生产环境推荐使用对象存储 Multipart Upload。
整体流程如下:
客户端选择文件
↓
客户端计算文件 Hash
↓
GET /upload/check
↓
服务端检查是否存在 AVAILABLE 文件
↓
如果存在,前端调用 POST /upload/init 完成秒传绑定
↓
如果不存在,初始化 Multipart Upload
↓
服务端生成预签名分片上传 URL
↓
前端直传分片到对象存储
↓
前端上传成功后回传 partNumber + ETag
↓
服务端记录 part 上报结果
↓
所有 part 上传完成
↓
服务端调用对象存储 ListParts 校验
↓
校验通过后 completeMultipartUpload
↓
文件进入 UPLOADED / SCANNING
↓
异步安全扫描
↓
扫描通过后变为 AVAILABLE
↓
用户下载时通过鉴权接口生成临时下载 URL整体架构如下:
前端
│
│ 1. 计算文件 Hash
│ 2. 请求上传任务
│ 3. 获取预签名 URL
│ 4. 直传分片
│ 5. 上报 part 结果
│ 6. 通知完成上传
▼
Java 后端
│
│ 1. 鉴权
│ 2. 秒传检查
│ 3. 创建上传任务
│ 4. 生成预签名 URL
│ 5. 记录上传状态
│ 6. 校验 ListParts
│ 7. 调用对象存储合并
│ 8. 提交安全扫描
│ 9. 下载鉴权
▼
对象存储
│
│ 1. 保存分片
│ 2. 完成 Multipart Upload
│ 3. 保存最终对象三、表结构设计
生产环境不建议只用一张 file_info 表。
更合理的做法是拆成:
file_storage:物理文件表
user_file:用户文件关系表
upload_task:上传任务表
upload_part:对象存储分片记录表
file_chunk:Java 后端合并版分片表
如果你的项目只采用对象存储 Multipart Upload,可以不建 file_chunk 表。
1. file_storage:物理文件表
file_storage 描述的是一个物理文件。
这里采用多租户隔离策略:
tenant_id + file_hash + file_size 唯一也就是说,不同租户之间即使上传相同内容,也不共享同一条物理文件记录。
表结构如下:
CREATE TABLE file_storage (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT NOT NULL COMMENT '租户ID,用于多租户数据隔离',
file_hash VARCHAR(128) NOT NULL COMMENT '文件Hash值,用于判断文件内容是否相同,例如MD5或SHA-256',
hash_algorithm VARCHAR(20) NOT NULL DEFAULT 'MD5' COMMENT 'Hash算法,例如MD5、SHA-256',
file_size BIGINT NOT NULL COMMENT '文件大小,单位字节',
storage_type VARCHAR(30) NOT NULL COMMENT '存储类型,例如LOCAL、MINIO、OSS、S3、COS',
bucket_name VARCHAR(100) COMMENT '对象存储Bucket名称,本地存储时可为空',
object_key VARCHAR(500) COMMENT '对象存储中的文件Key,本地存储时可为空',
storage_path VARCHAR(500) COMMENT '本地存储路径,对象存储时可为空',
public_url VARCHAR(500) COMMENT '公开访问地址,私有文件一般为空,下载时通过临时URL访问',
status VARCHAR(30) NOT NULL COMMENT '文件状态:INIT-初始化;MERGING-合并中;UPLOADED-已上传待扫描;SCANNING-扫描中;AVAILABLE-可用;BLOCKED-已拦截;FAILED_RETRYABLE-失败可重试',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME NOT NULL COMMENT '更新时间',
UNIQUE KEY uk_tenant_hash_size (tenant_id, file_hash, file_size),
KEY idx_object_key (object_key),
KEY idx_status_update_time (status, update_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物理文件存储表';如果你的业务允许跨租户全局去重,可以把唯一索引改成:
UNIQUE KEY uk_hash_size (file_hash, file_size)但 SaaS、多租户、企业文件系统,通常更推荐租户隔离。
file_storage.status 建议设计为:
INIT
表示物理文件记录已创建,但文件还没有真正上传完成。
通常出现在初始化上传任务之后。
MERGING
表示文件正在合并中。
在 Java 后端合并分片,或者对象存储 completeMultipartUpload 过程中,可以使用这个状态。
UPLOADED
表示文件已经上传完成,或者分片已经合并完成。
但此时文件还没有经过安全扫描,所以还不能直接下载,也不能被秒传复用。
SCANNING
表示文件正在进行安全扫描。
例如病毒扫描、文件类型校验、内容审核、敏感内容检测等。
AVAILABLE
表示文件已经可用。
只有这个状态下,文件才允许下载,也才允许被秒传复用。
BLOCKED
表示文件被安全策略拦截。
例如文件类型不允许、命中病毒扫描、内容审核不通过等。
FAILED_RETRYABLE
表示文件处理失败,但允许后续重试。
例如合并失败、对象存储临时异常、扫描服务暂时不可用等。注意:
秒传只允许 AVAILABLE 状态。2. user_file:用户文件关系表
user_file 描述的是用户和文件之间的关系。
同一个物理文件可以被多个用户引用。
CREATE TABLE user_file (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT NOT NULL COMMENT '租户ID,用于多租户数据隔离',
user_id BIGINT NOT NULL COMMENT '用户ID,表示该文件归属哪个用户',
storage_id BIGINT NOT NULL COMMENT '物理文件ID,关联 file_storage.id',
file_name VARCHAR(255) NOT NULL COMMENT '用户上传时的原始文件名,用于页面展示',
content_type VARCHAR(100) COMMENT '文件Content-Type,例如 image/png、video/mp4、application/pdf',
visibility VARCHAR(20) NOT NULL DEFAULT 'PRIVATE' COMMENT '文件可见性:PRIVATE-私有;PUBLIC-公开',
status VARCHAR(30) NOT NULL DEFAULT 'PENDING_SCAN' COMMENT '用户文件状态:PENDING_SCAN-待安全扫描;AVAILABLE-可用;BLOCKED-已拦截;DELETED-已删除',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME NOT NULL COMMENT '更新时间',
KEY idx_tenant_user (tenant_id, user_id),
KEY idx_storage_id (storage_id),
UNIQUE KEY uk_user_storage_name (tenant_id, user_id, storage_id, file_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户文件关系表';这里使用:
tenant_id + user_id + storage_id + file_name作为唯一约束。
含义是:
同一个用户可以用不同文件名保存同一份物理文件。如果你的业务不允许同一个用户重复保存同一份文件,可以改成:
UNIQUE KEY uk_user_storage (tenant_id, user_id, storage_id)user_file.status 建议设计为:
PENDING_SCAN
表示用户文件已创建,但还在等待安全扫描。
此时文件不能下载,也不能对用户展示为可用状态。
AVAILABLE
表示用户文件已经可用。
只有这个状态下,用户才可以正常查看、下载或使用该文件。
BLOCKED
表示用户文件被安全策略拦截。
例如病毒扫描不通过、文件类型不允许、内容审核失败等。
DELETED
表示用户文件已被用户删除。
通常只是删除用户和物理文件之间的关系,不一定立即删除 file_storage 中的物理文件。complete 成功后,用户文件不应该立刻变成 AVAILABLE。
更合理的状态是:
PENDING_SCAN安全扫描通过后,再变成:
AVAILABLE3. upload_task:上传任务
upload_task 描述的是某一次用户上传任务。
CREATE TABLE upload_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
upload_id VARCHAR(64) NOT NULL COMMENT '上传任务ID,业务系统生成的唯一标识',
tenant_id BIGINT NOT NULL COMMENT '租户ID,用于多租户数据隔离',
user_id BIGINT NOT NULL COMMENT '用户ID,表示该上传任务属于哪个用户',
file_hash VARCHAR(128) NOT NULL COMMENT '文件Hash值,用于判断文件内容是否相同,例如MD5或SHA-256',
hash_algorithm VARCHAR(20) NOT NULL DEFAULT 'MD5' COMMENT 'Hash算法,例如MD5、SHA-256',
file_size BIGINT NOT NULL COMMENT '文件大小,单位字节',
file_name VARCHAR(255) NOT NULL COMMENT '用户上传时的原始文件名,用于页面展示',
content_type VARCHAR(100) COMMENT '文件Content-Type,例如 image/png、video/mp4、application/pdf',
chunk_size BIGINT NOT NULL COMMENT '分片大小,单位字节',
total_chunks INT NOT NULL COMMENT '分片总数,Java后端合并版表示chunk数量,对象存储版表示part数量',
storage_type VARCHAR(30) NOT NULL COMMENT '存储类型,例如LOCAL、MINIO、OSS、S3、COS',
bucket_name VARCHAR(100) COMMENT '对象存储Bucket名称,本地存储时可为空',
object_key VARCHAR(500) COMMENT '对象存储中的文件Key,本地存储时可为空',
storage_upload_id VARCHAR(200) COMMENT '对象存储Multipart Upload ID,仅对象存储分片上传时使用',
status VARCHAR(30) NOT NULL COMMENT '上传任务状态:UPLOADING-上传中;MERGING-合并中;UPLOADED-已上传待扫描;SCANNING-扫描中;SUCCESS-成功;FAILED-失败;CANCELED-已取消;EXPIRED-已过期',
fail_reason VARCHAR(1000) COMMENT '失败原因,用于记录上传、合并、扫描等失败信息',
expire_time DATETIME NOT NULL COMMENT '上传任务过期时间,注意不是预签名URL过期时间',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME NOT NULL COMMENT '更新时间',
UNIQUE KEY uk_upload_id (upload_id),
KEY idx_tenant_user_hash_size (tenant_id, user_id, file_hash, file_size),
KEY idx_status_update_time (status, update_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传任务表';upload_task.status 建议设计为:
INIT
表示物理文件记录已创建,但文件还没有真正上传完成。
通常出现在初始化上传任务之后。
MERGING
表示文件正在合并中。
例如 Java 后端正在合并本地分片,或者对象存储正在执行 completeMultipartUpload。
UPLOADED
表示文件已经上传完成,或者分片已经合并完成。
但此时文件还没有经过安全扫描,所以不能下载,也不能被秒传复用。
SCANNING
表示文件正在进行安全扫描。
例如病毒扫描、文件类型校验、内容审核、敏感内容检测等。
AVAILABLE
表示文件已经可用。
只有这个状态下,文件才允许下载,也才允许被秒传复用。
BLOCKED
表示文件被安全策略拦截。
例如病毒扫描不通过、文件类型不允许、内容审核失败、命中敏感内容等。
FAILED_RETRYABLE
表示文件处理失败,但允许后续重试。
例如分片合并失败、对象存储临时异常、安全扫描服务暂时不可用等。这里要区分两个过期时间:
upload_task.expire_time:业务上传任务过期时间,通常是 1 天或 7 天。
预签名 URL 过期时间:通常是 5~30 分钟。
预签名 URL 过期,不代表上传任务过期。
URL 过期后,前端可以重新请求新的分片上传 URL。
4. upload_part:对象存储分片记录表
对象存储 Multipart Upload 场景下,建议增加 upload_part 表。
CREATE TABLE upload_part (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
upload_id VARCHAR(64) NOT NULL COMMENT '上传任务ID,关联 upload_task.upload_id',
part_number INT NOT NULL COMMENT '对象存储分片编号,通常从1开始',
etag VARCHAR(200) COMMENT '对象存储返回的ETag,用于完成Multipart Upload时提交',
part_size BIGINT COMMENT '分片大小,单位字节',
status VARCHAR(30) NOT NULL COMMENT '分片状态:CLIENT_REPORTED-前端已上报;VERIFIED-已通过对象存储校验;FAILED-分片异常',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME NOT NULL COMMENT '更新时间',
UNIQUE KEY uk_upload_part (upload_id, part_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对象存储分片上传记录表';upload_part.status 建议设计为:
CLIENT_REPORTED
表示前端已经上报该分片上传成功。
通常流程是:前端把分片上传到对象存储后,拿到对象存储返回的 ETag,然后调用后端接口,把 partNumber、ETag、partSize 上报给后端。
注意:这个状态只表示“前端已上报”,不代表后端已经完全信任这个分片。
---
VERIFIED
表示该分片已经通过后端校验。
后端在 completeMultipartUpload 前,会调用对象存储的 ListParts 接口,校验 partNumber、ETag、partSize 是否和对象存储中的真实分片一致。
校验通过后,才把状态改为 VERIFIED。
---
FAILED
表示该分片异常。
例如:
- 前端上报的 ETag 和对象存储实际 ETag 不一致
- partNumber 不合法
- 分片大小异常
- 对象存储中找不到该分片
- 分片上传失败这里有一个关键点:
前端回传的 ETag 不能直接完全信任。
更严谨的流程是:
前端上传 part 成功
↓
前端回传 partNumber + ETag
↓
后端记录为 CLIENT_REPORTED
↓
complete 前调用对象存储 ListParts
↓
校验 partNumber、ETag、partSize
↓
校验通过后标记为 VERIFIED
↓
再执行 completeMultipartUpload也可以在 complete 时直接以对象存储 ListParts 返回结果为准。
5. file_chunk:Java 后端合并版分片表
如果采用 Java 后端接收分片并合并,可以使用 file_chunk 表。
CREATE TABLE file_chunk (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
upload_id VARCHAR(64) NOT NULL COMMENT '上传任务ID,关联 upload_task.upload_id'
chunk_index INT NOT NULL COMMENT '分片下标,Java后端合并版通常从0开始',
chunk_size BIGINT NOT NULL COMMENT '分片大小,单位字节',
chunk_hash VARCHAR(128) COMMENT '分片Hash值,用于校验单个分片完整性,例如MD5或SHA-256',
chunk_path VARCHAR(500) COMMENT '分片在本地磁盘或共享存储中的临时路径',
status VARCHAR(30) NOT NULL COMMENT '分片状态:UPLOADING-上传中;SUCCESS-上传成功;FAILED-上传失败',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME NOT NULL COMMENT '更新时间',
UNIQUE KEY uk_upload_chunk (upload_id, chunk_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Java后端合并版分片记录表';file_chunk.status 建议设计为:
UPLOADING
表示分片正在上传中,或者后端已经创建了分片记录,但分片文件还没有完整写入成功。
常见场景:
- 后端先插入 file_chunk 记录
- 状态设置为 UPLOADING
- 然后开始写入临时分片文件
- 写入完成并校验通过后,再改为 SUCCESS
---
SUCCESS
表示分片已经上传成功。
这个状态通常代表:
- 分片文件已经写入本地磁盘或共享存储
- 分片 Hash 校验通过
- 分片路径 chunk_path 已经记录
- 后续合并文件时可以使用该分片
只有 SUCCESS 状态的分片,才应该参与最终文件合并。
---
FAILED
表示分片上传失败。
常见原因:
- 分片文件写入失败
- 分片 Hash 校验失败
- 上传过程中网络中断
- 临时文件移动失败
- 磁盘空间不足
FAILED 状态的分片可以允许前端重新上传。注意:
file_chunk 使用 chunk_index,从 0 开始。
upload_part 使用 part_number,从 1 开始。
四、接口设计
1. 秒传检查接口
GET /upload/check?fileHash=xxx&fileSize=524288000这个接口只检查,不写数据库。
它只判断:
当前租户下是否存在 file_storage.status = AVAILABLE 的文件返回示例:
{
"uploaded": true,
"available": true
}如果不存在可用文件:
{
"uploaded": false,
"available": false
}注意:
GET /upload/check 不创建 user_file 关系。
真正完成秒传绑定,应放到 POST /upload/init。
2. 初始化上传任务
POST /upload/init请求示例:
{
"fileName": "demo.mp4",
"fileHash": "abc123",
"hashAlgorithm": "MD5",
"fileSize": 524288000,
"chunkSize": 5242880,
"totalChunks": 100,
"contentType": "video/mp4"
}如果文件已经 AVAILABLE,则完成秒传绑定:
{
"uploaded": true,
"fileId": 10001,
"status": "AVAILABLE"
}如果需要继续上传,返回上传任务:
{
"uploaded": false,
"uploadId": "upload_xxx",
"uploadedChunks": [0, 1, 2]
}如果是对象存储 Multipart Upload,则返回:
{
"uploaded": false,
"uploadId": "upload_xxx",
"uploadedParts": [1, 2, 3]
}这里要严格区分:
uploadedChunks:Java 后端合并版,从 0 开始
uploadedParts:对象存储 Multipart Upload,从 1 开始3. 查询上传进度
GET /upload/progress?uploadId=upload_xxxJava 后端合并版返回:
{
"uploadId": "upload_xxx",
"status": "UPLOADING",
"totalChunks": 100,
"uploadedChunks": [0, 1, 2],
"progress": 3
}
对象存储版返回:
{
"uploadId": "upload_xxx",
"status": "UPLOADING",
"totalParts": 100,
"uploadedParts": [1, 2, 3],
"progress": 3
}
4. 对象存储 Multipart Upload 初始化
POST /upload/multipart/init返回示例:
{
"uploadId": "biz_upload_xxx",
"storageUploadId": "oss_upload_xxx",
"urlExpireSeconds": 900,
"uploadedParts": [1, 2, 3],
"partUploadUrls": [
{
"partNumber": 4,
"url": "https://oss.xxx.com/object-key?signature=xxx"
}
]
}这里的 urlExpireSeconds 是预签名 URL 的过期时间。
它通常比 upload_task.expire_time 短很多。
5. 记录单个 part 上传完成
POST /upload/multipart/part/complete
请求示例:
{
"uploadId": "biz_upload_xxx",
"partNumber": 1,
"etag": "etag-1",
"partSize": 5242880
}后端只把它记录为:
CLIENT_REPORTED最终是否可信,要在 complete 前通过对象存储 ListParts 校验。
6. 完成 Multipart Upload
POST /upload/multipart/complete请求示例:
{
"uploadId": "biz_upload_xxx"
}complete 时,后端不能直接信任前端上报的 ETag。
正确流程是:
查询 upload_task
↓
校验用户权限
↓
抢占任务状态为 MERGING
↓
调用对象存储 ListParts
↓
校验 partNumber、ETag、partSize
↓
校验通过后 completeMultipartUpload
↓
file_storage.status = UPLOADED 或 SCANNING
↓
user_file.status = PENDING_SCAN
↓
提交安全扫描
↓
返回 fileId + SCANNING返回示例:
{
"fileId": 10001,
"status": "SCANNING"
}这里不能直接返回:
{
"status": "AVAILABLE"
}因为安全扫描还没完成。
7. 下载临时 URL
私有文件不建议直接返回永久 URL。
下载时走鉴权接口:
GET /files/{fileId}/download-url返回:
{
"url": "https://oss.xxx.com/object-key?signature=xxx",
"expireSeconds": 300
}服务端需要校验:
当前用户是否拥有这个 user_file
user_file.status 是否为 AVAILABLE
file_storage.status 是否为 AVAILABLE只有都通过,才生成临时下载 URL。
五、Java 后端合并版实现
Java 后端合并版适合:
内部系统
单机系统
文件不算特别大的场景
学习分片上传原理它不适合高并发大文件生产场景。
因为应用服务器会承担大量文件流量、磁盘 IO 和合并压力。
1. 初始化上传任务
@Transactional
public InitUploadResponse initUpload(InitUploadRequest request) {
Long tenantId = LoginContext.getTenantId();
Long userId = LoginContext.getUserId();
FileStorage storage =
fileStorageMapper.selectAvailableByHashAndSize(
tenantId,
request.getFileHash(),
request.getFileSize()
);
if (storage != null) {
Long fileId = userFileService.bindAvailableUserFile(
tenantId,
userId,
storage.getId(),
request.getFileName(),
request.getContentType()
);
return InitUploadResponse.uploaded(fileId);
}
UploadTask existTask =
uploadTaskMapper.selectUnfinishedTask(
tenantId,
userId,
request.getFileHash(),
request.getFileSize()
);
if (existTask != null) {
List<Integer> uploadedChunks =
fileChunkMapper.selectUploadedChunkIndexes(
existTask.getUploadId()
);
return InitUploadResponse.waitUploadChunks(
existTask.getUploadId(),
uploadedChunks
);
}
String uploadId = UUID.randomUUID().toString().replace("-", "");
UploadTask task = new UploadTask();
task.setUploadId(uploadId);
task.setTenantId(tenantId);
task.setUserId(userId);
task.setFileHash(request.getFileHash());
task.setHashAlgorithm(request.getHashAlgorithm());
task.setFileSize(request.getFileSize());
task.setFileName(request.getFileName());
task.setContentType(request.getContentType());
task.setChunkSize(request.getChunkSize());
task.setTotalChunks(request.getTotalChunks());
task.setStorageType("LOCAL");
task.setStatus("UPLOADING");
task.setExpireTime(LocalDateTime.now().plusDays(1));
uploadTaskMapper.insert(task);
fileStorageMapper.insertIgnoreInit(
tenantId,
request.getFileHash(),
request.getHashAlgorithm(),
request.getFileSize(),
"LOCAL"
);
return InitUploadResponse.waitUploadChunks(
uploadId,
Collections.emptyList()
);
}
注意:
selectAvailableByHashAndSize 只查 AVAILABLE 文件。
不能查到 UPLOADED / SCANNING 就秒传。2. 上传分片
生产级分片上传建议以数据库状态为主线:
1. 插入 file_chunk,状态 UPLOADING
2. 写临时文件
3. 校验分片 Hash
4. move 成正式分片文件
5. 更新 file_chunk 状态 SUCCESS示例代码:
public void uploadChunk(String uploadId,
Integer chunkIndex,
String chunkHash,
MultipartFile chunkFile) throws IOException {
UploadTask task = uploadTaskMapper.selectByUploadId(uploadId);
if (task == null) {
throw new BizException("上传任务不存在");
}
if (!Objects.equals(task.getUserId(), LoginContext.getUserId())) {
throw new BizException("无权上传该任务分片");
}
if (!"UPLOADING".equals(task.getStatus())) {
throw new BizException("当前任务状态不允许上传分片");
}
if (chunkIndex < 0 || chunkIndex >= task.getTotalChunks()) {
throw new BizException("分片编号非法");
}
FileChunk exist =
fileChunkMapper.selectByUploadIdAndIndex(uploadId, chunkIndex);
if (exist != null && "SUCCESS".equals(exist.getStatus())) {
return;
}
fileChunkMapper.insertIgnoreUploading(
uploadId,
chunkIndex,
chunkFile.getSize(),
chunkHash
);
Path chunkDir = Paths.get(
uploadProperties.getTempDir(),
uploadId
);
Files.createDirectories(chunkDir);
Path tempPath =
chunkDir.resolve(chunkIndex + "." + UUID.randomUUID() + ".tmp");
Path chunkPath =
chunkDir.resolve(String.valueOf(chunkIndex));
try {
chunkFile.transferTo(tempPath.toFile());
if (StringUtils.hasText(chunkHash)) {
String actualChunkHash =
HashUtils.hash(
Files.newInputStream(tempPath),
task.getHashAlgorithm()
);
if (!chunkHash.equals(actualChunkHash)) {
throw new BizException("分片完整性校验失败");
}
}
Files.move(
tempPath,
chunkPath,
StandardCopyOption.ATOMIC_MOVE
);
fileChunkMapper.markSuccess(
uploadId,
chunkIndex,
chunkPath.toString()
);
} catch (Exception e) {
Files.deleteIfExists(tempPath);
fileChunkMapper.markFailed(
uploadId,
chunkIndex
);
throw e;
}
}这里不固定写死 MD5,而是根据:
hash_algorithm动态选择 MD5 或 SHA-256。
3. 合并分片
合并分片不建议用一个大事务包住全部文件 IO。
更合理的流程是:
短事务抢占合并任务
↓
事务外执行文件合并
↓
短事务更新上传完成状态
↓
提交安全扫描
合并完成后,不直接变为 AVAILABLE,而是进入:
UPLOADED / SCANNING
扫描通过后才可用。
public MergeFileResponse mergeFile(MergeFileRequest request) throws IOException {
UploadTask task =
uploadTaskMapper.selectByUploadId(request.getUploadId());
if (task == null) {
throw new BizException("上传任务不存在");
}
if (!Objects.equals(task.getUserId(), LoginContext.getUserId())) {
throw new BizException("无权合并该上传任务");
}
boolean locked =
uploadTaskService.markMerging(task.getUploadId());
if (!locked) {
throw new BizException("上传任务正在合并或已完成");
}
try {
Path targetPath = doMergeFile(task);
String mergedHash =
HashUtils.hash(
Files.newInputStream(targetPath),
task.getHashAlgorithm()
);
if (!task.getFileHash().equals(mergedHash)) {
throw new BizException("文件完整性校验失败");
}
FileStorage storage =
fileStorageService.markUploaded(
task.getTenantId(),
task.getFileHash(),
task.getFileSize(),
task.getStorageType(),
targetPath.toString()
);
Long fileId = userFileService.bindPendingScanFile(
task.getTenantId(),
task.getUserId(),
storage.getId(),
task.getFileName(),
task.getContentType()
);
uploadTaskService.markUploaded(task.getUploadId());
securityScanService.submitScanTask(storage.getId());
return MergeFileResponse.scanning(fileId);
} catch (Exception e) {
uploadTaskService.markFailed(
task.getUploadId(),
e.getMessage()
);
fileStorageService.rollbackToInit(
task.getTenantId(),
task.getFileHash(),
task.getFileSize()
);
throw e;
}
}注意:
mergeFile 返回 SCANNING,不返回 AVAILABLE。
六、生产推荐:对象存储 Multipart Upload
真实生产环境的大文件上传,更推荐对象存储 Multipart Upload。
原因是:
文件流量不经过 Java 应用服务器
分片直接进入对象存储
多实例部署更简单
应用服务器不承担大文件合并 IO
更适合高并发和大文件上传
1. 初始化 Multipart Upload
@Transactional
public MultipartInitResponse initMultipart(MultipartInitRequest request) {
Long tenantId = LoginContext.getTenantId();
Long userId = LoginContext.getUserId();
FileStorage storage =
fileStorageMapper.selectAvailableByHashAndSize(
tenantId,
request.getFileHash(),
request.getFileSize()
);
if (storage != null) {
Long fileId = userFileService.bindAvailableUserFile(
tenantId,
userId,
storage.getId(),
request.getFileName(),
request.getContentType()
);
return MultipartInitResponse.uploaded(fileId);
}
UploadTask task =
uploadTaskService.findOrCreateUnfinishedTask(
tenantId,
userId,
request
);
if (StringUtils.hasText(task.getStorageUploadId())) {
List<Integer> uploadedParts =
uploadPartMapper.selectClientReportedParts(
task.getUploadId()
);
List<PartUploadUrl> urls =
objectStorageClient.generatePartUploadUrls(
task.getBucketName(),
task.getObjectKey(),
task.getStorageUploadId(),
task.getTotalChunks()
);
return MultipartInitResponse.waitUploadParts(
task.getUploadId(),
task.getStorageUploadId(),
uploadedParts,
urls,
900
);
}
String objectKey =
buildObjectKey(
tenantId,
request.getFileHash(),
request.getFileName()
);
String storageUploadId =
objectStorageClient.createMultipartUpload(
request.getBucketName(),
objectKey,
request.getContentType()
);
uploadTaskService.bindStorageUploadInfo(
task.getUploadId(),
request.getBucketName(),
objectKey,
storageUploadId
);
List<PartUploadUrl> urls =
objectStorageClient.generatePartUploadUrls(
request.getBucketName(),
objectKey,
storageUploadId,
request.getTotalChunks()
);
return MultipartInitResponse.waitUploadParts(
task.getUploadId(),
storageUploadId,
Collections.emptyList(),
urls,
900
);
}
这里有三个 ID 不要混淆:
uploadId:业务系统自己的上传任务 ID
storageUploadId:对象存储的 Multipart Upload ID
objectKey:对象存储中的文件 Key
2. 记录 part 上传完成
前端上传 part 成功后,把对象存储返回的 ETag 回传给后端。
但后端不能直接完全信任它。
@Transactional
public void completePart(PartCompleteRequest request) {
UploadTask task =
uploadTaskMapper.selectByUploadId(
request.getUploadId()
);
if (task == null) {
throw new BizException("上传任务不存在");
}
if (!Objects.equals(task.getUserId(), LoginContext.getUserId())) {
throw new BizException("无权操作该上传任务");
}
if (!"UPLOADING".equals(task.getStatus())) {
throw new BizException("当前任务状态不允许记录分片");
}
if (request.getPartNumber() < 1 ||
request.getPartNumber() > task.getTotalChunks()) {
throw new BizException("partNumber 非法");
}
uploadPartMapper.insertOrUpdateClientReported(
task.getUploadId(),
request.getPartNumber(),
request.getEtag(),
request.getPartSize()
);
}这里记录的是:
CLIENT_REPORTED
最终 complete 前,还必须通过对象存储 ListParts 校验。
3. 完成 Multipart Upload
public MergeFileResponse completeMultipart(MultipartCompleteRequest request) {
UploadTask task =
uploadTaskMapper.selectByUploadId(
request.getUploadId()
);
if (task == null) {
throw new BizException("上传任务不存在");
}
if (!Objects.equals(task.getUserId(), LoginContext.getUserId())) {
throw new BizException("无权完成该上传任务");
}
boolean locked =
uploadTaskService.markMerging(task.getUploadId());
if (!locked) {
throw new BizException("上传任务正在合并或已完成");
}
try {
List<StoragePart> storageParts =
objectStorageClient.listParts(
task.getBucketName(),
task.getObjectKey(),
task.getStorageUploadId()
);
if (storageParts.size() != task.getTotalChunks()) {
throw new BizException("对象存储分片数量不完整");
}
storageParts.sort(
Comparator.comparing(StoragePart::getPartNumber)
);
for (int i = 1; i <= task.getTotalChunks(); i++) {
StoragePart part = storageParts.get(i - 1);
if (!Objects.equals(part.getPartNumber(), i)) {
throw new BizException("partNumber 不连续");
}
UploadPart reported =
uploadPartMapper.selectByUploadIdAndPartNumber(
task.getUploadId(),
i
);
if (reported == null) {
throw new BizException("缺少 part 上报记录");
}
if (!Objects.equals(reported.getEtag(), part.getEtag())) {
throw new BizException("part ETag 校验失败");
}
uploadPartMapper.markVerified(
task.getUploadId(),
i
);
}
objectStorageClient.completeMultipartUpload(
task.getBucketName(),
task.getObjectKey(),
task.getStorageUploadId(),
storageParts
);
FileStorage storage =
fileStorageService.markUploaded(
task.getTenantId(),
task.getFileHash(),
task.getFileSize(),
task.getStorageType(),
task.getBucketName(),
task.getObjectKey()
);
Long fileId =
userFileService.bindPendingScanFile(
task.getTenantId(),
task.getUserId(),
storage.getId(),
task.getFileName(),
task.getContentType()
);
uploadTaskService.markUploaded(task.getUploadId());
securityScanService.submitScanTask(storage.getId());
return MergeFileResponse.scanning(fileId);
} catch (Exception e) {
uploadTaskService.markFailed(
task.getUploadId(),
e.getMessage()
);
throw e;
}
}
这里有几个关键点:
1. complete 前必须调用对象存储 ListParts
2. 不能完全信任前端上报的 ETag
3. complete 后文件进入 UPLOADED / SCANNING
4. 返回 SCANNING,而不是 AVAILABLE
5. 安全扫描通过后,文件才真正可用
七、秒传怎么实现
秒传流程:
客户端计算文件 Hash
↓
GET /upload/check
↓
服务端只检查是否存在 AVAILABLE 文件
↓
如果存在,前端调用 POST /upload/init
↓
init 创建 user_file 关系
↓
返回 fileId
注意:
check 只查,不写。
init 才真正完成用户文件绑定。
只有 AVAILABLE 文件可以秒传。
如果文件状态是:
UPLOADED
SCANNING
BLOCKED都不能秒传。
八、断点续传怎么实现
Java 后端合并版:
通过 file_chunk 表查询 uploadedChunks返回:
{
"uploadedChunks": [0, 1, 2]
}对象存储 Multipart Upload 版:
通过 upload_part 表查询 uploadedParts
必要时通过对象存储 ListParts 兜底校验返回:
{
"uploadedParts": [1, 2, 3]
}恢复流程:
客户端重新选择文件
↓
重新计算 fileHash
↓
调用 init
↓
服务端查询未完成 upload_task
↓
返回 uploadId 和已上传分片
↓
客户端只上传缺失分片
这样即使刷新页面、关闭浏览器,也能继续上传。
九、文件完整性校验
不要简单认为:
ETag = 文件 MD5尤其是对象存储分片上传场景,ETag 不一定等于完整文件 MD5。
更稳妥的策略是:
如果对象存储支持 checksum,优先使用对象存储 checksum。
如果对象存储不支持,就保存客户端 fileHash,上传完成后通过异步任务流式读取对象并计算 Hash。
Hash 校验通过后,文件才能进入 AVAILABLE。状态流转:
UPLOADED
↓
SCANNING
↓
AVAILABLE / BLOCKED十、文件安全扫描
上传完成并不代表文件立即可用。
生产环境建议引入安全扫描:
病毒扫描
文件类型校验
敏感内容检测
图片审核
企业合规检查如果是 Java 后端合并版,可以在合并后提交扫描任务。
如果是对象存储直传版,可以在 completeMultipartUpload 后提交扫描任务。
扫描通过:
file_storage.status = AVAILABLE
user_file.status = AVAILABLE
upload_task.status = SUCCESS扫描失败:
file_storage.status = BLOCKED
user_file.status = BLOCKED
upload_task.status = FAILED 或 SUCCESS_WITH_BLOCKED_FILE
具体任务状态可以按业务决定。
但最重要的是:
扫描通过前,不允许下载,不允许秒传。
十一、私有文件下载
很多业务文件都是私有的,例如:
合同
简历
财务附件
企业资料
用户上传的私密文件这类文件不要返回永久公开 URL。
建议只保存:
bucket_name
object_key下载时通过后端鉴权:
GET /files/{fileId}/download-url后端校验:
当前用户是否拥有该 user_file
user_file.status 是否为 AVAILABLE
file_storage.status 是否为 AVAILABLE通过后生成临时下载 URL:
{
"url": "https://oss.xxx.com/object-key?signature=xxx",
"expireSeconds": 300
}这样即使 URL 泄露,也只能在短时间内访问。
十二、预签名 URL 安全
对象存储直传通常依赖预签名 URL。
必须注意:
预签名 URL 必须设置过期时间
只能上传到服务端生成的 objectKey
不能让前端任意指定 bucket 或 objectKey
限制 Content-Length
限制 Content-Type
complete 时必须校验 upload_task.user_id建议:
预签名 URL 有效期:5~30 分钟
upload_task 任务有效期:1 天或 7 天URL 过期后,前端可以重新请求新的上传 URL。
不要因为上传 URL 过期就直接让上传任务失败。
十三、过期任务和临时资源清理
上传中断后,临时资源可能一直存在。
需要定时清理:
超过 1 天未更新的本地临时分片
超过 7 天未完成的 upload_task
过期的 Multipart Upload
长期处于 MERGING 的任务
长期处于 SCANNING 的任务对象存储建议配置生命周期规则,自动清理未完成的 Multipart Upload。
同时后端也可以定时扫描:
UPLOADING 超时任务
MERGING 超时任务
SCANNING 超时任务根据实际状态做补偿。
十四、生产环境限流和配额
生产环境必须限制:
单文件最大大小
分片大小范围
分片数量上限
允许上传的文件类型
用户上传频率
用户存储配额
租户总存储容量
单用户并发上传任务数
否则容易出现:
恶意上传大文件撑爆磁盘
上传危险脚本文件
高频请求拖垮服务
某个租户占满全部存储空间
十五、Java 后端合并版和对象存储版怎么选
一句话总结:
Demo 和内部系统可以用 Java 后端合并。
生产大文件上传优先用对象存储 Multipart Upload。
十六、总结
大文件上传的核心不是简单接收一个 MultipartFile。
而是要解决三个问题:
秒传:避免重复上传
分片上传:降低大文件上传失败成本
断点续传:网络中断后继续上传如果是简单系统,可以先使用:
本地磁盘
+
数据库记录分片
+
Java 后端合并如果是生产系统,更推荐:
对象存储 Multipart Upload
+
upload_task 上传任务表
+
upload_part 分片记录表
+
租户隔离
+
权限控制
+
安全扫描
+
临时下载 URL
+
状态补偿
+
定时清理完整生产流程可以总结为:
客户端计算文件指纹
↓
服务端秒传检查
↓
初始化上传任务
↓
找回未完成任务
↓
生成预签名分片上传 URL
↓
前端直传对象存储
↓
前端上报 partNumber + ETag
↓
后端记录 CLIENT_REPORTED
↓
complete 前 ListParts 校验
↓
完成 Multipart Upload
↓
文件进入 UPLOADED / SCANNING
↓
异步安全扫描
↓
扫描通过后 AVAILABLE
↓
下载时生成临时访问 URL这样设计,才能真正支撑大文件、弱网络、多用户并发、多实例部署、租户隔离和生产级安全要求。