本节把 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-RangeIf-None-Match/If-Modified-Since→304 Not ModifiedAccept-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 兼容对象存储与预签名 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。