1. 项目背景与核心需求拆解
1.1 这个需求到底在解决什么问题
“C# 读取txt文件,并存入数据库”这个标题看起来简单,但背后涉及的是数据处理领域最经典的ETL场景之一——从非结构化文本中提取数据,经过清洗转换后持久化到结构化存储中。我在实际项目中遇到过太多次类似需求:业务方丢过来一个几万行的txt文件,说“把这个导进系统里”,然后你就得从编码格式、分隔符、字段映射一路处理到批量插入性能。
这个需求的核心价值在于打通数据孤岛。很多老旧系统、第三方接口、设备日志导出的数据都是txt格式,而现代应用需要把这些数据放到数据库里做查询、统计、关联分析。所以这个场景在制造业(设备日志导入)、金融(对账单处理)、物流(运单批量录入)、医疗(检验结果导入)等行业都非常常见。
适合阅读这篇内容的人群包括:刚入行的C#开发者、需要做数据迁移的运维人员、以及任何需要处理批量文本导入任务的技术人员。不管你是用WinForm、WPF还是ASP.NET Core,核心思路是一致的。
1.2 技术选型背后的考量
为什么用C#来做这件事?因为C#在Windows生态下的文件IO和数据库操作都有非常成熟的类库支持。System.IO命名空间下的StreamReader可以高效读取大文件,ADO.NET和Entity Framework提供了多层次的数据库访问方案,再加上SqlBulkCopy这种批量插入利器,整个链路非常顺畅。
但选型不是没有坑的。我见过太多人一上来就用File.ReadAllLines()把整个文件读进内存,文件小的时候没问题,一旦遇到几百MB的日志文件直接内存溢出。所以第一步就要根据文件大小和内存约束来决定读取策略。
数据库选型方面,SQL Server是最常见的搭配,但MySQL、PostgreSQL、SQLite也各有适用场景。如果只是本地小工具,SQLite足够;如果是企业级应用,SQL Server或MySQL更合适。下面的内容我会以SQL Server为主要示例,但核心逻辑可以平移到其他数据库。
2. 核心细节解析与实操要点
2.1 文件读取的三种策略与选择依据
读取txt文件看起来简单,但实际有三种截然不同的策略,选错了要么性能差,要么直接崩溃。
第一种:全量读取(File.ReadAllLines)。适合文件小于50MB、行数在10万以内的场景。代码最简洁,一行就能拿到所有行。但缺点是整个文件内容都在内存里,如果文件有500MB,你的进程内存直接飙升。
第二种:流式逐行读取(StreamReader.ReadLine)。这是最通用的方案,内存占用恒定,不受文件大小影响。我处理过一个2GB的设备日志文件,用这种方式稳定运行,内存始终保持在几十MB。缺点是读取速度受磁盘IO限制,但可以通过设置缓冲区大小来优化。
第三种:分块读取(StreamReader.ReadBlock)。适合需要自定义解析逻辑的场景,比如文件不是按行分隔而是按固定长度记录。这种方式最灵活但也最复杂。
我的建议是:默认用流式逐行读取,除非你确定文件很小且需要随机访问所有行。这个选择背后的逻辑是——内存是稀缺资源,而磁盘IO是可以接受的成本。
2.2 编码格式:最容易被忽视的坑
txt文件不像XML或JSON有明确的编码声明,它的编码完全取决于创建它的程序。我踩过最惨的一次坑是:用默认的Encoding.Default读取一个UTF-8文件,结果所有中文字段全是乱码,导入数据库后花了两个小时排查才发现是编码问题。
常见的编码情况:
- Windows记事本默认保存为带BOM的UTF-8(新版)或ANSI(老版)
- Linux系统导出的文件通常是UTF-8无BOM
- 老旧系统可能用GB2312或GBK
处理策略是:先检测BOM,再尝试UTF-8,最后回退到系统默认编码。.NET提供了StreamReader的detectEncodingFromByteOrderMarks参数,设为true可以自动检测BOM。对于无BOM的文件,可以先用Encoding.UTF8读取前几行,如果出现替换字符(U+FFFD),再换Encoding.GetEncoding("GB2312")重试。
注意:不要用
Encoding.Default作为首选,因为它在不同系统上行为不一致。.NET Core之后Encoding.Default统一为UTF-8,但.NET Framework下是系统ANSI代码页,这个差异会导致代码迁移时出现意外问题。
2.3 数据解析:分隔符与字段映射
txt文件的数据格式千变万化,但常见的有以下几种:
| 格式类型 | 示例 | 解析方式 |
|---|---|---|
| 逗号分隔 | 张三,25,北京 | Split(',') |
| 制表符分隔 | 张三\t25\t北京 | Split('\t') |
| 固定宽度 | 张三 25 北京 | Substring按位置截取 |
| 键值对 | name=张三;age=25 | 正则或Split(';')再Split('=') |
| 混合格式 | 表头+数据行 | 先读表头建立映射再逐行解析 |
实际项目中最麻烦的是字段中包含分隔符的情况。比如地址字段是“北京市,朝阳区”,用逗号分隔就会多出一列。这时候要么用引号包裹(CSV标准),要么用正则表达式做更智能的解析。我的经验是:如果文件是标准CSV格式,直接用成熟的CSV解析库;如果是自定义格式,一定要先跟数据提供方确认转义规则。
2.4 数据库写入:单条插入vs批量插入
这是性能差异最大的环节。我做过一个对比测试:导入10万条记录到SQL Server,单条INSERT语句逐条执行耗时约4分30秒,而用SqlBulkCopy只需要3秒。差距是90倍。
单条插入适合数据量小(几千条以内)、需要即时反馈、或者需要在插入过程中做复杂业务逻辑的场景。用SqlCommand配合参数化查询可以防止SQL注入。
批量插入适合大数据量场景。SQL Server用SqlBulkCopy,MySQL用MySqlBulkLoader或LOAD DATA INFILE,PostgreSQL用COPY命令。这些方式的共同原理是减少网络往返次数和事务开销。
折中方案:事务批量提交。把1000条插入包在一个事务里,既不像单条那样慢,也不像BulkCopy那样难以做错误处理。这个方案在实际项目中最常用,因为它在性能和可控性之间取得了平衡。
提示:
SqlBulkCopy的BatchSize属性很关键。设置太小(如100)会导致频繁网络往返,设置太大(如100000)会占用过多内存。我的经验值是5000到10000之间,具体要根据单行数据大小调整。
3. 完整实操流程与核心代码实现
3.1 环境准备与项目搭建
首先创建一个C#控制台项目(实际项目中可以是WinForm或Web API,核心逻辑一样)。需要引用的NuGet包:
# SQL Server场景 Install-Package System.Data.SqlClient # MySQL场景 Install-Package MySql.Data # 如果要用Dapper简化数据库操作 Install-Package Dapper数据库方面,先建一张目标表。假设我们要导入的是用户数据:
CREATE TABLE Users ( Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50) NOT NULL, Age INT, City NVARCHAR(100), ImportTime DATETIME DEFAULT GETDATE() );3.2 文件读取与解析的完整实现
下面是一个兼顾健壮性和性能的读取解析方案。核心思路是:流式读取、自动检测编码、逐行解析、解析失败的行记录到错误日志而不是直接崩溃。
public class TxtFileReader { private readonly string _filePath; private readonly string _delimiter; private readonly Encoding _encoding; public TxtFileReader(string filePath, string delimiter = ",") { _filePath = filePath; _delimiter = delimiter; _encoding = DetectEncoding(filePath); } private Encoding DetectEncoding(string filePath) { // 先用UTF-8读取前1024字节检测BOM using (var reader = new StreamReader(filePath, Encoding.UTF8, true)) { reader.Peek(); // 触发编码检测 return reader.CurrentEncoding; } } public IEnumerable<UserRecord> ReadRecords() { using (var reader = new StreamReader(_filePath, _encoding)) { string line; int lineNumber = 0; while ((line = reader.ReadLine()) != null) { lineNumber++; if (string.IsNullOrWhiteSpace(line)) continue; var parts = line.Split(new[] { _delimiter }, StringSplitOptions.None); if (parts.Length < 3) { // 记录错误行但不中断 Console.WriteLine($"第{lineNumber}行字段不足,跳过:{line}"); continue; } yield return new UserRecord { Name = parts[0].Trim(), Age = int.TryParse(parts[1].Trim(), out var age) ? age : 0, City = parts[2].Trim(), SourceLine = lineNumber }; } } } }这里有几个关键设计决策需要解释。第一,用yield return实现惰性求值,调用方可以边读边处理,不需要等所有数据加载完。第二,解析失败的行不抛异常而是记录后跳过,因为实际数据文件中总会有脏数据,不能因为一行有问题就整个任务失败。第三,SourceLine字段记录了原始行号,方便后续排查问题。
3.3 批量写入数据库的三种方案对比
方案一:事务批量提交(推荐)
public void SaveWithTransaction(List<UserRecord> records, string connectionString) { const int batchSize = 1000; using (var conn = new SqlConnection(connectionString)) { conn.Open(); for (int i = 0; i < records.Count; i += batchSize) { var batch = records.Skip(i).Take(batchSize).ToList(); using (var transaction = conn.BeginTransaction()) { using (var cmd = new SqlCommand( "INSERT INTO Users (Name, Age, City) VALUES (@Name, @Age, @City)", conn, transaction)) { cmd.Parameters.Add("@Name", SqlDbType.NVarChar, 50); cmd.Parameters.Add("@Age", SqlDbType.Int); cmd.Parameters.Add("@City", SqlDbType.NVarChar, 100); foreach (var record in batch) { cmd.Parameters["@Name"].Value = record.Name; cmd.Parameters["@Age"].Value = record.Age; cmd.Parameters["@City"].Value = record.City; cmd.ExecuteNonQuery(); } } transaction.Commit(); } } } }这个方案的核心是参数复用。SqlParameter对象只创建一次,每次循环只改Value,避免了重复创建参数的开销。1000条一个事务是经验值,太小则事务开销大,太大则锁持有时间长。
方案二:SqlBulkCopy(大数据量首选)
public void SaveWithBulkCopy(DataTable dataTable, string connectionString) { using (var bulkCopy = new SqlBulkCopy(connectionString)) { bulkCopy.DestinationTableName = "Users"; bulkCopy.BatchSize = 5000; bulkCopy.BulkCopyTimeout = 300; // 列映射:源列名 -> 目标列名 bulkCopy.ColumnMappings.Add("Name", "Name"); bulkCopy.ColumnMappings.Add("Age", "Age"); bulkCopy.ColumnMappings.Add("City", "City"); bulkCopy.WriteToServer(dataTable); } }用SqlBulkCopy需要先把数据转成DataTable或IDataReader。DataTable方式简单但内存占用高,IDataReader方式可以流式传输但实现复杂。如果数据量在百万级以内,DataTable够用。
方案三:表值参数(SQL Server 2008+)
这是介于两者之间的方案,把整个批次作为一个表值参数传给存储过程,一次网络往返完成一批插入。性能接近BulkCopy,但可以走正常的SQL逻辑,方便做冲突处理。
3.4 完整流程串联与异常处理
把上面的模块串起来,一个完整的导入流程应该是:
public class ImportService { public ImportResult Import(string txtPath, string connectionString) { var result = new ImportResult(); var reader = new TxtFileReader(txtPath); var records = new List<UserRecord>(); var errors = new List<string>(); try { foreach (var record in reader.ReadRecords()) { records.Add(record); // 每积累5000条就写一次库,避免内存堆积 if (records.Count >= 5000) { SaveBatch(records, connectionString); result.SuccessCount += records.Count; records.Clear(); } } // 处理剩余不足5000条的数据 if (records.Count > 0) { SaveBatch(records, connectionString); result.SuccessCount += records.Count; } } catch (Exception ex) { result.ErrorMessage = ex.Message; result.FailedCount = records.Count; } return result; } }这个设计的关键点是分批处理。不要等所有数据都读完再写库,而是边读边写,每5000条一个批次。这样做的好处是内存占用可控,而且如果中途出错,已经写入的数据不会丢失。
4. 常见问题与排查技巧实录
4.1 编码乱码问题的系统排查方法
乱码是最高频的问题。排查思路是:先确认文件真实编码,再确认读取时用的编码,最后确认数据库字段类型和排序规则。
确认文件编码的方法:用十六进制编辑器打开文件,看开头几个字节。EF BB BF是UTF-8 BOM,FF FE是UTF-16 LE,FE FF是UTF-16 BE,没有BOM的话需要根据内容判断。
数据库端的问题常被忽视。如果数据库字段是VARCHAR而不是NVARCHAR,即使C#端读取正确,写入后中文也会变成问号。因为VARCHAR使用数据库排序规则对应的代码页,不支持Unicode字符。只要涉及中文,数据库字段一律用NVARCHAR。
4.2 大数据量导入的性能优化清单
| 优化点 | 具体措施 | 预期效果 |
|---|---|---|
| 读取缓冲 | StreamReader缓冲区设为64KB | 减少IO次数 |
| 解析优化 | 避免正则,用Split或Substring | 提升解析速度 |
| 参数复用 | SqlParameter只创建一次 | 减少GC压力 |
| 批量提交 | 每1000-5000条一个事务 | 平衡性能与锁 |
| 关闭索引 | 导入前禁用非聚集索引 | 提升写入速度 |
| 调整恢复模式 | 临时改为Simple | 减少日志写入 |
| 并行处理 | 读和写分两个线程 | 重叠IO和CPU |
其中“导入前禁用索引”这一条要特别说明:如果目标表有多个非聚集索引,每条插入都要更新索引,数据量大时索引维护开销可能超过插入本身。导入完成后再重建索引,整体时间会短很多。但这个操作需要谨慎,生产环境要评估锁表影响。
4.3 数据一致性问题的处理策略
实际导入中最头疼的是部分成功部分失败的情况。比如1万条数据,导入到第6000条时数据库连接断了,前6000条已经提交,后4000条没进去。重新跑一遍又会重复插入前6000条。
解决方案有三种:
第一种:导入前清空目标表。适合全量导入场景,简单粗暴但有效。缺点是如果导入失败,原来的数据也没了。
第二种:用唯一键做去重。在目标表上建唯一索引,插入时用INSERT ... WHERE NOT EXISTS或MERGE语句。适合有业务主键的场景。
第三种:记录导入批次。每次导入生成一个批次号,所有记录带上批次号。失败后可以根据批次号删除已导入的部分,然后重跑。这是最稳妥的方案,但需要在表结构里加字段。
提示:不管用哪种方案,导入前一定要备份目标表。我见过太多“我以为没问题”结果把生产数据搞坏的案例。
4.4 常见异常速查表
| 异常信息 | 可能原因 | 解决方法 |
|---|---|---|
| OutOfMemoryException | 全量读取大文件 | 改用流式读取 |
| 中文显示为??? | 数据库字段是VARCHAR | 改为NVARCHAR |
| 字符串截断错误 | 目标字段长度不够 | 扩大字段长度或截断源数据 |
| 超时过期 | 单次操作数据量太大 | 减小BatchSize,增加Timeout |
| 连接池耗尽 | 连接未正确释放 | 确保using包裹Connection |
| 主键冲突 | 重复导入 | 加唯一索引或先清空 |
| 文件被占用 | 其他进程正在写 | 复制文件后再读取 |
4.5 几个我踩过的坑和对应技巧
坑一:空行和注释行。很多txt文件末尾有空行,或者中间有以#开头的注释行。如果不处理,解析时会报错或产生脏数据。我的做法是在读取循环里统一过滤:if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#")) continue;。
坑二:数字格式的千位分隔符。有些系统导出的数字是“1,234.56”这种格式,直接int.Parse会失败。需要先Replace(",", "")再解析。更稳妥的做法是用NumberStyles枚举指定允许的格式。
坑三:日期格式不统一。同一个文件里可能既有“2024-01-15”又有“01/15/2024”。这时候不要用DateTime.Parse的默认行为,而是用DateTime.TryParseExact指定多种格式逐一尝试。
坑四:大文件读取时的UI卡死。如果在WinForm里直接在主线程读文件,界面会假死。必须用Task.Run或BackgroundWorker把导入逻辑放到后台线程,通过ProgressChanged事件更新进度条。
坑五:数据库连接字符串泄露。硬编码连接字符串在代码里是常见但危险的做法。应该放在配置文件或环境变量里,并且生产环境的密码要加密存储。
5. 进阶优化与扩展思路
5.1 用Dapper简化数据库操作
如果不想手写那么多ADO.NET代码,Dapper是一个轻量级ORM,性能接近原生ADO.NET但代码量少很多。批量插入可以这样写:
public void SaveWithDapper(List<UserRecord> records, string connectionString) { using (var conn = new SqlConnection(connectionString)) { conn.Open(); using (var transaction = conn.BeginTransaction()) { conn.Execute( "INSERT INTO Users (Name, Age, City) VALUES (@Name, @Age, @City)", records, transaction); transaction.Commit(); } } }Dapper会自动把List里的每个对象展开成一次参数化插入,内部做了参数复用优化。代码简洁很多,性能也不错。
5.2 支持多种文件格式的抽象设计
实际项目中往往不只导入txt,还可能要导入CSV、Excel、JSON。这时候可以定义一个IDataSource接口,不同格式实现不同的读取器,上层导入逻辑不变。这是策略模式的应用,好处是新增格式时不需要改动核心导入代码。
public interface IDataSource { IEnumerable<Dictionary<string, object>> Read(); } public class TxtDataSource : IDataSource { ... } public class CsvDataSource : IDataSource { ... } public class ExcelDataSource : IDataSource { ... }用Dictionary<string, object>作为中间数据结构,可以灵活映射到不同的目标表。
5.3 导入进度反馈与日志记录
对于耗时较长的导入任务,进度反馈很重要。可以用IProgress<T>接口实现:
var progress = new Progress<ImportProgress>(p => { Console.WriteLine($"已处理 {p.Processed}/{p.Total} 条,当前速度 {p.Speed} 条/秒"); }); await Task.Run(() => ImportWithProgress(txtPath, connectionString, progress));日志方面,建议用Serilog或NLog这样的结构化日志库,把每批导入的结果、耗时、失败行号都记录下来。出问题时这些日志就是救命稻草。
5.4 定时任务与自动化集成
如果这个导入任务是周期性的(比如每天处理一次设备日志),可以把它做成控制台程序,然后用Windows任务计划程序定时调用。更现代的做法是做成.NET Worker Service,部署为Windows服务或Linux守护进程。
关键是要处理好文件锁和重复执行的问题。我的做法是:程序启动时先检查是否有同名的.lock文件,有就退出;处理完文件后把源文件移到archive目录并加上时间戳。这样即使任务被重复触发也不会重复处理。
6. 一些个人经验体会
做数据导入这些年,我最大的体会是:永远不要相信源数据的质量。你以为它是规整的CSV,打开一看可能分隔符混用、字段缺失、编码混乱、行尾有不可见字符。所以代码里每一层都要有防御性设计——读取时容错、解析时校验、写入时捕获异常。
另一个体会是性能优化要有数据支撑。不要凭感觉猜哪里慢,先用Stopwatch分段计时,找到真正的瓶颈再优化。我见过有人花大力气优化解析逻辑,结果发现90%的时间花在数据库写入上。
最后,测试数据要覆盖边界情况。空文件、只有表头、字段数不一致、超长字段、特殊字符、重复数据——这些情况在测试时都要覆盖到。我习惯准备一个“脏数据测试集”,每次改完导入逻辑都跑一遍,能避免很多上线后才发现的问题。
这个内容后续还可以扩展的方向包括:增量导入(只处理新增数据)、数据校验规则引擎、导入模板配置化、以及和消息队列结合做异步导入。核心的读取-解析-写入链路掌握了,这些扩展都是在这个基础上的自然延伸。