直接用http.Post传大文件会失败,因客户端将整个文件读入内存导致OOM,服务端易超时或拒绝;网络中断后无法续传,必须采用客户端分片、服务端拼接与校验位记录方案。

为什么直接用 http.Post 传大文件会失败
因为默认 HTTP 客户端没有分块能力,整个文件读进内存再发,1GB 文件直接 OOM;服务端也大概率在读取 body 时超时或拒绝。更关键的是,网络中断后只能重头来,没地方“续”。所以必须绕过一次性上传逻辑,改用「客户端切片 + 服务端拼接 + 校验位记录」三件套。
实操建议:
- 客户端用
os.Open配合io.CopyN或bufio.NewReader分段读,每块控制在 5–20MB(太小增加 HTTP 开销,太大影响并发和内存) - 每块请求带唯一标识:
file_id(如 UUID)、chunk_index、total_chunks、chunk_hash(推荐sha256.Sum256前 16 字节) - 服务端不要等全部块收完才写盘——收到一块就
os.WriteAt到临时文件对应偏移,避免顺序依赖
multipart/form-data 不适合断点续传的真相
它本质是单次请求封装多字段,没法单独重传某一块,也不带块序号元数据。一旦中间某块失败,整个 multipart boundary 流就断了,服务端 parser 直接报 multipart: NextPart: bufio: buffer full 或 http: request body too large。
正确做法是每个块走独立的 POST /upload/chunk 请求,body 用纯二进制(Content-Type: application/octet-stream),靠 URL 参数或 JSON body 传元信息:
立即学习“go语言免费学习笔记(深入)”;
POST /upload/chunk?file_id=abc123&index=4&total=12 Content-Type: application/octet-stream <binary data>
服务端解析时注意:
- 校验
index < total,防止恶意跳块攻击 - 用
io.ReadFull确保读满预期字节数,否则说明客户端发送不完整 - 写入前先用
os.OpenFile(..., os.O_WRONLY|os.O_CREATE, 0644)打开临时文件,再file.WriteAt(data, int64(index)*chunkSize)
如何安全判断哪些块已上传(避免重复写/覆盖)
不能只靠客户端传来的 index 就信它——得服务端自己确认这块是否真实存在且完整。最轻量方案是维护一个内存 map + 持久化 fallback:
- 启动时扫描
/tmp/uploads/{file_id}/下所有chunk_*.bin文件,按文件名提取 index,存入map[int]bool - 每次接收新块前,先查这个 map;若已存在,直接返回
200 OK(幂等),不重复写 - 关键:写完块后立刻
file.Sync(),再更新 map,否则崩溃后状态不一致 - 生产环境建议把块状态存到 Redis(
HSET upload:abc123 chunk_4 1),比文件系统快且可跨实例共享
漏掉 Sync() 是常见坑——Linux 默认页缓存延迟落盘,断电或 kill -9 后刚写的块可能消失,但 map 里却标记为已成功。
合并完成文件时为何 os.Rename 失败而 io.Copy 又慢
因为 os.Rename 跨文件系统会失败(比如临时目录在 /tmp 是 tmpfs,目标在 /data 是 ext4),而全量 io.Copy 又要重新读写一遍,10GB 文件多花 30 秒。
推荐方案是「原子替换 + 零拷贝拼接」:
- 用
os.OpenFile创建最终文件,O_CREATE|O_WRONLY|O_TRUNC - 对每个块文件,用
os.Open打开后,io.Copy追加进去(不是WriteAt) - 最后调
finalFile.Close()和os.RemoveAll(tempDir) - 如果追求极致性能,可用
syscall.CopyFileRange(Linux 5.3+),它在内核态直接搬运,不经过用户内存
真正容易被忽略的是:合并过程必须加分布式锁(哪怕单机也要用 sync.Mutex),否则两个并发合并请求可能把同一个 final 文件写乱。锁 key 应该是 file_id,不是随机字符串。


















