拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#与SQL Server交互:连接池、事务、批量写入与并发避坑

C#与SQL Server交互:连接池、事务、批量写入与并发避坑

说实话,做C#开发这些年,几乎没有一个正经项目能绕开数据库。尤其是和SQL Server打交道,从最早写桌面版MIS系统,到后来做上位机采集、ERP对接、数据分析平台,数据存取永远是核心环节。很多新手朋友一上来就卡在“怎么连库”“为什么连不上”“到底该用DataReader还是DataSet”,说白了就是对C#和SQL Server这套交互链路缺少一个整体认知。

这个项目标题看起来简单,但背后牵涉的东西其实很广:连接串怎么配、连接什么时候释放、事务怎么控制、批量数据怎么快、并发访问怎么不炸,还有SQL Server本身的坑(密码过期、安装失败、日志暴涨)怎么处理。我打算结合这些年实际踩过的坑和验证过的方案,把C#与SQL Server交互这件事从头到尾梳理一遍。这篇文章适合正在学C#数据库开发的新手,也适合已经把能跑通但总觉得不稳的中级开发者参考。

1. 项目概述与核心需求解析

1.1 这个项目到底在解决什么问题

C#与SQL Server交互,本质上就是让托管代码和关系型数据库之间完成双向的数据流通。往浅了说,就是增删改查;往深了说,涉及连接生命周期管理、事务边界控制、并发冲突处理、批量传输优化、异常隔离与重试策略。

早年我见过不少项目,数据访问代码全散落在窗体按钮事件里,一个页面上五六个SqlConnection,用完不Close,最后连接池被占满,系统跑一个上午就报“超时时间已到”。这类问题的根源不是不会写SQL,而是对交互机制缺少体系化的理解。

从实际需求来看,这个项目需要覆盖的能力至少包括:

  • 稳定的数据库连接建立与安全释放,理解连接池的工作机制。
  • 参数的传入传出,确保SQL语句能够携带数据并避免注入风险。
  • 查询结果集的读取与映射,将关系型数据转为C#对象。
  • 写入操作的执行,包括单条写入、批量写入和大文本/二进制数据处理。
  • 事务控制与异常回滚,保证业务数据的一致性。
  • 异步非阻塞操作,避免UI线程卡死。

这套能力搭起来之后,你会发现不管换什么业务方向,哪怕是后来转去写Web API、写微服务、写工控采集,底子都是这一套。

1.2 为什么选C#和SQL Server这套组合

选择C#与SQL Server,不是偶然,而是组合优势决定的。

C#是强类型语言,配合Visual Studio的IntelliSense,数据库字段映射到对象实体时,编译期就能发现类型不匹配的问题。相比Python写脚本时那种运行到一半才发现字段名拼错的情况,C#开发效率在数据密集型项目里更有保障。

SQL Server则有三个杀手级特性:

  • 事务日志机制:配合数据库的恢复模型,可以在故障场景下做到数据可恢复。
  • 索引结构优化:聚集索引与非聚集索引的配合,让千万级数据的查询依然可控。
  • 与.NET生态的深度集成:SqlClient、Entity Framework、Dapper等组件都是微软主导或深度优化的,版本兼容性好,出问题的概率远低于跨语言对接。

当然,这套组合也有它的脾气。比如SQL Server的任务默认用对大小写不敏感,但排序规则不同会引发诡异问题;再比如连接池默认最大连接数是100,超了就排队等待。理解这些脾性,后面的开发才能少踩坑。

2. 环境准备与连接配置的细节拆解

2.1 开发环境与SQL Server安装的常见坑

SQL Server的安装,看着是“下一步下一步”,实际上隐藏了不少坑。我装过的机器少说也有几十台,这里直接说重点。

实例名规划:开发机建议用默认实例(MSSQLSERVER),连接串里只需要写服务器名;如果是命名实例,连接串要写成服务器名\实例名。早期我吃过亏,公司服务器上装了默认实例又装了命名实例,结果应用配置指错了,排查半天才发现是实例问题。

