我的 C# 服务跑一段时间就报连接池耗尽、超时连不上数据库,代码看着都正常、连接也都"用完了",我对着 IDisposable 没释放排查了大半天的复盘
那是我用 C# 写的一个数据服务。上线后表现诡异:刚启动时一切正常,可跑上一段时间(几小时到一天不等)就开始报错:The connection pool has been exhausted(连接池耗尽)、获取连接超时,数据库操作全线卡死。重启一下又能好,然后过段时间又复发。我盯着代码看了又看:每个查询都好好地拿了连接、执行了 SQL、拿到了结果——逻辑上"连接都用完了"啊,怎么会耗尽?我甚至怀疑是数据库配置或连接池大小的问题。排查了大半天,我才真正理解了 C# 里那个对资源管理至关重要、却极易被忽略的概念:IDisposable 与 using——以及"用完了"和"释放了"是两码事。这篇就把这场"连接泄漏、池子耗尽"的事故,从头复盘一遍。
故障现场:连接"用完了",却没"还回去"
先看现场。问题就藏在那段"拿了连接却没释放"的代码里:
// 我的数据访问代码: 拿连接、查询、返回 —— 看起来很正常
public List<User> GetUsers()
{
var conn = new SqlConnection(connectionString); // ✗ 创建了连接
conn.Open();
var cmd = new SqlCommand("SELECT * FROM users", conn);
var reader = cmd.ExecuteReader(); // ✗ reader 也是 IDisposable
var users = new List<User>();
while (reader.Read())
{
users.Add(MapUser(reader));
}
return users;
// ✗✗✗ 致命问题: conn / cmd / reader 都【没有被释放(Dispose)】!
// 方法结束了, 但这些对象持有的"非托管资源"(数据库连接)没有归还!
}
// 事故是怎么发生的:
// 1. SqlConnection 背后是"数据库连接", 来自一个有限的"连接池"。
// 2. conn.Open() = 从池里"借"一个连接出来用。
// 3. 用完后, 必须 conn.Dispose()(或 Close())= 把连接"还"回池里。
// 4. 但我没有 Dispose! 方法返回后, conn 变量虽然不用了, 但:
// - C# 的 GC 不会"立刻"回收它(GC 是不确定时机的)。
// - 在 GC 回收它(并触发析构释放连接)之前, 这个连接【一直被占着】,
// 没还回池里!
// 5. 每次调用 GetUsers() 都"借"一个连接、却"不还" → 池里的连接越借越少。
// 6. 连接池有上限(默认100), 借光了 → 新请求拿不到连接 → 连接池耗尽!
// (GC 偶尔跑一下能回收一批, 所以"过段时间复发"、重启就好。)
// 现象拼图:
// - "用完了" ≠ "释放了"! 我逻辑上用完了连接, 但没显式释放它。
// - IDisposable 的对象(连接/文件/网络流), 持有的是"非托管资源",
// GC 不会及时回收, 必须【显式 Dispose】才能立刻归还。
// - 没 Dispose → 资源泄漏 → 有限的资源(连接池)被慢慢耗尽。
// - ★ 根因: 我依赖了 GC 去回收"本该我手动释放"的非托管资源 ——
// 而 GC 对这类资源, 既不及时、也不可靠。
看清真相后,我恍然大悟。问题的根源,是我创建的 SqlConnection、SqlCommand、SqlDataReader 这些 IDisposable 对象,用完后都没有被释放(Dispose)。关键在于:SqlConnection 背后是来自有限"连接池"的数据库连接,conn.Open() 是从池里"借"一个,用完必须 Dispose/Close 才能"还"回池里。而我没有 Dispose,方法返回后,C# 的 GC 不会"立刻"回收它(GC 时机不确定),在 GC 回收它之前,这个连接一直被占着、没还回池里。于是每次调用都"借"一个却"不还",池里的连接越借越少,借光了(默认上限 100)就连接池耗尽(GC 偶尔跑一下回收一批,所以"过段时间复发、重启就好")。我犯的根本认知错误是:"用完了" ≠ "释放了"——IDisposable 对象持有的是"非托管资源"(连接/文件/网络流),GC 不会及时回收,必须显式 Dispose 才能立刻归还;我错误地依赖了 GC 去回收"本该我手动释放"的非托管资源,而 GC 对这类资源既不及时也不可靠。
第一件事:搞懂托管资源、非托管资源与 IDisposable
要解决它,得先搞懂 C# 的内存管理:GC 管什么、不管什么,以及 IDisposable 为何存在。
GC、非托管资源 与 IDisposable
# 一、C# 的 GC(垃圾回收)管什么?
# - GC 自动回收"托管内存"(你 new 的普通对象占的内存)。
# - 特点: 自动、但【时机不确定】—— 它在它觉得合适时才跑, 你无法预知。
# - 对"纯内存对象", 这没问题: 晚点回收内存, 影响不大。
# 二、什么是"非托管资源"? GC 管不好它们!
# - 数据库连接、文件句柄、网络套接字、操作系统句柄... 这些不是"内存",
# 而是"操作系统/外部系统的有限资源"。
# - 它们的特点: 数量有限(连接池就那么大、句柄就那么多)、
# 且"不用了要尽快归还", 否则别人没得用。
# - GC 对它们的问题:
# * GC 只盯着"内存压力", 不知道"连接池快空了"这种外部资源压力。
# * 所以 GC 不会因为"连接池要满了"就赶紧跑 → 回收不及时。
# * 在 GC 回收前, 这些宝贵的非托管资源一直被占着 → 泄漏。
# 三、IDisposable: 让你"显式、及时"释放非托管资源
# - 凡是持有非托管资源的类, 都实现 IDisposable 接口, 提供 Dispose() 方法。
# - Dispose() = "我用完了, 立刻把非托管资源还回去"(关连接/关文件/释放句柄)。
# - 关键: 这个 Dispose【必须由你主动调用】, GC 不会及时帮你调!
# - using 语句 = 自动调用 Dispose 的语法糖(见正解), 保证一定释放。
# 四、一句话总结:
# - 托管内存: GC 管, 你不用操心(晚点回收无所谓)。
# - 非托管资源: GC 管不好, 必须你用 IDisposable/Dispose 主动、及时释放。
# - "实现了 IDisposable 的对象" = "在提醒你: 我有非托管资源, 用完请 Dispose 我!"
# 核心: GC只及时管托管内存, 对数据库连接/文件句柄等有限的非托管资源回收不及时;
# 这类对象实现IDisposable, 必须你主动且及时Dispose(用using)才能归还, 别赖GC。
原来,C# 的"自动内存管理"是有边界的。一、GC 管什么?——GC 自动回收"托管内存"(你 new 的普通对象),但时机不确定;对纯内存对象这没问题(晚点回收影响不大)。二、什么是非托管资源?——数据库连接、文件句柄、网络套接字这些不是"内存",而是"操作系统/外部系统的有限资源",数量有限且"不用了要尽快归还";GC 只盯内存压力、不知道"连接池快空了",所以不会及时回收它们,在回收前这些资源一直被占着、泄漏。三、IDisposable——持有非托管资源的类都实现它、提供 Dispose();Dispose 是"我用完了立刻把资源还回去";关键是它必须由你主动调用,GC 不会及时帮你调;using 是自动调用 Dispose 的语法糖。一句话:托管内存 GC 管你不用操心,非托管资源 GC 管不好、必须你用 IDisposable/Dispose 主动及时释放;"实现了 IDisposable 的对象"就是在提醒你"我有非托管资源,用完请 Dispose 我"。
第二件事:正解——用 using 确保 IDisposable 一定被释放
搞懂了原理,正解就清晰了:凡是 IDisposable 的对象,都用 using 包裹,保证无论是否异常都一定 Dispose。
// ====== 正解一(推荐, C# 8+): using 声明, 作用域结束自动 Dispose ======
public List<User> GetUsers()
{
using var conn = new SqlConnection(connectionString); // ✓ 自动释放
conn.Open();
using var cmd = new SqlCommand("SELECT * FROM users", conn);
using var reader = cmd.ExecuteReader();
var users = new List<User>();
while (reader.Read())
users.Add(MapUser(reader));
return users;
// ✓ 方法结束时, conn/cmd/reader 按相反顺序自动 Dispose, 连接归还连接池!
}
// ====== 正解二(经典): using 块, 显式作用域 ======
public List<User> GetUsers2()
{
using (var conn = new SqlConnection(connectionString))
{
conn.Open();
using (var cmd = new SqlCommand("SELECT * FROM users", conn))
using (var reader = cmd.ExecuteReader())
{
var users = new List<User>();
while (reader.Read())
users.Add(MapUser(reader));
return users;
}
} // ← 离开这个块(即使中间抛异常), conn 一定被 Dispose, 连接一定归还!
}
// → using 的本质: 编译成 try-finally, 在 finally 里调 Dispose。
// 所以【无论正常返回还是抛异常】, Dispose 都一定会执行 —— 这是关键!
// ====== 正解三: 自己的类持有非托管资源, 也要实现 IDisposable ======
public class MyResource : IDisposable
{
private SqlConnection _conn; // 持有的非托管资源
public void Dispose()
{
_conn?.Dispose(); // 释放持有的资源
GC.SuppressFinalize(this); // 标准 Dispose 模式
}
}
// → 如果你的类持有 IDisposable 字段, 你的类通常也该是 IDisposable,
// 并在 Dispose 里释放它们(否则又会泄漏)。
// ====== 正解四: 异步用 await using(C# 8+, 异步释放)======
await using var conn = new SqlConnection(connectionString);
// → 对实现了 IAsyncDisposable 的对象, 用 await using 异步释放。
// ====== 正解五: HttpClient 是特例, 别每次 new + using! ======
// HttpClient 虽然是 IDisposable, 但它【设计为复用】(每次new+using会
// 导致 socket 端口耗尽)。应: 单例复用, 或用 IHttpClientFactory。
// → "是 IDisposable" 不等于 "一定要 using 短生命周期", 看类的设计意图!
// 核心: 凡 IDisposable 都用 using(声明或块)包裹, 它编译成try-finally保证
// 即使异常也一定Dispose; 自己持有非托管资源的类也实现IDisposable; HttpClient例外要复用。
修复的核心,是"用 using 确保每个 IDisposable 对象,无论是否异常都一定被释放"。正解一(推荐,C# 8+):using 声明——using var conn = ...,作用域结束时自动按相反顺序 Dispose,连接归还连接池。正解二(经典):using 块——离开块时(即使中间抛异常)一定 Dispose;using 的本质是编译成 try-finally、在 finally 里调 Dispose,所以无论正常返回还是抛异常 Dispose 都一定执行(这是关键,手动调 Dispose 一旦中间抛异常就漏了)。正解三:自己的类持有非托管资源也要实现 IDisposable(否则又会泄漏)。正解四:异步用 await using(对 IAsyncDisposable)。而一个重要特例:正解五:HttpClient 别每次 new + using——它虽是 IDisposable 但设计为复用(每次 new+using 会导致 socket 端口耗尽),应单例复用或用 IHttpClientFactory;"是 IDisposable"不等于"一定要 using 短生命周期",看类的设计意图。归根结底:凡 IDisposable 都用 using 包裹(编译成 try-finally 保证异常也释放);自己持有非托管资源的类也实现 IDisposable;HttpClient 例外要复用。
第三件事:为什么手动 Dispose 不如 using 可靠
排查时我想过"那我手动调 Dispose 不就行了?",但深究后发现,手动调极易出错,using 才是正解。
手动 Dispose vs using
# ✗ 手动调 Dispose 的问题: 异常路径会漏掉!
var conn = new SqlConnection(...);
conn.Open();
DoSomething(conn); // ← 如果这里抛异常...
conn.Dispose(); // ← 这行就【不会执行】了! 连接泄漏!
# → 只要 Dispose 之前的任何一行抛异常, Dispose 就被跳过 → 泄漏。
# → 你得自己写 try-finally 才能保证, 但这正是 using 帮你做的!
# ✓ using 编译后等价于 try-finally:
using (var conn = new SqlConnection(...)) { ... }
# 等价于:
var conn = new SqlConnection(...);
try { ... }
finally { conn?.Dispose(); } # ← finally 保证一定执行, 异常也不漏
# → using 把"容易忘、容易因异常漏掉"的释放, 变成了"编译器保证一定执行"。
# 常见的 IDisposable(都该 using):
# - 数据库: SqlConnection, SqlCommand, DataReader, DbContext...
# - 文件/流: FileStream, StreamReader/Writer, MemoryStream...
# - 网络: TcpClient, NetworkStream, (HttpClient 是特例, 见上)...
# - 其他: 各种 Stream、加密对象、信号量、定时器...
# 怎么不漏? —— 让工具帮你:
# - 静态分析/IDE 警告: CA2000(对象用完未释放)等规则能检测出来。
# - 养成习惯: 看到 new 一个 IDisposable, 第一反应就是用 using 包它。
# 核心: 手动Dispose在异常路径会被跳过导致泄漏; using编译成try-finally保证
# 一定释放; 凡IDisposable(连接/流/文件)都用using包, 配静态分析(CA2000)兜底。
排查让我明白,"手动调 Dispose"看似可行,实则极易出错。手动调的致命问题:异常路径会漏掉——conn.Dispose() 之前的任何一行抛异常,这行就不会执行、连接泄漏;你得自己写 try-finally 才能保证,而这正是 using 帮你做的。using 编译后就等价于 try-finally——在 finally 里调 Dispose,把"容易忘、容易因异常漏掉"的释放,变成了"编译器保证一定执行"。常见的 IDisposable 都该 using:数据库(SqlConnection/Command/Reader/DbContext)、文件流(FileStream/StreamReader)、网络(TcpClient,HttpClient 例外)、各种 Stream/加密/信号量/定时器。怎么不漏?让工具帮你——静态分析规则(CA2000 检测对象用完未释放)、养成习惯(看到 new 一个 IDisposable,第一反应就是 using 包它)。归根结底:手动 Dispose 在异常路径会被跳过导致泄漏;using 编译成 try-finally 保证一定释放;凡 IDisposable 都用 using 包,配静态分析兜底。下面这张图,是这次连接泄漏的成因与解法:
第四件事:常见 IDisposable 资源速查
这次踩坑后,我把常见的需要 using 释放的 IDisposable 资源整理成一张表,写代码时对照着别漏。
| 类别 | 典型类型 | 泄漏后果 |
|---|---|---|
| 数据库 | SqlConnection/Command/DataReader/DbContext | 连接池耗尽(本文) |
| 文件 | FileStream/StreamReader/StreamWriter | 文件句柄耗尽/文件被锁 |
| 网络 | TcpClient/NetworkStream/Socket | 端口/句柄耗尽 |
| 内存流 | MemoryStream | 内存占用(影响小但仍应释放) |
| 同步原语 | Semaphore/Mutex/事件句柄 | 句柄泄漏/死锁 |
| 定时器 | Timer | 回调一直跑/资源不释放 |
| HttpClient(特例) | HttpClient | 反而要复用,别频繁new+using |
这张表,把常见的"用完要释放的资源"列了出来。它们的共同点是:都持有"有限的、外部的"资源(连接、句柄、端口、锁),泄漏了就会让这些有限资源被慢慢耗尽,引发"跑一段时间就崩"的诡异故障。它给我的启发是:在 C# 里,看到一个类型实现了 IDisposable,就等于看到一个明确的信号:"我持有需要你主动释放的资源,请用 using 管好我"。这其实是 C# 设计上的一种"显式契约":语言通过 IDisposable 接口,把"这个对象需要被显式清理"这件事,从"隐藏在文档里、靠你记住",变成了"明明白白写在类型签名上、IDE 能提示、静态分析能检查"。而我之前的错,就是无视了这个清晰的契约信号——明明 SqlConnection 实现了 IDisposable 在向我喊"释放我",我却把它当成普通对象一 new 了之。这让我领悟到:语言/框架提供的"类型层面的契约和约束"(接口、注解、签名),是它在替你"提示重要的注意事项";读代码时,这些信号(实现了什么接口、有什么特性标记)和业务逻辑同样重要,它们往往在无声地告诉你"这里有个你必须正确处理的东西"。
第五件事:GC 帮你管什么、不帮你管什么
这次事故让我把"GC 的能力边界"彻底厘清了。我把它该管和不该指望它管的整理了一下。
| 资源 | GC 管吗 | 你要做什么 |
|---|---|---|
| 普通对象内存 | ✓ 自动回收 | 什么都不用做 |
| 数据库连接 | △ 最终会,但不及时 | using 主动释放 |
| 文件/网络句柄 | △ 最终会,但不及时 | using 主动释放 |
| 事件订阅(+=) | ✗ 可能导致内存泄漏 | 用完 -= 取消订阅 |
| 静态引用持有的对象 | ✗ 一直不回收 | 别用静态长期持有大对象 |
| 非托管内存(Marshal) | ✗ 完全不管 | 手动 FreeHGlobal 等 |
这张表,彻底厘清了"GC 的能力边界"。核心结论是:GC 只对"纯托管内存"是自动且可靠的;对"非托管资源(连接/句柄)"是"最终会但不及时"(所以要 using 主动释放);而对"事件订阅、静态引用"等,GC 甚至可能根本回收不了(造成内存泄漏)。它纠正了我一个根深蒂固的误解:"有了 GC,我就完全不用管内存/资源了"——这是错的。GC 是一个强大的助手,但它有明确的能力边界,且边界之外的事(非托管资源、事件、静态引用)需要你主动管理。这让我领悟到一个关于"自动化工具"的普适道理:任何"自动化"(GC、自动内存管理、自动重连、自动伸缩……),都只在它设计覆盖的范围内有效;它的存在,容易让我们产生一种"一切都被自动处理了"的错觉,从而忽略了它覆盖范围之外、仍需我们手动处理的部分。所以,用任何自动化工具,都要清楚地知道:"它到底自动处理了什么?又有什么是它不管、需要我自己负责的?"——分清这条界线,才不会因为"过度信任自动化"而在它的盲区里栽跟头。
第六件事:用到一个对象时,我现在的资源管理习惯
现在每当我 new 一个对象,我都会先判断"它要不要我负责释放":
这张图的精髓,是"new 对象前,先判断它要不要我负责释放、以及怎么释放"。第一问 "它实现了 IDisposable 吗":没有就是普通对象 GC 管;实现了就说明它持有需要释放的资源。然后看生命周期:短生命周期(连接/流/文件)用 using 包裹用完即释放;设计为复用的(HttpClient)单例/工厂复用别频繁 new。异步资源用 await using。如果我的类持有它作为字段,我的类也要实现 IDisposable、在 Dispose 里释放它。最后配 CA2000 静态分析兜底。这套习惯,让我用对象时,从"new 完就不管了"变成了"先看它要不要我释放"——核心始终是:看到 IDisposable 就警觉它持有需释放的资源,用 using 管好它,别赖 GC。
我立下的几条规矩
这场"连接池耗尽"的事故,换来了我写 C# 时,刻进骨子里的几条铁律:
- "用完了"不等于"释放了"。非托管资源(连接/句柄)用完必须显式释放,GC 不及时。
- 凡 IDisposable 都用 using。它编译成 try-finally,保证即使异常也一定 Dispose。
- 别手动调 Dispose。异常路径会跳过它导致泄漏,用 using 让编译器保证。
- 自己持有非托管资源的类,也实现 IDisposable。否则又是一处泄漏。
- HttpClient 是特例,要复用别频繁 new。是 IDisposable 不代表要短生命周期 using。
- 看清 GC 的能力边界。它管托管内存,非托管资源/事件订阅/静态引用要你自己管。
- 用 CA2000 等静态分析兜底。让工具帮你揪出"用完未释放"的对象。
附:一段能"看见"连接泄漏 vs 正确释放的对比代码
口说无凭。下面用一段对比代码,通过监控连接池,让你亲眼看见"不释放"和"using释放"的天壤之别:
using System;
using System.Data.SqlClient;
class DisposeDemo
{
static string cs = "Server=...;Max Pool Size=10;"; // 故意把池设小(10)便于复现
// ✗ 泄漏版: 不释放连接, 很快耗尽池
static void LeakVersion()
{
for (int i = 1; i <= 20; i++)
{
try
{
var conn = new SqlConnection(cs); // ✗ 没 using
conn.Open(); // 借一个连接
new SqlCommand("SELECT 1", conn).ExecuteScalar();
// 不 Dispose, 连接不归还!
Console.WriteLine($"第{i}次: 成功(但连接没还)");
}
catch (Exception e)
{
Console.WriteLine($"第{i}次: 失败! {e.GetType().Name}: 连接池耗尽");
// 大约第11次开始, 池里10个连接全被占, 后续全失败/超时!
}
}
}
// ✓ 正确版: using 释放, 连接反复复用, 永不耗尽
static void CorrectVersion()
{
for (int i = 1; i <= 20; i++)
{
using (var conn = new SqlConnection(cs)) // ✓ using
{
conn.Open(); // 借
new SqlCommand("SELECT 1", conn).ExecuteScalar();
Console.WriteLine($"第{i}次: 成功(连接用完即还)");
} // ← 离开using, 连接归还池里, 下一次循环复用同一批连接
}
// → 20次循环, 实际只反复用了池里的少数几个连接, 永不耗尽。
}
static void Main()
{
Console.WriteLine("=== 泄漏版(不释放)===");
LeakVersion(); // 第11次左右开始报连接池耗尽
Console.WriteLine("\n=== 正确版(using释放)===");
CorrectVersion(); // 20次全部成功
}
}
/* 输出对比(Max Pool Size=10):
=== 泄漏版(不释放)===
第1次: 成功 ... 第10次: 成功
第11次: 失败! InvalidOperationException: 连接池耗尽 ← 借光了!
第12~20次: 全部失败
=== 正确版(using释放)===
第1次~第20次: 全部成功(连接反复复用) ← 永不耗尽!
*/
// 核心: 同样循环20次, 不释放版借满10个就耗尽崩溃, using版反复复用少数连接全成功;
// 把池设小(Max Pool Size=10)就能快速复现"泄漏耗尽", 亲眼看见using的价值。
这段对比代码,把"连接泄漏"这个平时要"跑几小时才暴露"的隐性问题,变成了几秒钟就能亲眼看见的现象。它的精妙,在于故意把连接池设小(Max Pool Size=10)——这样"泄漏"的后果就被急剧放大、快速暴露:泄漏版循环到第 11 次,就因为前 10 个连接全被借走没还而耗尽崩溃;而 using 版循环 20 次全部成功,因为每次用完连接都归还池里、被下一次循环复用。这,正是我想用这段代码,留给每个 C# 开发者的最后一课:对于"泄漏型"的 bug(连接泄漏、内存泄漏、句柄泄漏),它们在生产里往往"潜伏期很长"(资源充足时看不出来,跑很久慢慢耗尽才爆发),极难复现和排查;而一个有效的办法,是"主动把资源上限调小",人为地压缩潜伏期、放大泄漏后果,让它在测试时就快速暴露。这是一种很实用的测试思维:对于"缓慢累积、最终耗尽"型的问题,与其被动等它在生产爆发,不如主动制造"资源紧张"的极端条件,把它逼出来。把潜伏的、难复现的问题,通过"调小上限、加大压力"变成"快速、稳定可复现"的现象——这,是我从这场"连接池耗尽"事故里,带走的、关于"如何揪出泄漏型 bug"的、最实用的方法。让问题在测试台上以最快的速度、最明显的方式暴露,远胜过在生产的深夜里苦苦排查。
写在最后
回头看,这场由"IDisposable 未释放"引发的、连接池被慢慢耗尽的事故,真正教给我的,远不止"记得用 using"这一个技巧。它让我对"自动化"这件事,有了更清醒、更立体的认识。我栽跟头,是因为我对 C# 的 GC(垃圾回收)过度信任了:我以为"有了 GC,所有的资源回收都被自动搞定了,我只管 new、不用管收"。可这次事故狠狠地告诉我:GC 这个强大的"自动化",有它明确的能力边界——它擅长回收"内存",却管不好"连接、句柄"这类需要"及时归还"的外部有限资源。而我,正是因为太信任这个自动化,而忽略了它边界之外那片"仍需我手动负责"的区域。这让我领悟到一个使用一切"自动化工具"时都至关重要的道理:自动化是把双刃剑——它在解放我们的同时,也容易麻痹我们;它让我们习惯于"不用操心",从而在它"恰好不管"的地方,放松了本该有的警惕。真正用好自动化的前提,是清醒地理解它的能力边界:它自动处理了什么(让我安心地交给它)、又有什么是它不处理的(让我主动地接管过来)。GC 之于内存、连接池之于连接、框架之于种种——每一个自动化机制,都有这样一条"它负责"与"你负责"的分界线;而最危险的 bug,常常就潜伏在我们"误以为自动化会管、其实它不管"的那条边界上。既信任自动化、又看清它的边界,在它的盲区主动补位——这,是我用一次"连接池耗尽"的事故,换来的、关于 C#、也关于"自动化的能力边界"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次 new 一个 IDisposable 对象时,条件反射地用 using 把它包起来,那我对着那个反复耗尽的连接池熬的这大半天,就值了。
—— 别看了 · 2026