本节把 TaskHub 的附件接口升级成「可恢复」的:客户端分片上传,网络中断后能从服务端已确认的偏移继续,而不是从零重传。
上一节的预签名 URL 适合「一次传完」。但真实的移动网络会断:地铁进隧道、电梯里没信号、切 Wi-Fi 时 TCP 连接被重置。一个 800MB 的文件传到 720MB 断线,如果只能从头来,用户的流量和时间都白费了。断点续传要解决的就是这件事:把「一次大传输」拆成「一串可确认、可重放、可续接的小传输」。
13.3.1 先分清:续传、重试、分片是三件事
三个容易混的概念:
| 概念 | 解决的问题 | 状态存在哪 | 单位 |
|---|---|---|---|
| 重试(retry) | 单次请求失败 | 客户端内存 | 一个请求 |
| 分片(chunking) | 单个请求太大 | 无需状态 | 固定大小块 |
| 续传(resume) | 传输中途断开 | 服务端持久化 | 一个上传会话 |
续传的关键是服务端要知道「已经收到了多少」,并且这个「多少」必须是单调递增、可查询的。客户端重连后先问一句「你收到哪了」,再从那个位置继续——这就是整套协议的全部核心。
13.3.2 两种主流协议:tus 与 S3 Multipart
业界有两条成熟路线:
| 协议 | 机制 | 适合 | 复杂度 |
|---|---|---|---|
| tus(可恢复上传协议) | 自定义 Upload-Offset / PATCH 语义 | 应用自己存储 | 中,需自建服务 |
| S3 Multipart Upload | 分片 UploadPart + CompleteMultipartUpload | 直传对象存储 | 低,SDK 封装好 |
tus 是一个开放的 HTTP 协议,核心就三样东西:POST 创建上传、HEAD 查询已收偏移、PATCH 追加数据。S3 的分片上传则是对象存储原生能力:先 CreateMultipartUpload 拿一个 UploadId,分片各自 UploadPart 带上 PartNumber,最后 CompleteMultipartUpload 拼装。S3 路线的好处是分片可以并行、失败只需重传单个分片,且不占用应用带宽。
本节先用手写偏移协议把原理跑通(这样你能看懂 tus 为什么这么设计),最后再讲怎么映射到 S3 Multipart。
13.3.3 HTTP 契约:三个动作、四种状态码
在写代码之前先把「客户端和服务端之间说什么话」定死。这套协议只有三个动作:
| 动作 | 方法 | 请求 | 成功响应 | 语义 |
|---|---|---|---|---|
| 创建会话 | POST | 文件元信息(大小、名字) | 201 + Location | 分配上传 ID |
| 查询进度 | HEAD | 无 | 200 + Upload-Offset | 报告已收字节数 |
| 追加数据 | PATCH | Upload-Offset + 分片 | 204 + 新偏移 | 从偏移处续写 |
状态码的约定尤其重要,它决定了客户端能不能正确决策:
| 状态码 | 含义 | 客户端应该做什么 |
|---|---|---|
204 No Content | 分片写入成功 | 读响应头里的新偏移,继续下一个分片 |
409 Conflict | 偏移不匹配 | 重新 HEAD 协商,从新偏移继续 |
413 Payload Too Large | 分片超限 | 把分片切小再传 |
410 Gone | 会话已过期被清理 | 重新创建会话,从头传 |
204 而不是 200 是有讲究的:PATCH 成功没有响应体,唯一有用的信息在 Upload-Offset 头里,用 204 明确表达「没有 body」。而 409 的设计让「客户端状态过期」变成一种可恢复的正常流程,而不是需要人工介入的错误——这一点和分布式系统里的乐观锁是同一个思路。
13.3.4 服务端:偏移量就是状态机
服务端的职责收敛成两个动作:
HEAD /upload/{id}→ 返回Upload-Offset: <已收字节数>PATCH /upload/{id}+Upload-Offset: <本次起始偏移>→ 校验后追加,返回新的偏移
关键在 PATCH 的偏移校验:客户端声称从 3MB 开始续传,但服务端实际只收到 2MB,说明客户端状态过期了,必须拒绝,否则会写入错位的数据、拼出损坏的文件。
func handler(w http.ResponseWriter, r *http.Request) {
id := filepath.Base(r.PathValue("id"))
fp := filepath.Join(dir, id+".part")
switch r.Method {
case http.MethodHead:
st, err := os.Stat(fp)
var off int64
if err == nil {
off = st.Size() // 已落盘的大小就是已收偏移
}
w.Header().Set("Upload-Offset", strconv.FormatInt(off, 10))
w.WriteHeader(http.StatusOK)
case http.MethodPatch:
want, _ := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
var cur int64
if st, err := os.Stat(fp); err == nil {
cur = st.Size()
}
if want != cur {
// 客户端状态过期:拒绝,让客户端重新 HEAD 协商
http.Error(w, fmt.Sprintf("offset mismatch: want %d got %d", want, cur), http.StatusConflict)
return
}
f, err := os.OpenFile(fp, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
n, _ := io.Copy(f, r.Body) // 追加写入,仍然是流式
w.Header().Set("Upload-Offset", strconv.FormatInt(cur+n, 10))
w.WriteHeader(http.StatusNoContent)
}
}
三个设计决定值得说明:
O_APPEND保证追加语义:即使有并发 PATCH,内核保证每次写都落在文件末尾,不会互相覆盖。配合「偏移必须等于当前大小」的校验,天然排除了乱序写入。- HEAD 报的偏移来自
os.Stat,不是内存里的计数器。这样进程重启后偏移依然正确——状态存在文件系统里,天然持久。 - 偏移不匹配返回
409 Conflict,而不是静默接受。客户端收到 409 就应该重新 HEAD 协商,而不是盲目重试。
13.3.5 客户端:先问偏移,再续传
客户端逻辑同样简单:每次续传前先 HEAD,从服务端告诉的偏移开始,按固定大小切片发送。
// 1) 协商:问服务端收到哪了
hr, _ := http.NewRequest(http.MethodHead, srv.URL+"/upload/"+id, nil)
hresp, _ := http.DefaultClient.Do(hr)
hresp.Body.Close()
resume, _ := strconv.Atoi(hresp.Header.Get("Upload-Offset"))
// 2) 从断点继续,每个分片带自己的起始偏移
for off := resume; off < len(payload); off += chunk {
end := min(off+chunk, len(payload))
req, _ := http.NewRequest(http.MethodPatch, srv.URL+"/upload/"+id,
bytes.NewReader(payload[off:end]))
req.Header.Set("Upload-Offset", strconv.Itoa(off))
resp, _ := http.DefaultClient.Do(req)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return // 交给上层重试
}
}
注意 bytes.NewReader(payload[off:end])——每个分片是一个独立的请求体,天然流式,客户端不需要把整个文件读进内存(配合上一节的落盘读取,大文件也能边读边传)。
13.3.6 实测:10MB 分片传输与断点恢复
我用 10MB 载荷、1MB 分片跑了一遍,并且故意在前 3 个分片后中断,模拟网络断线:
payload sha256: c36448100c9f697de77abec780ca0483bc1b5867976bad796a5d9c454e41334a
chunk 0 -> 204, server offset=1048576
chunk 1 -> 204, server offset=2097152
chunk 2 -> 204, server offset=3145728
resume from offset: 3145728
wrong offset -> 409
final size=10485760 sha256=c36448100c9f697de77abec780ca0483bc1b5867976bad796a5d9c454e41334a
CHECKSUM MATCH: true
逐行读:
- 源文件 SHA-256 是
c3644810...,这是最终要校验的目标。 - 前 3 个分片各 1MB,服务端偏移从 1048576 一路涨到 3145728(3MB)。
- 断线后重新 HEAD,服务端准确报出
3145728——它记得住。 - 故意用一个错误的偏移(999)发 PATCH,服务端返回 409,拒绝错位写入。
- 从 3145728 继续传完剩余 7MB,最终文件大小正好 10485760 字节。
- 最终文件的 SHA-256 与源文件逐字节相同:
c3644810...,CHECKSUM MATCH: true。
这条链路证明了一件重要的事:只要偏移协商正确,续传后的文件与一次性上传完全等价,没有错位、没有重复、没有丢失。
13.3.7 用 curl 手工复现一遍
协议类的东西最好能脱离代码用手工命令验证,出问题时才能分清是「客户端 bug」还是「服务端语义不对」。同一套接口用 curl 走一遍(-I 发 HEAD,-X PATCH 发分片):
curl -s -I http://127.0.0.1:8099/upload/demo
curl -s -i -X PATCH -H 'Upload-Offset: 0' --data-binary @c0.bin http://127.0.0.1:8099/upload/demo
curl -s -i -X PATCH -H 'Upload-Offset: 1048576' --data-binary @c1.bin http://127.0.0.1:8099/upload/demo
真实输出:
HTTP/1.1 200 OK
Upload-Offset: 0
HTTP/1.1 204 No Content
Upload-Offset: 1048576
wrong offset -> 409
HTTP/1.1 204 No Content
Upload-Offset: 1572864
final: 1572864 bytes
先是空文件报偏移 0,传完 1MB 后偏移涨到 1048576;用错误的偏移 999999 重发立刻 409;从 1048576 续传 512KB 后偏移变成 1572864,磁盘上的文件大小正好等于 1572864 字节,与偏移完全吻合。这条手工路径是排查续传问题的第一工具——当客户端报「续传后文件损坏」时,先用 curl 复现一遍,能立刻定位是偏移算错还是分片本身有问题。
小坑:验证 HEAD 要用
curl -I,不要用curl -X HEAD。后者会让 curl 按「有响应体」处理而挂住等待,直到超时。
13.3.8 续传的四个工程细节
协议跑通只是开始,上生产还要处理这些:
一、上传会话要能过期。 用户传了一半关掉 App,服务端会永远留着一个半截的 .part 文件。要给每个上传会话记录「最后活跃时间」,超过 24 小时未续传就清理掉。清理任务应该定期扫描(第 8 章的定时任务),按时间阈值删除,而不是靠客户端主动通知——客户端崩溃时不会通知你。
二、最终一致性靠校验,不靠协议。 偏移对不代表内容对。分片可能在网络里被中间设备篡改(罕见但存在),也可能客户端本身有 bug。完成时客户端要带上整个文件的 SHA-256,服务端拼装后重算一遍对比,不一致就作废整个上传。上一节我们把 SHA-256 存进了数据库,正好复用。
三、幂等:同一分片重复发送必须安全。 客户端超时重试时,可能服务端其实已经收到了这个分片(只是响应丢包)。如果分片的偏移是「追加」语义,重复发送会导致数据翻倍。所以严格来说协议应该带分片序号而非裸偏移,服务端按序号去重(已经有的分片直接返回成功,不重复写)。本节的简化实现用 O_APPEND + 偏移校验,在「响应丢失但服务端已写」的场景下,客户端下次 HEAD 会拿到更大的偏移,从而跳过——靠的是重新协商,而不是去重,这是简化版的取舍。
四、并发上传同一文件要加锁。 如果同一个 id 有两个客户端同时续传,两个 PATCH 会交错写入,文件必然损坏。生产实现要么在会话上加互斥锁(内存 + 分布式锁),要么用「偏移必须严格等于当前大小」来保证只有一个客户端能推进——后者在多数场景够用。
13.3.9 映射到 S3 Multipart Upload
如果附件最终要进对象存储,直接用手写 .part 文件反而绕远路。S3 原生支持分片,思路和上面完全同构:
| 本节手写协议 | S3 Multipart |
|---|---|
HEAD 查偏移 | ListParts 查已上传分片 |
PATCH 追加数据 | UploadPart(带 PartNumber) |
O_APPEND 到 .part | 服务端各自存分片对象 |
| 最终 SHA-256 校验 | CompleteMultipartUpload + ETag 校验 |
| 偏移不匹配 → 409 | PartNumber 重复 → 覆盖该分片 |
S3 路线的额外好处是分片可以并行上传(PartNumber 互不依赖),一个 1GB 文件切成 8 个 128MB 分片并发传,耗时能压到单线程的几分之一。minio-go 的 PutObject 内部对超过分片阈值的对象就是自动走 Multipart 的,多数情况下你不需要手写。
必须说明:本节的 S3 Multipart Upload 部分未在本机实测,因为真实 MinIO 镜像拉取失败(见 13.2 节)。上面的协议映射基于 S3 API 的公开语义,实际接入时需在真对象存储上验证 ListParts 的返回结构与 CompleteMultipartUpload 的 ETag 拼接规则(最终 ETag 是各分片二进制 MD5 摘要依次拼接后再取一次 MD5,末尾追加 -分片数)。
小结
- 断点续传 = 分片 + 服务端持久化偏移 + 客户端重新协商,三者缺一不可。
- 服务端用「已落盘大小」作为权威偏移,进程重启后依然正确。
- PATCH 的偏移必须严格匹配,不匹配返回
409,避免错位写入拼出损坏文件。 O_APPEND提供追加语义,配合偏移校验天然排除乱序写入。- 实测:10MB 载荷在 3MB 处中断,重新协商后精确续传,最终 SHA-256 与源文件一致。
- 上传会话需要过期清理,靠时间阈值而非客户端通知。
- 完成校验必须重算整个文件的 SHA-256,不能只信偏移。
- 要并行和直传对象存储,用 S3 Multipart Upload(本机未实测)。
文件进出、存储、续传三件事都打通了,TaskHub 的附件模块可以交付。但「能在本机跑起来」和「能进生产环境」之间还差一层——下一章我们把 TaskHub 打成容器镜像,从 500MB 的朴素镜像压到 15MB 的 distroless 镜像,并解决非 root、健康检查与资源限制。
阅读导航:上一节:13.2 S3 兼容对象存储与预签名 · 下一节:14.1 多阶段与 distroless 最小镜像 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。