《Go 语言编程实战》13.1 上传下载与流式处理

TaskHub 的附件模块要扛 GB 级文件:本节用 multipart.Reader 边读边落盘替代 io.ReadAll,实测 256MB 上传峰值堆内存从 600MB 降到 0,再用 http.ServeContent 白拿 Range 断点下载、ETag 条件请求与 Content-Type 嗅探。

本节把 TaskHub 推进到「附件存储」:任务卡片可以挂文件,上传接口必须能扛住几百 MB 的附件而不把进程内存撑爆,下载接口要支持断点续传与缓存协商。

前 12 章我们一直在处理「结构化的小数据」——JSON 请求体、数据库行、缓存条目。从这一章开始,TaskHub 要面对非结构化的大数据:用户上传的附件、导出报表、图片。这类数据和 JSON 有一个本质区别:体积不可控。一个用户可能上传 2KB 的配置,也可能上传 2GB 的视频。默认写法在第二种情况下会直接把服务打挂。

这一节我们只做一件事:把「文件进出进程」这条路走对。所有示例都在本机用 go1.27.0 实测,输出是真实的。

13.1.1 先 ReadAll 再处理,是一个会炸的默认写法

最直觉的 Go 上传处理长这样:

body, err := io.ReadAll(r.Body)   // 看起来人畜无害
if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
}

对一个 2GB 的请求体,io.ReadAll 会把整个文件读进内存。更糟的是 ReadAll 内部用 append 扩容,峰值内存通常是文件大小的 1.5~2 倍。我用一个「生成 N 字节后丢弃」的 io.Reader 做了对照实验:

const size = 256 << 20 // 256MB

// 流式:边读边喂给 hash,不保留
io.Copy(hash, reader)

// 缓冲:全读进内存
buf, _ := io.ReadAll(reader)

真实输出(runtime.ReadMemStats 统计本次操作的 TotalAlloc):

stream       totalAlloc=   0.0MB peakHeapSys=   3.9MB
readall      totalAlloc= 599.8MB peakHeapSys= 624.5MB

结论很直白:256MB 的输入,ReadAll 路径分配了 599.8MB(约 2.3 倍),峰值堆 624.5MB;流式路径分配 0.0MB,峰值堆 3.9MB。 在容器内存限额 512MB 的环境里,前者直接 OOMKilled。这不是优化技巧,是能不能上线的问题。

13.1.2 服务端:MultipartReader 边读边落盘

net/http 提供了两个层次的多部分解析 API:

API行为适用场景
r.ParseMultipartForm(maxMemory)小于 maxMemory 的部分进内存,超出落临时文件小表单、少量字段
r.MultipartReader()返回流式 reader,逐个 part 读,永不整体缓冲大文件、要边读边处理

TaskHub 的附件接口用后者。核心是拿到 part 后立刻 io.Copy 到目标位置,绝不在内存里攒:

func uploadHandler(w http.ResponseWriter, r *http.Request) {
	mr, err := r.MultipartReader()
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	for {
		part, err := mr.NextPart()
		if err == io.EOF {
			break
		}
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		if part.FileName() == "" {
			io.Copy(io.Discard, part) // 普通字段,读掉即可
			part.Close()
			continue
		}
		// 关键:边读边写盘,内存占用与文件大小无关
		tmp, err := os.Create(filepath.Join(uploadDir, filepath.Base(part.FileName())))
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		n, err := io.Copy(tmp, part)
		part.Close()
		tmp.Close()
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		fmt.Fprintf(w, "file=%s size=%d\n", part.FileName(), n)
		return
	}
	http.Error(w, "no file part", http.StatusBadRequest)
}

mr.NextPart() 一次只把一个 part 暴露成 io.Reader;io.Copy 内部用 32KB 的缓冲区在 part 和文件之间搬运。所以无论上传 2KB 还是 2GB,进程内存占用都是常数级。

实测一次 5MB 上传:

UPLOAD 200 file=report.bin size=5242880 sha256=16b632f11cf950dda67dc4c184a3f9e0aa1ffa4c18927bb8977e7da97ca25bca

