1. 资源释放的顺序与时机接手这个项目的第一件事就是梳理资源释放的整体顺序。ASP.NET 应用里最典型的资源问题往往不是某个资源没释放而是释放的时机不对。比如数据库连接池的连接如果过早释放后续代码还没执行完就出问题如果过晚释放高并发下连接池会被打满整个站点直接卡死。我的做法是先把资源的生命周期划分成三个层级请求级资源跟随一次 HTTP 请求创建和销毁比如SqlConnection、StreamReader、HttpClient的短生命周期实例。会话级资源跨多个请求但属于同一用户会话比如用户登录后的权限缓存、临时的上传文件句柄。应用级资源整个应用生命周期内只初始化一次比如全局缓存对象、数据库连接字符串的配置对象、后台定时任务的线程句柄。每个层级的释放策略完全不同。请求级的必须在finally或using块内确保释放因为一旦请求结束ASP.NET 的线程池会复用线程如果连接没还回去下个请求拿到脏连接或者拿不到连接排查起来让人头大。会话级的资源要关注过期机制不能只依赖 IIS 的会话超时因为会话集合里的对象不会自动调用释放方法需要自己实现会话清理事件。应用级的资源其实最容易出问题因为它们看起来是常驻的很多人就不管了但比如定时器、后台线程在应用池回收时如果没做优雅停止就会出现内存泄漏或者文件句柄积压。一个常见的错误示范是把HttpClient放进using块每次请求都新建。这个方法本身就是为复用设计的短生命周期使用反而会让底层 socket 处于 TIME_WAIT 状态压测时端口直接耗尽。1.1 请求级资源的释放模板请求级资源释放我用一个固定的模板来处理基本杜绝了忘记释放的问题try { using var conn new SqlConnection(connectionString); using var cmd conn.CreateCommand(); cmd.CommandText SELECT * FROM orders WHERE OrderId id; cmd.Parameters.AddWithValue(id, orderId); conn.Open(); using var reader cmd.ExecuteReader(); if (reader.Read()) { return MapOrder(reader); } return null; } catch (SqlException ex) { _logger.LogError(ex, 订单查询失败参数{OrderId}, orderId); throw; }这里用了 C# 8 的using var语法作用域是整个方法块。有个细节容易被忽略SqlCommand和SqlDataReader也要释放很多人只释放了连接。虽然释放连接时 reader 也会被回收但如果你在释放连接后还想读取 reader 的某个属性就会抛异常。正确的顺序是先释放 reader再释放命令最后释放连接严格遵守后进先出。关于AddWithValue我建议在参数化查询时尽量避开它。这个方法的类型推断在某些情况下会被 SQL Server 的隐式转换坑到比如传入一个 C# 的int值但 SQL 列是varchar数据库会做隐式转换导致索引失效。用cmd.Parameters.Add(id, SqlDbType.Int).Value orderId;这样显式声明类型更稳。1.2 会话级资源的过期处理会话级资源的典型场景是用户上传的临时文件。用户在一个页面批量上传图片后端先把图片存到临时目录等用户点击确认后才移到正式目录。如果用户中途放弃临时文件就会一直留在磁盘上积累多了磁盘就满了。我在项目中封装了一个ITempFileCleaner服务它不拦截请求而是通过一个后台任务定期扫描临时目录删除 24 小时前创建且状态不为已提交的文件。这里有个关键点判断文件是否被占用。Windows 下文件被占用时删除会抛IOException所以清理任务要在方法内捕获这个异常把失败的文件视为正在使用跳过等下轮再清理。会话里保存的对象如果实现了IDisposable不要指望 ASP.NET 会在会话结束时帮你调用。实测中用 Session 存储的MemoryStream会话超时后进程内存并没有立刻下降就是因为没有显式释放。我需要重写Session_OnEnd事件遍历会话键值找出IDisposable实现并调用Dispose()。protected void Session_OnEnd() { foreach (string key in Session.Keys) { if (Session[key] is IDisposable disposable) { disposable.Dispose(); } } }这个做法有个局限如果应用被异常重启Session_OnEnd可能根本不会触发。所以对于重要资源不能依赖这个事件还是需要后台扫描兜底。1.3 应用级资源的关闭钩子应用级资源回收是 ASP.NET 应用池回收时最麻烦的问题。IIS 默认的回收机制是隔一段时间回收一次回收时会杀掉工作进程再启动新进程。如果应用里有后台线程正在写文件或者处理队列消息强杀进程会导致数据不一致或者文件损坏。要优雅关闭需要两个钩子。第一个是Application_End事件它在应用池回收或应用停止时触发在这里发信号给后台线程让它们完成当前任务后退出。第二个是CancellationTokenSource后台任务的循环结构里加入取消检查。public class BackgroundWorkerService { private readonly CancellationTokenSource _cts new(); public void Start() { Task.Run(() ProcessQueue(_cts.Token)); } public void Stop() { _cts.Cancel(); _cts.Dispose(); } private async Task ProcessQueue(CancellationToken token) { while (!token.IsCancellationRequested) { // 从队列拉取任务 await Task.Delay(1000, token); } } }关键细节在于Task.Delay(1000, token)传入 token 后取消时会抛OperationCanceledException这个异常要在循环外捕获不能中断其他清理工作。2. 数据库连接与事务的释放陷阱数据库连接释放的坑我在这个项目里踩了个遍特别是事务和连接嵌套的场景。很多人以为只要连接放在using块里就安全了实际上事务的提交/回滚顺序、连接的开启时机都会引出怪异问题。2.1 连接池耗尽问题排查记录有一次测试环境突然报连接池已满的错误时间点在上午 10 点半到 11 点之间正好是高并发时段。我第一反应是看代码里有没有哪里没释放连接查了一圈所有查询都用了using。后来加了连接池的计数器日志发现问题出现在一个看起来人畜无害的方法里public DataTable GetData(string sql) { var table new DataTable(); using (var conn new SqlConnection(_connStr)) using (var cmd new SqlCommand(sql, conn)) { conn.Open(); using (var adapter new SqlDataAdapter(cmd)) { adapter.Fill(table); } } return table; }这个写法本身没问题问题出在调用方的循环里。某个批量导入功能一次性处理 5 万行数据每行数据都调用GetData做一次查询虽然单次查询结束后连接就释放了但高并发下很多请求同时进入这个循环每个请求在短时间内连续创建和释放连接连接池的创建速度跟不上达到池上限后就阻塞了。救急的临时方案是调大Max Pool Size从默认的 100 调到 200但这只是延缓问题。根治方案是把查询改成表值参数或者批量查询把 5 万次查询合并成几十次。注意连接池的大小最好不要随便调大。连接池的本质是复用物理连接连接多了数据库端的线程资源也会被占用。与其调大连接池不如减少不必要的数据库往返。2.2 事务释放的顺序事务里最容易踩的坑是连接在事务提交前就被关闭。看这个错误写法using (var conn new SqlConnection(_connStr)) using (var tx conn.BeginTransaction()) { using (var cmd new SqlCommand(sql, conn, tx)) { conn.Open(); cmd.ExecuteNonQuery(); } tx.Commit(); }注意conn.Open()被放在BeginTransaction()之后这个顺序是错的。BeginTransaction要求连接必须已经打开否则会在运行时抛异常。正确的顺序是using var conn new SqlConnection(_connStr); conn.Open(); using var tx conn.BeginTransaction(); try { // 执行多个命令 tx.Commit(); } catch { tx.Rollback(); throw; }释放顺序上using块的变量声明是逆序释放的先释放tx再释放conn这个顺序是正确的。如果手动写 try-catch-finally一定要把Rollback放在Commit的 catch 里不能放在finally里因为Commit成功后finally里再调Rollback会抛异常。我实际项目中还遇到过一个诡异问题一个事务里执行了 3 条UPDATE第 2 条语句在运行时成功了但tx.Commit()抛异常提示事务已中止。为什么会这样因为第 2 条语句实际上在数据库端触发了外键约束冲突但ExecuteNonQuery没有报错只有到Commit时才报。这种情况需要启用SqlConnection.FireInfoMessageEventOnUserErrors事件来捕获 SQL Server 的RAISERROR级别的错误或者把每条语句的执行结果都检查一下RecordsAffected。2.3 为什么说要避免长时间持有连接有个报表功能需要从多个表聚合数据原来的实现是一个数据库连接打开后连续执行 20 多个查询中间还有一些复杂的计算整体耗时超过 10 秒。这个连接在这 10 秒内一直占用着高并发下连接池秒满。我的优化思路是把大查询拆成多个独立的小查询每个查询拿到结果后就释放连接计算结果在内存中做关联。代价是牺牲了一点性能但换来了连接池的健康状态。拆完之后遇到一个问题内存关联的代码量增加了而且容易写错。后来用了DataTable.Load(IDataReader)的方式先数据落地再操作主体查询保持了事务一致性关联查询允许脏读。这样既不用长时间持有连接又能在数据一致性要求不高的场景下快速响应。3. 文件句柄与缓存对象的释放这个项目的业务里涉及大量的文件处理上传的图片、导出的 Excel、临时的中间结果文件。文件句柄的释放比数据库连接更隐蔽因为很多情况下操作完文件后代码没有报错但文件一直被人占用导致后续的移动、删除操作全部失败。3.1 文件流处理的释放细节我总结了一个文件操作的自检清单使用FileStream时必须指定FileShare.ReadWrite或FileShare.Delete否则其他进程无法访问文件。StreamReader.ReadLine()读取完不要立刻关闭流对象先让调用的业务方法完成数据消费。处理MultipartFormDataContent上传时读取临时文件的流一定放在using块里并且要在返回响应前释放。看一个我实际写过的导出功能代码public async TaskIActionResult ExportOrders() { var memoryStream new MemoryStream(); await using (var writer new StreamWriter(memoryStream, Encoding.UTF8, leaveOpen: true)) { await writer.WriteLineAsync(订单号,金额,日期); // 写入数据... await writer.FlushAsync(); } memoryStream.Position 0; return File(memoryStream, text/csv, orders.csv); }这里有个关键StreamWriter构造函数里传了leaveOpen: true这表示释放 writer 时不要关掉底层的memoryStream因为我后面还要用这个流作为返回内容。如果不加这个参数返回的文件内容会是空的因为流已经被关闭了。这个细节值得留意。另外FileStreamResult在 ASP.NET Core 中会负责释放响应流但在传统 ASP.NET 中Response.TransmitFile不会帮你释放流需要自己管理。我在项目中遇到过内存不断上涨的问题原因就是StreamWriter包装了MemoryStream但MemoryStream从未释放堆积在托管堆里。3.2 缓存对象释放的几种策略缓存对象一般是不会被垃圾回收的除非你手动移除或设置过期时间。我在这个项目里用了IMemoryCache有三种释放策略绝对过期到时间直接移除适合变化频率低的数据。滑动过期只要被访问就延长过期时间适合频繁读取的配置。依赖过期基于缓存键依赖某个键失效时级联移除适合有主从关系的数据。但缓存里放的如果是大对象比如一个 10MB 的DataTable即使设置了过期时间内存压力也不会立刻缓解。因为 GC 不会主动回收而是要等内存不足时才开始压缩降低优先级。所以对于大对象我会同时设置缓存大小限制并在写入前检查对象大小超过阈值就换文件缓存。有一个比较隐蔽的问题缓存对象的Dispose时机。如果缓存的是实现了IDisposable的对象当缓存过期移除时你需要手动调用Dispose否则底层资源不会释放。IMemoryCache有一个RegisterPostEvictionCallback方法可以在对象被逐出时做资源清理。var cacheEntryOptions new MemoryCacheEntryOptions() .RegisterPostEvictionCallback((key, value, reason, state) { if (value is IDisposable disposable) { disposable.Dispose(); } });这个方法特别好用但有个坑回调是在缓存逐出时异步调用的如果此时对象正在被其他线程使用可能引发ObjectDisposedException。为了避开需要自己封装一个引用计数机制或者只对不会被并发访问的对象用这种清理方式。4. 非托管资源的终极方案SafeHandle 与自定义 Dispose 模式前面讲的都是标准资源的释放真正考验功力的是自定义封装的非托管资源。这个项目里有一个自定义的打印机驱动程序封装类它通过 P/Invoke 调用了第三方 DLL 的句柄如果不正确释放进程内会留下一个悬空句柄导致打印机状态永远卡在忙碌。4.1 自定义 Dispose 的写法要求实现IDisposable不只是调用Dispose那么简单要正确处理终结器、Dispose(bool disposing)模式。这是业内公认的标准写法但很多实现细节容易忽略。public class PrinterWrapper : IDisposable { private IntPtr _printerHandle; private bool _disposed; ~PrinterWrapper() { Dispose(false); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // 释放托管资源 } // 释放非托管资源 if (_printerHandle ! IntPtr.Zero) { ClosePrinter(_printerHandle); _printerHandle IntPtr.Zero; } _disposed true; } }为什么要有disposing参数因为终结器里不能访问托管资源它们可能已经被 GC 回收了。只有Dispose(true)时才能安全地释放托管资源比如事件处理器、Stream 对象。这个模式能避免终结器中的二次释放问题。项目中我用这个模式封装了一个在线支付接口的签名句柄支付网关要求每个请求都要传递句柄请求结束后释放。开始没写终结器结果测试时发现句柄数量只增不减因为有些调用路径抛了异常直接跳过了Dispose调用。加上终结器和GC.SuppressFinalize后即使忘记调用DisposeGC 也能在回收对象时自动释放句柄。规范所有实现了IDisposable的类都应该提供公开的无参Dispose()方法并且继承自IDisposable。如果你发现自己写的类里没有Dispose方法但持有了文件流、数据库连接等资源八成是设计有问题。4.2 SafeHandle 替代 IntPtr在 .NET 中直接用IntPtr表示非托管句柄是一种简化但不够健壮。SafeHandle类型才是正道它能自动处理句柄的释放甚至在异常安全方面更可靠。我印象最深的一个案例是自定义注册表操作的封装。原来的代码里用IntPtr保存注册表键的句柄忘记释放后导致注册表操作越来越慢。后来我改用SafeRegistryHandle它继承自SafeHandleZeroOrMinusOneIsInvalid在Dispose时自动调用RegCloseKey完全不用手工管理。using Microsoft.Win32.SafeHandles; using var handle new SafeRegistryHandle(existingHandle, ownsHandle: true); // 使用 handle 进行注册表操作ownsHandle参数很关键如果句柄是从第三方 API 获取的并且拥有权转移到了你的代码中那么传true表示由 SafeHandle 负责关闭如果是某个父对象持有的句柄副本传false表示不负责关闭避免重复关闭。在 ASP.NET 项目中非托管资源大多出现在设备交互和第三方系统集成中。遇到这些场景优先考虑使用SafeHandle的子类而不要再用裸的IntPtr。安全性和代码可读性都有提升。4.3 异步方法中的释放问题异步方法里释放资源比同步更麻烦。C# 8 引入的await using语法是针对IAsyncDisposable的比如HttpClient、Stream等。但有些旧的TextReader没有实现IAsyncDisposable只能同步释放这会导致在异步方法里释放时线程阻塞。我遇到过一个问题异步方法里用StreamReader读取大文件代码写成了await using var reader new StreamReader(filePath);这行代码在 .NET Core 3.1 里会编译通过因为StreamReader实现了IAsyncDisposable。但在 .NET Framework 老项目里StreamReader没有实现IAsyncDisposable用了这个语法会编译失败。为了让代码在不同目标框架下都能工作我写了一个扩展方法public static ValueTask DisposeAsyncSafe(this IDisposable disposable) { if (disposable is IAsyncDisposable asyncDisposable) { return asyncDisposable.DisposeAsync(); } disposable.Dispose(); return ValueTask.CompletedTask; }然后统一用await reader.DisposeAsyncSafe();替代dispose调用。这个封装让代码兼容性更好在同一个项目中同时处理两种释放模式不会因为框架版本不同而挂。异步释放的核心问题是Stream.DisposeAsync内部会做异步操作但有些Stream子类根本没实现异步只是同步实现了Dispose调用ValueTask.CompletedTask即可。5. 释放失败时的排查思路做完上面的规整工作后我在这个 ASP.NET 项目里真正落地了一套资源释放的规范。但规范是规范实际运行还是会出问题。分享一下排查释放问题时的几条经验。5.1 从性能计数器和内存监控切入排查资源释放问题第一步是看数据不要靠猜。我用了两类工具Process Explorer查看进程打开的句柄数如果句柄数只增不减基本可以定位到某个句柄没有释放。dotnet-counters监控System.Runtime下的gc-heap-size、threadpool-queue-length如果托管堆大小稳定上涨说明存在内存泄漏。有一次排查HttpClient导致的端口耗尽用netstat -ano查看 TIME_WAIT 状态的连接数当这个数值持续超过 1 万时基本可以确定代码里每次请求都新建了HttpClient。改造成单例后TIME_WAIT 数量立刻降到几百。5.2 用代码标记资源生命周期我在一些关键资源的创建和释放处加入了日志_logger.LogInformation(连接已创建当前活跃连接数{ActiveCount}, CountActiveConnections());释放时同样记录。这样从日志时间线可以还原出资源从创建到释放的完整链路发现哪些路径只创建没释放。这种方法比事后分析内存快照更直观。也可以利用ConditionalAttribute把日志代码控制在 DEBUG 环境发布时自然移除避免额外的性能损耗。[Conditional(DEBUG)] private void LogResourceLifecycle(string resourceName, bool created) { // 输出创建/释放日志 }5.3 不要把 Bug 误判为释放问题有一次接口偶发超时我一开始怀疑是连接没释放加了一堆日志查不到异常。后来翻代码发现真正原因是服务里有个静态列表请求处理结束后往列表里添加记录但没加锁多个线程同时写入导致数据错乱某个分支死循环。这根本不是释放问题。排查这类问题需要分开看待。先定性地确认资源是否真的没有被释放方法很简单在测试环境压测同时观察线程数和句柄数。如果线程数稳定句柄数稳定但响应变慢那多半就是锁竞争或死循环与资源释放无关。6. 给这个项目彻底做一次体检最后说说整个项目的资源释放改造流程也算一个总结性的实操参考。6.1 代码审查清单我会对照下面这个清单逐项检查代码。所有SqlConnection、SqlCommand是否都在using块或try-finally中所有FileStream是否指定了FileShare所有实现了IDisposable的类是否都提供了公开的Dispose()所有自定义封装非托管资源的类是否实现了完整的Dispose(bool disposing)模式所有缓存对象是否设置了过期时间大对象缓存是否有大小限制HttpClient是否为单例避免频繁创建后台任务是否监听了应用停止的信号并优雅退出是否存在通过Session存储的大型IDisposable对象清单上的项全部打钩只代表静态检查通过。真正判断是否泄露还要通过运行时监控。6.2 压测与监控基准健康的状态是这样的连接池中的活跃连接数平稳不会随时间增长。句柄数保持在合理区间几百以内不会无限上升。进程内存保持锯齿状波动因为有 GC但总体稳定。接口的 P95 响应时间不随运行时间增长而变慢。如果连续压测 30 分钟内存从 500MB 涨到 2GB基本确定有泄漏。这时候按上面第 5 节的方法定位即可。6.3 资源释放的四个原则经过这个项目的反复折腾我总结出四句口诀每次写代码时都会过一遍谁创建谁释放。创建资源的代码块负责释放资源不要把释放职责转交给调用方除非调用方明确接管了所有权。释放顺序后进先出。先释放子资源再释放父资源和栈的行为一致。异常路径也要释放。释放逻辑要写在finally或using中保证任何执行路径都能到达释放点。不重复释放。释放后把引用置空或者用_disposed标志位保护确保重复调用Dispose是幂等操作。这几条原则在项目推广后明显的效果是有段时间线上频繁的内存报警消失了运维那边也省了很多事。我个人在实际接手这类老项目时还有一个习惯先把 HttpContext 里所有IDisposable的资源列表打印出来再逐条核对释放情况。有时候问题不在代码逻辑而在框架底层没有释放它创建的资源这时就需要在Application_End里做兜底清理。资源释放这件事做得再多都不算多关键是要形成一套可复用的模式而不是东补一块西补一块。