断点续传必须用 http.servecontent,而非手动解析 range 头+seek+io.copyn;它自动处理 206/416 状态、content-range 等逻辑,但要求 size 为真实文件长度、modtime 准确、且显式设置 accept-ranges: bytes。

断点续传必须用 http.ServeContent,别自己写 Range 解析
手动解析 Range 头再 Seek + io.CopyN 看似可控,但极易出错:比如 io.CopyN 遇到文件尾返回 io.ErrUnexpectedEOF 时没正确处理,会导致响应体长度与 Content-Range 不匹配;又或者用 bufio.Reader 包裹文件后 Seek 错位,跳转偏移量完全失准。
http.ServeContent 已封装全部边界逻辑,只要传入的 size 和 modtime 正确,它就能自动返回 206 Partial Content 或 416 Range Not Satisfiable。关键约束有三个:
-
size必须是文件真实字节长度(不是os.Stat().Size()后就完事,要确保文件没被并发写入、且未被截断) -
modtime不能直接用os.Stat().ModTime()—— 若文件内容变更但时间戳未更新(如 NFS 挂载),会触发412 Precondition Failed - 必须显式调用
w.Header().Set("Accept-Ranges", "bytes"),否则客户端不会发Range请求
Gin 中无法直接用 c.File() 支持断点续传
c.File() 内部调用的是 http.ServeFile,它不设 Accept-Ranges,也不校验 Range 请求头,所有请求都走完整文件响应(200 OK),客户端无法发起断点续传。
正确做法是绕过 Gin 封装,直接操作底层 http.ResponseWriter:
- 先调用
c.Writer.WriteHeader(http.StatusOK)清空 Gin 的 header 缓存 - 再手动设置
Content-Type、Content-Disposition、Accept-Ranges: bytes - 最后用
http.ServeContent(w, r, filename, modtime, reader),其中reader是io.ReadSeeker(如*os.File)
注意:c.Data()、c.JSON() 等方法会覆盖状态码和 header,一旦调用就不可逆。
流式传输时 Content-Length 不能硬编码
如果文件是动态生成的(比如加密后实时读取)、或大小未知(如从远端流拉取),就绝不能设置 Content-Length。Gin 默认会尝试自动计算并写入,这会导致:
- 客户端卡住等待未到达的字节数
- 响应被截断(尤其在 Nginx 反向代理下更明显)
解决方法只有两个:
- 长度已知:调用
c.Writer.WriteHeader(http.StatusOK)后,再用c.Header("Content-Length", strconv.FormatInt(size, 10))—— 注意不能用c.Header("Content-Length", ...)单独设,因为 Gin 的 header 是 lazy write,会被后续c.DataFromReader覆盖 - 长度未知:彻底省略
Content-Length,依赖 Go HTTP Server 自动启用Transfer-Encoding: chunked,客户端靠 EOF 判断结束
分片上传 ≠ 断点续传,但二者常共存
很多人混淆这两个概念:断点续传(resumable download)指客户端下载中断后,下次从断点继续;分片上传(chunked upload)指客户端把大文件切片后逐个上传。它们解决的是不同方向的问题,但在实际系统中往往一起出现。
例如视频平台提供「边上传边预览」功能,后端需同时支持:
- 接收分片上传的
/upload/chunk接口(含文件唯一 ID、分片序号、总片数) - 提供合并后的完整文件下载接口,且该接口必须支持
Range请求(即断点续传) - 合并前临时文件不能暴露给下载接口——否则用户可能下载到不完整的中间态
最易被忽略的一点:合并完成后的文件,其 modtime 应设为原始文件的创建/修改时间,而不是合并操作的时间,否则会影响客户端缓存判断和 If-Modified-Since 行为。