身份验证模式:安装时选择“混合模式”(SQL Server身份验证 + Windows身份验证),否则之后用sa或自定义账号登录会直接拒绝。

防火墙端口:SQL Server默认端口是1433。如果客户端和数据库不在同一台机器,Windows防火墙不会自动放行这个端口。安装完务必手动添加“入站规则-端口-1433”。

SQL Server 2012密码过期问题:很多人遇到过。SQL Server 2012默认开启了密码策略,如果用的是SQL账号登录,密码有效期到期后就会报“密码已过期”。解决方式是:

ALTER LOGIN sa WITH CHECK_POLICY = OFF; ALTER LOGIN sa WITH PASSWORD = '你的新密码';

这属于安全策略和易用性的权衡。生产环境建议保留密码策略,但开发机或者内网测试环境可以直接关掉,省得半夜系统突然报错。

安装失败的处理思路:绝大多数SQL Server安装失败都能在C:\Program Files\Microsoft SQL Server\<版本号>\Setup Bootstrap\Log目录下的日志文件里找到真正原因。最常见的几个原因是:

  • 已存在损坏的实例残留,需要先用官方工具清理再装。
  • 服务账户没有本地管理员权限。
  • .NET Framework版本不满足要求。

遇到安装失败千万别反复点“修复”,先把日志翻出来看,定位到具体错误码再动手。

2.2 连接字符串的配置与优化

连接字符串是整个交互的第一步,也是很多人不重视的一步。一个典型的连接串长这样:

Server=localhost;Database=MyAppDb;User Id=sa;Password=123456;TrustServerCertificate=True;Pooling=True;Min Pool Size=3;Max Pool Size=50;Connect Timeout=15;

这里的每个参数背后都有讲究,我挑几个最影响稳定性的说:

Pooling(连接池):这是性能的关键。默认就是True。连接池会在物理连接之上维护一组逻辑连接,应用程序Close连接时,逻辑连接回到池中,物理连接并没有真正断开,下次再Open时直接复用。这能大幅减少创建TCP连接和认证的开销。但连接池也意味着如果连接串里某个关键参数变了,池会被重新创建,所以在代码里不要动态拼连接串,最好全局统一。

Min Pool Size / Max Pool Size:最小连接数决定了应用启动时会预建多少条连接,最大连接数是硬上限。如果不设置,默认Max是100,Min是0。高并发场景下,连接数不够就会排队,导致“超时时间已到”的错误。同时,设一个合理的Min Pool Size(比如3~5),可以消除系统刚启动时的性能毛刺。

Connect Timeout:默认15秒。对于内网数据库,建议设成5秒,快速失败总比傻等15秒好。公网环境可以适当放宽。

TrustServerCertificate:新版本SqlClient默认要求加密传输,如果服务器证书不是受信任的CA证书,连接会被拒绝。开发环境设成True即可,生产环境建议部署正式证书。

Encrypt:默认是False,在SQL Server 2022之后有变化,但一般内网不需要强制加密,开了反而影响性能。

每次打开连接时建议显式指定一个较短超时:

using var conn = new SqlConnection(connString); conn.Open();

我见过一种很糟糕的写法,在循环里频繁Open/Close连接,又不复用连接池参数,导致每次循环都创建新的物理连接。连接池再智能也经不起这种用法,正确的做法是大循环外开连接,循环内复用命令。

2.3 连接的安全释放与using语法

资源释放这个问题,说是老生常谈,但确实每次都能遇到。C#里处理SqlConnection、SqlCommand、SqlDataReader这些实现了IDisposable的对象,推荐无脑用using。

