接口一放开,机器一压,最先崩的通常不是磁盘,也不是网卡,是你那层“看起来没问题”的上传逻辑。
文件上传这事,单机跑通很容易。FormFile 一拿,io.Copy 一写,接口就通了。真到并发上来,问题马上换脸:临时文件打满、对象存储抖一下请求堆死、同一个文件被重复传几十次、下载把带宽顶满,日志里一堆 context canceled、broken pipe、timeout while reading body。这类问题我一般不先看业务表,先看链路上哪一段在堵。
企业级文件存储,核心不是“把文件存进去”,而是高并发下还能稳,出了抖动还能兜,下载热点来了别把自己打穿。
我比较信这套拆法:接入层负责限流和快速落盘,元数据层只管文件状态,真正耗时的上传、转存、校验、缩略图这些动作,尽量异步化。别一上来就在 HTTP 请求里把所有事干完,不然用户传个 500MB 视频,你这条连接就陪它耗着。
先看上传入口,第一步别急着读全量到内存,边读边算 hash,顺手做去重。
type UploadResult struct { FileID string Sha256 string Size int64 Exists bool}funcSaveStream(r io.Reader, dst *os.File)(string, int64, error) { h := sha256.New() n, err := io.CopyBuffer(io.MultiWriter(dst, h), r, make([]byte, 256*1024))if err != nil {return"", 0, err }return hex.EncodeToString(h.Sum(nil)), n, nil}这段代码不花哨,但很实用。上传过程中把摘要算出来,后面你查元数据表时就知道是不是重复文件。很多系统并发一上来,先死在重复上传上。同一份合同、同一个报表,被前端重试、网关重试、用户手抖重复点,存储层傻乎乎照单全收,容量长得飞快。
元数据表我一般至少放这些字段:
createtable file_meta ( file_id varchar(64) primary key, sha256 varchar(64) notnull, biz_type varchar(32) notnull,sizebigintnotnull, storage_key varchar(256) notnull,statustinyintnotnull, ref_count intnotnulldefault1, created_at timestampnotnull);createuniqueindex uk_file_sha on file_meta(sha256);这里的 status 很关键,别上传完才插库。实际要反过来,先写一条“上传中”,成功后再置为“可用”。否则对象存储成功了,数据库失败,或者数据库成功了,对象存储超时,线上就开始出现脏数据。这种事查起来很烦,日志看着都成功,实际文件就是下不下来。这个坑和线上排障里那种“业务日志正常,但链路里有超时”是一个味道。
再往下是并发控制。上传不是线程越多越好,尤其是转存到 MinIO、S3、NAS 这种后端时,必须做池化和限速。不然 300 个请求同时进来,数据库连接、磁盘 IO、对象存储连接一起抢,最后整体吞吐反而掉。
type Uploader struct { sem chanstruct{}}funcNewUploader(n int) *Uploader {return &Uploader{sem: make(chanstruct{}, n)}}func(u *Uploader)Do(ctx context.Context, fn func()error) error {select {case u.sem <- struct{}{}:deferfunc() { <-u.sem }()return fn()case <-ctx.Done():return ctx.Err() }}这玩意很土,但比裸 goroutine 靠谱。很多 Go 项目喜欢上来就 go func(){},写得飞快,出事也飞快。企业里并发控制不怕土,怕失控。
真正落存储时,建议分片上传。不是为了炫技,是大文件高并发时,失败重试成本差太多。一个 2GB 文件最后 10MB 网络抖了,你整文件重传,用户要骂人的。分片后可以只补失败片段。
type PartMeta struct { PartNo int Etag string Size int64}funcUploadPart(ctx context.Context, partNo int, data []byte)(PartMeta, error) {// 这里模拟上传到对象存储 sum := md5.Sum(data)return PartMeta{ PartNo: partNo, Etag: hex.EncodeToString(sum[:]), Size: int64(len(data)), }, nil}上传完不要立刻对外宣称成功,合并分片、校验分片数、更新状态,这几个动作一个不能少。少一步,后面下载就是隐患。线上最烦的不是接口报错,是接口 200,用户拿到的是坏文件。
下载这边也别小看。高并发下载最容易犯两个毛病:一是每次都从存储后端整文件回源,二是热点文件没有任何缓存。结果就是一个热门安装包、一个热门模板,被几千人同时下,源站带宽直接见顶。
Go 里做下载代理,最少要支持 Range,不然断点续传、视频拖动播放都别想。
funcserveFile(w http.ResponseWriter, r *http.Request, f *os.File, name string)error { stat, err := f.Stat()if err != nil {return err } http.ServeContent(w, r, name, stat.ModTime(), f)returnnil}别自己手搓字节范围解析,标准库能扛的先让标准库扛。你真正该花精力的是权限校验、签名下载、CDN 回源策略这些地方。
还有个点很多人容易漏:上传成功不等于业务完成。比如图片要压缩、文档要抽文本、视频要转码,这类后处理不要堵在主链路里,老老实实丢队列异步做。主流程只返回“文件已接收”,后面状态机推进。这个思路跟高并发系统里用 MQ 削峰是一样的,先把入口稳住,再慢慢处理后面的脏活累活。
夜雨聆风