后端中转扛不住
数据治理服务接收的论文 PDF、数据集文件普遍在几十到几百 MB。早期上传走的是「客户端 → 后端 → MinIO」的中转,后端的带宽和内存压力很大:Go 的 c.FormFile 会把 multipart 内容写临时文件,并发一上来磁盘 IO 和 GC 都吃紧。更难受的是,大文件传到一半断了,只能从头再来。
结论很自然:客户端直接把文件 PUT 到对象存储,后端只负责签发上传 URL 和记录元信息。S3 兼容协议(我们用 MinIO)的预签名 URL 正好干这件事。
客户端直传,后端只签名
后端提供一个 POST /files/presign 接口,入参是文件名、大小、MIME、内容 SHA256。后端做四件事:
- 生成全局 file_id 和 object key(
raw/{tenant}/{yyyy}/{mm}/{file_id}.ext); - 调 S3 SDK 生成一个 15 分钟有效的 PUT 预签名 URL,绑定 Content-Type;
- 在 file_objects 表插一条 status=uploading 的记录;
- 把 URL 和 file_id 返回给客户端。
客户端拿着这个 URL 直接 PUT 文件到 MinIO,传完回调 POST /files/{id}/complete。后端校验对象是否真实存在、大小是否匹配,状态置为 uploaded,然后投递解析任务。
超过 100MB 的文件走分片上传(CreateMultipartUpload → 各分片预签名 → CompleteMultipartUpload),支持断点续传。

Presign 与 Complete
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| import (
"context"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type PresignReq struct {
FileName string `json:"file_name"`
Size int64 `json:"size"`
MIMEType string `json:"mime_type"`
Sha256 string `json:"sha256"`
}
func (h *FileHandler) Presign(c *gin.Context) {
var req PresignReq
c.ShouldBindJSON(&req)
fileID, _ := h.sf.NextID()
key := buildKey(c.GetInt64("tenant_id"), fileID, req.FileName)
input := &s3.PutObjectInput{
Bucket: aws.String(h.bucket),
Key: aws.String(key),
ContentType: aws.String(req.MIMEType),
Metadata: map[string]string{
"sha256": req.Sha256,
"file-id": strconv.FormatInt(fileID, 10),
},
}
presignClient := s3.NewPresignClient(h.s3Client,
func(o *s3.PresignOptions) { o.Expires = 15 * time.Minute })
resp, err := presignClient.PresignPutObject(c.Request.Context(), input)
if err != nil {
c.JSON(500, gin.H{"msg": "presign failed"})
return
}
h.db.Create(&FileObject{
ID: fileID, TenantID: c.GetInt64("tenant_id"),
FileKey: key, FileName: req.FileName,
Size: req.Size, MIMEType: req.MIMEType,
Hash: req.Sha256, Status: "uploading",
})
c.JSON(200, gin.H{
"file_id": fileID,
"upload_url": resp.URL,
"method": resp.Method,
"headers": resp.SignedHeader,
})
}
|
Complete 时用 HeadObject 校验:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| func (h *FileHandler) Complete(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var obj FileObject
h.db.First(&obj, id)
out, err := h.s3Client.HeadObject(c.Request.Context(), &s3.HeadObjectInput{
Bucket: aws.String(h.bucket),
Key: aws.String(obj.FileKey),
})
if err != nil || *out.ContentLength != obj.Size {
h.db.Model(&obj).Update("status", "failed")
c.JSON(400, gin.H{"msg": "upload verify failed"})
return
}
h.db.Model(&obj).Update("status", "uploaded")
h.publishParseTask(obj.ID)
c.JSON(200, gin.H{"msg": "ok"})
}
|
五个坑
第一个最常见:预签名 URL 绑定了 HTTP 方法和 headers,客户端必须原样使用。前端爱犯的错是 PUT 时手动加了个 Content-Type,值却和签名时不一致,S3 直接甩回来一个 SignatureDoesNotMatch。要么严格对齐,要么签名时不指定 ContentType 让客户端自定,但后者有类型被篡改的风险。
第二个是权限。预签名 URL 本身不鉴权,拿到的人都能传。我们把有效期压到 15 分钟,object key 用不可猜的 Snowflake ID,防枚举。
第三个是大文件。单片 PUT 虽然简单,500MB 的文件断一次就得重传,体验很差。分片上传每片 16MB,并发传,单片失败只需重传那一片。
第四个:HeadObject 校验不能省。客户端可能拿到 URL 后根本不传,或者传一半就调 complete,必须从 S3 侧确认对象真实存在且大小匹配。
第五个是 MinIO 版本。它的预签名 v4 和 AWS S3 行为基本一致,但部分老版本对带 Metadata 的签名有兼容问题,升级到最新稳定版就好。
后来
预签名上传把后端从数据通路里摘了出去,只做签名和校验,带宽压力降得非常明显,客户端还能直接用上对象存储的分片和断点续传。配上 file_objects 表的状态机,上传、校验、解析三步衔接得很清楚。大文件场景,我觉得这个模式值得用。
封面图:Aaron Volkening / Flickr · CC BY 2.0