调用存储过程时抛出异常,连接却没有被关闭,这是数据库连接泄露最典型的成因之一。表面上看只是少写了一行Close(),实际上随着请求量增加,连接池中的可用连接会被逐步耗尽,最终出现GetConnection timeout之类的报错,整个服务不可用。本文从现象定位、正确写法、连接池机制和事务场景四个层面,完整讲解如何确保存储过程调用在异常发生后依然安全释放连接。

一、连接泄露的典型症状与成因定位
连接泄露最常见的表现是系统运行一段时间后突然变慢,日志中出现大量类似“连接超时”“连接池已达到最大连接数”的异常。重启应用后一切恢复正常,但过几小时又复发。在SQL Server端可以通过系统视图观察当前连接情况,快速确认是哪一个应用在长期占用连接:
SELECT
DB_NAME(database_id) AS DatabaseName,
COUNT(*) AS ConnectionCount,
MAX(login_time) AS LastLoginTime
FROM sys.dm_exec_sessions
WHERE is_user_process = 1
GROUP BY DB_NAME(database_id)
ORDER BY ConnectionCount DESC;
如果发现某个数据库的连接数持续上升且从不回落,基本可以断定存在泄露。而成因几乎都指向同一种代码模式:先打开连接、执行存储过程、读取结果后关闭连接,中间任何一步抛出异常,关闭语句就被跳过了。例如以下这段有缺陷的C#代码:
public DataTable GetUserOrders(int userId)
{
var conn = new SqlConnection(connStr);
conn.Open();
var cmd = new SqlCommand("usp_GetUserOrders", conn);
cmd.CommandType = CommandType.StoredProcedure;
cmd.Parameters.AddWithValue("@UserId", userId);
var dt = new DataTable();
dt.Load(cmd.ExecuteReader()); // 如果这里抛异常,下面的 Close 永远不会执行
conn.Close();
return dt;
}
当存储过程内部报错,例如违反约束、参数类型不匹配,或者网络闪断时,dt.Load这行会抛出SqlException,方法直接中断,conn.Close()被完全跳过。连接对象虽然不再被业务代码引用,但它仍处于打开状态,只能等待GC在某个不确定的时间点触发Finalizer来清理,而Finalizer的执行时机完全不可控。在高并发场景下,这短短几秒到几分钟的窗口期,足以让连接池被占满。
二、用try finally与using确保异常后关闭连接
解决问题的关键在于把关闭动作从正常流程中剥离出来,放到无论是否发生异常都必然执行的代码块里。C#提供了两种等价手段:try...finally和using语句。先看try finally版本:
public DataTable GetUserOrders(int userId)
{
var conn = new SqlConnection(connStr);
try
{
conn.Open();
var cmd = new SqlCommand("usp_GetUserOrders", conn);
cmd.CommandType = CommandType.StoredProcedure;
cmd.Parameters.AddWithValue("@UserId", userId);
var dt = new DataTable();
dt.Load(cmd.ExecuteReader());
return dt;
}
finally
{
// 无论是否抛出异常,finally 中的关闭逻辑都会执行
conn.Close();
conn.Dispose();
}
}
更简洁的写法是using语句。SqlConnection实现了IDisposable接口,using块结束时编译器会自动生成等价于try finally的代码,并调用Dispose(),而Dispose内部会先关闭连接再释放资源,因此不需要再手动调用Close:
public DataTable GetUserOrders(int userId)
{
using (var conn = new SqlConnection(connStr))
using (var cmd = new SqlCommand("usp_GetUserOrders", conn))
{
cmd.CommandType = CommandType.StoredProcedure;
cmd.Parameters.AddWithValue("@UserId", userId);
var dt = new DataTable();
dt.Load(cmd.ExecuteReader());
return dt; // 离开 using 块时连接必然被释放
}
}
Java开发者面临同样的问题。JDBC中Connection、Statement、ResultSet都实现了AutoCloseable,推荐使用try-with-resources语法,编译器会按声明的逆序自动关闭资源,代码既简洁又不会遗漏:
public List<Order> getUserOrders(int userId) throws SQLException {
List<Order> orders = new ArrayList<>();
String url = "jdbc:sqlserver://localhost:1433;databaseName=Shop";
try (Connection conn = DriverManager.getConnection(url, "sa", "pwd");
CallableStatement cs = conn.prepareCall("{call usp_GetUserOrders(?)}")) {
cs.setInt(1, userId);
try (ResultSet rs = cs.executeQuery()) {
while (rs.next()) {
orders.add(new Order(rs.getInt("OrderId")));
}
}
} // 无论是否异常,cs 和 conn 都会自动关闭
return orders;
}
需要特别注意Close与Dispose在连接池场景下的语义差异。对于启用连接池的SqlConnection而言,调用Close()或Dispose()并不是物理断开连接,而是把连接归还给池子复用,所以频繁开关连接的性能代价远比想象中低,千万不要为了“节省开销”而试图缓存连接对象长期不关,那才是真正的泄露源头。
三、理解连接池行为与超时回收机制
排查连接泄露时,理解连接池的工作方式非常重要。以ADO.NET为例,连接池按连接字符串区分,默认最大池大小为100。当业务代码Close一个连接时,它进入池中的空闲状态;当下次请求相同连接字符串时,直接从池中取出复用,省去了TCP握手和身份验证的成本。但如果业务代码从不Close,连接就回不了池子,池中可用数量持续下降,直到第101个请求等待超过15秒(默认超时)后抛出InvalidOperationException。
连接池本身也有兜底机制:一个空闲连接超过一定时间(SQL Server默认约为4到8分钟)会被后台线程物理移除。这个机制只能回收“已归还但太久没用”的连接,对“从未归还”的泄露连接无能为力,这也解释了为什么泄露问题不能指望连接池自己恢复。此外,如果SQL Server端重启或网络中断,池中的陈旧连接会失效,可以通过在连接字符串中加入Pooling=true;配合Connection Reset=True等参数优化行为,必要时调用SqlConnection.ClearAllPools()清空池子做应急处理。
监控方面,建议在应用层暴露当前连接池状态。可以通过性能计数器(如NumberOfPooledConnections)或定期查询数据库端sys.dm_exec_sessions的方式建立基线,一旦发现连接数曲线只升不降,立即结合最近发布日志和异常堆栈定位泄露点。
四、事务与存储过程场景下的配套注意事项
存储过程经常涉及事务。如果连接在事务未提交或未回滚的状态下被归还给池子,虽然连接池会自动执行sp_reset_connection清理事务状态,但业务数据可能处于不确定的中间态。正确的做法是在catch中显式回滚,再在finally中释放连接:
using (var conn = new SqlConnection(connStr))
{
conn.Open();
var tran = conn.BeginTransaction();
try
{
using (var cmd = new SqlCommand("usp_TransferStock", conn, tran))
{
cmd.CommandType = CommandType.StoredProcedure;
cmd.Parameters.AddWithValue("@FromId", fromId);
cmd.Parameters.AddWithValue("@ToId", toId);
cmd.ExecuteNonQuery();
}
tran.Commit();
}
catch
{
tran.Rollback(); // 异常时先回滚事务
throw;
}
} // 连接在这里被安全归还连接池
另外几个容易踩的坑也值得留意。第一,AddWithValue会隐式推断参数类型,可能导致存储过程执行计划失效引发超时,间接增加连接占用时间,建议显式指定SqlDbType和长度。第二,使用ExecuteReader返回的DataReader必须自己关闭,最稳妥的写法是把Reader也放进using块,否则即使连接释放了,Reader持有的服务端游标也会占用数据库资源。第三,异步场景下如果用了OpenAsync却忘记await,同样会造成连接状态混乱。
总结来说,解决存储过程连接泄露的核心原则只有一条:资源的释放不能依赖正常执行路径,必须放进finally、using或try-with-resources这类语言级别的保障机制中。再配合连接池监控和事务的正确回滚,就能从根本上杜绝“重启治百病”的被动局面。