1. 连接字符串与连接管理:一切交互的地基
1.1 连接字符串的正确打开方式
C#与SQL Server交互的第一步,不是写SQL语句,而是先搞定连接字符串。很多新手上来就卡在这里,因为连接字符串写错一个分号、漏掉一个参数,程序跑起来就直接抛异常。
一个标准的SQL Server连接字符串长这样:
string connStr = "Server=localhost;Database=MyShop;User Id=sa;Password=your_password;TrustServerCertificate=True;";如果是Windows身份验证(也叫集成安全),写成这样:
string connStr = "Server=localhost;Database=MyShop;Integrated Security=True;TrustServerCertificate=True;";这里面有几个参数需要认真对待。
Server字段,本地开发写localhost或者.都可以,如果实例是命名实例,写成localhost\\SQLEXPRESS这种格式。Database字段就是你要连接的库名。User Id和Password是SQL Server登录账号,用Windows认证就写Integrated Security=True。
TrustServerCertificate=True这个参数很多人容易漏。在较新版本的.NET(比如.NET 6及以上)连接SQL Server时,默认会对服务器证书做校验,如果没配好证书就会报证书链错误。开发环境里直接加这个参数可以跳过证书校验,省去一堆麻烦。但生产环境要谨慎,最好用正式的证书。
连接字符串建议放在配置文件里,不要硬编码在代码中。在.NET Framework项目里放App.config或Web.config,在.NET Core/.NET 5+项目里放appsettings.json。这样部署到不同环境时不用重新编译代码,只改配置文件即可。
1.2 using语句:让连接资源有借有还
连接对象是昂贵的资源,底层对应着SQL Server的连接池条目。用完必须释放,否则连接池耗尽,程序就会出现"连接池已满"的报错。
推荐的做法是用using语句块包裹连接对象的使用范围:
string connStr = "Server=localhost;Database=MyShop;Integrated Security=True;TrustServerCertificate=True;"; using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); // 执行数据库操作 }C#的using语句在代码块结束时自动调用Dispose(),等价于conn.Close()加资源清理。即使代码中途抛出异常,using也能保证连接被正确释放,不需要写try-catch-finally来手动关闭。
我见过不少项目里,连接对象是类的一个成员字段,长期持有不释放。这种做法在桌面程序里可能感觉不到问题,但在Web应用里几乎必然导致连接耗尽。正确的做法是"用时创建,用完释放",走连接池机制让系统复用底层连接。
连接池默认是开启的,连接字符串里可以配置Pooling=True(默认就是True)和Max Pool Size=100(默认值)。也就是说,using块里释放连接并不会真正断开数据库,而是把连接还回池里,下次再Open()时直接复用,性能开销很小。这个机制理解透了,就不会纠结频繁开关连接会不会太慢。
2. 命令行对象:从查询到执行的完整链路
2.1 SqlCommand的三种基础操作
连接打开之后,接下来要创建SqlCommand对象来执行SQL。SqlCommand支持三种基础操作:ExecuteReader()用于执行查询并返回结果集,ExecuteNonQuery()用于执行增删改操作,ExecuteScalar()用于返回单个值(比如COUNT统计结果)。
using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand()) { conn.Open(); cmd.Connection = conn; // 方式一:ExecuteReader,读取多行结果 cmd.CommandText = "SELECT Id, Name, Price FROM Products WHERE Status = 1"; using (SqlDataReader reader = cmd.ExecuteReader()) { while (reader.Read()) { int id = reader.GetInt32(0); string name = reader.GetString(1); decimal price = reader.GetDecimal(2); } } // 方式二:ExecuteNonQuery,执行INSERT/UPDATE/DELETE cmd.CommandText = "DELETE FROM Products WHERE Id = 1001"; int affectedRows = cmd.ExecuteNonQuery(); // 方式三:ExecuteScalar,执行聚合查询 cmd.CommandText = "SELECT COUNT(*) FROM Products WHERE Status = 1"; int totalCount = (int)cmd.ExecuteScalar(); }这里有三个细节要注意。
第一,SqlCommand也需要释放,所以同样放进using里。如果连着写多个using,C#的语法允许嵌套的using语句块带花括号,也可以在同一行连续声明,让代码更紧凑:
using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand("SELECT ...", conn)) { conn.Open(); // ... }第二,SqlDataReader读取列值时,可以用GetInt32(0)这种按索引取的方式,也可以用reader["Name"]按列名取。按列名取代码可读性更好,但会多一次字符串查找的微小开销;按索引取性能稍好,但列顺序一变代码就崩。实际项目里我更推荐按列名取,因为SQL语句加列、调整列顺序太常见了,可维护性比那一点性能差异重要得多。
第三,ExecuteNonQuery返回的是受影响的行数,做插入、更新、删除操作时,可以利用这个返回值判断操作是否真正生效。比如更新操作,如果返回0说明没有匹配到行,可能ID不对或者数据已被删除,这时候可以做相应处理。
2.2 参数化查询:堵住SQL注入的门口
这是整个数据库交互里最不能省的一步。拼接SQL字符串是很多初学者最爱写的:
// 错误示范:直接用字符串拼接 string sql = "SELECT * FROM Users WHERE Name = '" + inputName + "'";这段代码如果inputName是' OR '1'='1,整个查询就变成了SELECT * FROM Users WHERE Name = '' OR '1'='1',直接返回全部用户数据。如果再狠一点,输入'; DROP TABLE Users; --,后果不敢想。
正确做法是用参数化查询:
string sql = "SELECT * FROM Users WHERE Name = @name AND Age > @age"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@name", inputName); cmd.Parameters.AddWithValue("@age", age); conn.Open(); using (SqlDataReader reader = cmd.ExecuteReader()) { // ... } }参数用@前缀标识,AddWithValue方法把实际值赋给参数。SQL Server接收到的是参数化命令,输入内容被当作数据而不是SQL代码处理,注入攻击从根本上被堵死。
参数化还有一个额外好处:SQL Server对带参数的查询会缓存执行计划,重复执行同样结构的SQL时速度更快。我测试过一个循环插入场景,参数化比字符串拼接快20%以上,而且是越复杂的SQL差距越明显。
不过AddWithValue有一个需要留意的地方:如果SQL参数类型是varchar,AddWithValue默认会推断为nvarchar。类型不匹配时,SQL Server可能做隐式转换,索引用不上。如果表的列是varchar(50),参数化时最好显式指定类型:
cmd.Parameters.Add("@name", SqlDbType.VarChar, 50).Value = inputName;数据量小的表无所谓,但在大表上参数类型不匹配可能导致索引失效,全表扫描,性能掉一个数量级。这个坑我踩过,后来一律显式声明类型。
2.3 读取结果集:从DataReader到实体对象的映射
实际开发里,不会直接拿DataReader去绑定界面,而是要转成业务对象。手动映射是最直观的方式:
public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public DateTime CreateTime { get; set; } } List<Product> list = new List<Product>(); string sql = "SELECT Id, Name, Price, CreateTime FROM Products WHERE Status = @status"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.Add("@status", SqlDbType.Int).Value = 1; conn.Open(); using (SqlDataReader reader = cmd.ExecuteReader()) { while (reader.Read()) { Product p = new Product(); p.Id = reader.GetInt32(reader.GetOrdinal("Id")); p.Name = reader.GetString(reader.GetOrdinal("Name")); p.Price = reader.GetDecimal(reader.GetOrdinal("Price")); p.CreateTime = reader.GetDateTime(reader.GetOrdinal("CreateTime")); list.Add(p); } } }GetOrdinal方法把列名转成索引,兼顾了可读性和性能。但每次Read()循环里都调用GetOrdinal会有轻微开销,可以把列索引在循环外先取好:
int idxId = reader.GetOrdinal("Id"); int idxName = reader.GetOrdinal("Name"); while (reader.Read()) { p.Id = reader.GetInt32(idxId); // ... }注意reader.GetString有一个坑:如果数据库列值是NULL,GetString会直接抛异常。读取可空字段前要先用IsDBNull判断,或者用SqlDataReader的GetValue返回object再手动判断:
p.Name = reader.IsDBNull(idxName) ? null : reader.GetString(idxName);还有一种更省事的方案:reader.GetFieldValue<string>(idxName)配合DBNull处理。总之,空值处理一定要做好,否则线上一个NULL值就能让你的接口崩掉。
3. DataAdapter与DataTable:面向集合的批量操作
3.1 填充DataSet/DataTable:何时该用它
SqlDataReader是逐行读取的流式访问,适合大数据量查询。但如果需要把结果缓存在内存里、做二次筛选、排序,或者绑定到WinForms/WPF的表格控件上,用SqlDataAdapter填充DataTable更方便。
string sql = "SELECT Id, Name, Price FROM Products"; DataTable dt = new DataTable(); using (SqlConnection conn = new SqlConnection(connStr)) using (SqlDataAdapter adapter = new SqlDataAdapter(sql, conn)) { adapter.Fill(dt); }Fill方法内部会自动打开连接、执行查询、填充数据表、关闭连接,不需要显式调用conn.Open()。这个特性让代码简洁不少,但有个副作用不容易察觉:如果连接没打开,Fill会临时打开一个连接用完再关闭;如果连接由外部先打开,Fill就复用这个连接。所以用DataAdapter时连接管理要格外注意,别以为有using就万事大吉。
DataTable适合中小规模的数据集。几百行、几千行都问题不大,但如果一次查询返回几十万行还要全部装进内存,就要考虑分页查询了。DataTable不是流式的,数据都会驻留在内存中,内存占用和行数是线性关系。曾经有个报表接口,一次查了一个月的明细数据扔进DataTable,结果服务器内存直接涨了1GB多,后来改成按天分批查询才解决。
3.2 批量写入的实用套路
用DataAdapter配合Update方法可以把DataTable的改动批量写回数据库,但实际项目里直接用它做批量写入的并不多,因为需要配置InsertCommand、UpdateCommand、DeleteCommand,麻烦且不够直观。
我更推荐用SqlBulkCopy类做真正的批量写入。它在底层使用SQL Server的BCP协议,性能远高于循环执行INSERT语句。
DataTable dt = BuildDataTable(); // 构建与目标表结构匹配的DataTable using (SqlConnection conn = new SqlConnection(connStr)) using (SqlBulkCopy bulk = new SqlBulkCopy(conn)) { conn.Open(); bulk.DestinationTableName = "Products"; bulk.ColumnMappings.Add("Id", "Id"); bulk.ColumnMappings.Add("Name", "Name"); bulk.ColumnMappings.Add("Price", "Price"); bulk.WriteToServer(dt); }用SqlBulkCopy有几个注意点:目标表的列名和DataTable列名不一致时,必须用ColumnMappings建立映射;大批量写入时,可以设置BatchSize控制每一批写入的行数;写入过程中最好包在事务里,写一半失败时能整体回滚。
批量写入的常规场景包括:从Excel导入数据、异构系统间同步数据、每日跑批任务初始化报表数据。我实践过写入5万行数据,循环INSERT耗时大约90秒,SqlBulkCopy不到5秒,差距接近20倍。这种场景下用批量写入是刚性需求,没得商量。
4. 存储过程与事务:复杂逻辑的组织与管理
4.1 调用存储过程的完整姿势
存储过程把业务逻辑下沉到数据库层,适合复杂查询、多条语句的编排、报表统计这类场景。C#端调用存储过程和执行普通SQL的写法类似,区别在于CommandType要设置成StoredProcedure。
using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand("usp_GetProductListByPage", conn)) { cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.Add("@pageIndex", SqlDbType.Int).Value = 1; cmd.Parameters.Add("@pageSize", SqlDbType.Int).Value = 20; cmd.Parameters.Add("@totalCount", SqlDbType.Int).Direction = ParameterDirection.Output; conn.Open(); using (SqlDataReader reader = cmd.ExecuteReader()) { // 处理结果集 } int totalCount = (int)cmd.Parameters["@totalCount"].Value; }存储过程的返回值有两种情况。一种是通过RETURN语句返回整数状态码,用cmd.Parameters.Add("@returnValue", SqlDbType.Int).Direction = ParameterDirection.ReturnValue去接。另一种是输出参数,像上面代码里的@totalCount那样,调完ExecuteReader或ExecuteNonQuery之后再去取.Value。
存储过程的执行计划是复用的,SQL Server对存储过程的优化和缓存比对普通SQL更积极,所以复杂查询走存储过程往往比动态拼接SQL性能更稳定。
调试存储过程是另一个话题。C#调存储过程报错时,最直接的办法是对照存储过程的参数列表,逐一检查C#端传的参数是否匹配。常见问题是:参数个数不一致、参数顺序不对、存储过程内部抛了异常没被捕获。把SqlCommand对象的CommandText打出来看一眼前缀有没有漏掉dbo.,这个也是经典的报错来源。
4.2 事务:让多个操作要么全成功要么全失败
转账、下单、库存扣减这类业务,多个数据库操作必须打包成一个原子单元,全部成功才算成功,任何一个失败都要回滚。C#里使用SqlTransaction实现本地事务。
using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); SqlTransaction transaction = conn.BeginTransaction(); try { using (SqlCommand cmd1 = new SqlCommand("UPDATE Accounts SET Balance = Balance - @amount WHERE Id = @fromId", conn, transaction)) { cmd1.Parameters.Add("@amount", SqlDbType.Decimal).Value = amount; cmd1.Parameters.Add("@fromId", SqlDbType.Int).Value = fromAccountId; cmd1.ExecuteNonQuery(); } using (SqlCommand cmd2 = new SqlCommand("UPDATE Accounts SET Balance = Balance + @amount WHERE Id = @toId", conn, transaction)) { cmd2.Parameters.Add("@amount", SqlDbType.Decimal).Value = amount; cmd2.Parameters.Add("@toId", SqlDbType.Int).Value = toAccountId; cmd2.ExecuteNonQuery(); } transaction.Commit(); } catch { transaction.Rollback(); throw; } }关键点在于SqlCommand创建时必须把transaction作为第三个参数传进去,这样命令才会在那个事务上下文中执行。有同事踩过这个坑:开了事务但创建命令时没传事务对象,代码不报错,但操作根本没进事务,一旦出错无法回滚,数据就错了。
事务默认的隔离级别是ReadCommitted,事务期间其他会话读不到未提交的数据,能防止脏读。但如果两个事务同时修改同一行数据,后提交的那个会等待前一个释放锁。事务时间长了容易引发死锁或阻塞,所以事务块里的操作要尽量精简,把耗时的外部服务调用放在事务外面。
4.3 提升事务性能的几个习惯
不要在事务里执行大查询。把查询读到内存后,再在事务里做修改,事务持有锁的时间就短。
不要在一个事务里放太多语句。事务越长,锁覆盖的资源越多,阻塞和死锁的概率越大。
不要用SELECT COUNT(*) FROM ...判断数据存在性来加锁。判断某行是否存在,最好用IF EXISTS(SELECT 1 FROM ...),性能完全不同。
分布式事务(TransactionScope配合MSDTC)尽量别用。跨数据库操作时,TransactionScope会自动升级到分布式事务,配置复杂而且性能很差。优先考虑用消息机制做最终一致性,或者把数据放同一个库。
5. 异步操作:别让数据库拖垮你的应用程序
5.1 async/await改写数据库代码
桌面程序和Web应用里,同步执行数据库操作会阻塞当前线程。WinForm里UI线程被阻塞界面就卡住,Web应用里线程池被占满吞吐量就掉下来。.NET里的异步数据库API可以解决这个问题。
public async Task<List<Product>> GetProductsAsync() { var list = new List<Product>(); string sql = "SELECT Id, Name, Price FROM Products WHERE Status = @status"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.Add("@status", SqlDbType.Int).Value = 1; await conn.OpenAsync(); using (SqlDataReader reader = await cmd.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { Product p = new Product(); p.Id = reader.GetInt32(0); p.Name = reader.GetString(1); p.Price = reader.GetDecimal(2); list.Add(p); } } } return list; }OpenAsync、ExecuteReaderAsync、ReadAsync和同步版本一一对应,用法上几乎没区别。事件处理器里的await不会阻塞UI线程,数据加载完成后通过同步上下文更新界面。
注意:所有异步方法一定要一路await到底。如果某个环节用了.Result或者.Wait()强制阻塞等待,不仅失去异步的优势,还可能出现死锁。经典的死锁场景是UI线程调用异步方法,内部用.Result等待返回,而异步操作需要UI线程同步上下文来继续执行,两边互相等待,程序就卡死了。这也是一直强调为什么要从头到尾异步。
5.2 CancellationToken与超时控制
异步操作还有一个额外的好处:可以配合CancellationToken实现真正的操作取消。
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand("SELECT ...", conn)) { await conn.OpenAsync(cts.Token); using (SqlDataReader reader = await cmd.ExecuteReaderAsync(cts.Token)) { while (await reader.ReadAsync(cts.Token)) { // ... } } }CancellationTokenSource设置30秒超时,超过30秒查询自动取消。用户点取消按钮直接调用cts.Cancel()也能中断操作。这个特性在报表查询、文件导入这类耗时操作里特别有用——不用再傻等数据库返回,用户体验完全不同。
还要把CommandTimeout的概念说清楚:SqlCommand.CommandTimeout属性默认是30秒,它指的是从命令开始执行到服务器返回结果的等待时间。CommandTimeout和CancellationToken是两个独立机制,CommandTimeout超时后SQL Server端会中止命令执行,CancellationToken是客户端主动发取消。两者配合能形成双保险。
复杂的报表查询经常超过30秒,需要合理调大CommandTimeout,但调大后连接断开的情形要处理清楚:CommandTimeout超时抛的异常是SqlException,不是TaskCanceledException,排查问题时可以先看异常类型再决定处理策略。
6. 常见问题与排查技巧实录
6.1 连接超时与连接池泄漏
错误信息像是Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool. This may have occurred because all pooled connections were in use and max pool size was reached.
这个问题十有八九是连接没释放。排查时先数一遍代码里SqlConnection的创建处,确认每个Open()都有对应的using块或Close()。常见的坑是:把连接存成了静态字段、在try块里Open但catch里忘记Close、用DataTable加载数据时把连接开在循环里却只在循环外Close一次。
如果代码扫完没发现问题,检查连接字符串里Max Pool Size的设置。默认是100,如果有大量并发请求同时执行数据库操作,100个连接可能不够,适当调大到200或更多,但这只是缓解方案,根治还是靠连接复用。
死锁的报错特征是Transaction (Process ID X) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Please rerun the transaction.。解决办法有两个方向:优化SQL减少锁粒度,比如让事务操作按固定顺序访问表,避免循环等待;添加重试机制,遇到死锁时等待随机毫秒后重新执行。
6.2 SQL执行慢的几个隐藏原因
代码层面排查性能问题时,先分清楚慢在哪个环节。是建立连接慢,还是SQL执行慢,还是数据传输慢?
建立连接慢一般和网络环境有关,连接字符串里可以试着Connect Timeout加长一点,也可以用Application Name参数给连接取个名字,方便在sys.dm_exec_connections里定位来自哪个模块。
SQL执行慢最常见的大坑是参数类型不匹配导致索引失效。前面提到过AddWithValue推断类型的问题,在有大表的查询里,参数是nvarchar而列是varchar时,SQL Server不会用索引扫描而是全表扫描。
另一个隐藏大坑是隐式转换。比如列类型是int,但参数传了字符串,SQL Server会做隐式转换,导致查询不走索引。写代码时务必让C#类型和数据库列类型对齐。
传输慢通常是因为SELECT了不该选的列。SELECT *是经典的性能杀手,把不用的TEXT字段、大数据量字段都查出来,传输时间翻倍。我见过一个列表接口,表里有个nvarchar(max)的备注列,列表页根本不用这个字段,但用了SELECT *,结果2万行数据每次查询多传了几百MB的冗余内容,改完指定列后接口从4秒降到300毫秒。
6.3 连接字符串密钥保护
连接字符串里有数据库账号密码,直接写在代码里本身就不安全。至少要把它放到配置文件里,然后给配置文件加密。更稳妥的做法是使用应用配置中心、环境变量,或者其他密钥管理方案动态获取连接字符串。
从环境变量读取示例:
string connStr = Environment.GetEnvironmentVariable("DB_CONN_STRING");实际部署时,在服务器上设置好这个环境变量即可。这样代码库里的配置文件不含敏感信息,源码泄露也不会直接暴露数据库密码。
还要把账号权限收小。程序运行用的SQL账号只授予它真实需要的权限,比如只读账号、只写账号分开,不要给sa这种超级账户。即使连接字符串泄露,攻击者能做的操作也有限。桌面程序里保存密码的场景要特别注意,SQL Server账号密码不外泄的基本原则是:能不用明文就坚决不用明文。
实操总结与个人经验
我做了几年C#和SQL Server相关的项目,最大的感慨是,这门技术本身并不难,难的是在一次次踩坑后积累出来的经验。连接管理、参数化、事务、异步、超时控制这些点,单看都简单,但在真实项目里组合起来,有一个环节粗心,就会出现线上事故。
还有一个建议:用DataReader时,Read()循环里不要写太多业务逻辑。读取结果集就专心读取,数据映射、业务处理都放到循环外面,这样代码清晰,也方便性能调优。很多人写博客抱怨DataReader性能差,其实往往是循环里塞了不该塞的逻辑。
从工具层面来说,SQL Server Management Studio(SSMS)是调试SQL语句的标配。写C#代码时的SQL语句,先在SSMS里跑一遍看执行计划,确认索引效果,再粘到代码里,能省去大量远程排查时间。SET STATISTICS TIME ON和SET STATISTICS IO ON这两句也是排查SQL性能的利器,能快速看到语句的IO开销和时间开销。
希望这篇文章对正在做C#与SQL Server交互的朋友有帮助。按照这些经验去写代码,初期可能觉得约束多、写法啰嗦,但线上跑的稳定才是真正的胜利。