using (var conn = new SqlConnection(connString)) { conn.Open(); using (var cmd = new SqlCommand("SELECT * FROM Users WHERE Id = @id", conn)) { cmd.Parameters.AddWithValue("@id", userId); using var reader = cmd.ExecuteReader(); while (reader.Read()) { // 业务逻辑 } } }

这段代码的好处是:无论中间抛不抛异常,using块结束时会自动调用Dispose,把连接归还给连接池。有人习惯手动写try-catch-finally,然后在finally里Close,问题是代码一旦嵌套层次深了,很容易漏掉某个分支。用using从语法层面杜绝泄漏。

3. 核心增删改查的完整实操

3.1 查询数据:DataReader与DataSet选型

我见过太多新手在这两个API之间纠结,其实选型标准很简单。

SqlDataReader是流式读取,一次只缓存一行数据到内存,速度极快,对大数据集友好。但它是只进只读的流,相当于数据库客户端在“拉”数据,不能跳回上一行。它的典型使用场景就是纯粹的查询展示、导出、计算。

DataSet/DataTable是把整个结果集加载到内存里,好处是可以离线操作、支持断开式数据访问、方便绑定到DataGridView。坏处是内存占用大,数据量超过几万行时内存会明显飙升。

我的经验是:能用DataReader就DataReader,尤其是面向结果的、不修改回库的读操作。DataSet只用在需要批量展示、远程传输或者做本地缓存时。

具体到查询代码,有一个细节值得注意:如果想直接拿到单值,比如统计数量,推荐用ExecuteScalar:

using var cmd = new SqlCommand("SELECT COUNT(*) FROM Orders WHERE Status = 'Pending'", conn); conn.Open(); int count = (int)cmd.ExecuteScalar();

ExecuteScalar返回结果集第一行第一列,比用DataReader再去Read一次要干净利落。如果做什么都不返回,用ExecuteNonQuery,返回的是受影响行数。

3.2 插入、更新、删除:参数化是底线

写操作的基本套路是一样的:创建SqlCommand,设置CommandText,添加参数,调用ExecuteNonQuery。这里最核心的原则只有一个:永远使用参数化查询,绝对不要拼SQL字符串。

先说为什么。

假设有一种写法是:

string sql = $"INSERT INTO Users(Name, Age) VALUES('{name}', {age})";

如果name里包含了单引号,比如传入的是James'); DROP TABLE Users;--,这条SQL就会变成两条语句,后果不堪设想。这不是什么高深黑客技术,很多脚本小子扫的就是这种漏洞。

再说怎么做。用SqlParameter把值和SQL语句分离:

using var cmd = new SqlCommand( "INSERT INTO Users(Name, Age, CreatedAt) VALUES(@name, @age, @createdAt)", conn); cmd.Parameters.Add("@name", SqlDbType.NVarChar, 50).Value = name; cmd.Parameters.Add("@age", SqlDbType.Int).Value = age; cmd.Parameters.Add("@createdAt", SqlDbType.DateTime).Value = DateTime.Now; conn.Open(); cmd.ExecuteNonQuery();

有几点细节需要说明。

SqlDbType显式指定类型,而不是用AddWithValue。AddWithValue虽然方便,但它会通过值推断类型,推断不准会影响索引使用。比如传入一个.NET的string默认映射到NVARCHAR,但表里字段是VARCHAR,就会导致查询时走不了索引。在插入大量数据时,类型不匹配的隐式转换可能让性能差一个数量级。

参数化还有一个额外好处:同一句话发多条数据时,可以复用同一个SqlCommand,只修改参数值,避免反复构造对象:

cmd.Parameters["@age"].Value = newAge; // 修改参数而不是新建Command cmd.ExecuteNonQuery();

3.3 字段映射:从DataReader到强类型对象

DataReader读出来的数据还停留在“列”层面,业务层通常需要把它转成对象。这一步我推荐用手动映射,哪怕代码看起来繁琐,也不要写什么“自动映射”黑魔法。

常见的手动映射方式有两种。

列名序数映射,读取快,但可读性差:

var user = new User { Id = reader.GetInt32(0), Name = reader.GetString(1), Age = reader.IsDBNull(2) ? 0 : reader.GetInt32(2) };

列名查找映射,可读性强,但每次都查一遍Schema,数据量大时有额外开销:

var user = new User { Id = reader.GetInt32(reader.GetOrdinal("Id")), Name = reader.GetString(reader.GetOrdinal("Name")), Age = reader.IsDBNull(reader.GetOrdinal("Age")) ? 0 : reader.GetInt32(reader.GetOrdinal("Age")) };

我的建议:在循环外先把GetOrdinal的结果缓存到局部变量,循环内直接用序数读取,兼顾可读性和性能。

还有一个高频坑:如果数据库字段允许NULL,读取时一定要先判断IsDBNull再GetValue,否则会抛“列数据无效”异常。处理NULL的推荐方式是使用reader.GetFieldValue<T>()配合可空类型。

3.4 使用Dapper还是原生SqlClient

写了这么多原生代码,是不是意味着项目里必须抛弃ORM?恰恰相反,我个人的习惯是:单表简单CRUD用Dapper,复杂报表或需要精细控制SQL的用原生SqlClient,Entity Framework留给人多的团队协作项目。

using Dapper; using var conn = new SqlConnection(connString); var users = conn.Query<User>("SELECT Id, Name FROM Users WHERE Age > @age", new { age = 18 }).ToList();

Dapper本质上就是一个扩展方法库,内部还是走SqlClient,但帮你把参数映射、结果集映射这些样板代码省掉了。性能上几乎没有损耗,阅读体验却好很多。

那么什么时候非用原生不可?当你需要精细控制CommandTimeout粒度、批量执行多条语句时用同一个事务、或者读取像varbinary(max)这样的二进制字段做流式处理,原生SqlClient更方便。

4. 进阶交互:事务、存储过程与批量写入

4.1 事务的范围与控制

没有事务的多步写操作,就像没有安全绳的高空作业。假设有一个订单创建流程:插入Order表,更新库存表,写入日志表。这三个操作必须同时成功或同时失败,否则库存扣了订单没建,或者日志记录了但订单没落库,都是灾难。

SQL Server的事务支持是原生c#的转移控件,从C#这边控制事务有几种方式。

使用SqlTransaction:

using var conn = new SqlConnection(connString); conn.Open(); using var tran = conn.BeginTransaction(IsolationLevel.ReadCommitted); try { using var cmd = new SqlCommand("INSERT INTO Orders(OrderNo, Total) VALUES(@orderNo, @total)", conn, tran); cmd.Parameters.AddWithValue("@orderNo", orderNo); cmd.Parameters.AddWithValue("@total", total); cmd.ExecuteNonQuery(); using var cmd2 = new SqlCommand("UPDATE Inventory SET Stock = Stock - @qty WHERE ProductId = @pid", conn, tran); cmd2.Parameters.AddWithValue("@qty", qty); cmd2.Parameters.AddWithValue("@pid", productId); cmd2.ExecuteNonQuery(); tran.Commit(); } catch { tran.Rollback(); throw; }

注意几个关键点:

  • 每个SqlCommand必须挂在同一个SqlConnection和同一个SqlTransaction下,否则报“ExecuteNonQuery要求具有有效命令”的错误。
  • 事务开启后一定设置合理的IsolationLevel。默认是ReadCommitted,但如果不指定,SQL Server会用数据库默认级别,一般来说取ReadCommitted就好,避免脏读。对一致性要求极高的场景(资金相关)建议用Serializable,但要接受锁竞争带来的性能下降。
  • 不要偷懒把事务范围放到最大,只包裹最小的写操作集合。事务越长,持有的锁越多,死锁概率越高。

判断事务是否应该使用还有一种更简单的方法:这些写操作如果中间故障后可以接受部分成功,那就不需要事务;不能接受,就必须事务。搞明白业务语义,比背API重要得多。

4.2 存储过程:什么时候应该用

说实话,现在纯应用层开发里,存储过程用得比以前少了。但有些场景它仍然不可替代。

适合用存储过程的场景:

  • 复杂统计报表,涉及多个临时表、多次循环、条件拼接,写在一个存储过程里可以在数据库端直接跑完,避免App到DB端来回传数据。
  • 数据校验逻辑必须与业务表紧密耦合时,存过可以直接访问schema元数据。
  • 权限需要精确控制时,只给账号执行存储过程的权限,而不让它直接访问表。

C#调用存储过程很简单:

using var cmd = new SqlCommand("usp_GetUserOrders", conn); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@userId", userId); cmd.Parameters.Add("@totalCount", SqlDbType.Int).Direction = ParameterDirection.Output; conn.Open(); using var reader = cmd.ExecuteReader(); // 读取结果 cmd.ExecuteNonQuery(); // 执行完成后获取输出参数 int totalCount = (int)cmd.Parameters["@totalCount"].Value;

注意,如果存储过程同时返回结果集和输出参数,必须先读完DataReader再取输出参数值。因为输出参数的值是在整个结果集读完后才赋上的。

不适合的场景:简单CRUD,用SQL直接写清爽得多。把CRUD逻辑全塞到存储过程里,会导致业务逻辑分散在代码和数据库两层,维护起来极其痛苦。凡是动态拼接SQL、试图在存过里处理复杂缓存逻辑的做法,我都建议放回到C#这边来。

4.3 批量写入的性能关键:SqlBulkCopy

批量插入几千上万行数据时,逐条Insert是最慢的。每条Insert都要走一遍SQL解析、权限检查、日志写入,性能完全无法接受。这时候要用SqlBulkCopy。

一个典型场景是上位机:设备采集了几万条温度/压力数据,需要一次性入库。SqlBulkCopy底层走的是BCP协议,直接把DataTable灌入目标表,速度是逐条插入的几十倍。

using var bulkCopy = new SqlBulkCopy(connString, SqlBulkCopyOptions.KeepIdentity); bulkCopy.DestinationTableName = "SensorData"; bulkCopy.BatchSize = 1000; // 每批次提交行数 bulkCopy.BulkCopyTimeout = 300; // 秒 foreach (var col in dataTable.Columns) { bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); } bulkCopy.WriteToServer(dataTable);

这里最常见的问题就是热搜词里提到的“表变动有影响”。如果目标表在批量写入过程中被加了列、删了列、改了列名,ColumnMappings就可能映射不上,要么报错,要么静默把数据写错列。解决方法是在映射时从DataTable.Columns取列名,而不是手写字符串数组,同时注意做空值检查:

if (dataTable.Columns.Count == 0) return;

另一个容易踩的坑是:SqlBulkCopy不会自动创建表。如果目标表不存在,WriteToServer会报“对象名无效”。要么先建表,要么使用SqlBulkCopyOptions.TableLock。

关于BatchSize,我个人经验是1000~5000比较稳妥。太小了性能上不去,太大了容易把事务日志占满,特别是简单恢复模式下日志增长会很快。对于超大文件(几十万行),建议分批调用WriteToServer,每批之间休息几秒,给日志一点喘息时间。

5. 并发与异步:Task和连接池的正确姿势

5.1 异步操作:别让UI线程卡死在数据库请求上

上位机开发或桌面应用里最常见的糟糕体验是什么?点击“查询”按钮后,界面卡死,鼠标转圈,几秒后才有反应。原因就是数据库操作在UI线程同步执行。解决思路是用async/await。

private async void BtnQuery_Click(object sender, EventArgs e) { try { var users = await GetUsersAsync(); dataGridView1.DataSource = users; } catch (Exception ex) { MessageBox.Show(ex.Message); } } private async Task<List<User>> GetUsersAsync() { await using var conn = new SqlConnection(connString); await conn.OpenAsync(); await using var cmd = new SqlCommand("SELECT Id, Name FROM Users", conn); cmd.CommandTimeout = 10; var users = new List<User>(); await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { users.Add(new User { Id = reader.GetInt32(0), Name = reader.GetString(1) }); } return users; }

这里有个容易被忽略的坑:SqlConnection的OpenAsync并不总是真正异步,在连接池还有空闲连接时它是同步的,只有在物理连接建立场景下才是异步的。但无论如何,用async版本不会错。

还有一个细节:如果加了ConfigureAwait(false),后面操作就切不到UI线程了,所以在访问UI控件之前不要加。在WinForms里就让人不安心,正经做法是别把await后的UI操作放到同一方法里,用异步方法返回数据,UI层再绑定。

5.2 连接池耗尽时的诊断与应对

连接池耗尽的表现是经典的“Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool”。翻译成人话就是:池里所有连接都被业务代码占着没释放,新请求借不到连接。

遇到这个报错,第一反应别去调大Max Pool Size,先找为什么连接不释放。常见原因有:

  • SqlConnection没有Dispose:漏写using。
  • DataReader没有关:DataReader打开后,它占用的连接会保持打开状态,直到reader关闭。
  • 事务没有提交或回滚:事务开着,连接不能回池。
  • 异步代码没有await:如果调用了某个异步方法却忘了await,后续代码可能会重复打开新连接。

排查手段很简单,执行下面的SQL看当前连接:

SELECT DB_NAME(dbid), loginame, hostname, program_name, status, COUNT(*) AS cnt FROM sys.sysprocesses GROUP BY dbid, loginame, hostname, program_name, status;

发现某个hostname下挂了大量连接没释放,顺着Program_name就能大概判断是哪个应用模块。

5.3 高并发写入时的线程安全策略

上位机或者Web后端都有高并发写入的场景。单纯开多个线程丢SqlCommand去执行,短期内能压榨性能,但长期看会遇到两个问题:连接池耗尽和死锁。

正确的并发写入姿势有两类。

串行化写队列:引入一个BlockingCollection作为写队列,生产者线程把数据丢到队列,消费者线程排队写库。这样数据库端的写并发恒定为1,既不会死锁,也能利用SqlBulkCopy批量写入的威力。

private readonly BlockingCollection<MyData> _queue = new(10000); void Produce(MyData item) => _queue.Add(item); void Consume() { foreach (var item in _queue.GetConsumingEnumerable()) { // 写库 } }

分库分表或者语义隔离:把不同业务的表放在不同的连接上,避免跨表事务交集引起的死锁。同时所有表无论大小,都按约定顺序写入,降低死锁概率。

这边要提醒一点:连接对象本身不能跨线程共享。SqlConnection在第一次Open后,线程亲和性就确定了,换线程操作同一个连接会抛异常。切记多线程场景下每线程单独建连接,或者统一走连接池。

6. 常见问题与排查技巧实录

6.1 数据库安装与连接类问题的速查

问题现象可能原因解决方案
“无法连接到localhost”SQL Server服务未启动服务管理器启动MSSQLSERVER服务
“用户'sa'登录失败”身份验证模式为仅Windows改用混合模式,或重置sa密码
“密码已过期”SQL Server密码策略生效执行ALTER LOGIN关闭策略检查(开发环境)
“在建立与服务器的连接时出错”防火墙拦截1433端口防火墙放行1433入站
“证书链是由不受信任的机构颁发”Encrypt开启但未信任证书连接串加TrustServerCertificate=True

6.2 SqlBulkCopy相关问题的现场实录

我在实际项目中遇到过几次SqlBulkCopy写入后数据对不上号的问题。排查到最后发现都是ColumnMappings配置引起的。比如目标表加了列,但映射里没有对应字段,数据不会报错,只会被丢弃或者填默认值,静默出错比报错更吓人。

推荐的做法是封装一个方法,自动同步列名:

public static void MapSameNameColumns(DataTable source, SqlBulkCopy target) { foreach (DataColumn col in source.Columns) { target.ColumnMappings.Add(col.ColumnName, col.ColumnName); } }

另外,SqlBulkCopy还有一个经常被误用的参数:TableLock。它确实能提升写入速度,但在并发读写同一张表的场景下,会导致其他请求长时间阻塞。生产库慎用。

6.3 性能优化:索引与查询的常见误用

C#这边能做的事有限,但有些SQL写法会让索引彻底失效,最有名的就是“在索引列上使用函数”。

  • WHERE YEAR(CreatedAt) = 2024会让CreatedAt上的索引失效。正确写法是WHERE CreatedAt >= '2024-01-01' AND CreatedAt < '2025-01-01'。
  • WHERE Name LIKE '%abc%'无法命中普通索引,只能全表扫。如果确实需要模糊搜索,考虑全文索引。
  • 避免SELECT *,只取需要的列。
  • 大分页用OFFSET/FETCH配合合适的排序索引,而不是老式的ROW_NUMBER()。

在实际开发中,我会让每个核心查询都跑一下执行计划,看有没有警告。SQL Server Management Studio里选中查询按Ctrl+L就能看预估执行计划,熟练之后一眼能看出问题在哪。

6.4 日志文件无限增大的处理

SQL Server跑久了,数据库文件不变但日志文件越来越大,甚至占据整个磁盘。原因通常是恢复模型为“完整”模式,又没有定期做日志备份。

处理方式:若业务允许最坏的丢失窗口,把恢复模型降为“简单”模式,然后收窄日志文件:

ALTER DATABASE MyAppDb SET RECOVERY SIMPLE; DBCC SHRINKFILE (MyAppDb_log, 100);

注意,完整恢复模式本身是生产环境用来做时间点恢复的,别出一次故障就永久改成简单模式,要权衡数据丢失风险和磁盘成本。

7. 个人经验总结与后续扩展思路

7.1 我踩过的那些数据库交互的坑

做了这么多年C#与SQL Server交互,回忆起来有几次刻骨铭心的故障都发生在我觉得“代码写得没问题”的时候。

第一次是生产环境大批量导入,我有意用了SqlBulkCopy,但没有控制BatchSize,几万行数据一把梭,直接把事务日志撑爆,数据库进入“日志满”状态。那次之后,凡是批量操作我都会先算一下数据量,必要时拆成多批。

第二次是Windows服务模式跑数据同步,用了Timer定时调用同步逻辑,但同步逻辑本身没加锁。两个周期重叠时,两个线程同时写同一批表,造成大量死锁重试,日志刷得飞起。后来所有定时任务入口都加SemaphoreSlim或者直接用单线程队列。

第三次是在多表关联查询时,我把N+1查询说成是小问题,结果关联行数一大,查询延迟指数上升。教训是:能一次JOIN出来就一次JOIN,不要在循环里查库。

7.2 再往前走一步

C#与SQL Server交互,到这里已经覆盖了日常开发的绝大部分场景。如果要再进一步,建议往以下几个方向拓展:

  • 深入学习Entity Framework Core的映射机制,了解表达式树如何转成SQL,这对写复杂查询会有启发。
  • 了解消息队列(如RabbitMQ或Azure Service Bus)与数据库配合的分布式事务方案,比如本地消息表和最终一致性。
  • 研究读写分离与分库分表,知道在数据量突破单实例瓶颈时该怎么拆。
  • 把SQL Server的索引结构搞明白,特别是B+树的页分裂、碎片整理的原理,这比多背几个API有价值得多。

7.3 一点小技巧收尾

最后送一个我在多项目里一直在用的技巧:在项目启动时统一注册一个全局的SqlConnection工厂方法,把连接串、超时、池大小都收敛到一个配置项里,业务层永远从工厂拿连接。这样出现问题时,改配置就能救活整个项目,而不是逐个文件去改连接串。

public static class DbFactory { private static readonly string ConnString = ConfigurationManager.ConnectionStrings["Default"].ConnectionString; public static SqlConnection CreateConnection() { var conn = new SqlConnection(ConnString); conn.Open(); return conn; } }

配合using语法使用,整个项目的数据库访问代码就能保持整洁且不易出错。

这就是我关于C#与SQL Server交互这几年的核心经验。技术迭代很快,但连接管理、参数化、事务、批量写入、并发控制这些底层的功夫,十年后依然适用。一开始多花点时间把基础打扎实,后面做任何项目都会顺很多。

返回列表