13.1.3 完整性校验:MultiWriter 一次遍历算 hash

上传完必须校验完整性,否则网络中断会留下半截文件。天真做法是「先落盘,再开一遍文件算 SHA-256」——多一次完整磁盘读。更优雅的是 io.MultiWriter:写盘和算 hash 共用一次遍历。

h := sha256.New()
// 数据同时流向 文件 和 hash,只遍历一遍
n, err := io.Copy(io.MultiWriter(tmp, h), part)
if err != nil {
	http.Error(w, err.Error(), http.StatusInternalServerError)
	return
}
sum := hex.EncodeToString(h.Sum(nil))

io.MultiWriter 返回一个 Writer,每次 Write 会按顺序写给所有目标。代价是 hash 计算与磁盘写串行,但对于上传场景,磁盘写本来就慢于 hash,实测差异可忽略。

生产上还要做两件事:写临时文件 + 校验通过后 os.Rename 到最终路径(Rename 在同分区是原子的,避免读者看到半截文件),以及把 sha256 存进数据库,供后续下载校验与去重。

13.1.4 大小上限:MaxBytesReader 在源头掐断

流式处理解决了内存问题,但没解决「恶意超大文件」问题。http.MaxBytesReader 给请求体加一个硬上限,一旦超限立刻中断读取并让连接不可复用,而不是傻读到底:

r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB 上限
n, err := io.Copy(io.Discard, r.Body)
if err != nil {
	var mbe *http.MaxBytesError
	if errors.As(err, &mbe) {
		http.Error(w, "too large", http.StatusRequestEntityTooLarge)
		return
	}
	http.Error(w, err.Error(), http.StatusInternalServerError)
	return
}

实测:

1KB  -> 200 read 1000
2MB  -> 413 too large

注意错误判定要用 errors.As(err, &mbe) 匹配 *http.MaxBytesError,别去字符串比较错误信息。上限值应该来自配置(第 3 章的配置层),按租户套餐区分——免费用户 10MB,企业用户 2GB。写死常量迟早会被业务推翻。

13.1.5 下载:ServeContent 免费送你 Range 与条件请求

下载端的坑和上传相反:不要自己手写 Content-Length 和分块逻辑。http.ServeContent 只要拿到一个 io.ReadSeeker(文件天然满足),就会自动实现:

  • Range 请求 → 206 Partial Content + Content-Range
  • If-None-Match / If-Modified-Since → 304 Not Modified
  • Accept-Ranges: bytes
  • 正确的 Content-Length 与 Last-Modified
func downloadHandler(w http.ResponseWriter, r *http.Request) {
	name := filepath.Base(r.PathValue("name"))
	f, err := os.Open(filepath.Join(uploadDir, name))
	if err != nil {
		http.NotFound(w, r)
		return
	}
	defer f.Close()
	st, _ := f.Stat()

	// 文件名含中文/空格时,FormatMediaType 会自动加 filename* RFC 5987 编码
	cd := mime.FormatMediaType("attachment", map[string]string{"filename": name})
	w.Header().Set("Content-Disposition", cd)
	w.Header().Set("ETag", fmt.Sprintf("%q", fmt.Sprintf("%x-%x", st.ModTime().UnixNano(), st.Size())))

	// 传入 name 用于嗅探 Content-Type;modtime 用于条件请求
	http.ServeContent(w, r, name, st.ModTime(), f)
}

实测一个 5MB 文件的三种请求:

DOWNLOAD full status=200 len=5242880 accept-ranges="bytes" etag="18dd0967ceec3679-500000" cd="attachment; filename=report.bin"
RANGE status=206 len=100 content-range="bytes 100-199/5242880"
COND status=304

一次完整下载返回 200 和全部 5242880 字节;带 Range: bytes=100-199 的请求返回 206 和 100 字节,Content-Range 标明区间与总长;带 If-None-Match 命中 ETag 时返回 304,响应体为空。这三样都是 ServeContent 白送的,自己写要几百行还容易错。

