os.Walk配合crypto/md5是最稳起点,因文件名或大小判断极不可靠,必须基于内容哈希;os.Walk可靠遍历任意深度目录,crypto/md5生成固定长度摘要,是去重事实标准。

为什么 os.Walk 配合 crypto/md5 是最稳的起点
直接用文件名或大小判断重复极不可靠——同名不同内容、同内容不同名都很常见。必须基于内容哈希,而 os.Walk 能可靠遍历任意深度目录,crypto/md5(或 sha256)生成固定长度摘要,是去重的事实标准。
注意:别用 os.Stat().Size 做第一筛——小文件多时反而拖慢整体速度;大文件则必须先比大小再哈希,否则 I/O 浪费严重。
- 优先对文件大小分组,只对同大小文件计算哈希
- 用
io.Copy+hash.Hash.Write流式计算,避免os.ReadFile全量加载到内存 - 哈希前检查
os.ModeIrregular和os.ModeSymlink,跳过设备文件、符号链接,防止 panic 或误判
如何避免 md5.Sum([]byte) 这种典型错误
常见误区是把整个文件读成 []byte 再喂给 md5.Sum,这在几百 MB 文件上直接 OOM。正确做法是复用 hash.Hash 实例,边读边写:
h := md5.New()
if _, err := io.Copy(h, file); err != nil {
return nil, err
}
sum := h.Sum(nil) // 注意:不是 md5.Sum(fileBytes)
h.Sum(nil) 返回的是底层切片拷贝,安全;而 md5.Sum 函数是为小字节串设计的,不适用于文件流。
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 每次处理新文件前必须调用
h.Reset(),否则哈希值会累加 - 若需更高抗碰撞性,换
sha256.New(),性能差距在现代机器上可忽略 - 不要用
fmt.Sprintf("%x", sum)转字符串——用hex.EncodeToString(sum),更准更快
用 map[[16]byte][]string 存哈希结果比字符串 key 更省内存
md5.Sum 返回的是 [16]byte,直接当 map key 比用 string(要额外分配、GC)高效得多。Go 对数组类型 key 有专门优化,查找快、无逃逸。
dupMap := make(map[[16]byte][]string) // ... dupMap[sum] = append(dupMap[sum], path)
如果硬要用字符串做 key(比如日志输出需要),也应在最后阶段转换,而非存 map 时就转。
- 切忌用
map[string][]string存原始哈希字符串——小文件多时内存暴涨数倍 - 若需支持 SHA-256,key 类型改为
[32]byte,其余逻辑完全不变 - map 值用
[]string而非[][]byte,路径后续可能要做filepath.Abs或打印,string 更直接
并发哈希时为何 sync.Map 反而拖慢速度
单核 CPU 上并发读写 sync.Map 开销远超收益;即使多核,I/O 本身是瓶颈,加 goroutine 只会让磁盘寻道更乱。实测显示:4 个 goroutine 并发哈希 10k 小文件,比单 goroutine 慢 17%。
真正该并发的是「路径发现」和「哈希计算」解耦——用一个 goroutine 调 os.Walk 发路径到 channel,多个 goroutine 从 channel 拿路径算哈希,结果汇总到普通 map(加 sync.Mutex 写)。
- channel 缓冲设为 128~512,太小易阻塞,太大占内存
- 哈希 goroutine 数量建议 =
runtime.NumCPU() / 2,留核给系统和 I/O - 千万别对每个文件启一个 goroutine——调度开销压倒计算收益
哈希本身是 CPU 密集型,但文件读取是 I/O 密集型,混在一起并发只会让两者都卡住。实际项目里,先老老实实单 goroutine 跑通逻辑,再按 profile 数据针对性并发,比一上来就加 go 关键字靠谱得多。

















