本节目标:让「上传电子书附件」这种几十上百 MB 的文件可续传、可并发、可直传——用 S3 分片上传实现断点续传,用预签名 URL 让客户端绕过应用服务器直传,并想清楚直传之后「谁保证大小与类型、谁来确认」。
适用版本:Spring Boot 4.1.x(Java 21)、AWS SDK for Java v2(software.amazon.awssdk:s3)
14.3 断点续传与直传
14.2 用 putObject 一次传完,对封面这种小文件没问题。但图书附件可能是几百 MB 的 PDF 或音视频,一次 HTTP 请求传完有三个现实问题:网络一断就得从头再来、大请求体长时间占用应用连接、失败后用户要重传全部字节。
分片上传(multipart upload)把一个大对象拆成多个分片分别上传,服务端只记录一个 uploadId;全部传完后调一次「完成」把分片拼成一个对象。断点续传、并发上传、失败只重传单个分片都建立在这个机制上。
14.3.1 分片上传三阶段
S3 分片上传分三步,SDK v2 里分别对应三个方法:
| 阶段 | SDK 方法 | 关键返回 |
|---|---|---|
| 初始化 | createMultipartUpload | uploadId |
| 传分片 | uploadPart(可并发、可乱序) | 每片的 eTag |
| 完成 | completeMultipartUpload | 合并后的对象 |
初始化:
public String initMultipart(String bucket, String key, String contentType) {
CreateMultipartUploadRequest request = CreateMultipartUploadRequest.builder()
.bucket(bucket)
.key(key)
.contentType(contentType)
.build();
return s3Client.createMultipartUpload(request).uploadId();
}
传一个分片。partNumber 从 1 开始,它和 uploadId 一起唯一标识这个分片:
public CompletedPart uploadPart(String bucket, String key, String uploadId,
int partNumber, byte[] data) {
UploadPartRequest request = UploadPartRequest.builder()
.bucket(bucket)
.key(key)
.uploadId(uploadId)
.partNumber(partNumber)
.contentLength((long) data.length)
.build();
String eTag = s3Client.uploadPart(request, RequestBody.fromBytes(data)).eTag();
return CompletedPart.builder().partNumber(partNumber).eTag(eTag).build();
}
完成时把所有分片的 partNumber 与 eTag 按序提交:
public void completeMultipart(String bucket, String key, String uploadId,
List<CompletedPart> parts) {
CompleteMultipartUploadRequest request = CompleteMultipartUploadRequest.builder()
.bucket(bucket)
.key(key)
.uploadId(uploadId)
.multipartUpload(CompletedMultipartUpload.builder().parts(parts).build())
.build();
s3Client.completeMultipartUpload(request);
}
只有 completeMultipartUpload 成功后,对象才可见。 在此之前,已上传的分片只是「未完成的碎片」,不可读、不占正式对象空间,但会占用存储并计费。所以异常路径上一定要有 abortMultipartUpload 兜底,或依赖 14.2 讲的生命周期 AbortIncompleteMultipartUpload 自动清理。
一个必须记住的硬约束:除最后一片外,每个分片必须不小于 5MB(AWS S3 的规定,各兼容实现大多沿用)。最后一片可以小于 5MB。这条约束直接决定了分片大小怎么选——见下一节。
14.3.2 分片大小与并发
分片大小是一笔权衡,没有万能值:
| 分片大小 | 单对象最大分片数 | 优点 | 缺点 |
|---|---|---|---|
| 5MB | 1 万个 | 断点粒度细,重传代价小 | 请求数多,开销大 |
| 8–16MB | 1 万个 | 均衡 | — |
| 100MB | 1 万个 | 请求少 | 单分片失败重传代价大 |
S3 规定单个对象最多 1 万个分片。按 5MB 算,单对象上限约 50GB;要传更大的对象必须用更大的分片。工程上常用的经验值:分片取 8–16MB,既让单分片失败的重传代价可控,又不会产生过多请求。具体取值要按你的最大文件尺寸反推——最大文件大小 / 分片大小 ≤ 10000。
并发上传是分片机制的天然红利:多个分片可同时传,整体吞吐取决于客户端上行带宽。并发数不是越大越好:过高会争抢带宽、触发连接数限制,反而更慢;一般从 3–5 路起,用真实网络压测再调。分片可以乱序完成,只要 complete 时按 partNumber 提交正确的 eTag 列表即可——这让「先传小的、失败的重传」这类策略变得可行。
量级说明:并发上传相比串行,在带宽充足时能显著提升吞吐,但具体倍数取决于客户端带宽、网络 RTT 与分片大小,必须用真实环境测,不要照搬别人的数字。
14.3.3 ListParts 续传与失败重传
断点续传的关键是:客户端重新连上后,能知道「哪些分片已经传成功」,只补缺失的。服务端用 listParts 查询某个 uploadId 下已完成的分片:
public Map<Integer, String> uploadedParts(String bucket, String key, String uploadId) {
Map<Integer, String> done = new HashMap<>();
Integer marker = null;
do {
ListPartsRequest request = ListPartsRequest.builder()
.bucket(bucket)
.key(key)
.uploadId(uploadId)
.partNumberMarker(marker)
.build();
ListPartsResponse response = s3Client.listParts(request);
for (Part part : response.parts()) {
done.put(part.partNumber(), part.eTag());
}
marker = response.isTruncated() ? response.nextPartNumberMarker() : null;
} while (marker != null);
return done;
}
listParts 结果是分页的(单次最多 1000 片),要用 partNumberMarker 翻页,别假设一次拿全。
续传流程:
- 客户端带着「文件指纹 + 已拿到的
uploadId(若有)」请求续传。 - 服务端
listParts得到已成功的分片集合。 - 客户端只上传「分片计划里缺失的那些」,已完成的直接跳过。
- 全部到齐后
completeMultipartUpload。
失败分片的重传就是「缺失集合里再加一个刚失败的分片号」,逻辑与首次上传缺失片完全一致——分片机制让「重传」退化成了「补传」,这正是它相对 putObject 的核心价值。
服务端要把 uploadId 与文件标识关联存起来,否则客户端换设备或换会话后就找不回这个上传了。通常落一张「上传会话表」:upload_id、storage_key、file_hash、total_parts、created_at、expires_at。
14.3.4 前端直传:预签名 URL
到此为止字节流都经过应用服务器。对几百 MB 的文件,这会让应用实例同时扛着大量长连接与内存/磁盘临时文件,应用成了纯转发管道——这正是直传要消除的。
直传的做法:服务端只签发预签名 URL,客户端拿 URL 直接 PUT 到对象存储,字节流完全不经过应用。
单次小文件的直传,服务端签发一个 presignPutObject 的 URL:
public String presignUpload(String bucket, String key, String contentType, Duration ttl) {
PutObjectRequest put = PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(contentType)
.build();
PutObjectPresignRequest presign = PutObjectPresignRequest.builder()
.putObjectRequest(put)
.signatureDuration(ttl)
.build();
return s3Presigner.presignPutObject(presign).url().toString();
}
客户端直接 PUT 这个 URL、body 放文件字节即可,不再经过 Java 服务。
大文件的分片直传更复杂,但同样可用预签名:S3Presigner 提供了 presignCreateMultipartUpload、presignUploadPart、presignCompleteMultipartUpload、presignAbortMultipartUpload,客户端可以自己驱动整个分片流程,服务端只在「初始化」「完成」两个节点参与。实践中更常见的折中是:初始化与完成由服务端调用(因为要用服务端凭证拿到 uploadId 并登记元数据),中间的分片上传由客户端用 presignUploadPart 拿到的 URL 直传。
// 服务端为某个分片签发直传 URL
public String presignPart(String bucket, String key, String uploadId, int partNumber, Duration ttl) {
UploadPartRequest part = UploadPartRequest.builder()
.bucket(bucket)
.key(key)
.uploadId(uploadId)
.partNumber(partNumber)
.build();
UploadPartPresignRequest presign = UploadPartPresignRequest.builder()
.uploadPartRequest(part)
.signatureDuration(ttl)
.build();
return s3Presigner.presignUploadPart(presign).url().toString();
}
直传的收益很直接:应用服务器的带宽、连接、临时磁盘全部释放,扩容压力从应用转移到对象存储(后者本就为此设计)。代价是「上传逻辑的一半跑在了客户端」,随之而来的是一堆校验问题(14.3.6)。
14.3.5 服务端只做「发签名 + 收回调确认」
直传后,服务端的职责收敛成两件事:
- 发签名:校验用户身份与配额,为「这个用户、这个对象键、这个操作」签发短时 URL。
- 收回调、落元数据:客户端传完后,把「对象键 + 大小 + 校验和」回报服务端,服务端向对象存储核对后再写库。
关键在于第 2 步的核对。客户端回报的「传完了、大小是 X」是不可信的声明,服务端必须自己确认对象真的存在、大小对得上:
public void confirmUpload(String bucket, String key, long expectedSize, String expectedSha) {
HeadObjectResponse head = s3Client.headObject(b -> b.bucket(bucket).key(key));
if (head.contentLength() != expectedSize) {
throw new IllegalStateException("size mismatch: " + head.contentLength());
}
// sha256 若在自定义元数据里带上,可在此比对;否则下载回算或信任 ETag(非分片时)
attachmentRepository.markUploaded(key, head.contentLength(), head.eTag());
}
分片上传完成后,headObject 拿到的 eTag 形如 <md5>-<partCount>(带 -N 后缀),它不等于整个文件的 MD5,不能直接当内容校验和。要做强校验,就在 createMultipartUpload 时把整体 sha256 放进自定义元数据,完成后再比对。
如果对象存储支持事件通知(S3 Event Notification 或对象存储的 webhook),也可以让存储侧在对象创建后回调服务端,用「存储发起的事件」替代「客户端回报」,可信度更高——因为事件是存储自己产生的,不是客户端声明的。
14.3.6 直传带来的校验问题
直传把字节流交给了客户端,于是「谁保证这个文件合规」必须重新回答。核心问题有三个:
大小由谁保证? 服务端签发的预签名 URL 无法限制上传体的大小(S3 预签名不校验 Content-Length)。客户端可以传一个比声明大得多的文件。补救手段:
- 用 POST Policy 形式的预签名(
presignPostObject类机制)可以在策略里写死content-length-range,超过就拒绝——这是比 PUT 预签名更适合「限制大小」的方式。 - 或者传完后在
headObject里核对contentLength,超限就删除对象并拒绝登记。这是「事后补救」,但能兜住。
类型由谁保证? 客户端声明的 Content-Type 同样不可信。预签名时可以把 contentType 写进签名,要求客户端上传的 Content-Type 必须与签名时一致,否则签名不匹配——这把「类型」也纳入了签名约束。即便如此,Content-Type 与真实内容仍可能不符,需要服务端在确认阶段做魔数校验或交给下游处理。
回调签名由谁校验? 如果走「存储回调服务端」的路子,回调请求必须验签,否则任何人都能伪造一个「上传完成」事件骗服务端登记元数据。回调一般带签名头,服务端要用共享密钥或对象存储的公钥验签后再处理。
一句话总结直传的安全模型:预签名 URL 管住了「谁能传、传到哪个键、什么操作」,但管不住「传了多大、内容是什么」;后两者必须在服务端确认阶段补上。
14.3.7 决策表:小文件、大文件、极简场景
不是所有上传都要上分片与直传。按文件大小与场景复杂度选:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 小文件(封面、头像,< 几 MB) | 后端中转 + putObject | 简单、可在服务端直接校验与落元数据,直传的收益抵不过复杂度 |
| 大文件(附件、音视频,几十 MB 以上) | 分片直传(初始化/完成走服务端,分片走 presignUploadPart) | 省应用带宽、可续传、可并发 |
| 极简场景(内网、单机、临时工具) | 后端中转 + 本地磁盘或 putObject | 不值得引入直传与分片的状态管理 |
| 需要强校验/强合规 | 后端中转(字节流过服务端) | 服务端能对完整字节流做校验、病毒扫描、转码,直传做不到 |
决策的两个主轴:文件大小决定要不要分片,校验强度要求决定要不要中转。小文件、强校验的场景老老实实后端中转;大文件、弱校验的场景用分片直传换吞吐。
14.3.8 常见坑
坑一:非末片小于 5MB。 直接 complete 会失败,且错误信息未必直白。分片大小至少要 5MB,工程上取 8–16MB。
坑二:忘了 abortMultipartUpload。 用户放弃上传后,已传分片长期占空间并计费。异常路径要 abort,生命周期要配 AbortIncompleteMultipartUpload。
坑三:completeMultipartUpload 的 eTag 用错。 必须用每片 uploadPart 返回的 eTag,不能自己算。乱序完成时按 partNumber 排序提交。
坑四:把分片完成后的 eTag 当文件 MD5。 分片上传的 eTag 带 -N 后缀,不是整体 MD5。强校验要在初始化时把 sha256 放进元数据。
坑五:预签名 URL 无限期。 用临时凭证时 URL 有效期不能超过凭证有效期,否则访问即失败。按操作给几分钟到几小时。
坑六:直传后不核对就落库。 客户端说「传完了」不算数,必须 headObject 核对存在与大小,或改用存储侧事件回调。
坑七:listParts 不分页。 结果按 1000 片分页,大文件续传必须用 partNumberMarker 翻页,否则会漏片、重复传。
坑八:以为预签名能限制上传大小。 PUT 预签名不校验 Content-Length。要限大小用 POST Policy,或在确认阶段核对并清理。
小结
- 分片上传三阶段:
createMultipartUpload拿uploadId→uploadPart传分片拿eTag→completeMultipartUpload合并;只有最后一步成功后对象才可见。 - 除末片外每片不小于 5MB、单对象最多 1 万个分片,分片大小按最大文件反推,工程上取 8–16MB;分片可乱序完成、可并发。
- 续传靠
listParts(注意分页)得到已完成分片集合,只补缺失片;失败重传与首次补传逻辑相同。 - 直传用预签名 URL 让字节流绕过应用,服务端只「发签名 + 收回调确认」,并用
headObject或存储事件核对,不信客户端声明。 - 直传管不住「传了多大、内容是什么」:大小用 POST Policy 的
content-length-range或事后核对兜底,类型用签名时固定contentType约束,回调必须验签。 - 决策看两个轴:文件大小决定是否分片,校验强度决定是否中转;小文件与强校验场景走后端中转。
文件存好了,接下来是把它交付到生产:15.1 从镜像分层开始,讲怎么把应用打包成可部署的镜像。
阅读导航:上一节:14.2 对象存储集成 · 下一节:15.1 容器镜像分层 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。