外部排序需分块排序再归并:先流式读取、分块内存排序写临时文件,再用带source跟踪的最小堆多路归并,严格管控缓冲区、临时文件生命周期及堆节点内存,归并阶段禁止并发。

外部排序不是“开 goroutine 就行”,得先分块再归并
Go 里对超大文件做排序,sort.Slice 或 sort.Ints 直接报错或 OOM 是常态——它们只适用于内存能装下的数据。真正可行的路径是「外部排序」:把文件切分成若干块,每块在内存中独立排序后写入临时文件,最后用多路归并(k-way merge)合并所有有序块。
关键不在“并发读”,而在“可控分块”和“无重复加载”:
- 用
os.Open+bufio.NewReaderSize(f, 1<strong><code>MB) 流式读取,避免os.ReadFile一次性吃光内存 - 每读够 N 条(比如 10 万整数),就调用
sort.Ints排序,再用bufio.NewWriterSize写入一个临时文件(如chunk_001.tmp) - 记录每个临时文件的路径和当前读取位置(用于后续归并时 seek),别依赖
os.Stat查大小——它不准,尤其 NFS 或容器挂载卷 - 临时文件名建议带哈希前缀(如
tmp_7f3a_chunk_001.tmp),防止多进程冲突
归并阶段必须用最小堆,不能靠 channel 拼接
常见误区是起一堆 goroutine 分别从各 chunk 读首条,然后用 select 等最小值——这会因 channel 缓冲区未满/阻塞导致死锁或漏数据。正确做法是手动维护一个最小堆,每个节点存:data、sourceID(标识来自哪个 chunk)、reader(指向该 chunk 的 *bufio.Reader)。
Go 标准库没提供带 source 跟踪的堆,得自己封装:
type heapItem struct {
data int
sourceID int
reader *bufio.Reader
}
比较函数只比 data;Pop 后立刻从对应 reader 再读一条进堆(注意处理 EOF);写入最终结果文件时,用 bufio.NewWriterSize(outFile, 1<strong><code>MB) 缓冲输出。
容易踩的坑:
- 堆初始化时,若某 chunk 已空,跳过它,否则
heap.Init会 panic - 从
reader读整数要用fmt.Fscanf(r, "%d", &v),别用ReadString('\n')—— 行末可能无换行符,或数字跨缓冲区边界 - 归并循环结束条件不是 “堆空”,而是 “堆中所有 item 的 reader 都返回 EOF”
内存峰值控制不住?检查 bufio 缓冲区和临时文件生命周期
实测发现,即使分块合理,内存仍缓慢上涨,八成是临时文件没及时删除,或 bufio.Reader 底层缓冲区被长期引用。
必须显式管理:
- 每个 chunk 排序写完后,立即调用
os.Remove(chunkPath),别等 defer —— defer 在函数退出才执行,而归并阶段还在跑 - 归并用的
*bufio.Reader创建后,一旦该 chunk 数据读完,立刻reader.Reset(nil)并置为 nil,帮助 GC 回收缓冲内存 - 如果 chunk 数量多(比如 > 100),堆节点本身也会占内存;考虑用
sync.Pool复用heapItem实例,避免高频分配 - 用
runtime.ReadMemStats在归并循环中定期打点,确认Alloc不持续上升
并发分块排序收益有限,别盲目加 goroutine
对单个大文件分段并发读+排序,在 SSD 上可能提速,但在机械盘或网络存储上往往更慢——磁头寻道开销远大于 CPU 排序时间。
是否开启并发,取决于三个硬指标:
- 文件是否本地磁盘(非 NFS / S3FS / CephFS)
- 逻辑 CPU 核数 ≥ 4,且当前系统负载
- 单块排序耗时 > 50ms(用
time.Now()实测)
满足才考虑用 sync.WaitGroup + chan error 控制并发度(建议固定为 min(4, runtime.NumCPU())),且每个 goroutine 必须操作独立的 os.File 和 bufio.Reader,禁止共享文件句柄。
最常被忽略的一点:归并本身**不要并发**。多路归并是顺序 I/O 密集型任务,单 goroutine + 最小堆的吞吐已接近磁盘极限,加 goroutine 只会引入锁竞争和调度开销。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











