服务端应从multipart.FileHeader.Open()获取的io.ReadCloser流实时计算MD5,避免依赖客户端传值或读取临时文件;需用io.Copy配合md5.New()流式计算,禁止io.ReadAll以防OOM,并在计算后重新Open流以保存文件。

上传时直接计算文件MD5,不依赖客户端传值
客户端传的 Content-MD5 或自定义 header 不可信,Gin 本身也不解析它。必须在服务端读取上传的文件流并实时计算 MD5,这是唯一可靠的方式。注意:不能用 c.FormFile 后再打开文件计算——临时文件可能已被清理或权限受限,应从 *multipart.FileHeader 的 Open() 得到的 io.ReadCloser 流上直接计算。
- 调用
c.MultipartForm()或更推荐的c.FormFile("file")获取*multipart.FileHeader - 用
header.Open()打开流,别用header.Filename去磁盘读 - 用
io.Copy配合hash.Hash(如md5.New())边读边算,避免把整个文件加载进内存 - 计算完立即
Close()流,否则后续c.SaveUploadedFile()会失败(流已耗尽)
Gin 中完整示例:读流 + 计算 MD5 + 保存文件
下面这段代码在一次请求中完成三件事:校验 MD5、避免重复、保存文件。关键点是复用同一个流——先 tee 给 hasher,再保存到磁盘。
func uploadHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.String(400, "no file uploaded")
return
}
<pre class="brush:php;toolbar:false;">src, err := file.Open()
if err != nil {
c.String(500, "failed to open file: %v", err)
return
}
defer src.Close()
hasher := md5.New()
multiWriter := io.MultiWriter(hasher, ioutil.Discard) // 只需要 hash,不存原始数据到内存
// 复制流:一边算 MD5,一边丢弃(为下一步保存做准备)
if _, err = io.Copy(multiWriter, src); err != nil {
c.String(500, "failed to read file: %v", err)
return
}
md5Sum := fmt.Sprintf("%x", hasher.Sum(nil))
// 重置流位置(需底层支持 Seek),但 *os.File 支持,*multipart.Part 不支持 → 所以必须重新 Open
// 正确做法:再次 Open,或用 io.TeeReader + 临时 buffer(小文件)或分两步(推荐)
// ✅ 推荐:二次 Open(安全、通用)
src2, _ := file.Open()
defer src2.Close()
// 这里可查数据库/Redis 判断 md5Sum 是否已存在
if existsInDB(md5Sum) {
c.String(409, "file already exists: %s", md5Sum)
return
}
if err := c.SaveUploadedFile(file, "/tmp/uploads/"+md5Sum); err != nil {
c.String(500, "save failed: %v", err)
return
}
c.JSON(200, gin.H{"md5": md5Sum})}
大文件上传时 MD5 计算的性能与内存陷阱
直接 io.Copy(hasher, src) 是流式计算,内存占用恒定(≈几 KB),但如果你误用 io.ReadAll(src) 再传给 hasher.Write(),1GB 文件就会吃掉 1GB 内存,直接 OOM。
- 永远避免
io.ReadAll或bytes.Buffer缓存整个上传体 - 不要在计算 MD5 后还试图从同一
src读取内容——multipart.FileHeader.Open()返回的流通常不可 seek,且只可读一次 - 若需同时校验和保存,用
io.TeeReader(src, hasher)+io.Copy(dst, teeReader),但注意TeeReader不会自动 Close 源,仍需手动管理 - 生产环境建议加超时和最大尺寸限制:
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 100
去重逻辑该放哪?Redis 还是数据库?
MD5 去重本质是“写前检查”,必须在保存物理文件之前完成。用 Redis 存 MD5(set 或 string)最轻量,但要注意原子性:不能先查再存,得用 SETNX 或 Lua 脚本保证查+存一体。如果已有业务数据库且 QPS 不高,用带唯一索引的表也够用。
- Redis 示例:
if redisClient.SetNX(ctx, "file:md5:"+md5Sum, "1", 720*time.Hour).Val() { /* 新文件 */ } - 避免用普通
GET+SET,竞态下可能两个相同 MD5 请求同时通过检查 - 如果文件删除逻辑存在,记得同步清理 MD5 记录(用 Redis 的 key 过期或数据库级联)
- MD5 碰撞概率极低,但合规场景建议升到 SHA-256;不过 SHA-256 值更长,索引和存储开销略增
实际部署时最容易漏的是流关闭顺序和二次 Open 的必要性——很多人卡在“第一次算完 MD5 后,保存时报 ‘invalid argument’ 或空文件”,基本都是因为流已 EOF 且没重新打开。











