外部排序是分而治之:切块→内存排序→写临时文件→多路归并;需按字节切分、用BinaryWriter提效、PriorityQueue控内存、严格编码一致、异常后清理临时文件。

大文件无法全量加载到内存时,Sort 会直接抛 OutOfMemoryException
这不是代码写得不对,是物理限制——比如一个 20GB 的日志文件,哪怕你用 File.ReadAllLines,.NET 还没开始排序就崩了。外部排序的本质是「分而治之」:把大文件切块、每块单独加载排序、写回临时文件,再做多路归并。C# 标准库不提供现成的外部排序 API,必须自己搭骨架。
关键判断点:单块大小 ≈ 可用内存的 60%~70%(留出 GC 和归并缓冲区空间)。别按行数切,要按字节估算——因为不同行长度差异极大。
- 用
StreamReader按字节读取,累计到阈值后切分,避免误切断一行(结尾加\n判断) - 每块排序后写入独立临时文件,文件名带序号,如
chunk_001.tmp - 不要用
string.Split('\n')解析整块——它仍会生成大量小字符串对象,加剧 GC 压力
FileStream + BinaryWriter 写临时排序块比 StreamWriter 更稳
文本排序的核心是比较逻辑,但 IO 效率决定整体耗时。用 StreamWriter 写字符串,内部会反复编码/解码 UTF-8 或 UTF-16;而用 BinaryWriter 直接写 byte[],跳过编码开销,尤其当原始文件编码已知(如纯 ASCII 日志)时,性能提升明显。
注意:写临时块前必须先排序该块内容。别用 List ——它默认按 Unicode 码点比较,中文、数字、符号顺序可能不符合业务预期。
- 用
Array.Sort(string[], StringComparer.Ordinal)获得确定性字节序(适合日志时间戳前缀) - 若需按数字语义排序(如
file10.txt在file2.txt后),改用StringComparer.OrdinalIgnoreCase或自定义IComparer - 临时文件务必用
Path.GetTempFileName()创建,避免权限或路径冲突
多路归并时用 PriorityQueue 控制内存峰值
归并 N 个已排序的临时文件,传统做法是为每个文件打开一个 StreamReader,每次取一行比较。但 N 很大时(比如切了 500 块),同时维持 500 个流句柄和缓冲区,容易触发 IOException 或句柄耗尽。.NET 6+ 的 PriorityQueue 可以只维护「当前最小行」集合,每次只预读少量行。
实际操作中,归并阶段才是最易出错的环节——漏行、重复行、编码错乱都发生在这里。
- 每个
StreamReader必须指定和原文件一致的Encoding(如Encoding.UTF8),否则中文变??? - 归并循环里,每次从队列取最小值后,立即从对应文件读下一行并重入队列;若该文件已到末尾,则不再入队
- 用
using var output = new BinaryWriter(File.OpenWrite(outputPath))写结果,避免文件锁残留
OutOfMemoryException 在归并阶段出现?说明缓冲区或队列项太多
即使分块合理,归并时仍可能爆内存——常见原因是把整行字符串塞进 PriorityQueue 作为 TElement,而每行平均 2KB,500 行就是 1MB,队列内部结构还会额外开销。更轻量的做法是存 (line, streamIndex) 元组,真正需要比较时才从对应流读取(延迟加载)。
另一个隐蔽坑:临时文件未及时删除。Windows 下如果进程异常退出,.tmp 文件可能被锁住,下次运行时 File.Create 报 UnauthorizedAccessException。
- 用
try/finally包裹归并逻辑,在finally中调用File.Delete()清理所有chunk_*.tmp - 归并前检查磁盘剩余空间:目标文件大小 ≈ 原文件大小 × 1.2(含临时文件+碎片)
- 对超大文件(>100GB),考虑用
MemoryMappedFile替代部分流读取,但仅限 Windows 且需管理员权限
外部排序不是调一个函数的事,而是对内存、IO、编码、异常恢复的综合权衡。最容易被忽略的是:临时文件的编码一致性、归并时的行边界处理、以及进程意外中断后的清理可靠性——这些地方不出问题则已,一出就是数据错乱,且难以验证。











