io.copy是唯一靠谱的流式合并方式,因它不加载全文进内存、不缓存、不拼接,只做字节流搬运;需用os.create覆盖目标文件、显式数字排序分片、校验sha256而非仅比大小。

用 io.Copy 流式合并,别碰 os.ReadFile
小文件合并看似简单,但一不留神就会把内存吃光或写坏内容。真正稳的路径只有一条:每个源文件用 os.Open 打开,直接喂给 io.Copy(dst, src),中间不经过 []byte、strings.Join 或 bytes.Buffer。哪怕只有几十 KB 的小文件,一旦数量上万,手动拼接照样触发 GC 压力甚至 OOM。
-
io.Copy内部默认用 32KB 缓冲区,对 SSD/NVMe 足够快,无需手动调优 - 若目标是日志类追加场景,用
os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - 若目标是覆盖重做(比如重建缓存包),必须用
os.Create(dstPath)或os.OpenFile(... | os.O_TRUNC),否则旧数据残留会污染结果 - 每次
io.Copy返回后要检查 error,尤其注意io.ErrUnexpectedEOF——它意味着某个源文件被截断,得立刻中断并清理目标文件
文件名排序必须提取数字,不能信 filepath.Glob 默认顺序
filepath.Glob("part_*.bin") 返回 part_1.bin、part_10.bin、part_2.bin 是常态,不是 bug。字符串字典序排出来就是 1→10→2,合并顺序错一位,整个二进制文件就废了——PDF 打不开、ZIP 解压报 CRC 错、视频花屏,都是静默错位的典型表现。
- 生成分片时强制零填充:
fmt.Sprintf("part_%03d.bin", i)→part_001.bin、part_002.bin - 读取前必须显式排序:
sort.Slice(files, func(i, j int) bool { return extractNum(files[i]) ,其中 <code>extractNum用正则或strings.TrimPrefix+strconv.Atoi提整数 - 别依赖
os.ReadDir返回顺序——ext4、NTFS、APFS 都不保证,Go 不背这个锅 - 如果前缀复杂(如
log_20240615_chunk_77),先strings.Split或正则匹配数字段再转int,别图省事硬切索引
合并后必须校验 sha256.Sum256,os.Stat().Size 等于零
文件大小一致 ≠ 内容正确。末尾少写几个字节、中间某块被 I/O 中断截断、甚至磁盘对齐导致的静默填充,都可能让 Size() 看起来正常,但实际内容已损坏。尤其是 ZIP、MP4、数据库 dump 这类强结构文件,错一个字节就全盘不可用。
- 分割前流式算哈希:
hash := sha256.New(); io.Copy(hash, srcFile),保存hash.Sum(nil) - 合并后对新文件做同样操作,对比两个
[32]byte是否完全相等 - 校验失败必须立刻
os.Remove(dstPath),留着脏文件比报错更危险 - 别用
md5——碰撞风险高;也别跳过校验——“写对了却没校验”才是线上事故高发点
并发写入分片时,临时目录和文件锁不能省
小文件合并常出现在上传服务中,多个请求同时写同一任务的分片,不加约束就会丢数据。更隐蔽的是磁盘爆满:用户上传中断后,临时分片留在 /tmp 下没人清理,几天就能占满。
- 每个上传任务用 UUID 创建独立临时目录,如
/tmp/upload_abc123/ - 写入分片前用
os.OpenFile(..., os.O_CREATE|os.O_EXCL),失败说明已存在,可跳过或报错 - 定期扫描
filepath.Glob("/tmp/upload_*/*"),删掉 24 小时未更新的碎片(用os.Chtimes记最后写入时间) - 不要靠文件名哈希做唯一标识——同名文件不同用户会冲突;也不要等所有块收齐才合并——最后一块可能永远不来
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











