《Go 语言编程实战》13.3 断点续传与大文件

移动端上传 800MB 附件断在 90% 时不能从头再来:本节用偏移量协议实现断点续传,服务端以 HEAD 报进度、PATCH 追加分片、409 拒绝错位写入,实测 10MB 分片传输在中断后精确恢复,且最终 SHA-256 与源文件逐字节一致。

本节把 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报告已收字节数
追加数据PATCHUpload-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

逐行读:

  1. 源文件 SHA-256 是 c3644810...,这是最终要校验的目标。
  2. 前 3 个分片各 1MB,服务端偏移从 1048576 一路涨到 3145728(3MB)。
  3. 断线后重新 HEAD,服务端准确报出 3145728——它记得住。
  4. 故意用一个错误的偏移(999)发 PATCH,服务端返回 409,拒绝错位写入。
  5. 从 3145728 继续传完剩余 7MB,最终文件大小正好 10485760 字节。
  6. 最终文件的 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 校验
偏移不匹配 → 409PartNumber 重复 → 覆盖该分片

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 最小镜像 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练