C#大文件合并实战:流式处理解决内存溢出与编码BOM问题
先说个真实场景。前年我维护一套上位机日志系统串口设备一天能吐三四 GB 的调试文本每周要合并一次做存档分析。一开始图省事直接用File.ReadAllLines读完再WriteAllLines结果程序在合并第 6 天日志的时候弹出OutOfMemoryException整个上位机界面直接卡死串口数据也停了。后来我把整个合并逻辑重写成流式处理内存占用从 7 GB 压到 80 MB 以内合并 10 GB 文件毫无压力。这篇文章就把这套方案掰开揉碎讲清楚。主要内容包括为什么你会内存溢出、怎么用 C# 正确合并大型文本文件、编码和 BOM 的隐藏问题、缓冲区调优、并行合并的误区以及我在实际项目里踩过的各种坑。适合做上位机、日志采集、数据分析整理这类方向被大文件合并折磨过的 C# 开发者。1. 一次 OOM 事故合并 4GB 日志时程序直接挂掉1.1 事故现场与排查结论当时合并代码长得很正常没有任何复杂的逻辑var lines new Liststring(); foreach (var file in Directory.EnumerateFiles(D:\logs, *.log)) { lines.AddRange(File.ReadAllLines(file)); } File.WriteAllLines(D:\logs\merged.log, lines);6 天的日志加起来大概 4.2 GB程序跑到一半先看到内存占用飙到 5 GB 多随后 CPU 占用打到 100%界面假死最后弹了OutOfMemoryException。我一开始以为是 List 扩容不够高效换成了预分配容量的方式var lines new Liststring(1024 * 1024 * 8);结果还是卡死。这时候我才意识到问题不在 List 上而是把所有行都读进内存这个模型本身就错了。一行日志可以读完就扔为什么要攒到全部读完再写盘呢1.2 用几行代码复现内存膨胀做一个简单的实验就能看清内存模型。用File.ReadAllLines读一个 1 GB 的日志文件平均每行 100 个字符在 64 位 .NET 进程里项目占用每个字符串对象100 字符约 216 字节1000 万行字符串对象约 2.16 GBstring[] 数组引用8 字节/个约 80 MB数组扩容预留空间额外 50%~100%合计峰值轻松突破 4 GBReadAllLines会返回一个string[]这里面每一个元素都是一个独立的堆对象。CLR 不会自动合并内容相同的字符串哪怕 100 万行都是INFO也照样会创建 100 万个独立对象。你看着是读一个文件实际上是在堆上复制了整个文件内容的若干倍副本。ReadAllText也好不到哪里去。一个 1 GB 的 ASCII 文本文件读进来变成 UTF-16 字符串后直接膨胀到约 2 GB 内存加上 .NET 对象头和对齐总占用在 2.1 GB 左右。也就是说任何一次性读入的 API在大型文本文件面前都等于自杀。1.3 先估算内存再决定方案一张纸上的粗算踩过一次坑以后我养成了习惯写文件处理代码之前先算一遍最坏情况的内存占用。估算公式不复杂。文件大小字节除以平均行字节数得到行数行数乘以单行字符串的实际内存开销对象头加字符数据就是行对象的总内存。再把数组引用、扩容预留、GC 临时对象叠加上去基本就是峰值内存。举个例子文件 2 GB平均每行 200 字节那就是 1000 万行。每行字符串在 64 位 .NET 下大约 416 字节1000 万行就是 4.16 GB。加上string[]的引用和扩容预留没有 6 GB 下不来。如果进程是 32 位.NET Framework 默认 x862 GB 虚拟地址空间上限直接触发 OOM。算完这笔账结论很清楚合并大文本文件必须走流式让内存占用变成常数而不是随文件大小线性增长。2. 流式合并的核心原理让内存占用恒定不变2.1 StreamReader.ReadLine 解决了什么问题StreamReader.ReadLine的行为看似简单但它是文本流式处理的核心。它的工作机制是底层FileStream从磁盘按块读取字节到内部缓冲区ReadLine从缓冲区里找换行符找到就返回这一行的字符串没找到就继续读下一块。关键点在于每次调用只返回一行返回之后这行字符串就是垃圾回收的候选对象不再持有任何文件内容。你可以把它理解成传送带流水线一件货物从传送带这头进来处理完立刻从另一头出去仓库里永远只有一件货。而ReadAllLines是把整座仓库的货全堆在客厅里再慢慢处理。用流式合并内存占用只跟最长那行的长度相关和文件总大小无关。日志场景中一行通常不超过几 KB所以哪怕合并 100 GB 的文件内存也稳如磐石。2.2 为什么按行流式而不是按块流式更符合合并场景有人会问为什么不直接用FileStream按固定大小分块读取比如每次读 64 KB写入目标文件这样连行都不用解析性能不是更高吗按块读取本身没问题但它解决的是纯二进制拼接问题。合并文本文件时你面对的往往是这些需求保证每个文件最后一行之后有换行符避免两个文件首尾粘连输出统一的行尾符比如把\n统一成\r\n过滤空行、按行号做异常定位处理不同源文件的编码差异。这些需求全都要在行这个粒度上操作。按块读取时一行日志可能被切在缓冲区边界上你必须自己维护半行状态实现起来绕且容易出 bug。ReadLine把这些脏活都干完了它识别\r\n、\n、\r三种换行符还自动处理缓冲区跨界的半行拼接。2.3 StreamWriter 侧缓冲区是你的朋友很多人只知道StreamWriter会自动 Flush不知道它的缓冲区大小可以直接控制。构造函数里有bufferSize参数单位是 char默认值约 1 KB。写入侧的关键问题是如果每写一行就真正落盘一次磁盘会频繁执行小规模写入性能极差。SSD 的小文件写放大效应比大块顺序写高好几倍。正确做法是给StreamWriter配一个大缓冲区让它在内存里攒够一定量再一次写盘。我的习惯是给StreamWriter设置 64 KB 到 128 KB 的缓冲区再配合FileStream的 64 KB 缓冲区磁盘写入次数可以降低几个数量级。这个调优在后面的实测章节有数据。3. 完整实现从两个文件到任意数量文件3.1 最简版本合并两个文件先看核心代码。合并这个动作本身很简单关键是选对 APIpublic static void MergeTwoFiles(string input1, string input2, string output) { using var writer new StreamWriter(output, false, Encoding.UTF8, 64 * 1024); using (var reader new StreamReader(input1, Encoding.UTF8, true, 64 * 1024)) { while (reader.ReadLine() is { } line) { writer.WriteLine(line); } } using (var reader new StreamReader(input2, Encoding.UTF8, true, 64 * 1024)) { while (reader.ReadLine() is { } line) { writer.WriteLine(line); } } }几个容易被忽略的细节StreamWriter的第二个参数append设为false表示重新创建输出文件。如果你在 for 循环里多次调用这个函数第二次会覆盖第一次的结果所以更通用的做法是让调用方控制 append 属性。StreamReader的第三个参数detectEncodingFromByteOrderMarks设为true让读取器自动识别 BOM。这个参数后面专门有一章讲它决定了你会不会在输出文件开头看到隐藏的\uFEFF字符。using var是 C# 8 的语法作用域到当前方法结束比嵌套using {}清爽。要注意不要在大循环里反复创建StreamWriter创建一次用到底。3.2 批量版本合并整个目录的文件实际项目中源文件往往不止两个而是几十上百个。下面这个版本接收一个文件路径集合按顺序流式合并public static void MergeFiles(IEnumerablestring inputFiles, string outputFile) { using var writer new StreamWriter(outputFile, false, new UTF8Encoding(true), 128 * 1024); foreach (var file in inputFiles) { using var reader new StreamReader(file, Encoding.UTF8, true, 128 * 1024); while (reader.ReadLine() is { } line) { writer.WriteLine(line); } } }调用方式var files Directory.EnumerateFiles(D:\logs, *.log) .OrderBy(f f, StringComparer.OrdinalIgnoreCase); MergeFiles(files, D:\logs\merged.log);Directory.EnumerateFiles返回的是延迟求值的IEnumerablestring它不会一次性把文件名数组全加载到内存。当然文件路径列表本身不大攒着也没事但它体现了一个好习惯能用流式地方绝不用数组。排序时用OrdinalIgnoreCase可以保证log9排在log10前面不按字典序产生的混乱。如果你想要按文件名中的日期数字排序需要自己解析编号这个细节在日志场景里很常见。3.3 边界处理空文件、末尾无换行、换行符统一空文件是第一个边界情况。合并遇到空文件时ReadLine直接返回null循环体不会执行输出文件不会多出任何东西行为是正确的。第二个是很多新手会踩的坑源文件最后一行没有换行符。比如文件内容是ABC没有结尾换行另一个文件内容是DEF。如果直接做字节拼接得到的是ABCDEF两个逻辑行粘连了。用ReadLine处理时ABC和DEF会被视为两个独立的行写入时通过WriteLine自动补上换行符天然规避了这个隐患。第三个是换行符统一问题。StreamWriter.WriteLine默认使用当前环境的Environment.NewLine在 Windows 上是\r\n在 Linux 上是\n。如果你想输出固定风格可以显式传入自定义的换行符writer.NewLine \n; // 强制使用 Unix 风格或者是反过来统一成\r\n。对于要丢给老牌 Windows 上位机软件解析的日志建议统一成\r\n不少旧程序只认这个。4. 编码与 BOM合并后出现乱码的最常见原因4.1 BOM 的位置问题第一个文件带 BOM 第二个文件混入BOMByte Order Mark是文本文件开头的一串特殊字节用来标记编码。UTF-8 的 BOM 是EF BB BFUTF-16 LE 的 BOM 是FF FE。Windows 下的记事本在保存 UTF-8 文件时默认会写 BOM。合并场景中 BOM 的经典事故是这样的第一个文件带 BOM第二个文件也带 BOM读完第一个再读第二个时第二个文件的 BOM 被当成了正文内容导致合并结果里出现一行带\uFEFF的不可见字符。用StreamReader的detectEncodingFromByteOrderMarks: true参数可以自动跳过 BOM所以这个问题本身不难解决。但坑在另一个方向如果你用detectEncodingFromByteOrderMarks: false并且显式指定Encoding.UTF8那么\uFEFF就会出现在每行开头肉眼看不见解析程序却会莫名报错。输出侧的 BOM 也很关键。new StreamWriter(path)默认使用 UTF-8 无 BOM 编码这没问题。但如果下游程序只认 UTF-8 BOM比如一些老旧解析器你需要显式构造var utf8Bom new UTF8Encoding(true); // true 表示写入 BOM using var writer new StreamWriter(output, false, utf8Bom, 128 * 1024);4.2 无 BOM 文本的编码检测方法与兜底策略麻烦的是没有 BOM 的文件。老式 Windows 记事本默认用 ANSI 保存中文系统下 ANSI 就是 GBK/GB2312。如果你把所有文件都按 UTF-8 读中文部分会变成一排乱码而且这种乱码一旦写进输出文件就不可逆了。我在项目中采用了一个简单但可靠的启发式方案public static Encoding DetectEncoding(byte[] head) { if (head.Length 3 head[0] 0xEF head[1] 0xBB head[2] 0xBF) return new UTF8Encoding(true); if (head.Length 2 head[0] 0xFF head[1] 0xFE) return Encoding.Unicode; if (head.Length 2 head[0] 0xFE head[1] 0xFF) return Encoding.BigEndianUnicode; try { var strictUtf8 new UTF8Encoding(false, true); strictUtf8.GetString(head); return Encoding.UTF8; } catch (DecoderFallbackException) { return Encoding.GetEncoding(GBK); } }思路是先看 BOM没有 BOM 就先按严格 UTF-8 校验字节序列是否合法。UTF-8 的字节模式有强约束比如多字节序列必须遵循特定模式0xC0、0xC1这种不能出现在有效 UTF-8 中。如果是合法的 UTF-8大概率本来就是 UTF-8校验抛异常就当成 GBK 处理。需要注意的是任何启发式检测都有误判的可能。纯 ASCII 文本在两种编码下效果一样没有乱码问题可以放心交给这个逻辑。对于重要数据最稳妥的方式还是让用户在界面上指定编码。4.3 .NET Core 下 GBK/GB2312 的坑.NET Framework 里直接用Encoding.GetEncoding(GBK)没问题但 .NET Core 为了保持运行时精简默认只支持 Unicode 系列编码ASCII、UTF-8/16/32 这些不支持 GBK、GB2312、Big5 等代码页编码。解决方案是引用System.Text.Encoding.CodePages包然后在程序启动时注册Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);注册之后Encoding.GetEncoding(GBK)和Encoding.GetEncoding(936)才能正常工作。这个步骤漏了的话程序运行到读取 GBK 文件时会直接抛ArgumentException错误信息很迷惑完全看不出来是缺注册。顺带一提GB2312 和 GBK 都映射到代码页 936在 .NET 里通常用哪个名字都行。GB18030 是 GBK 的超集如果你的数据源里有生僻汉字用GB18030更保险。5. 性能调优的实测缓冲区大小、并行合并与方案取舍5.1 缓冲区从 1KB 调到 1MB实测数据对比我在这台机器上做过一次对比测试i7-12700 三星 980 Pro SSD.NET 6合并 10 个共约 2 GB 的日志文件输出到另一块 SSD。方案峰值内存耗时ReadAllLinesWriteAllLinesOOM逼近 6 GB 后崩溃未完成ReadAllTextWriteAllText约 4.7 GB约 48 秒StreamReader/Writer 默认 1 KB 缓冲约 50 MB约 21 秒StreamReader/Writer 64 KB 缓冲约 50 MB约 12 秒StreamReader/Writer 128 KB 缓冲约 50 MB约 11 秒FileStream 1 MB 字节块复制约 2 MB约 6 秒结论很清晰只要不把文件读进内存内存占用基本恒定缓冲区从 1 KB 提升到 64 KB 能带来接近一倍的性能提升继续加大到 128 KB 或 1 MB收益就开始递减。推荐默认配置是FileStream缓冲区 64 KB、StreamReader缓冲区 64 KB、StreamWriter缓冲区 128 KB。再往上加因为每次刷盘的数据量已经接近内存页和磁盘 I/O 调度器的极限性能提升微乎其微反而多占内存。5.2 并行合并听起来很美实际上多数情况更慢很多人一看到多文件合并就想到并行但这是大坑。磁盘的物理特性决定了顺序读的性能远远高于随机读。并行读取多个文件时磁头在各文件之间来回跳或者 SSD 的多个队列被同时占用整体吞吐量反而下降。更糟的是如果多个线程共享同一个StreamWriter必须加锁锁等待的开销直接抵消了并行带来的收益。下面这个写法看起来很对但在机械硬盘上会明显变慢在 SSD 上也不见得快Parallel.ForEach(files, file { using var reader new StreamReader(file); while (reader.ReadLine() is { } line) { lock (syncRoot) { writer.WriteLine(line); } } });真正值得并行的场景只有一种源文件分散在多个不同的物理磁盘上输出文件在另一块磁盘上并且每块磁盘的负载都比较低。这种情况下可以给每块源盘分配一个读取线程各自独立读取并写入各自的临时输出文件最后再串行合并临时文件。结构复杂很多但收益只在极限场景下体现得出来。我更推荐的做法是读写串行预处理并行。比如你要从 100 个源文件里先过滤掉带[DEBUG]标记的行可以先用多个线程并行读取、过滤、统计把最终要写入的行写入各自独立的临时文件最后再串行合并这些临时文件。这样既规避了写锁冲突又能利用多核的算力。5.3 FileStream 直接字节复制什么时候能用到如果需求只是把多个文件按字节拼成一个文件不关心换行符统一和编码转换那么最快的方案是直接字节复制using var input new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 1024 * 1024); using var output new FileStream(dest, FileMode.Append, FileAccess.Write, FileShare.None, 1024 * 1024); input.CopyTo(output, 1024 * 1024);实测中这种方案的耗时比流式按行读写快接近一倍因为省掉了编码解析和换行符处理。但代价是文件 A 的末尾如果没有换行符文件 B 的开头就会和它粘连。除非你能保证每个源文件末尾都有换行符否则不建议在文本日志合并中用纯字节复制。我的取舍习惯是能确认数据格式的用字节复制不能确认的用按行流式。对于日志合并这种数据源五花八门的场景按行流式是安全且够快的默认选择。6. 工程化经验汇总让合并工具真正能交付使用6.1 进度显示、取消机制与错误处理实际交付使用时一个黑窗口跑十几分钟没有任何反馈会被用户骂死。进度显示至少要做到文件级别var total files.LongCount(); var processed 0L; foreach (var file in files) { processed; Console.WriteLine($[{processed}/{total}] 正在合并 {file}); // ... }如果想做行级或字节级进度可以用reader.BaseStream.Position / reader.BaseStream.Length计算百分比。但要注意StreamReader有缓冲BaseStream.Position反映的是底层流实际读到的位置可能略超前于逻辑上已消费的行位置作为进度估算足够用了。取消机制对长时间任务很重要。给合并方法传入CancellationToken在每行循环里检查public static void MergeFiles(IEnumerablestring inputFiles, string outputFile, CancellationToken ct) { using var writer new StreamWriter(outputFile, false, Encoding.UTF8, 128 * 1024); foreach (var file in inputFiles) { ct.ThrowIfCancellationRequested(); using var reader new StreamReader(file, Encoding.UTF8, true, 128 * 1024); while (reader.ReadLine() is { } line) { ct.ThrowIfCancellationRequested(); writer.WriteLine(line); } } }配合CancellationTokenSource.CancelAfter(TimeSpan.FromMinutes(30))就能实现超过 30 分钟自动终止的兜底策略避免死等。6.2 合并结果的校验策略合并完成不等于合并正确。实际项目中我至少做两层校验。第一层是行数对比。在读取时用long累加每个源文件的行数注意一定要用longint 在超过 21 亿行时会溢出写入时同样用一个计数器累加输出行数最后对比两者是否一致。不一致说明读取或写入过程中丢行了需要报警。第二层是文件大小对比。合并后文件的大小不可能精确等于源文件大小之和因为涉及换行符和编码转换但也有参考意义。如果合并后文件比源文件总大小少了 30% 以上多半是有文件没读完整需要检查。最坏情况下的兜底方案是抽样打开输出文件检查首行、末行和随机若干行。文本合并的场景抽样检查能发现 99% 的编码和拼接问题。6.3 项目实战中的另外几个坑最后整理几个实际项目中反复踩到的坑都是文档里不会明说的。第一个是文件占用。日志文件通常被采集程序持续打开写入。读取时用FileShare.ReadWrite可以避免IOException: 文件正由另一进程使用using var fs new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader new StreamReader(fs);第二个是输出文件被输入通配符扫到。比如你把输出文件放在D:\logs\merged.log然后下一次扫描*.log时把它也扫进去了导致合并结果里出现自己。解决方法是输出文件写到单独的目录、使用不同扩展名或者循环时显式跳过输出文件路径。第三个是行尾符不统一导致下游解析错位。如果源文件来自 Linux 设备行尾是\nWindows 老程序可能整行解析失败。统一writer.NewLine是这个问题的标准解法。第四个是路径过长。.NET Framework 下路径加文件名超过 260 个字符会抛PathTooLongException。.NET Core 3.0 以后默认支持长路径但如果程序运行在旧环境建议用相对路径或者把输出目录放在盘符根目录附近。做合并工具做到最后技术上的东西反而次要了。我最大的体会是大文件处理的价值观就一句话能流式就不要整载能推迟就不要急切能校验就不要盲信。这套写完我后来再处理其他场景比如日志清洗、数据分组、格式转换思路都是一样的。碰到大文件第一反应不是内存够不够大而是能不能一行一行来这个思维转换比任何具体 API 都值钱。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →