C#读取txt文件并存入数据库:编码、批量入库与幂等设计实战
1. 从文本到数据表一个被低估的日常需求C# 读取txt文件并存入数据库——这个标题看起来平平无奇甚至有点像大学课程设计的题目。但如果你在制造业、物流、金融后台或者医疗信息化这些行业待过就会知道这几乎是最高频的刚需场景之一。设备日志、传感器采集数据、第三方系统导出的对账文件、历史遗留系统的批量导出结果大量以纯文本形式存在而业务系统需要的是结构化的数据库记录。我做过一个统计在中小型企业的数据集成需求里纯文本文件导入能占到四成以上。原因很简单txt格式兼容性最好任何系统都能生成不依赖特定软件出了问题肉眼就能排查。但恰恰因为太简单很多人写出来的导入代码经不起考验——编码识别错误导致中文乱码、大文件一次性读入内存直接崩溃、格式稍有偏差就整批失败、重复执行产生脏数据。这些问题在测试环境用几百行样例数据根本暴露不出来一上生产就原形毕露。这篇文章面向的是需要把文本数据可靠落库的C#开发者无论你用的是.NET Framework还是.NET 6/7/8核心思路是通用的。我会从文件读取的编码陷阱讲起到流式解析、批量入库、事务控制、幂等设计把这条链路里每一个容易翻车的地方都拆开讲清楚。读完之后你应该能写出一份在生产环境跑得住、出了问题能快速定位的导入程序而不是一个只能跑通Demo的玩具代码。2. 读取环节编码、分隔符与内存的三重考验2.1 编码判断为什么你的中文变成了问号读取txt文件第一个坑就是编码。很多人直接写File.ReadAllLines(path)在中文Windows上默认走的是系统ANSI代码页GB2312/GBK如果文件实际是UTF-8中文立刻变乱码反过来如果文件是GBK而你按UTF-8读同样一团糟。更麻烦的是有些文件带BOM字节顺序标记有些不带处理方式还不一样。我的做法是分两步走。第一步检测BOM。UTF-8的BOM是EF BB BFUTF-16 LE是FF FEUTF-16 BE是FE FF。读文件前三个字节就能判断。第二步如果没有BOM就需要做编码猜测。一个实用的启发式方法是先按UTF-8严格模式解码如果抛出异常说明不是合法UTF-8再回退到GBK。这个逻辑用Encoding.GetEncoding(utf-8, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback)配合try-catch就能实现。public static Encoding DetectEncoding(string filePath) { byte[] buffer new byte[3]; using (var fs new FileStream(filePath, FileMode.Open, FileAccess.Read)) { int read fs.Read(buffer, 0, 3); if (read 3 buffer[0] 0xEF buffer[1] 0xBB buffer[2] 0xBF) return new UTF8Encoding(true); if (read 2 buffer[0] 0xFF buffer[1] 0xFE) return Encoding.Unicode; if (read 2 buffer[0] 0xFE buffer[1] 0xFF) return Encoding.BigEndianUnicode; } // 无BOM尝试UTF-8严格解码 try { var strictUtf8 new UTF8Encoding(false, true); using var sr new StreamReader(filePath, strictUtf8); sr.ReadToEnd(); return new UTF8Encoding(false); } catch (DecoderFallbackException) { return Encoding.GetEncoding(GBK); } }注意Encoding.GetEncoding(GBK)在.NET Core及以后需要先注册CodePagesEncodingProvider.Instance否则会抛异常。在程序启动时加一行Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);即可。这个检测逻辑不是百分之百准确比如一个纯ASCII文件两种编码都能解但那种情况下本来也不会有乱码问题。真正需要警惕的是混合编码或者文件中间出现非法字节的情况这时候只能靠日志记录原始字节位置人工介入。2.2 分隔符选择逗号、制表符还是自定义txt文件的分隔符五花八门。CSV用逗号但字段内容里可能包含逗号需要用引号包裹日志文件常用制表符或竖线还有些老系统用固定宽度每个字段占固定的字符数。解析之前必须先确认分隔规则不能想当然。对于逗号分隔如果字段值里可能含逗号必须实现引号转义逻辑。简单用Split(,)在遇到张三,李四,25这种数据时会把一个字段拆成两个。正确的做法是写一个状态机解析器逐字符扫描遇到引号进入引号内状态引号内的逗号不作为分隔符。这个逻辑不复杂但很多人图省事直接用Split上线后遇到带逗号的地址字段就出问题。固定宽度格式更考验耐心。你需要知道每个字段的起始位置和长度通常由上游系统提供格式说明文档。解析时用Substring按位置截取然后Trim掉填充空格。这种格式的坑在于中文字符的宽度问题——如果上游按字节数填充而C#的字符串按字符计数一个中文字符占两个字节但算一个字符位置就会错位。遇到这种情况需要先把字节数组按位置切分再逐段解码。2.3 大文件处理别把几个G的文件一次性读进内存File.ReadAllLines会把整个文件加载到内存几百MB还能扛上G的文件直接OutOfMemory。正确的做法是用StreamReader逐行读取内存占用恒定。using var reader new StreamReader(filePath, encoding); string line; int lineNumber 0; while ((line reader.ReadLine()) ! null) { lineNumber; // 处理每一行 }但逐行读也有讲究。如果单行特别长比如某些日志把整个JSON塞在一行ReadLine同样会占用大量内存。这时候需要按固定缓冲区读取自己处理换行符。不过大多数业务场景下逐行读已经足够。另一个容易被忽略的点是文件锁。如果文件正在被其他进程写入直接打开会抛IOException。稳妥的做法是用FileShare.ReadWrite打开允许其他进程同时读写然后读取当前已写入的内容。但这样可能读到半行数据需要在解析时做容错把最后不完整的一行丢弃或缓存到下次处理。3. 解析与校验把自由文本变成规整数据3.1 行级解析逐字段映射与类型转换读进来一行字符串之后下一步是把它拆成字段并映射到数据模型的属性上。这里的关键是防御性解析——永远不要假设数据是干净的。上游系统可能因为各种原因产生脏数据字段缺失、多出分隔符、数字字段里混入字母、日期格式不统一。我的习惯是定义一个解析结果类包含成功标志、数据对象和错误信息。每一行解析时先检查字段数量是否匹配再逐个字段做类型转换任何一步失败都记录行号和原因但不中断整个流程。public class ParseResultT { public bool Success { get; set; } public T Data { get; set; } public string Error { get; set; } public int LineNumber { get; set; } }类型转换时int.TryParse、decimal.TryParse、DateTime.TryParseExact这些方法比直接Parse安全得多。日期格式尤其要注意DateTime.Parse会受当前线程的区域设置影响同一个日期字符串在不同服务器上可能解析成不同结果。指定CultureInfo.InvariantCulture和明确的格式字符串如yyyy-MM-dd HH:mm:ss才能保证一致性。3.2 业务校验哪些错误必须拦截哪些可以放行解析成功不代表数据合法。业务层面的校验包括必填字段是否为空、数值是否在合理范围、外键关联的记录是否存在、日期是否在有效区间内。这些校验的严格程度取决于业务容忍度。我的经验是分两级处理。硬性错误如主键为空、金额为负数直接拒绝该行记录到错误文件软性警告如备注字段超长、日期比当前时间晚几分钟可以截断或修正后入库但要在日志里标记出来。这样既不会因为个别脏数据导致整批失败也不会让明显错误的数据污染数据库。错误行的处理方式也值得斟酌。我通常会把原始行内容和错误原因写入一个import_errors表或单独的日志文件方便业务人员核对修正后重新导入。如果只是打印到控制台出了问题根本无从追溯。3.3 批量攒批多少条提交一次最合适逐条插入数据库的性能是灾难性的。每插入一条都要建立网络往返、写日志、提交事务一万条数据可能要跑几分钟。批量插入能把性能提升一到两个数量级但批次大小需要权衡。批次太小网络往返次数多性能提升有限批次太大单次事务占用内存和日志空间多失败回滚代价高。根据我的实测SQL Server下用SqlBulkCopy时五千到一万条一批比较合适用EF Core的AddRange时一千到两千条一批并且要关闭自动变更追踪AsNoTracking对插入不适用但可以用AutoDetectChangesEnabled false。var batch new ListMyEntity(batchSize); foreach (var item in parsedItems) { batch.Add(item); if (batch.Count batchSize) { await BulkInsertAsync(batch); batch.Clear(); } } if (batch.Count 0) await BulkInsertAsync(batch);提示SqlBulkCopy要求目标表名和列映射完全正确且默认不触发触发器。如果表上有触发器或需要走特定逻辑得改用表值参数或分批INSERT。4. 入库策略事务、幂等与性能的平衡4.1 事务边界整批一个事务还是分批独立事务事务边界的选择直接影响失败后的恢复成本。整批一个事务的好处是原子性——要么全成功要么全回滚数据不会处于中间状态。但坏处也很明显如果一万条数据在第9999条失败前面9998条全部回滚重跑一遍又要从头来。分批独立事务则相反每批成功就提交失败只影响当前批。重跑时可以从失败批次继续但需要处理部分成功带来的幂等性问题——重跑时不能重复插入已经成功的批次。我的选择取决于数据量和业务要求。数据量小几千条以内且要求强一致用单事务数据量大且可以接受最终一致用分批事务加断点续传。断点续传的实现方式是在数据库里记录已处理的文件偏移量或行号下次启动时从断点继续。4.2 幂等设计重复导入不产生重复数据生产环境里同一个文件被重复导入是常态——可能是操作人员误操作也可能是程序异常重启后自动重试。如果没有幂等保护数据库里就会出现重复记录。最简单的幂等方案是利用业务主键。如果txt文件里有一个唯一标识字段如订单号、设备编号时间戳在数据库表上对该字段建唯一索引插入时用INSERT ... WHERE NOT EXISTS或捕获唯一约束冲突。这样重复导入时已存在的记录会被跳过不会产生重复。另一种方案是文件级幂等计算文件的哈希值MD5或SHA256在导入记录表里查这个哈希是否已处理过。如果处理过且状态为成功直接跳过。这种方案适合整个文件作为一个处理单元的场景但要注意文件内容不变但文件名变化的情况以及哈希计算本身对大文件的性能开销。public static string ComputeFileHash(string filePath) { using var sha SHA256.Create(); using var fs File.OpenRead(filePath); byte[] hash sha.ComputeHash(fs); return BitConverter.ToString(hash).Replace(-, ).ToLowerInvariant(); }4.3 性能优化从索引到参数化批量插入的性能瓶颈通常在数据库端。几个关键优化点插入前禁用目标表的非聚集索引插入后重建使用SqlBulkCopy而不是逐条INSERT确保连接字符串里没有开MultipleActiveResultSets批量插入不需要如果目标表有聚集索引且插入顺序与索引顺序一致性能会更好。参数化查询是必须的不仅为了防SQL注入也为了利用数据库的执行计划缓存。拼接SQL字符串的方式在批量场景下会导致大量硬解析性能急剧下降。用SqlParameter或Dapper的匿名对象参数都能达到参数化效果。还有一个容易被忽略的点CommandTimeout。默认30秒在批量插入大文件时经常不够需要根据数据量调大比如设成300秒。但也不能无限大否则真出问题时程序会卡死很久。5. 那些只有踩过才知道的坑5.1 文件被占用共享读取与重试机制生产环境的文件往往不是静止的。上游系统可能正在写入或者杀毒软件正在扫描或者同步工具正在复制。直接File.Open很容易遇到文件正由另一进程使用的异常。我的处理方式是用FileShare.ReadWrite打开文件允许其他进程同时读写如果仍然失败加入重试机制间隔递增比如1秒、2秒、4秒、8秒最多重试五次。重试期间记录日志如果最终失败则把文件移到失败目录通知人工处理。public static FileStream OpenWithRetry(string path, int maxRetries 5) { int delay 1000; for (int i 0; i maxRetries; i) { try { return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); } catch (IOException) when (i maxRetries - 1) { Thread.Sleep(delay); delay * 2; } } throw new IOException($无法打开文件{path}); }5.2 空行与注释行别让它们变成脏数据txt文件里经常夹杂空行、注释行以#或//开头、表头行。如果不做过滤这些行会被当成数据解析产生一堆错误记录。我的做法是在解析循环里先做行级过滤string.IsNullOrWhiteSpace(line)跳过以特定前缀开头的跳过第一行如果是表头也跳过但要把表头字段名记录下来用于后续字段映射校验。表头校验是个好习惯。如果文件有表头解析前先比对表头字段名和预期的字段列表不一致就报警。这样上游改了导出格式你能第一时间发现而不是等到数据错乱才排查。5.3 进度反馈长任务不能静默运行导入一个几十万行的文件可能跑好几分钟。如果程序没有任何输出操作人员会以为卡死了然后强制关闭导致数据不一致。所以进度反馈很重要。简单的做法是每处理一千行打印一次进度或者用IProgressT接口回调更新UI进度条。如果是控制台程序可以用Console.Write(\r已处理 {0} 行, count)实现原地刷新。如果是服务程序写日志或更新数据库里的任务状态表。进度反馈的粒度要适中。每行都报太频繁影响性能每十万行报一次又太稀疏用户等得心焦。我的经验是每五千到一万行报一次同时报告已用时和预估剩余时间。5.4 日志记录出了问题能查才是好日志导入程序的日志要包含几个关键信息开始时间、文件路径、文件大小、检测到的编码、总行数、成功数、失败数、失败原因分类、结束时间、总耗时。这些信息在排查问题时缺一不可。失败原因要分类统计比如字段数量不匹配多少条、日期格式错误多少条、必填字段为空多少条。这样一眼就能看出主要问题在哪。如果只是笼统地记解析失败排查时还得逐条看原始数据效率极低。日志的存储位置也有讲究。不要只输出到控制台要写文件或数据库。控制台输出在程序崩溃或窗口关闭后就丢了。写文件时注意日志文件本身不要无限增长按日期滚动或按大小切割。6. 一份可直接参考的完整实现骨架把前面讲的要点串起来一个健壮的导入程序大致长这样public class TxtImporter { private readonly string _connectionString; private readonly int _batchSize 5000; public async TaskImportResult ImportAsync(string filePath) { var result new ImportResult { FilePath filePath, StartTime DateTime.Now }; var encoding DetectEncoding(filePath); var batch new ListMyEntity(_batchSize); int lineNumber 0; using var reader new StreamReader( OpenWithRetry(filePath), encoding); string line; while ((line reader.ReadLine()) ! null) { lineNumber; if (string.IsNullOrWhiteSpace(line) || line.StartsWith(#)) continue; var parseResult ParseLine(line, lineNumber); if (!parseResult.Success) { result.AddError(lineNumber, parseResult.Error, line); continue; } batch.Add(parseResult.Data); if (batch.Count _batchSize) { await InsertBatchAsync(batch); result.SuccessCount batch.Count; batch.Clear(); ReportProgress(result.SuccessCount, lineNumber); } } if (batch.Count 0) { await InsertBatchAsync(batch); result.SuccessCount batch.Count; } result.EndTime DateTime.Now; return result; } }这个骨架里DetectEncoding、OpenWithRetry、ParseLine、InsertBatchAsync、ReportProgress都是前面详细讨论过的环节。实际项目中InsertBatchAsync可以用SqlBulkCopy或Dapper的批量执行ParseLine根据具体文件格式实现ImportResult记录统计信息和错误明细。注意SqlBulkCopy的DestinationTableName和ColumnMappings必须与目标表严格对应列顺序和类型都要匹配。如果源数据某个字段可能为null目标列必须允许null否则会抛异常。7. 从能跑到好用几个提升幸福感的小改进第一支持配置文件。把文件路径、连接字符串、批次大小、编码方式这些参数放到appsettings.json里改参数不用重新编译。用Microsoft.Extensions.Configuration几行代码就能搞定。第二支持命令行参数。dotnet run -- --file data.txt --batch 10000这种调用方式比改配置文件更灵活适合自动化脚本调用。用System.CommandLine库可以快速实现参数解析和帮助文档。第三导入前做预检。在正式入库前先扫描一遍文件统计总行数、检测编码、抽样解析前一百行验证格式。预检通过再开始正式导入避免跑到一半才发现格式不对。预检结果可以输出给操作人员确认。第四失败文件归档。导入成功的文件移到processed目录失败的文件移到failed目录并附带错误日志。这样目录里不会堆积已处理的文件也方便追溯。第五支持断点续传。在数据库里建一张import_progress表记录文件哈希、已处理行号、状态。程序启动时先查这个表如果发现未完成的导入任务从断点继续。这个功能在导入大文件时特别有用程序崩溃或服务器重启后不用从头再来。我在实际项目里把这些改进逐步加上去之后导入程序的运维成本大幅下降。以前每次导入出问题都要开发人员介入排查现在业务人员自己看日志就能定位大部分问题只有真正复杂的格式异常才需要找开发。这才是把能跑变成好用的价值所在。