上周有个做产线的朋友找我,说他们车间每天会固定导出几份Excel报表,希望我能把这些报表“实时”同步到SQL Server里,方便后面的看板和统计查询直接用数据库。这种需求我接过不止一次,C#、Excel、SQL数据库这套组合在工厂上位机、运维日结、门店对账的场景里非常常见。今天就把我从方案选型到最终落地踩过的坑整体写一遍,重点讲实时读取的实现思路、Excel解析的细节、以及批量入库时那些容易翻车的点。
1. 先把需求拆明白:实时读取到底要“实时”到什么程度
1.1 你面对的真实场景是什么
做技术方案之前,我最先问对方的不是“你想怎么实现”,而是“这个Excel是谁在什么时候放过来的”。同样是Excel导入数据库,背后的场景差别非常大。
常见的也就这么几类:人工导出后放到共享目录,第三方系统按固定时间生成文件,上位机设备程序跑完一个批次落一份Excel,或者是某个老系统定的输出路径。这些场景决定了数据的产生频率,也决定了“实时”到底怎么定义。
如果文件是每天产出一份,那你做一个定时任务,每隔几分钟扫一次目录就够用了。如果文件是每半小时生成一次,那用轮询也能接受。真正需要上文件监听的时候,往往是数据产生没有固定规律、业务方又希望数据出现后尽快进入库里的场景。所以在动手之前,一定要把“多久必须入库”这个指标问清楚,它直接决定后面的技术选型。
1.2 常见的三条实现路线,怎么选
我常用的方案其实有三种。
第一是轮询目录。用Timer定时扫描指定路径,发现新文件就处理。优点是简单可靠,出了问题重启就行;缺点是实时性有损耗,文件量大时候扫描整目录也费资源。适合低频、文件不多的场景。
第二是用FileSystemWatcher监听目录。这是系统级的文件变更通知,文件一落地,几百毫秒内程序就能感知到,实时性最好。缺点在后面细说,主要是事件重复触发、缓冲溢出、文件占用这些问题,但处理好了非常稳。
第三是直接用SQL Server自带的导入工具或数据库同步软件。比如SQL Server的Import/Export向导、Integration Services,或者专门做数据同步的第三方软件。这类工具的强项是批量、定时、稳定,但弱点是灵活度不够。要做数据清洗、去重、多规则校验、失败单独重试这些定制逻辑时,用通用工具反而不如自己写代码来得顺手。
我的默认选择是“FileSystemWatcher监听 + 延时读取 + SqlBulkCopy批量入库”这条链路。监听保证了实时性,延时读取是为了避开文件写入未完成的问题,SqlBulkCopy保证数据吞吐量,中间再用自己的代码把解析和校验逻辑兜住。这条链路我用了很多年,大小项目都跑过,基本挑不出毛病。
1.3 为什么别去碰Excel COM组件
一听到“用C#读Excel”,有些刚入行的同事第一反应是打开Excel进程操作。这个思路在本地桌面程序上勉强能用,但在服务端就是灾难。
服务器要装Office全家桶,授权和依赖问题一堆;频繁调用COM对象容易造成进程残留,内存一直涨;更麻烦的是,如果用户正在编辑同一个目录的文件,服务端程序再强行打开,会跟用户的Excel抢文件锁,轻则导出失败,重则数据错乱。而且COM调用Excel的启动速度极慢,遇到大数据量文件那是雪上加霜。
所以在服务端解析Excel,一律用第三方解析库直接读文件,不让Excel进程介入。后面我会讲EPPlus和NPOI的具体选型逻辑。
2. 核心模块设计:监听、解析、入库三块各有讲究
2.1 FileSystemWatcher的正确打开方式
FileSystemWatcher用起来很简单,new一个实例,设置Path和Filter,订阅Created和Changed事件就行。但生产环境能稳定跑下去,必须处理好三个细节。
第一,事件会重复触发。一个Excel文件被保存时,文件系统会产生多次变更通知,可能是Created,也可能是Changed,WriteToServer没准备好之前文件还可能被分多次写入。如果每个事件都去处理一次,重复导入是必然的。我的做法是事件触发后不立刻处理,而是await Task.Delay(1500)左右,延迟结束再检查文件大小和最后写入时间。如果文件在两次检查之间大小稳定了,说明写入基本完成。
第二,处理过程必须加防重入锁。我用一个ConcurrentDictionary<string, byte>记录当前正在处理的文件名,处理前先尝试加入,处理完再移除。要是文件A的事件触发两次,第二次进来发现已经在处理列表中,直接跳过,这个机制能堵住绝大多数重复处理漏洞。
第三,InternalBufferSize要调大。文件系统事件会先写进一个内部缓冲区,如果监听的文件多、事件密集,缓冲区满了之后系统会直接丢弃后续事件,造成漏文件。这个坑非常隐蔽,我早期就吃过亏,文件少的时候一切正常,文件一多就有个别文件“没反应”。把InternalBufferSize设为64KB左右,基本能覆盖正常业务场景。
2.2 Excel解析:xlsx其实是个压缩包
很多人以为解析xlsx很神秘,其实xlsx文件本质上是一个zip压缩包,里面是一堆xml文件,表格数据都藏在sheet1.xml这些文件里。EPPlus和NPOI这类库做的事情,说白了就是在内存里解压、读xml、还原成行列结构。正因如此,服务端解析完全不需要安装Office。
选型上,我优先推荐EPPlus,它对.xlsx格式的支持非常完整,API设计也顺手,性能在几万行的数据量下依然很好。需要注意EPPlus从5.0开始采用了商业授权模式,开发环境要设置ExcelPackage.LicenseContext = LicenseContext.NonCommercial,正式商用要先确认许可条款。
如果业务里还有老式的.xls文件,那就要用NPOI。NPOI同时支持.xls和.xlsx,开源免费,用起来稍微啰嗦一点,但兼容性是真的稳。两种库的选择依据很简单:只要xlsx就上EPPlus,必须兼容老格式就NPOI。
解析Excel时有四个高频坑,我每次都提前规避。日期列是最坑的,Excel里的日期本质上是OADate数字,如果单元格格式是日期,EPPlus读出来是DateTime没问题,但如果那列格式是文本,读出来就是字符串“2023/7/24 14:00”,必须用DateTime.TryParse兜底转一遍。合并单元格也要特别注意,只有左上角那个单元格有值,下面全是null,需要做向下填充逻辑。然后空行要跳过,很多系统导出的Excel最后几行都是格式残留。最后是列名映射,Excel表头跟数据库字段名经常对不上,必须做一层显式映射,不要靠死板的位置序号。
2.3 SQL批量入库:SqlBulkCopy的玩法
数据解析到DataTable之后,最忌讳的是循环里一条条执行INSERT。几千行数据逐条插入,光是SQL解析和网络往返就能拖到几十秒,插入中途来一次异常,前面的操作全要回滚,问题很难排查。
SqlBulkCopy是.NET里专门做SQL Server批量导入的组件,底层走的是BCP协议,性能跟逐条Insert完全不在一个量级。一万行数据通过SqlBulkCopy入库,通常也就一两秒。
用SqlBulkCopy时有三个点必须注意。列映射要显式配置,DataTable里列名和表字段名不一致时,要用ColumnMappings把两边的对应关系建立清楚,不然数据全错位。事务要包住,SqlBulkCopy支持传入SqlTransaction,这样一旦出错可以整体回滚,数据不会只进了一半。BatchSize不要贪大,实践中1000到5000之间比较合适,太大容易造成表锁长时间占用,太小又浪费了批量传输的优势。
2.4 幂等与去重:怎么防止同一份文件被反复导入
实时导入最容易出的问题不是导不进去,而是同一条数据被导了好几次。FileSystemWatcher事件重复、文件被手动重新复制、程序重启后重新扫描目录,每一种情况都可能让数据重复入库。
我的防护是三层。第一层是文件指纹表,数据库里建一张FileProcessLog表,记录每个文件的MD5哈希和处理时间。处理新文件前先算哈希,查一下这张表,处理过就直接跳过。第二层是文件处理目录,成功处理的文件移动到Processed目录,失败的移动到Failed目录,避免下次启动时又扫到。但移动目录并不能完全替代指纹表,因为同名同内容的文件有可能从两个不同目录拷贝过来。
第三层是Excel内部数据去重。这个要看业务需求,比如报表里因为格式原因会出现重复行,可以在入库前用DataTable按业务唯一键分组去重,也可以在目标表上建立唯一索引,批量导入前先清掉可能冲突的数据。用什么策略取决于你对数据的信任程度,我做项目时一般是用目标表的唯一键来兜底,这样再有脏数据也会被数据库挡下来。
3. 从零搭建一个可运行的实时导入服务
3.1 项目骨架与依赖配置
我用.NET 6以上版本的控制台应用做演示,生产环境发布时可以转成Windows服务,或者用Worker Service模板跑。项目里需要四个NuGet包:EPPlus用于读xlsx,Microsoft.Data.SqlClient用于连SQL Server,Microsoft.Extensions.Configuration.Json用于读配置文件,日志随便选一个体系,我用Serilog。
配置文件设计成这样的结构:
{ "ExcelWatcher": { "WatchPath": "D:\\ExcelImport\\Incoming", "ProcessedPath": "D:\\ExcelImport\\Processed", "FailedPath": "D:\\ExcelImport\\Failed", "ExtensionFilter": "*.xlsx", "DelayMilliseconds": 1500, "MaxRetryCount": 5 }, "ConnectionStrings": { "TargetDB": "Server=.;Database=ReportDb;Trusted_Connection=True;TrustServerCertificate=True;" } }DelayMilliseconds是事件触发后等待文件稳定的时间,MaxRetryCount是文件被占用时重试的次数。配置里不建议写死任何用户名密码,生产环境用环境变量或者Windows凭据库都行。
3.2 文件监听与预处理
核心监听逻辑我封装成一个ExcelWatcherService,初始化阶段把目录建好、创建FileSystemWatcher、注册事件,然后启动监听。
var watcher = new FileSystemWatcher { Path = config.WatchPath, Filter = config.ExtensionFilter, NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size, InternalBufferSize = 64 * 1024, EnableRaisingEvents = true }; watcher.Created += OnFileCreated; watcher.Changed += OnFileChanged;事件处理方法里,我先过滤掉Excel的临时文件,也就是以~$开头的文件,再等待延迟时间,然后检查文件是否稳定。检查稳定的办法是连续读取两次文件大小和最后写入时间,如果一致,再尝试以独占模式打开文件。
private async void OnFileCreated(object sender, FileSystemEventArgs e) { if (e.Name.StartsWith("~$")) return; // 过滤Excel临时文件 string fullPath = e.FullPath; if (!_processing.TryAdd(fullPath, 0)) return; // 防重入 try { await Task.Delay(_config.DelayMilliseconds); if (!IsFileStable(fullPath, 2)) { _logger.Warning("文件在延迟窗口内仍未稳定,跳过本次:{Path}", fullPath); return; } await ProcessSingleFileAsync(fullPath); } catch (Exception ex) { _logger.Error(ex, "处理文件异常:{Path}", fullPath); } finally { _processing.TryRemove(fullPath, out _); } }IsFileStable的代码很简单,连续采样文件名、大小、修改时间,只要两次结果一致就认为文件写完了。有一次我遇到一个特例,Excel在另存为时偶尔会先生成一个临时文件再改名,改名会触发Renamed事件。所以如果业务上用了另存为流程,最好也订阅一下Renamed事件,不能只盯着Created和Changed。
3.3 Excel解析为DataTable
解析这层我单独写一个ExcelHelper。读取xlsx用EPPlus,返回一个填充好的DataTable。这个类要处理的逻辑包括合并单元格向下填充、日期规范化、空行过滤。
public static DataTable ReadExcelToTable(string filePath) { ExcelPackage.LicenseContext = LicenseContext.NonCommercial; using var package = new ExcelPackage(new FileInfo(filePath)); var worksheet = package.Workbook.Worksheets[0]; if (worksheet.Dimension == null) return new DataTable(); int rowCount = worksheet.Dimension.End.Row; int colCount = worksheet.Dimension.End.Column; var table = new DataTable(); // 读取表头 for (int col = 1; col <= colCount; col++) { string colName = worksheet.Cells[1, col].Text; if (string.IsNullOrWhiteSpace(colName)) colName = "Column" + col; table.Columns.Add(colName, typeof(string)); } string lastValue = string.Empty; for (int row = 2; row <= rowCount; row++) { var dataRow = table.NewRow(); bool isEmptyRow = true; for (int col = 1; col <= colCount; col++) { var cell = worksheet.Cells[row, col]; if (cell.Merge) // 合并单元格向下填充 { string mergedValue = GetMergedCellValue(worksheet, row, col); if (!string.IsNullOrWhiteSpace(mergedValue)) lastValue = mergedValue; dataRow[col - 1] = NormalizeCellValue(cell.Value, lastValue); } else { lastValue = cell.Value?.ToString() ?? string.Empty; dataRow[col - 1] = NormalizeCellValue(cell.Value, lastValue); } if (!string.IsNullOrWhiteSpace(dataRow[col - 1]?.ToString())) isEmptyRow = false; } if (!isEmptyRow) table.Rows.Add(dataRow); } return table; }这里面NormalizeCellValue是个关键函数,专门处理日期。EPPlus里如果单元格是日期格式,cell.Value的类型是DateTime,直接转字符串没问题。如果格式是文本,拿到的可能是“45123”这种OADate数字,我用DateTime.FromOADate转一遍。最保险的逻辑是判断字符串能不能被DateTime.TryParse解析,解析成功就统一输出成yyyy-MM-dd HH:mm:ss,这样跟SQL Server的datetime2类型兼容性最好。
3.4 SqlBulkCopy批量入库
数据库导入这层不要越写越复杂,核心就是把DataTable灌给目标表。我封装成下面的方法:
public static void BulkInsert(DataTable table, string tableName, SqlConnection connection, SqlTransaction transaction) { using var bulk = new SqlBulkCopy(connection, SqlBulkCopyOptions.Default, transaction) { DestinationTableName = tableName, BatchSize = 2000, BulkCopyTimeout = 60 }; // 显式列映射 foreach (DataColumn col in table.Columns) { bulk.ColumnMappings.Add(col.ColumnName, col.ColumnName); } bulk.WriteToServer(table); }导入前我会先检查DataTable的列名跟目标表字段是否完全匹配,不匹配就报错或按配置做映射。这里我吃过亏:Excel表头里带着空格和特殊符号,反射到SQL字段时直接验证失败。后来我在解析阶段就把列名做了清洗,空格转下划线,特殊字符去掉,入库阶段就顺利很多。
如果要启用事务回滚,调用方这样做:
using var connection = new SqlConnection(connectionString); await connection.OpenAsync(); using var transaction = connection.BeginTransaction(); try { var table = ExcelHelper.ReadExcelToTable(filePath); DatabaseHelper.BulkInsert(table, "dbo.DailyReport", connection, transaction); transaction.Commit(); // 记录文件指纹,移动文件到已处理目录 FileHelper.RecordHash(filePath, md5); File.Move(filePath, Path.Combine(processedPath, fileName)); } catch { transaction.Rollback(); // 移动到失败目录,记录异常日志 throw; }如果说哪个环节最不能省,那就是文件指纹记录。很多项目的实时导入逻辑本身没问题,但重启服务或者复制的文件重名时,数据重复导入。把文件指纹表这张表建好,整个导入链路才算闭环。
3.5 日志、统计与状态记录
实时导入服务跑起来之后,最难的不是功能,是出问题时你能多快定位。我坚持给每个文件处理过程打完整日志,成功也好失败也好,至少要记录文件名、大小、行数、耗时、入库条数。
我用Serilog输出到文件和控制台,日志名按日期滚动。每条文件处理的开始和结束都打Info日志,出错打Error日志并附带完整异常堆栈。除了日志,我还会往一张ImportLog表写摘要记录,方便业务同事自己在数据库里查,不用远程服务器翻文件。
4. 常见问题与排查技巧实录
实时导入这个方向,问题集中体现在几个固定的坑上,我做了一个速查表,都是实际踩出来的。
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| 读文件时报“文件正在被另一进程使用” | 文件还在写入或程序打开的句柄未释放 | 延迟重试,不要强制结束Excel进程 |
| 文件事件不触发或偶尔漏文件 | FileSystemWatcher内部缓冲区溢出 | 调大InternalBufferSize,必要时加轮询兜底 |
| 同一份数据被重复导入 | 事件重复触发、程序重启丢状态 | 防重入锁,加MD5文件指纹表 |
| Excel里的日期导入SQL报Conversion failed | 日期被当成OADate数字或文本 | 解析阶段统一为yyyy-MM-dd HH:mm:ss,字段用datetime2 |
| 老版.xls文件解析不出来 | EPPlus不支持xls格式 | 换NPOI |
| 大批量导入时超时 | BatchSize过大或CommandTimeout太小 | BatchSize设2000左右,BulkCopyTimeout调大 |
| EPPlus抛LicenseContext异常 | 5.x及以上需要授权声明 | 启动时设置LicenseContext为NonCommercial或购买授权 |
| 服务跑一段时间后内存暴涨 | 每次事件创建一个新的ExcelPackage没释放 | 用using释放,不带xlsx文件路径直接用流读 |
| 目标表字段对不上 | Excel表头有空格和非法字符 | 解析阶段清洗列名,入库前做列映射 |
日期转换那个问题非常典型,我单独说一下。Excel里日期就是个序列号,比如45123代表的是某个具体日期。如果某列在Excel里被设置成了文本格式,EPPlus读出来的就不是DateTime,而是字符串“45123”,直接塞进SQL Server的datetime字段当然会报conversion failed when converting date and/or time。我的做法是解析阶段判断字符串能不能解析成日期,不能就尝试用DateTime.FromOADate转,最后统一输出标准格式字符串,这样入库永远不会因为这层报错。
还有一个经验是,FileSystemWatcher不可靠这件事必须心理有数。它监听的是操作系统文件通知,但如果程序崩溃、服务重启,或者监听目录被人为大规模复制文件,事件一定会丢。所以我的方案里永远留着一手轮询扫描作为兜底,每隔5分钟把监听目录里还没处理过的文件主动扫一遍。有轮询打底,实时监听负责日常,异常情况也不会漏数据。
另一个高频问题是“表头不在第一行”。有些系统导出的Excel头顶还有两行标题说明,真正字段在第三行。解析时用worksheet.Dimension.Start.Row拿到实际数据起始行,而不是硬编码第1行。这个细节不处理好,导入的数据从一开始就是错位的。
5. 生产环境还要补哪些东西才算完整
5.1 监听与导入解耦
基础版代码里,FileSystemWatcher的事件处理函数里直接完成了解析和入库,这在文件不多时没问题。但文件密集到达时,事件处理函数会被长时间占用,新的文件通知就可能堆积或丢失。
我的生产版做法是引入一个Channel<FileEntry>队列,监听线程只负责往队列里塞文件路径,后台一个或多个消费者线程负责读取、解析、入库。这样监听端永远轻快,处理端就算慢了也只是队列积压,不会丢事件。
5.2 失败文件的重试与隔离
失败的文件不能一直在原目录里躺着,否则下次轮询又处理一遍,一直失败一直卡。我把失败文件移动到Failed目录,同时记录失败原因和重试次数。对于某些临时性错误,比如数据库刚好重启、连接超时,我会做一个简单的延时重试,重试3到5次还不行就移入Failed目录并告警。
5.3 做一个简单的自检接口
服务部署到服务器后,如果是个黑洞一样跑着的进程,运维会很痛苦。我习惯给服务加一个极简的HTTP自检接口,或者定时写心跳日志。自检内容包括:监听目录是否可访问、数据库是否连通、最近一次成功处理文件的时间、未处理的积压数量。这些信息在排查“为什么某段时间数据没更新”时能秒速定位问题方向。
5.4 真实项目里的几段话收尾
这个方案我在好几个项目里跑过,加起来处理过的Excel文件少说也有几十万份。我最深的体会是,别把FileSystemWatcher当成万能钥匙,它只是“实时”的入口,真正让数据不漏、不重、不乱的是状态表、防重入、失败隔离这些兜底设计。很多半路夭折的导入项目,都是只写了监听和导入,没管重复触发和异常状态,上线第一天看着还行,跑一个月开始漏数据,最后翻日志查到崩溃。
如果你现在正要做一个C#读取Excel导入SQL库的需求,我建议先把文件到了之后“怎么判断该不该处理”这个模型想清楚,再动手写解析和入库。模型想清楚了,用EPPlus还是NPOI、用SqlBulkCopy还是逐条Insert,都是顺理成章的事。