维护过 .NET 数据访问层的同学,应该都见过这个报错:System.InvalidOperationException: Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool...,如果没有见过,说明你或者你的同事运气确实比较好。数据库连接迟迟无法从连接池拿到,第一反应通常是数据库扛不住了或网络抖动,但我排查过类似问题后得出的结论是:绝大多数时候,问题就出在 ADO.NET 对象的使用方式——有人漏掉了 SqlConnection 的释放,有人在一个连接还没结束事务时开了另一条命令执行,还有人把 DataReader 当普通查询结果存起来,迟迟不关闭。
ADO.NET 从数据库连接到事务控制,从来不只是一堆 API 的排列组合。你理解了连接池、命令对象、数据读取器、离线数据模型、事务隔离级别,以及不同数据库 Provider 之间的差异,才能真正在一个项目的生命周期里把数据访问层做好。这篇文章会从最底层的五大对象开始讲,一直到事务控制和跨库切换,尽量把每一处的“为什么”也讲透,而不是只告诉你怎么写能跑。
1. 五大对象的分工背后,其实是一套“连接生命周期”设计
很多人刚接触 ADO.NET 时,会先背这五个名词:Connection、Command、DataReader、DataAdapter、DataSet。背是记住了,但一写代码仍然乱,因为在现实中它们并不是平级关系,而是按“连接是否一直开着”被分成两组。
先说结论:Connection、Command、DataReader 属于“连接态”模型,一段完整的数据操作里,连接从打开到关闭必须保持连续;DataAdapter、DataSet 属于“断开态”模型,DataAdapter 像一个搬运工,它把数据库数据搬进内存之后,连接就可以立即关闭或归还给连接池,后面你操作的只是一份内存数据。
1.1 五大对象各自管什么
我习惯用一张职责表帮助新同事快速建立概念:
| 对象 | 核心职责 | 生命周期特征 |
|---|---|---|
Connection |
维护到数据库的连接,负责打开、关闭、连接池复用 | 必须被及时释放 |
Command |
承载 SQL 语句、存储过程调用及参数 | 随连接使用,也需释放 |
DataReader |
以只进、只读方式逐条读取数据库返回的结果集 | 读取期间连接保持打开 |
DataAdapter |
连接数据库与内存数据模型的桥梁,可用于查询和更新回写 | Fill/Update 后连接可立即归还池 |
DataSet / DataTable |
内存中的关系数据容器,可包含多表、约束、关系 | 完全离线使用 |
一个典型的连接态流程是:Connection.Open() → 创建 Command 并赋予它这条连接 → Command.ExecuteReader() 得到 DataReader → 循环 Read() 处理每一条数据 → 关闭 DataReader → 释放连接。这里有个很容易忽略的细节:DataReader 不能脱离连接单独存在,它活在连接之上,读取期间如果去关闭连接,读取就会直接中断;反过来,连接如果不释放,数据库那边会话就会一直挂着。
断开态流程则完全不同:DataAdapter.Fill(DataTable) 内部自己去打开连接、执行 SelectCommand、把数据填进 DataTable,操作完成后再把连接状态恢复到原来的样子。你不需要在调用前手动 Open(),也不需要每次 Fill 后额外关一次连接。
1.2 分不清连接态和断开态会看到哪些诡异报错
最经典的报错是 There is already an open DataReader associated with this Command which must be closed first.,这句话通常出现在连接态模型里。比如一个方法里你用同一个连接先执行了一条查询,得到一个 DataReader,然后在尚未关闭这个 DataReader 的情况下,又用同一个连接去执行第二条 SQL,就会触发这个异常。
新手经常疑惑:我不是已经用 using 包住了吗?这里其实要注意一个层次问题:
csharp复制using var connection = new SqlConnection(connectionString);
using var command = new SqlCommand("SELECT ...", connection);
await connection.OpenAsync();
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
// 处理逻辑
}
// 如果在这个位置,还没有退出 using 的作用域,
// 但又想用同一个 connection 再执行一条 SQL,
// 就必须先关掉 reader。
只有在 reader 关闭后,它所占用连接上的活动结果集才算结束。你外层虽然写了 using connection,但那只是保证方法结束时一定会释放,不代表方法中间可以随便复用它。
我见过有人为了省事,把 DataTable 当临时缓存放在静态字段里长期保存,这个方向也不算完全错,但要意识到它属于离线模型,数据是旧数据,不会自动和数据库同步。如果你要拿它去更新数据库,必须再次通过 DataAdapter.Update() 这类机制,而且还要面对主键、并发版本等一堆问题。理解清楚生命周期之后,你才会知道哪一层该用哪种对象。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SqlConnection 的连接串和连接池:多数线上异常都先从这两处查
很多人以为只要 SqlConnection 能连上数据库就万事大吉,真正到了生产环境,连接串的配置和连接池机制才是决定系统稳定性的关键。
先看一个真实场景。某天线上日志突然出现大量 Timeout expired,开发人员第一反应是把 Connection Timeout 调到 60 甚至 120 秒。实际上, Connection Timeout 只是 Open() 方法在等待建立连接时的超时时间,真正的问题往往不是“单次建立连接太慢”,而是“连接池里已经拿不到连接了”。把超时时间调大,只会让请求在池边等更久,反而拖垮整体吞吐。
2.1 连接字符串里值得你逐项确认的参数
| 参数 | 作用 | 常见坑 |
|---|---|---|
Server / Data Source |
数据库实例地址 | 实例名、端口号拼错会直接连接失败 |
Database / Initial Catalog |
初始数据库名 | 缺省时可能连到登录用户默认库 |
Integrated Security |
Windows 身份认证开关 | 与用户名密码认证二选一 |
User ID / Password |
SQL 身份认证 | 不要硬编码在代码里 |
Connection Timeout |
Open 时等待连接建立的秒数 | 默认 15 秒,不是命令执行超时 |
Pooling |
是否启用连接池 | 默认 true,一般不需要关 |
Min Pool Size / Max Pool Size |
池的最小/最大连接数 | Max 默认 100,调大前先检查是否泄漏 |
Application Name |
客户端应用名 | 可帮助定位连接来源 |
Encrypt / TrustServerCertificate |
传输加密相关 | 不同驱动版本默认值不同,最容易踩版本坑 |
这里最典型的坑是 Encrypt 和 TrustServerCertificate。新版驱动对加密行为的要求越来越严格,如果你本地测试用的是旧连接字符串,直接搬到生产环境,有可能原本能连的数据库突然全部连接失败。遇到这种情况,先看两端 SDK 版本,再确认证书是否可信,不要盲目把 TrustServerCertificate 设为 true 了事。
另一个容易忽略的是 Application Name。如果你在代码里动态拼接连接字符串,并且每次拼接出来的 Application Name 都不同,比如带上了当前用户 ID,那么连接池会认为它们是不同的连接串,从而创建出大量独立连接池。每个池都会按需创建物理连接,最终数据库连接数暴涨,但每一条看起来又都很正常。
2.2 连接池到底帮我们做了什么
SQL Server 的驱动默认开启了连接池。你可以把它想象成“数据库连接的共享单车”:Open() 相当于扫码开锁,Close() / Dispose() 相当于还车。你说“我关连接了”,对驱动来说通常并不是真的把物理连接断开,而是把这条连接标记为空闲,归还到池子里等下一个 Open() 复用。
这样做是因为建立一条物理连接的开销非常大:网络握手、TLS 协商、登录认证、Session 初始化,一系列步骤下来可能要几十毫秒甚至更久。对于高频短查询接口,每次都重新建立物理连接是完全不可接受的。
连接池的复用规则非常朴素:连接串必须逐字相同才会复用到同一个池。这也意味着你把密码写死在代码里,每次动态生成,哪怕只是末尾多了一个空格,也会因为你“看起来是同一个库”而实际创建多个池。要避免这类问题,最好使用 SqlConnectionStringBuilder 来构造连接串,而不是手动拼字符串。
了解了这个机制,再回看“连接泄漏”就很好理解了。如果你只 Open() 却不 Close(),物理连接就一直被你占用着。当池里空闲连接耗尽,新的 Open() 就只能等待,直到 Connection Timeout 超时后抛异常。日志里的表现就是满屏的超时错误,数据库服务器上的 session 数却居高不下。
2.3 连接泄漏的排查链路
如果你接到一个“数据库连接池爆了”的工单,不要急着加 Max Pool Size,先确认代码里是不是把连接漏了。排查思路大致是这样:
先看数据库侧的会话数。如果是 SQL Server,可以查 sys.dm_exec_sessions:
sql复制SELECT
s.session_id,
s.login_name,
s.program_name,
s.status,
c.connect_time,
s.last_request_end_time
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_connections c
ON s.session_id = c.session_id
WHERE s.is_user_process = 1
ORDER BY s.last_request_end_time;
如果看到大量 status = 'sleeping' 的会话,且 last_request_end_time 已经很久没有变化,基本能确定连接长时间没有被释放。再往下,去代码里找出所有创建过 Connection 的地方,逐一检查是否满足两个条件:
- 是否放在局部变量里,而不是被静态字段、集合长期持有;
- 是否有
Close()/Dispose(),最稳妥的是用using包裹。
我见过不少项目把 SqlConnection 保存到成员变量里,想着“反正程序会长期运行,连接复用它不香吗?”这等于绕过连接池,自己造了一个长连接管理。一旦遇到数据库重启、网络切换、连接空闲被防火墙回收,这个“随手复用”的连接就会变成定时炸弹。
正确的姿势是每次都 short-lived 地创建连接,靠连接池去复用物理连接。上面的排查链路走下来,90% 的“连接池爆了”问题都会归结到某个 using 缺失,而不是连接池配置太小。
3. Command 与 DataReader:查询写得再漂亮,也要注意释放节奏
SqlCommand 的用法看起来很简单,但往下拆,里面仍然有不少值得注意的地方。你至少要搞清楚它和执行结果的分层关系:ExecuteNonQuery()、ExecuteScalar()、ExecuteReader() 返回的东西完全不同。
ExecuteNonQuery():执行 INSERT / UPDATE / DELETE 等无返回集的命令,返回受影响行数;ExecuteScalar():执行查询并返回第一行第一列的值,适合取 COUNT、MAX 这类聚合值;ExecuteReader():真正流式读取结果集,返回DbDataReader,一次只从数据库读一条数据到客户端内存。
3.1 为什么参数化查询是底线而不是可选项
先说一个很多人写过的反面例子:
csharp复制var sql = "SELECT * FROM Users WHERE Name = '" + name + "'";
如果 name 来自用户输入,这就是一条标准的 SQL 注入入口。用户只需要在输入框里构造一段特殊字符,就能改变整个 SQL 的语义。哪怕你只写内部系统,也必须假设输入是不可信的,因为内部系统的威胁往往同样来自内部人员。
参数化写法本身非常简单:
csharp复制var sql = "SELECT Id, Name, Email FROM dbo.Users WHERE Name = @name;";
using var connection = new SqlConnection(connectionString);
using var command = new SqlCommand(sql, connection);
command.Parameters.Add(new SqlParameter("@name", SqlDbType.NVarChar, 50)
{
Value = name
});
await connection.OpenAsync();
using var reader = await command.ExecuteReaderAsync();
这里我特意用了 new SqlParameter("@name", SqlDbType.NVarChar, 50) 而不是网上教程常见的 AddWithValue("@name", name),原因是 AddWithValue 会让驱动根据传入值的 .NET 类型去猜数据库类型。它猜错时最典型的情况就是把 varchar 列当成 nvarchar 来比较,导致该字段上的索引失效,你明明写了参数化查询,性能却仍然很糟糕。对于大表查询,索引失效的影响比 SQL 注入还隐蔽。
参数化的另一个价值体现在执行计划复用上。后面换不同参数值时,如果参数类型、长度都一致,数据库可以复用已经编译好的执行计划,减少重复编译开销。当然,执行计划复用的逻辑在不同数据库上并不完全一致,但至少方向是对的。
3.2 DataReader 是流式读取,不是结果集快照
拿 DataReader 和 List<T> 对比,最容易理解它的特性:List<T> 是一次性把全部数据加载到内存里;DataReader 则是“读一行,处理一行”,内存占用很低,适合处理大结果集。
但流式也意味着:在你 Read() 的整个过程中,连接必须是打开状态。如果你在读取中途去执行别的耗时操作,比如调用第三方 HTTP API,那么连接会一直被占用,连接池里的这个连接也没办法服务别人。高并发场景下,这种写法很快就会把连接池拖垮。
另外一个细节是,DataReader 默认只在它自己的读取过程结束后,才会释放它对连接的命令占用。如果你在一个方法里用 ExecuteReader 得到了 reader,却没等它读完就异常抛出,那么连接上的活动状态可能没有被正确清理。这里建议你显式指定 CommandBehavior.CloseConnection:
csharp复制using var connection = new SqlConnection(connectionString);
using var command = new SqlCommand(sql, connection);
await connection.OpenAsync();
using var reader = await command.ExecuteReaderAsync(CommandBehavior.CloseConnection);
while (await reader.ReadAsync())
{
var id = reader.GetInt32(0);
// 业务处理
}
加上 CommandBehavior.CloseConnection 后,当 reader.Dispose() 发生时,关联的连接也会自动关闭。这能让连接尽可能早地归还连接池,而不是非要等外层 using 作用域自然结束。如果你不设置 CommandBehavior.CloseConnection,就必须保证 connection 在外层作用域结束时能够被及时释放,否则连接停留在读者手中,隐患很大。
3.3 一个连接上能不能同时跑多个 DataReader
默认情况下,同一个 SqlConnection 在同一时间只允许一个活动的 DataReader。如果你想在第一个 Reader 没关闭的情况下,用同一条连接再执行另一个查询,会得到我们前面提到的“一个打开的 DataReader”异常。
有人为了绕开这个限制,在连接串里加 MultipleActiveResultSets=true。MARS 确实允许在同一个连接上存在多个活动结果集,但它也会带来额外开销,可能让一些潜在问题被掩盖。如果不是界面绑定、批量处理等确实需要 MARS 的场景,我更建议你在应用层拆开操作:把第一个 Reader 读完就关闭,再发起第二个查询。连接池会替你做好连接复用,你并不需要为了一次请求保持多路并发而强行开 MARS。
到这里,其实你已经能处理绝大多数常见的连接态数据访问了。不过在写回、联动、离线编辑场景里,只靠连接态代码会很冗长,这时 DataAdapter 和 DataSet 的作用就会显现出来。
4. 别丢下 DataAdapter/DataSet:离线更新模型在批处理里依然值得用
这些年很多新项目直接用 Dapper 或 EF Core,几乎不碰 DataSet,于是有人觉得它已经过时。但从原理上讲,Dapper 和 EF Core 底层都还是 ADO.NET,只是替你封装了 Connection、Command、DataReader 之间的大量重复代码。而 DataSet 代表的“离线更新”模型,在批处理、导入导出、表格批量修改这些场景里,仍然有它不可替代的顺滑之处。
4.1 DataAdapter 的 Fill/Update 到底做了什么
SqlDataAdapter 内部维护四个核心命令属性:SelectCommand、InsertCommand、UpdateCommand、DeleteCommand。调用 Fill() 时用的是 SelectCommand;调用 Update() 时,它会根据内存中每一条 DataRow 的状态自动选择对应的命令。
csharp复制var sql = @"
SELECT ProductId, ProductName, UnitPrice
FROM dbo.Products
WHERE CategoryId = @cid;
";
using var connection = new SqlConnection(connectionString);
var adapter = new SqlDataAdapter(sql, connection);
adapter.SelectCommand.Parameters.AddWithValue("@cid", categoryId);
var table = new DataTable();
adapter.Fill(table);
你不需要在 Fill() 前手动写 connection.Open(),DataAdapter 会在需要时自己打开连接;操作完成后,如果连接状态原先就是关闭的,它也会自动把连接关闭。这种“用的时候自动连,用完自动关”的行为,让 Fill() 这段过程的连接占用时间非常短,比某些手写连接开关却忘记释放的方式要安全得多。
需要特别注意,Fill() 只是把数据从数据库复制到内存,它和后续的 Update() 之间没有隐式事务。你在内存里改了 100 行,执行 Update() 时不代表这 100 个更新是原子的。如果中间某条因为数据被其他人删掉而失败,默认情况下后面的还会继续执行。你需要靠 adapter.ContinueUpdateOnError 和 adapter.RowUpdated 事件自己决定错误处理策略,或者把 Update() 放到显式事务里。
4.2 DataSet 比 DataTable 多出来的关系模型能力
DataTable 可以看成一张关系表,DataSet 则可以同时承载多张表,以及表之间的 DataRelation 关系、外键约束、唯一约束。为什么老一代 WinForm 项目那么喜欢它?因为数据绑定和界面操作非常方便:
DataGridView可以直接绑定一张DataTable;- 在主从表界面里,客户信息和订单信息可以分别放在两张
DataTable,再通过DataRelation建立关联; - 用户在前端随意增删改,数据不会立刻打到数据库,只有最后点“保存”时统一提交。
如果你的 Web 项目只是把数据查出来展示,我完全建议直接用 DataTable 或 Dapper 的查询结果,不需要把整个 DataSet 概念引进来。但如果你在做一个后台管理系统的批量编辑功能,用户在一个网格里改几十行数据后再统一保存,用 DataTable 配合 SqlDataAdapter 是天然贴合的思路。
4.3 用 CommandBuilder 自动生成命令前,先确认前提
SqlCommandBuilder 可以根据你的 SelectCommand 自动推导出 InsertCommand、UpdateCommand、DeleteCommand,省去手写四条 SQL 的功夫。但它有几个硬性前提:
- 查询必须基于单表,不能是多表 JOIN;
SELECT中必须包含主键或唯一列;- 查询选出的列要能映射回原表字段。
如果查询是从两张表 JOIN 出来的,CommandBuilder 生成的更新命令往往无法定位到正确行,甚至出现更新失败或更新行数不对。更严重的是,当表有触发器、计算列、时间戳列时,自动命令可能把不该更新的字段也更新一遍。
所以我的建议是:CommandBuilder 可以拿来快速做原型或内部小工具,但生产级的离线更新,最好明确手写每一条 UpdateCommand,并配置好 SqlParameter 的 SourceColumn,这样你才真正清楚每一条 UPDATE 在做什么。
4.4 大结果集与内存占用
DataTable 会把所有数据一次载入内存。如果你在 Fill() 一个包含 100 万行的结果集,内存会瞬间飙升。此时无论 DataSet 多方便,你都不该这么做。正确做法是先分页查询,或者直接用 DataReader 流式处理。
反过来看,如果数据量不大,比如几百行到几千行,DataTable 带来的开发效率提升反而比手写大量逐行更新代码更高。这也呼应了标题里“完全指南”的定位:不是让你所有场景都用 DataSet,而是让你知道每个工具该在哪个场景出现。
5. 事务控制的关键不是 BeginTransaction,而是隔离级别和并发冲突
事务这部分,我会从“两条 SQL 必须一起成功”的场景切入。很多人第一次接触事务,写的是这样的伪代码:Connection.Open() → connection.BeginTransaction() → cmd1.ExecuteNonQuery() → cmd2.ExecuteNonQuery() → transaction.Commit()。这个流程本身没错,但它远远不是事务控制的全部。真正复杂的是并发环境下的事务隔离和锁竞争。
5.1 手动事务到底该写在什么粒度上
先说一个最常见的仓储简化场景:给商品扣减库存,同时写一条库存流水。如果只扣库存成功,写流水失败,库存数据就和对不上账了。这里必须保证两条 SQL 在同一个事务里:
csharp复制using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted);
try
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = @"
UPDATE dbo.Inventory
SET Quantity = Quantity - @qty
WHERE ProductId = @productId AND Quantity >= @qty;
";
command.Parameters.Add(new SqlParameter("@productId", SqlDbType.Int) { Value = productId });
command.Parameters.Add(new SqlParameter("@qty", SqlDbType.Int) { Value = qty });
var affected = await command.ExecuteNonQueryAsync();
if (affected == 0)
{
throw new InvalidOperationException("库存不足或商品不存在");
}
command.Parameters.Clear();
command.CommandText = @"
INSERT INTO dbo.InventoryLog(ProductId, ChangedQty, OperateTime)
VALUES(@productId, @qty, GETUTCDATE());
";
await command.ExecuteNonQueryAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
这个代码里有几个容易被忽略的细节:
第一,command.Transaction = transaction; 这一步经常有人忘。如果你已经 BeginTransaction(),随后执行命令时却没有给 Command 指定事务对象,SQL Server 驱动会抛异常:当前连接处于 pending transaction,命令必须指定事务。
第二,UPDATE 里加了 Quantity >= @qty 条件,再通过受影响行数判断。这比“先 SELECT 再判断库存”更可靠,因为在高并发下,两次 SELECT 之间库存可能已经被其他事务改掉。把条件放进 UPDATE
