Java 大文件上传实践:秒传、分片上传、断点续传

作者:old wang 发布时间: 2026-06-20 阅读量:0 评论数:0

文件上传是后台系统里很常见的功能。

如果只是上传几 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

安全扫描通过后,再变成:

AVAILABLE

3. 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_xxx

Java 后端合并版返回:

{
  "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 后端合并版和对象存储版怎么选

场景

推荐方案

内部系统

Java 后端合并

小文件低并发

Java 后端合并

学习原理

Java 后端合并

大文件高并发

对象存储 Multipart Upload

多实例部署

对象存储 Multipart Upload

移动端弱网络

分片上传 + 断点续传

私有化部署

MinIO Multipart Upload

SaaS 多租户

对象存储 + 租户隔离 + 配额

一句话总结:

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

这样设计,才能真正支撑大文件、弱网络、多用户并发、多实例部署、租户隔离和生产级安全要求。

评论