C# CSV合并实践:从解析原理到编码与避坑全攻略
发布时间:2026/10/15 15:50:32 锦皓数字建站

简介面向需要在C#项目中处理CSV数据的开发人员这份资源共享读取与合并CSV文件的完整思路与可运行源码。内容围绕StreamReader逐行读取、Split解析字段、列表结构暂存记录、合并并写回文件等核心步骤展开同时提及列不一致、表头处理等实际问题和CsvHelper第三方库的扩展用法。资源包共32个文件以6个.cs源代码为主附带Visual Studio解决方案与工程文件、调试生成的exe/pdb/cache、界面资源文件等整体约360KB可直接打开工程查看运行效果。当前已有467人学习下载适合C#初学者理解文件I/O和数据合并逻辑也能作为日常处理表格数据时快速复用的小型工具参考。1. 读取与合并CSV文件C# 里的 CSV 合并为什么总在奇怪的地方翻车以前做一个数据导入任务要合并几十个 CSV。当时觉得这是最简单的部分ReadAllLines 按行读Split(,) 逐列切循环写入完事。结果第一轮就翻车带引号的字段里藏着逗号某行文本内部还夹着换行符按行读取把一条记录撕成两半换了个编码的文件中文全乱。花了两天才意识到CSV 合并不难难在“怎么先把每个文件准确拆回行与列”。这篇把读取、合并、校验拆成三块讲附可以直接跑的 C# 代码和常年踩的边界坑适合用 C# 写数据处理脚本、做批量导入导出、拼业务报表的开发者直接抄作业。2. 先把读取做对引号转义、内嵌换行与两种解析实现2.1 为什么简单的 Split(,) 会拆错RFC 4180 的两个核心规则CSV 并不是“逗号分隔”四个字就能概括的格式。RFC 4180 里有两条规则直接影响解析结果字段可以用双引号包裹字段内部如果出现双引号要用两个连续双引号表示一个。这两条合起来意味着逗号和换行符都可能出现在“一个字段”内部只要它们被双引号包着。举个例子。某行文本在业务系统里是他说你好然后离开存进 CSV 后长这样他说你好,然后离开注意原文你好两边的双引号变成了四个双引号而中间那个逗号仍然在字段内部。这时候还用ReadLine().Split(,)会切出他说你好和然后离开两段彻底错位。更极端的情况是字段内部有换行符整个文件用 ReadLine 读都会出问题。所以判断一个 CSV 解析器靠不靠谱只需要看它对三种输入是否处理正确引号转义、引号内逗号、引号内换行。下面先写一个最简状态机实现认清原理后再考虑要不要换库。2.2 手写状态机解析器逐字符处理并保护引号内内容下面这个ReadCsv是我常用的解析方法。它处理的是流而不是物理行一次读一个字符维护一个“当前是否在引号内”的布尔状态static Liststring[] ReadCsv(StreamReader reader) { var records new Liststring[](); var field new StringBuilder(); var record new Liststring(); bool inQuotes false; int ch; while ((ch reader.Read()) ! -1) { char c (char)ch; if (inQuotes) { if (c ) { // 双引号后面紧跟双引号说明是转义的双引号 if (reader.Peek() ) { field.Append(); reader.Read(); // 跳过第二个双引号 } else { inQuotes false; // 右边界退出引号状态 } } else { field.Append(c); // 引号内的逗号、换行都算字段内容 } } else { if (c ) { inQuotes true; } else if (c ,) { record.Add(field.ToString()); field.Clear(); } else if (c \r || c \n) { // 先消费 CRLF 里的 LF避免同一个行尾被处理两次 if (c \r reader.Peek() \n) { reader.Read(); } record.Add(field.ToString()); field.Clear(); // 空行不收录 if (record.Count 1 || record[0].Length 0) { records.Add(record.ToArray()); } record.Clear(); } else { field.Append(c); } } } // 文件末尾没有换行符时把最后一条记录补进去 if (field.Length 0 || record.Count 0) { record.Add(field.ToString()); records.Add(record.ToArray()); } return records; }核心逻辑是那个inQuotes状态机进入引号后只有双引号字符才可能结束引号状态其余字符一律归入当前字段遇到时通过reader.Peek()往前看一位判断是转义还是边界。record.Count 1 || record[0].Length 0这个条件用来跳过真正空白的行避免把源文件里多余的空行也当成一条记录。这个方法返回Liststring[]每项是一条完整记录下标 0 是第一个字段。注意reader由调用方负责打开和释放函数内部不关流。它没有处理“文件读到末尾但仍在引号内”的情况这个坑留到第 4.4 节专门讲。2.3 不想维护状态机就用开源库配置项与脏数据回调如果你的 CSV 来源单一比如都是自己程序导出的手写解析器够用。但如果 CSV 来自多个业务系统字段里什么内容都有我更倾向用成熟的开源 CSV 库把引号、换行、格式错误处理都交给它using var reader new StreamReader(input.csv, Encoding.UTF8); var config new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord true, TrimOptions TrimOptions.Trim, BadDataFound ctx { Console.WriteLine($脏数据: {ctx.RawRecord}); } }; using var csv new CsvReader(reader, config); var rows new Liststring[](); csv.Read(); csv.ReadHeader(); while (csv.Read()) { var row new string[csv.HeaderRecord.Length]; for (int i 0; i row.Length; i) { row[i] csv.GetField(i); } rows.Add(row); }HasHeaderRecord决定是否把第一行当表头TrimOptions.Trim会自动去掉字段首尾的空格很多业务表导出时字段两侧带着空格这个选项能省不少事BadDataFound是一个回调当库内部遇到明显损坏的记录时不会直接抛异常而是把原始内容给你方便做后续隔离。选择建议很简单一次性内部工具、不想引入额外依赖就用手写版本项目要长期维护、数据源不固定用库更稳妥。下行文里我写的手写解析器能在大多数场景下工作代码也更透明适合照着改。3. 按需选合并策略垂直拼接、主键补列与列重映射3.1 垂直合并同构表向下拼接表头只保留一次垂直合并是最常见的场景几个分月文件、几个分地区文件结构一致上下拼成一张总表。最容易犯的错是“按行拷贝”因为字段内部换行符的存在源文件的一个“物理行”不等于一条记录。正确做法是沿用第 2.2 节的ReadCsv解析出记录再逐条写回。下面这个MergeVertical接收一组输入文件和一个输出路径static void MergeVertical(string[] inputFiles, string outputPath, bool hasHeader true) { using var writer new StreamWriter(outputPath, false, new UTF8Encoding(true)); bool headerWritten false; foreach (string file in inputFiles) { using var reader new StreamReader(file, Encoding.UTF8, true); Liststring[] records ReadCsv(reader); for (int i 0; i records.Count; i) { // 表头行只写第一个文件的 if (hasHeader i 0) { if (!headerWritten) { writer.WriteLine(EscapeCsvLine(records[i])); headerWritten true; } continue; } writer.WriteLine(EscapeCsvLine(records[i])); } } } static string EscapeCsvLine(string[] fields) { return string.Join(,, fields.Select(f f.Contains(,) || f.Contains() || f.Contains(\r) || f.Contains(\n) ? \ f.Replace(\, \\) \ : f)); }hasHeader为 true 时只有第一个文件的表头写入输出文件其余文件的第一行直接跳过。EscapeCsvLine是做“反向转义”解析时我们把引号去掉了写回时必须按 CSV 规则重新包一层否则合并结果里一旦有带逗号的字段下游再打开就会错位。这个版本用ReadCsv一次性把整个文件读进内存几十 MB 内的小文件没问题。文件再大就要换成流式处理后面第 5 章专门讲。3.2 水平合并按主键补列处理一对多冲突水平合并是把两张表通过某个键拼到一起左表有用户 ID 和姓名右表有用户 ID 和手机号目标是合成一张含三列的总表。实现思路是先把右表按主键建成字典再逐行读左表去查static void MergeHorizontal(string leftFile, string rightFile, string outputFile, string keyColumn) { var left ReadCsvFile(leftFile); var right ReadCsvFile(rightFile); string[] leftHeader left[0]; string[] rightHeader right[0]; int leftKeyIdx Array.IndexOf(leftHeader, keyColumn); int rightKeyIdx Array.IndexOf(rightHeader, keyColumn); var rightIndex new Dictionarystring, string[](); var dupKeys new Liststring(); for (int i 1; i right.Count; i) { string key right[i][rightKeyIdx]; if (!rightIndex.TryAdd(key, right[i])) { dupKeys.Add(key); // 一对多冲突只保留第一条 } } // 输出列 左表所有列 右表除主键外的列 var header leftHeader .Concat(rightHeader.Where((h, idx) idx ! rightKeyIdx)) .ToArray(); using var writer new StreamWriter(outputFile, false, new UTF8Encoding(true)); writer.WriteLine(string.Join(,, header)); for (int i 1; i left.Count; i) { string key left[i][leftKeyIdx]; var row new string[header.Length]; Array.Copy(left[i], row, left[i].Length); if (rightIndex.TryGetValue(key, out var rightRow)) { int outIdx leftHeader.Length; for (int r 0; r rightHeader.Length; r) { if (r rightKeyIdx) continue; row[outIdx] rightRow[r]; } } writer.WriteLine(EscapeCsvLine(row)); } if (dupKeys.Count 0) { File.WriteAllLines(duplicate_keys.log, dupKeys.Distinct()); } }这段代码默认做“左连接”左表有、右表没有的主键输出行里右表对应列留空。右表出现重复主键时TryAdd只在键不存在时写入所以重复键只保留第一条其余全部记录到duplicate_keys.log。TryAdd是 .NET 6 以上的 API老框架项目改成if (!rightIndex.ContainsKey(key)) rightIndex.Add(key, ...)即可。这个实现把两张表都读进内存适合几万行级别的数据。如果右表很大可以改成逐行扫描副表把键值对写入字典的方式但核心逻辑不变。3.3 字段映射列顺序不一致时按映射对齐更麻烦的情况是两张表列名不一样。比如左表叫“姓名”右表叫“名字”合并前需要先指定一个统一 schema再建立源列名到目标列名的映射static void WriteMappedCsv(string inputPath, string outputPath, Dictionarystring, string sourceToTarget, string[] targetColumns) { using var reader new StreamReader(inputPath, Encoding.UTF8, true); Liststring[] records ReadCsv(reader); if (records.Count 0) return; string[] srcHeader records[0]; int[] targetIdx new int[targetColumns.Length]; for (int i 0; i targetColumns.Length; i) { string target targetColumns[i]; string sourceCol sourceToTarget .Where(kv kv.Value target) .Select(kv kv.Key) .FirstOrDefault(); targetIdx[i] sourceCol null ? -1 : Array.IndexOf(srcHeader, sourceCol); } using var writer new StreamWriter(outputPath, false, new UTF8Encoding(true)); writer.WriteLine(string.Join(,, targetColumns)); for (int r 1; r records.Count; r) { string[] output new string[targetColumns.Length]; for (int c 0; c targetIdx.Length; c) { output[c] targetIdx[c] 0 ? records[r][targetIdx[c]] : ; } writer.WriteLine(EscapeCsvLine(output)); } }sourceToTarget的键是源文件中的列名值是目标 schema 里的列名。每次循环先反查目标列对应的源列名直接用Array.IndexOf拿到下标。目标列在源文件里不存在时targetIdx[c]为 -1输出留空。这种做法把“列名对齐”和“数据拷贝”分开后续如果源文件新增一列只需要改映射字典不用动循环逻辑。4. 劝退现场读取合并的五个典型坑下面这些坑没有一个是理论问题我每个都在实际项目里踩过而且每个都导致过返工。4.1 现象表头第一列多了个隐形字符合并后做列名校验发现输出文件的第一列列名完全对不上任何字段。用调试器看字符串前面多了一个看不见的\ufeff。原因源文件是 Excel 保存的 UTF-8 with BOM 格式文件开头有三个字节的 BOM 头。StreamReader如果没开启 BOM 检测会把\ufeff当作表头第一个字段的第一个字符吞进去。解决打开文件时显式传入编码检测using var reader new StreamReader(file, Encoding.UTF8, true);第三个参数detectEncodingFromByteOrderMarks: true会让读取器自动识别 BOM。如果文件来源混乱可以在取表头后加一道保险header[0] header[0].TrimStart(\uFEFF)。4.2 现象字段里的换行符把记录数统计错合并后手工数源文件的行数再用程序统计输出文件记录数两边差了几十行。检查业务数据发现有些“备注”字段里本身就含有换行符。原因用ReadLine()按物理行读取遇到字段内的换行符会把一条记录拆成两条而解析器又把拆分后的残缺行当成完整记录处理。解决统计和合并都改成基于记录的状态机解析以“解析出的记录数”为准不要以物理行数为准。验收时也只看记录数不比对物理行数。4.3 现象记事本打开正常程序读出来乱码同一个文件记事本打开中文完全正常C# 程序读出来全是乱码。原因是文件实际是 GB2312 编码而程序默认按 UTF-8 解码。解决不要写死编码。先试探 BOM无 BOM 时尝试严格 UTF-8 解码失败就回退到 GB2312。注意在 .NET Core 上需要先注册代码页提供程序Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding gb Encoding.GetEncoding(GB2312);注册代码通常放在Main入口或静态构造函数里之后才能通过名字拿到 GB2312 编码对象。4.4 现象一个孤立双引号把后续所有内容吞进一个字段某行数据里出现一个单个双引号比如他说你好结果解析器认为引号没有闭合把之后整个文件都算作这一个字段的内容。原因手写状态机遇到 EOF 时仍然处于inQuotes状态代码会静默地把当前 field 当作正常字段处理。用户 Excel 里的脏数据不会考虑 RFC 规则一个半角引号就可能触发。解决在解析器里检测“EOF 仍在引号内”把它视为格式错误而不是正常数据if (inQuotes) { throw new FormatException($第 {records.Count 1} 条记录引号未闭合); }抛出异常后由上层 catch 记录错误文件路径而不是让整个合并任务带着脏数据继续跑。4.5 现象几十 MB 文件合并就内存爆掉用File.ReadAllLines配合Liststring[]合并文件 100MB 左右程序直接 OutOfMemory 退出。原因ReadAllLines把整个文本一次性读入字符串数组每个字符串都是一个独立托管对象加上解析后的Liststring[]内存占用通常是文件体积的 5~8 倍。解决改成流式处理。StreamReader.Read()逐字符从内部缓冲区取数据不会产生大量中间字符串记录解析一条写出一条内存峰值大幅下降。具体参数配置在下一章。5. 把读取通道做稳编码探测、缓冲区设置与失败隔离5.1 编码不要写死BOM 探测与 GB2312 后备我在工具类里会写一个编码探测方法先看 BOM再尝试严格 UTF-8最后回退 GB2312static Encoding DetectEncoding(string path) { using var fs File.OpenRead(path); byte[] bom new byte[4]; int n fs.Read(bom, 0, 4); if (n 3 bom[0] 0xEF bom[1] 0xBB bom[2] 0xBF) return new UTF8Encoding(true); if (n 2 bom[0] 0xFF bom[1] 0xFE) return Encoding.Unicode; try { var utf8Strict new UTF8Encoding(false, true); using var sr new StreamReader(path, utf8Strict); sr.ReadToEnd(); return new UTF8Encoding(false); } catch (DecoderFallbackException) { return Encoding.GetEncoding(GB2312); } }严格 UTF-8 的throwOnInvalidBytes: true会在遇到非法字节序列时抛异常。注意这不是绝对保险有些 GB2312 字节序列恰好也能通过 UTF-8 解码但概率低出现时结合后续的抽查环节能抓出来。这个方法不适合超大文件因为要读完整文件做探测实用中我一般只探测前几千字节足够判断 BOM 和非法序列。5.2 StreamReader 的参数细节缓冲区、FileShare 与逐字符消费流式读取时StreamReader 本身自带内部缓冲区逐字符Read()不会每次触发磁盘 IO。不过 FileStream 的参数值得显式指定using var fs new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, // 允许 Excel 等进程同时打开避免文件占用冲突 81920); // 80 KB 缓冲区按磁盘块大小对齐 using var reader new StreamReader(fs, Encoding.UTF8, true);FileShare.ReadWrite很关键。用户经常在合并任务运行时还开着源文件默认的FileShare.Read会直接抛“文件正由另一进程使用”。81920是 80KB接近 NTFS 的典型簇大小倍数能让 FileStream 的读效率比默认 4KB 高不少。需要注意逐字符reader.Read()返回的是 int循环判断不等于 -1。如果追求极致性能可以改用reader.Read(Spanchar)批量读取但代码复杂度会上升一般 CSV 合并场景没必要。5.3 隔离失败脏数据单独落盘不拖垮整个合并合并任务出错时最常见的问题是一个文件解析失败整个任务中断前面已写入的数据全部作废。我一般把错误文件和业务文件分开处理并且结果先写临时文件string outputTmp outputFile .tmp; try { using var writer new StreamWriter(outputTmp, false, new UTF8Encoding(true)); using var errorLog new StreamWriter(error.log, true); foreach (var file in inputFiles) { try { ProcessOneFile(file, writer); } catch (FormatException ex) { errorLog.WriteLine(${file}: {ex.Message}); } } // 全部成功后用临时文件替换最终文件 File.Move(outputFile, outputTmp, true); } catch { // 任何未预期异常都不留下半成品 File.Delete(outputTmp); throw; }这里有个易被忽略的细节File.Move(outputFile, outputTmp, true)里的覆盖参数在 .NET Core 3.0 以上才有。旧框架需要先File.Delete(outputFile)再File.Move。这个“写临时文件再改名”的习惯能避免合并到一半程序崩溃时目标文件里残留半个结果让下游误以为是完整数据。6. 合并结果不再黑匣校验清单、哈希比对与抽查合并完成只是第一步真正让人放心的是知道结果没丢数据。我会先写一个基本检查方法对任意 CSV 文件重新解析一次输出记录数和列数static (int rowCount, int colCount) InspectCsv(string path) { using var reader new StreamReader(path, Encoding.UTF8, true); var rows ReadCsv(reader); return (rows.Count, rows[0].Length); } static string FileHash(string path) { using var fs File.OpenRead(path); using var sha SHA256.Create(); return Convert.ToHexString(sha.ComputeHash(fs)); }合并前的“家底清单”我会打印每张源表的行数、列数、主键重复数和哈希值合并后对输出文件重新解析垂直合并断言输出行数等于各源表有效记录数之和水平合并断言输出主键集合与左表主键集合一致。哈希比对用于确认文件本身没有在中间环节被外部改动。抽查环节我会用取模抽样而不是随机抽样保证每次跑同一批文件抽到的行一致方便复现问题static void SpotCheck(string path, int step 17) { using var reader new StreamReader(path, Encoding.UTF8, true); int lineNo 0; while (reader.ReadLine() is string line) { if (lineNo % step 0) { Console.WriteLine($[抽查] 第 {lineNo} 行: {line.Substring(0, Math.Min(120, line.Length))}); } } }这个流程看起来简单但每次合并前把清单跑一遍能挡掉大多数不该犯的错。也是因为吃过亏——有次合并完直接交付下游对账差了几十行最后定位到是源文件里多了一个空行。从那以后我每次做 CSV 合并都先跑清单校验和哈希对比再谈合并本身。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。