两个细节值得记:

  • ServeContent 不会自己生成 ETag:条件请求(If-None-Match、If-Range)只有在 handler 里显式设了 w.Header().Set("ETag", ...) 时才有 ETag 可比,上面那行正是干这个的。不设也能工作,只是条件请求退化成 Last-Modified + If-Modified-Since。
  • 断点续传客户端(如浏览器下载器、curl -C -)依赖 Accept-Ranges: bytes。只要走了 ServeContent,这个头自动出现。

13.1.6 Content-Type 嗅探:只读前 512 字节

上传时如果客户端没给 Content-Type,可以用 http.DetectContentType 按内容嗅探,它只看前 512 字节:

fmt.Println("sniff pdf :", http.DetectContentType([]byte("%PDF-1.7\n%...\n")))
fmt.Println("sniff png :", http.DetectContentType([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")))
fmt.Println("sniff zip :", http.DetectContentType([]byte("PK\x03\x04\x14\x00\x00\x00")))
fmt.Println("sniff json:", http.DetectContentType([]byte(`{"task":"x"}`)))

实测输出:

sniff pdf : application/pdf
sniff png : image/png
sniff zip : application/zip
sniff json: text/plain; charset=utf-8

注意最后一行:DetectContentType 不认识 JSON,{"task":"x"} 被识别成 text/plain。它的实现基于 mimesniff 规范的魔数表,只认二进制格式和少数文本类型。所以:

  • 用嗅探结果做安全决策是危险的——它只证明「文件头长得像」,不能证明内容真的合法。
  • 真正的类型校验应该在服务端按扩展名 + 魔数双重判断,并把「用户声明的类型」和「嗅探出的类型」不一致时记审计日志(第 17 章展开)。
  • 对外返回时优先用服务端认定的类型,而不是回显客户端声明的,避免 XSS(把 HTML 当图片返回会被浏览器执行)。

13.1.7 附件接口清单

把这一节的落地经验收成一张表:

关注点做法反例
读取方式r.MultipartReader() + io.Copy 边读边落盘io.ReadAll(r.Body)
大小限制http.MaxBytesReader,值来自配置无限读取
完整性io.MultiWriter 同遍历算 SHA-256落盘后再读一遍
原子性写临时文件 + os.Rename直接写最终路径
下载http.ServeContent手写 Content-Length 与 Range
文件名mime.FormatMediaType直接拼 filename=" + name + "
类型判定扩展名 + 魔数双校验只信 DetectContentType
路径安全filepath.Base 去掉目录成分直接用用户给的文件名拼路径

最后一行单独强调:永远不要用客户端给的文件名直接拼路径。../../etc/passwd 这类路径穿越是最经典的漏洞之一,filepath.Base 是底线防线,更稳的做法是服务端生成 UUID 作为存储名,把原始文件名只存进数据库当元数据(第 17 章会再回到这个话题)。

小结

  • io.ReadAll 处理大请求体会以文件大小 2 倍以上的内存分配打爆进程;实测 256MB 输入分配 599.8MB。
  • r.MultipartReader() 提供流式 part 读取,配合 io.Copy 落盘,内存占用与文件大小解耦。
  • io.MultiWriter 让「写盘」和「算 SHA-256」共用一次遍历,不用读两遍。
  • http.MaxBytesReader 从源头掐断超大请求体,用 errors.As 匹配 *http.MaxBytesError 返回 413。
  • http.ServeContent 一个调用就带来 Range/206、条件请求/304、Accept-Ranges,实测全部正确。
  • http.DetectContentType 只读 512 字节,且不识别 JSON,不能作为安全判据。
  • 文件名必须 filepath.Base 或服务端重命名,杜绝路径穿越。

到这里,文件能安全地进出单个进程了。但文件落在本地磁盘意味着多副本部署时每个 Pod 看到的文件都不一样,滚动更新还会把文件连同容器一起删掉。下一节我们把存储层抽出来,接到 S3 兼容对象存储,并用预签名 URL 把流量从应用进程卸载出去。

阅读导航:上一节:12.3 内存与 GC 调优 · 下一节:13.2 S3 兼容对象存储与预签名 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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