这是我们 .NET 平台与后端团队 12 个人耗时 87 天,把一套用了八年的"古老 .NET Framework 4.x 体系 + 应用只能跑在 Windows 上挂在 IIS 里换不动平台 + 全程同步阻塞 IO 一上量线程池就耗尽 + 数据访问靠 ADO.NET 手写 SQL 字符串拼接埋着注入隐患 + 序列化全靠 Newtonsoft 反射又慢又吃内存 + 依赖全靠手动 new 或硬塞第三方容器对象图一团乱 + Web 层还是 Web Forms 和老 MVC 一堆样板 + 值类型装箱拆箱满天飞 GC 压力山大 + 到处 NullReferenceException 半夜被叫起来查空指针 + 配置塞在 web.config 的 XML 里改一下要重启 + 启动靠 global.asax 和满地反射又慢又黑盒"的粗放后端体系,整体重构到 2026 年".NET 8/9 跨平台自宿主 Kestrel 容器化 + async/await 全链路异步 + EF Core 参数化 ORM + System.Text.Json 源生成序列化 + 内建依赖注入容器 + Minimal API + Span<T>/Memory<T> 零分配高性能 + 可空引用类型消灭空指针 + appsettings.json + IOptions 强类型配置 + 顶级语句 + Generic Host 通用主机"现代 .NET 体系的真实战役复盘。重构前,我们的服务是典型的"只能跑 Windows、一上量线程池耗尽假死、SQL 拼字符串提心吊胆、空指针半夜炸、改个配置要重启进程"的危局;一次促销的流量就能把同步阻塞的线程池打穿。重构后,我们用异步摁住了线程耗尽、用 EF Core 堵死了注入、用可空引用类型消灭了空指针、用源生成器把启动和序列化的反射开销削掉。这 87 天里我们沉淀了 47 套工程修法、7 个 P0 事故复盘和 6 条工程哲学,本文毫无保留地分享出来。
需要先说明:.NET 现代化不是"把项目文件升个版本号"这么简单——它是从"绑死 Windows、同步阻塞、反射满天飞、空引用裸奔、配置靠 XML"的粗放运行时,跃迁到"跨平台容器化、全异步、编译期源生成、空安全、强类型配置"的现代运行时的范式更替。下面这张表,概括了我们重构前后在十个核心维度上的对比,每一行背后都是数周攻坚。
| 维度 | 重构前(.NET Framework 4.x) | 重构后(2026 .NET 8/9) |
|---|---|---|
| 运行平台 | 仅 Windows + IIS | 跨平台容器 Kestrel |
| 并发模型 | 同步阻塞线程池耗尽 | async/await 全异步 |
| 数据访问 | ADO.NET 拼 SQL 易注入 | EF Core 参数化 ORM |
| 序列化 | Newtonsoft 反射慢 | System.Text.Json 源生成 |
| 依赖注入 | 手动 new / 第三方容器 | 内建 DI 容器 |
| Web 框架 | Web Forms / 老 MVC | Minimal API |
| 内存性能 | 装箱拆箱频繁 GC | Span<T> 零分配 |
| 空安全 | NullReferenceException | 可空引用类型 |
| 配置管理 | web.config XML 改要重启 | appsettings + IOptions |
| 应用启动 | global.asax 反射黑盒 | 顶级语句 Generic Host |
一、跨平台与宿主:从绑死 Windows/IIS 到 .NET 8 跨平台 Kestrel
重构的第一仗,是把应用从 Windows 和 IIS 的捆绑里解放出来。古早时代我们的服务是 .NET Framework 写的,它先天只能跑在 Windows 上、必须挂在 IIS 这个重量级的进程外宿主里,部署就是把一坨 DLL 拷到服务器、在 IIS 管理器里点点点配应用池——想上 Linux 容器、想用云原生那一套编排,门都没有,我们被死死地锁在了 Windows 服务器和 IIS 的成本与运维模式里。现代做法是迁移到 .NET 8/9:它是跨平台的,同一份代码可以编译后直接跑在 Linux 上、打成几十兆的精简容器镜像,而且应用通过内置的 Kestrel 服务器自宿主(self-host)、自己就是一个独立可执行程序、不再依赖外部的 IIS。下面是现代 .NET 自宿主启动的对比:
// 重构前:.NET Framework + IIS 宿主,global.asax 里靠反射注册一切,只能跑 Windows
// public class Global : HttpApplication {
// protected void Application_Start() {
// AreaRegistration.RegisterAllAreas(); // 反射扫描注册,黑盒又慢
// GlobalConfiguration.Configure(WebApiConfig.Register);
// // 必须部署到 IIS,配应用池,绑 Windows,无法进 Linux 容器
// }
// }
// 重构后:.NET 8 顶级语句 + Kestrel 自宿主,跨平台,直接打容器镜像跑 Linux
var builder = WebApplication.CreateBuilder(args); // 通用主机,自带配置/日志/DI
builder.Services.AddControllers(); // 显式注册,无反射黑盒
var app = builder.Build();
app.MapControllers();
app.Run(); // Kestrel 自宿主:应用自己就是可执行程序,不依赖 IIS
// 同一份代码 dotnet publish 后可直接跑在 Linux 容器里,镜像几十 MB,云原生友好
// Dockerfile: FROM mcr.microsoft.com/dotnet/aspnet:9.0 → 跨平台部署
跨平台与宿主让我们的运行环境从"绑死 Windows、必须挂在重量级 IIS 里、部署靠拷 DLL 配应用池、进不了 Linux 容器"进化到了".NET 8/9 跨平台、Kestrel 自宿主应用即可执行程序、一份代码直接跑 Linux 精简容器、云原生友好":过去我们的服务用 .NET Framework 写成、先天就被钉死在 Windows 平台、必须依赖 IIS 这个又重又是进程外的宿主才能跑起来,部署是把一堆 DLL 拷到 Windows 服务器、在 IIS 管理器里手点配置应用池和站点,想用 Linux 容器、想接入 Kubernetes 那套现代编排根本无从谈起,我们就这么被锁死在了 Windows 服务器授权成本和 IIS 运维模式里动弹不得;现在我们迁到了 .NET 8/9,它本身就是跨平台的运行时,同一份 C# 代码 dotnet publish 之后可以直接编译运行在 Linux 上、打成一个几十兆的精简容器镜像,应用通过内置的高性能 Kestrel 服务器自宿主、自己就是一个独立的可执行程序、彻底摆脱了对外部 IIS 的依赖,启动也从 global.asax 里那套靠反射扫描注册的黑盒变成了 Program.cs 里清晰显式的顶级语句。我们的纪律是"新服务一律 .NET 8/9 跨平台、用 Kestrel 自宿主严禁再依赖 IIS、一切以容器为部署单元、启动配置显式注册严禁反射黑盒"。跨平台与宿主的本质认知是:把运行时绑死在单一操作系统和单一重量级宿主上,等于把整个技术栈的部署形态、成本结构和演进方向都抵押给了那个平台——你既享受不到 Linux 容器的轻量和云原生生态,也无法在成本和架构上自由选择;现代 .NET 的智慧是让运行时本身跨平台、让应用自宿主成为独立的可执行单元,从而把"跑在哪、怎么部署"的决定权重新交还给团队,让 .NET 服务能够无缝融入以 Linux 容器和 Kubernetes 为基座的现代云原生世界,这是 .NET 后端现代化的第一块、也是最解放生产力的一块基石。
二、异步编程:从同步阻塞线程耗尽到 async/await 全异步
第二仗,是并发模型,这是关乎服务能不能扛住高并发的命门。古早时代我们的代码全是同步阻塞的:一个请求进来,处理线程执行到查数据库、调外部 API 这些 IO 操作时,就死死地卡在那里、什么也不干地等着 IO 返回,这根线程被白白占用着——而服务器的线程池就那么大,高并发时大量线程全卡在等 IO 上、线程池被瞬间耗尽,新请求拿不到线程只能排队甚至超时,服务看起来就像假死了一样,可 CPU 其实闲得很。现代做法是 async/await 全链路异步:遇到 IO 操作时用 await 把线程"还"给线程池去服务别的请求、等 IO 真正完成了再继续,一根线程因此能在等待期间服务大量并发请求。下面是异步化的对比:
// 重构前:同步阻塞,线程执行到 IO 就死等,高并发线程池瞬间耗尽,服务假死
// public IActionResult GetOrder(int id) {
// var order = _db.Query("SELECT ...", id); // 同步:线程卡在这里干等 DB 返回
// var detail = _http.Get(detailUrl); // 同步:又卡在这里干等外部 API
// return Ok(Merge(order, detail)); // 一根线程全程被占着啥也不干
// } // 1000 并发就要 1000 根线程都卡在等 IO,线程池打穿,新请求排队超时
// 重构后:async/await 全异步,await 时线程归还线程池去干别的活,等 IO 完再续
public async Task GetOrderAsync(int id) {
// await 处线程被释放回池中服务其它请求,IO 完成后再恢复执行
var order = await _db.QueryAsync("SELECT ...", id); // 不占线程地等 DB
var detail = await _http.GetAsync(detailUrl); // 不占线程地等外部 API
return Ok(Merge(order, detail));
}
// 同样的线程池,异步下一根线程能在等待期间服务成百上千的并发请求,吞吐量飙升
// 关键:异步必须一异到底,中途一个同步阻塞(.Result/.Wait())就可能死锁
异步编程让我们的并发模型从"同步阻塞、线程执行到 IO 就死等白白占着、高并发线程池瞬间耗尽、服务假死而 CPU 闲着"进化到了"async/await 全链路异步、await 时线程归还线程池服务别的请求、一根线程在等待期间服务大量并发、吞吐量飙升":过去我们的代码全是同步阻塞写法,一个请求的处理线程执行到查数据库、调外部 HTTP 接口这类 IO 操作时就死死地卡在那一行什么也不做地干等着结果返回、这根宝贵的线程在整个等待期间被白白占用,而服务器线程池的线程数是有限的,一遇高并发大量线程全部卡在等 IO 上、线程池被瞬间榨干,后来的请求连一根线程都分不到只能排队、超时、雪崩,服务从外面看就像彻底假死了、可这时候 CPU 其实闲得发慌——线程不是在干活,是在干等;现在我们做了 async/await 全链路异步改造,代码遇到 IO 操作时用 await 把当前线程主动归还给线程池、让它去服务其它嗷嗷待哺的请求,等到 IO 真正完成了运行时再调度一根线程回来接着往下执行,于是同样大小的线程池下、一根线程能在它本该干等的那段时间里轮转着服务成百上千个并发请求,服务的吞吐量和抗并发能力成倍飙升。我们的纪律是"所有 IO 操作一律走异步 API、异步必须一异到底严禁中途 .Result/.Wait() 同步阻塞(会死锁)、库方法异步版优先、CPU 密集型才考虑用线程而非占着异步线程"。异步编程的本质认知是:Web 服务的瓶颈在绝大多数业务场景下不是 CPU 算力、而是等待 IO(数据库、缓存、外部接口)的时间——同步阻塞模型最大的浪费,是让一根能干很多活的线程在等 IO 时彻底闲置、却又被占着不放,用大量线程的闲置等待去对抗并发,线程池迟早被打穿;async/await 的智慧是把"等待"和"占用线程"这两件本该解耦的事彻底分开,让线程只在真正有 CPU 活干时才被占用、在等 IO 时回归池中待命,用少量线程的高效轮转去支撑海量的并发等待,这是 .NET 服务在高并发下既扛得住又省资源的根本所在。
三、数据访问:从 ADO.NET 拼 SQL 到 EF Core 参数化 ORM
第三仗,是数据访问,这一仗既关乎安全也关乎效率。古早时代我们访问数据库用的是裸 ADO.NET,SQL 语句靠字符串拼接拼出来——"SELECT * FROM Orders WHERE Uid = " + uid,这种拼接埋着致命的 SQL 注入隐患(用户输入里塞一段 SQL 就能拖库),而且我们要手写一大堆样板:开连接、建命令、绑参数、读 DataReader、一列列手动映射成对象、关连接,每个查询都要重复一遍、又啰嗦又容易漏关连接泄漏。现代做法是用 EF Core(或 Dapper)这样的 ORM:用强类型的 LINQ 表达查询、由 ORM 生成参数化的 SQL(参数化天然杜绝注入)、自动把结果映射成实体对象、自动管理连接生命周期。下面是数据访问的对比:
// 重构前:裸 ADO.NET 字符串拼 SQL,埋 SQL 注入,手写连接/命令/映射一堆样板
// var sql = "SELECT * FROM Orders WHERE Uid = " + uid + " AND Status = '" + status + "'";
// using (var conn = new SqlConnection(cs)) { // 手动开连接
// conn.Open();
// var cmd = new SqlCommand(sql, conn); // 拼接的 SQL,uid 塞段 SQL 就拖库
// var reader = cmd.ExecuteReader();
// while (reader.Read()) { // 手动一列列读取映射
// order.Id = reader.GetInt32(0); // 啰嗦、易错、漏关连接就泄漏
// }
// }
// 重构后:EF Core 用强类型 LINQ 查询,自动生成参数化 SQL(杜绝注入)+ 自动映射
public async Task> GetOrdersAsync(int uid, string status) {
return await _db.Orders // 强类型 DbSet
.Where(o => o.Uid == uid && o.Status == status) // LINQ,编译期类型检查
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(); // 异步、自动映射成 List
// EF Core 生成的是参数化 SQL(@uid/@status),用户输入永远是数据不是代码,注入绝迹
// 连接的开启/释放、对象映射、变更追踪全部由 EF Core 托管,无样板无泄漏
}
数据访问让我们从"裸 ADO.NET 字符串拼 SQL 埋注入隐患、手写开连接建命令绑参数读 reader 一列列映射一堆样板、漏关连接就泄漏"进化到了"EF Core 强类型 LINQ 查询、自动生成参数化 SQL 杜绝注入、自动映射实体、自动托管连接生命周期":过去我们访问数据库就是用裸 ADO.NET 把 SQL 语句用字符串硬拼出来,这种拼接方式埋着最危险的 SQL 注入漏洞——用户在输入里塞一段构造好的 SQL 就能绕过逻辑、拖走整库数据,而且每写一个查询都要手动重复那一长串样板:开数据库连接、创建 Command 对象、一个个绑定参数、执行拿到 DataReader、再一列一列地手动把字段读出来映射到对象的属性上、最后还得记得关连接,啰嗦、易错、稍微忘了释放连接就造成连接泄漏最终池满;现在我们改用 EF Core 这样的 ORM,查询用强类型的 LINQ 表达式写出来、编译期就能做类型检查,EF Core 会把 LINQ 翻译成参数化的 SQL(参数和 SQL 代码彻底分离、用户输入永远只是参数值而不可能被当成 SQL 执行、注入从根上绝迹),查询结果自动映射成强类型的实体对象、数据库连接的开启与释放和对象的变更追踪也全部由 EF Core 自动托管,我们再也不用手写那些样板、也再不会漏关连接。我们的纪律是"严禁任何形式的 SQL 字符串拼接、一切查询走 ORM 的参数化或 Dapper 的参数化、复杂高性能查询可下沉到 Dapper 但同样参数化、连接和事务交给框架托管"。数据访问的本质认知是:字符串拼接 SQL 的根本罪恶在于它把"代码(SQL 指令)"和"数据(用户输入)"混在了一起、让用户输入有机会被解释执行为指令——这正是 SQL 注入这一最古老最致命漏洞的根源;ORM 和参数化的智慧是从机制上把代码和数据彻底分开、让用户输入永远只能作为参数值被安全地传递、绝无可能被当成 SQL 执行,同时用强类型查询和自动映射把开发者从易错的样板和手动资源管理中解放出来,这是让数据访问既安全又高效的工程化底座。
四、依赖注入与 Minimal API:从 new 满天飞到内建 DI + 极简端点
第四仗,是把对象的组装方式和 Web 端点的写法现代化。古早时代我们的依赖关系一团乱麻:要用哪个服务就当场 new 一个、或者搞个静态单例到处取,对象之间的依赖关系硬编码、写死在各处的 new 里,想换个实现、想在测试里替换成 mock,几乎不可能;Web 端点则是 Web Forms 或老式 MVC 那套,一个简单接口要建 Controller 类、配路由特性、一堆样板。现代做法是双管齐下:用 .NET 内建的依赖注入容器在启动时统一注册所有依赖、由容器自动按需构造和注入(面向接口、便于替换和测试),用 Minimal API 把一个端点压缩成寥寥几行的 lambda、去掉一切样板。下面是 DI + Minimal API 的对比:
// 重构前:依赖靠当场 new 或静态单例,硬编码死耦合,无法替换无法测试 + 老 MVC 样板
// public class OrderController : Controller {
// public IActionResult Get(int id) {
// var repo = new OrderRepository(new SqlConnection(...)); // 当场 new,死耦合
// var svc = new OrderService(repo); // 依赖硬编码,改不动
// return Json(svc.Get(id)); // 无法注入 mock 测试
// }
// }
// 重构后:启动时统一注册依赖到内建 DI 容器,面向接口,容器自动构造注入
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped(); // 注册:接口→实现
builder.Services.AddScoped(); // 可换实现可注入 mock
builder.Services.AddDbContext();
var app = builder.Build();
// Minimal API:一个端点就是几行 lambda,依赖由容器自动注入,无 Controller 样板
app.MapGet("/orders/{id}", async (int id, IOrderService svc) =>
await svc.GetAsync(id) is { } o ? Results.Ok(o) : Results.NotFound());
// ↑ IOrderService 由 DI 容器自动解析注入,无需手动 new
app.Run();
依赖注入与 Minimal API 让我们从"依赖靠当场 new 或静态单例硬编码死耦合、无法替换实现无法注入 mock 测试、Web 端点一堆 Controller 路由样板"进化到了"启动时统一注册依赖到内建 DI 容器、面向接口由容器自动构造注入、Minimal API 一个端点几行 lambda 无样板":过去我们的对象组装方式极其原始——要用某个服务就在用到的地方当场 new 一个出来、或者弄个静态单例满世界去取,对象与对象之间的依赖关系就这么硬编码、写死在散落各处的 new 语句里,这导致想把某个实现换成另一个、或者在单元测试里把真实的数据库仓储替换成 mock 几乎不可能做到,因为依赖根本不是注入进来的而是在内部写死 new 出来的,Web 层还是 Web Forms 或老 MVC 那一套、写个简单接口都要建一个 Controller 类、加一堆路由特性和样板;现在我们用 .NET 内建的依赖注入容器,在应用启动时就把所有的依赖关系以"接口映射到实现"的方式统一注册到容器里、由容器在运行时按需自动构造对象并把它们的依赖一层层注入进去,代码一律面向接口编程、想换实现只需改注册一行、测试里想注入 mock 也轻而易举,Web 端点则改用 Minimal API、一个接口被压缩成寥寥几行的 lambda 表达式、需要的服务直接作为 lambda 参数由容器自动注入、再没有 Controller 类和路由特性那一堆样板。我们的纪律是"一切依赖通过构造函数注入严禁当场 new 或静态单例、面向接口注册便于替换和测试、生命周期(Scoped/Singleton/Transient)按语义谨慎选择、简单端点用 Minimal API 复杂分组再用 Controller"。依赖注入的本质认知是:对象在内部自己 new 出它的依赖,等于把"我用什么"和"我怎么用"这两件事死死焊在了一起、让依赖关系僵化得无法替换无法测试;DI 的智慧是把"对象的构造和组装"这件事从业务对象内部抽离出来、交给一个统一的容器去集中管理,让每个对象只声明"我需要什么(接口)"而不关心"具体给我的是哪个实现、它怎么造出来",从而实现了依赖的可替换、可测试和松耦合,这是构建可维护、可测试的大型应用的基础设施,而 Minimal API 则在此之上把端点的表达精简到了极致。
五、高性能:从装箱拆箱 GC 压力到 Span<T> 与源生成
第五仗,是压榨性能、削减 GC 压力。古早时代我们对性能相当粗放:大量值类型在和 object、集合打交道时频繁地装箱拆箱(把栈上的值类型包成堆上的对象再拆开)、字符串和数组的切片处理动不动就分配新的中间数组和子串、序列化全靠 Newtonsoft.Json 在运行时用反射逐字段读写——这些操作都在疯狂地往托管堆上分配临时对象,导致 GC(垃圾回收)被频繁触发、每次 GC 又带来停顿,高负载下 GC 压力大到成为性能瓶颈。现代做法是用 .NET 现代的高性能特性:用 Span<T>/Memory<T> 对已有内存做零分配的切片访问、用源生成器(Source Generator)在编译期就把序列化代码生成好彻底干掉运行时反射、用 ValueTask 等减少异步的分配。下面是高性能特性的对比:
// 重构前:子串/切片狂分配中间对象,Newtonsoft 运行时反射序列化,GC 压力山大
// string part = bigStr.Substring(10, 50); // 分配一个新字符串(堆分配)
// var json = JsonConvert.SerializeObject(o); // 运行时反射逐字段读写,慢且吃内存
// object boxed = 42; // int 装箱成 object,堆分配
// 重构后:Span 对已有内存零分配切片 + System.Text.Json 源生成编译期干掉反射
ReadOnlySpan part = bigStr.AsSpan(10, 50); // 切片:不分配新串,只是个视图
foreach (var ch in part) { /* 处理 */ } // 零堆分配地遍历原内存的一段
// System.Text.Json 源生成:编译期生成序列化代码,运行时无反射,快且省内存
[JsonSerializable(typeof(Order))]
public partial class AppJsonContext : JsonSerializerContext { } // 源生成上下文
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
// ↑ 用编译期生成的序列化器,无运行时反射,吞吐高、分配少、还支持 AOT 裁剪
高性能让我们从"值类型频繁装箱拆箱、字符串数组切片狂分配中间对象、Newtonsoft 运行时反射序列化、GC 被频繁触发停顿成瓶颈"进化到了"Span<T>/Memory<T> 对已有内存零分配切片、源生成器编译期生成序列化代码干掉反射、ValueTask 减少异步分配":过去我们写代码对内存和 GC 相当不讲究,大量值类型在塞进 object 或非泛型集合时频繁地装箱(在堆上分配一个对象把值包进去)又拆箱、处理字符串和数组时动不动就 Substring、ToArray 分配出一堆临时的中间字符串和数组、JSON 序列化全靠 Newtonsoft.Json 在运行时用反射一个字段一个字段地读写,这些操作都在不停地往托管堆上制造短命的临时垃圾对象,逼得 GC 一轮接一轮地频繁回收、每一轮 GC 又带来或长或短的停顿,在高负载下 GC 压力直接成了拖垮吞吐和延迟的瓶颈;现在我们用上了 .NET 现代的高性能武器,需要处理一段字符串或数组的某一部分时用 Span<T>/ReadOnlySpan<T> 去做零分配的切片——它只是指向原有内存一段区间的轻量视图、根本不分配新对象,JSON 序列化改用 System.Text.Json 配合源生成器、在编译期就把每个类型的序列化代码生成好、运行时直接执行这些生成好的代码彻底不走反射,既快又省内存还能支持 AOT 裁剪,异步返回值在合适的地方用 ValueTask 替代 Task 来减少分配。我们的纪律是"热点路径严控堆分配优先用 Span/Memory 切片、序列化一律 System.Text.Json 源生成、避免不必要的装箱和中间集合分配、用 BenchmarkDotNet 量化性能改动而非凭感觉"。高性能的本质认知是:在托管语言里,性能的隐形杀手往往不是算法本身、而是那些不经意间制造出的海量短命临时对象给 GC 带来的持续压力——每一次装箱、每一个中间子串、每一轮反射序列化都在悄悄地喂养着 GC 这头需要不时停顿来清理的野兽;现代 .NET 高性能特性的智慧是提供了一整套"不分配或少分配"的工具,让我们能够对已有内存做零拷贝的访问、把运行时的反射开销前移到编译期、在不牺牲代码可读性的前提下大幅削减堆分配和 GC 压力,从而把 .NET 从一门"够用就好"的语言压榨成能与系统级语言掰手腕的高性能平台,这是 .NET 在性能敏感场景下立足的硬实力。
六、空安全:从 NullReferenceException 满天飞到可空引用类型
第六仗,是消灭那个困扰了我们无数个深夜的幽灵——NullReferenceException(空引用异常)。古早时代 C# 的引用类型默认就可以是 null,而编译器对此一无所知也不做任何提醒,于是代码里到处都是"这个对象到底可不可能是 null"的不确定性:某个方法可能返回 null、某个属性可能没赋值、某个参数可能传进来个 null,而我们要么忘了判空、要么到处防御性地写一堆 if (x != null) 把代码搞得又脏又啰嗦,最终总有那么几个漏判的地方在生产环境某个边角场景里冷不丁抛出 NullReferenceException、把人半夜从被窝里揪起来排查。现代做法是开启可空引用类型(nullable reference types):引用类型默认变成"不可为 null",可能为 null 的要显式用 ? 标注(如 string?),编译器据此在编译期就分析出所有可能解引用 null 的危险点、给出警告。可空引用类型让我们对空值的处理从"引用类型默认可空编译器毫不知情、到处不确定能不能为 null、要么漏判空生产炸要么防御性 if 判空写一堆、NullReferenceException 半夜炸"进化到了"引用类型默认不可空、可空必须显式 ? 标注、编译器编译期分析出所有解引用 null 的危险点给警告":过去 C# 的引用类型默认就允许是 null、而编译器对一个变量到底可不可能为 null 完全没有概念也从不提醒,结果就是代码里弥漫着挥之不去的不确定性——这个方法的返回值会不会是 null?这个属性初始化了吗?这个参数调用方会不会传个 null 进来?我们要么心存侥幸忘了判空、留下一个定时炸弹,要么草木皆兵地在每处使用前都写上 if (x != null) 的防御代码把逻辑搞得又脏又乱,可即便如此总还是会有几个考虑不周的角落,在生产环境某个罕见的输入路径下冷不丁地抛出 NullReferenceException、然后就是一次线上事故和一个被惊醒的深夜;现在我们全面开启了可空引用类型,引用类型默认就是"保证不为 null"的、凡是真的可能为 null 的地方必须显式地用 string? 这样的问号语法标注出来,编译器据此在编译期就对整个代码流做空值分析、把每一处"可能在 null 上解引用"的危险点都揪出来给我们警告,让本来要等到运行时才爆炸的空指针问题在编码阶段就暴露并被强制处理。我们的纪律是"所有项目开启 nullable enable、可空与非空必须显式标注、对编译器的可空警告零容忍当错误处理、用模式匹配和空合并运算符优雅处理可空而非满地 if"。空安全的本质认知是:NullReferenceException 之所以是"价值十亿美元的错误",根源在于"任何引用都可能为 null"这件事在类型系统里是隐式的、不可见的、编译器无法帮你检查的——它把一个本可以在编译期发现的问题推迟到了运行时才以崩溃的形式爆发;可空引用类型的智慧是把"可不可以为 null"这个关键信息显式地编码进类型系统(string 不可空、string? 可空),让编译器能够像检查类型错误一样在编译期检查空值的误用、把空指针这个最常见的运行时崩溃源头大面积地拦截在上线之前,这是把缺陷防线从"运行时崩溃"前移到"编译期警告"的又一次工程化胜利。
七、配置管理:从 web.config 的 XML 到 appsettings 与强类型 IOptions
第七仗,是配置。古早时代我们的配置全塞在 web.config 这个巨大的 XML 文件里——数据库连接串、各种开关、外部地址,全是一坨层层嵌套的 XML 标签,读取时要靠 ConfigurationManager.AppSettings["SomeKey"] 用魔法字符串去取、取出来还都是 string 要自己手动转型,key 写错了编译期发现不了、要到运行时取到个 null 才知道,而且最要命的是 web.config 一改往往就触发应用域重启、改个无关紧要的小开关都要重启进程。现代做法是用 appsettings.json 分环境管理配置、用强类型的 IOptions 模式把配置段绑定成一个强类型的配置类注入使用、配置变更还能热重载不必重启。配置管理让我们从"配置全塞 web.config 的 XML 里、魔法字符串取值还要手动转型、key 写错运行时才知道、改配置触发应用重启"进化到了"appsettings.json 分环境管理、强类型 IOptions 把配置段绑定成配置类注入、编译期类型安全、变更可热重载不重启":过去我们的配置是一个庞大的 web.config XML 文件,数据库连接串、功能开关、第三方服务地址全都是层层嵌套的 XML 节点,代码里要用某个配置就得用 ConfigurationManager.AppSettings 加一个手写的魔法字符串 key 去取、取回来的还都是 string 类型得自己手动 Parse 成 int 或 bool,这个 key 字符串要是敲错了一个字母编译器根本不会报错、只会在运行时取到 null 然后某处崩掉才暴露,更让人头疼的是改动 web.config 往往会触发整个应用域的重启、哪怕只是调一个无足轻重的小开关也得让进程重启一次、中断正在处理的请求;现在我们改用 appsettings.json 来管理配置、还能按 Development/Production 等环境分文件覆盖,代码里则用强类型的 IOptions 模式、把 JSON 里的某个配置段在启动时绑定到一个我们定义好的强类型配置类上、然后这个配置类通过依赖注入直接注入到需要它的服务里,访问配置就是访问一个对象的强类型属性、编译期类型安全、再没有魔法字符串和手动转型,配合 IOptionsMonitor 还能在配置文件变更时热重载、无需重启进程。我们的纪律是"配置一律 appsettings.json 分环境管理、一切配置通过强类型 IOptions 绑定注入严禁魔法字符串散取、敏感配置(连接串密钥)走环境变量或密钥管理而非明文入库、需要动态调整的配置用可热重载的 Options"。配置管理的本质认知是:配置虽然不是业务代码,但用魔法字符串去访问无类型的配置、用一改就重启的笨重格式去承载配置,会让配置成为一个既不安全(key 写错运行时才炸)又不灵活(改一下要重启)的脆弱环节;强类型配置的智慧是把配置也纳入类型系统的保护之下——让配置项变成强类型类的属性、让访问配置享受编译期检查和 IDE 智能提示、让配置的来源(文件、环境变量、密钥库)和绑定方式标准化,从而把配置从一堆危险的魔法字符串提升为安全、灵活、可分环境、可热更新的一等工程资产。
八、迁移:Upgrade Assistant 与双轨灰度,而非断崖式重写
第八仗,是怎么把一个跑了八年的 .NET Framework 大项目实际迁移过来——这一仗的风险绝不亚于前面任何一仗。把一个庞大的老项目从 .NET Framework 一步到位地重写成 .NET 8 然后某天直接切换,是最诱人也最危险的做法:工程量巨大、周期漫长、业务还在持续迭代,极易演变成一场看不到头、最后切不过去的豪赌。现代做法是渐进式迁移:先用微软的 .NET Upgrade Assistant 等工具评估和自动化处理大部分机械的升级改动、把不兼容的 API 和写法标记出来,再借助 .NET Standard 做过渡层让新老代码能共享类库,然后按模块逐步迁移、新老服务双轨并行、用灰度把流量一点点从老服务切到新服务上、紧盯指标稳了再继续。迁移的智慧在于把".NET Framework 整体迁到 .NET 8"这件高风险的事,从"把整个大项目一次性重写再某天断崖式切换"的豪赌,变成了"用 Upgrade Assistant 自动化处理机械改动 + .NET Standard 过渡共享类库 + 按模块逐步迁移 + 新老双轨并行灰度放量"的渐进迁移:断崖式重写一个跑了八年、承载着全部业务又还在天天加需求的老项目,几乎注定是一场灾难——工程量大到周期遥遥无期、重写期间业务还在变新代码永远追不上、好不容易写完了却和老系统行为对不齐、约定的切换日一到处处暴雷只能连夜回滚;我们走的是渐进迁移的路子,先用 .NET Upgrade Assistant 这类工具把项目扫一遍、自动化地完成大量机械性的升级改动(改项目文件格式、换等价 API)、并把那些不兼容、需要人工介入的写法清清楚楚地标记出来,再用 .NET Standard 作为过渡层让还没迁的老代码和已经迁好的新代码能共享同一批类库、避免重复造轮子,然后以模块为单位一块一块地迁移、迁好的模块以新服务的形式和老服务双轨并行地跑着,用灰度把生产流量一个百分点一个百分点地从老服务挪到新服务上、每挪一步都死死盯住错误率和延迟指标、确认稳了再挪下一步。我们的纪律是"严禁断崖式整体重写、一律 Upgrade Assistant 评估 + 逐模块渐进迁移、用 .NET Standard 做新老过渡、每个模块迁移后新老双轨灰度可回退、迁移全程新老行为持续对账"。迁移的本质认知是:技术栈升级真正的风险从来不在"新栈好不好用",而在"如何在业务一刻不停的前提下、安全地把庞大的存量代码从旧栈搬到新栈"——断崖式重写之所以屡屡失败,是因为它要求你停下来等一个追不上移动业务的庞然大物、并把全部赌注押在某个切换瞬间;渐进迁移的智慧是用自动化工具削平机械改动的成本、用过渡层让新老共存、用逐模块的灰度把一次大豪赌拆成一连串小而可控可回退的步骤,让风险被分散稀释、让价值随迁移持续兑现,这是大型遗留系统升级唯一靠谱的活法。
九、7 个 P0 事故复盘
7 事故:(1) 异步改造时一处 .Result 同步阻塞在高并发下触发线程池饥饿死锁整个服务卡死,全面排查消灭同步阻塞点、异步一异到底;(2) 一段老代码残留字符串拼 SQL 被注入拖走用户表,全量审计禁绝拼接 SQL、统一参数化;(3) 可空引用类型未开启某边角路径空指针把核心下单接口打挂,全项目开启 nullable + 可空警告当错误;(4) Newtonsoft 反射序列化在大对象高频接口上吃满 CPU 拖垮吞吐,切 System.Text.Json 源生成;(5) web.config 改了个开关触发应用池重启把正在处理的批量任务全中断,迁 appsettings + 热重载 Options;(6) DI 生命周期配错把 Scoped 的 DbContext 注入了 Singleton 导致并发访问同一上下文崩,梳理并固化生命周期规范;(7) 迁移中某模块新老双轨数据写串导致对不上账,补幂等 + 新老写对账 + 灰度回退。每个 P0 都做 5-Why 复盘,固化成异步规范、参数化门禁或 DI 生命周期清单,确保同类问题不再复发。
十、.NET 工程师的 6 条工程哲学
6 哲学:(1) 异步要一异到底——半异步半同步比全同步更危险,一个 .Result 就能死锁全服务;(2) 永远参数化,永不拼接——SQL 注入是拼字符串的原罪,ORM 和参数化从机制上根除它;(3) 让编译器替你抓 bug——开可空引用类型、用强类型配置,把运行时崩溃前移成编译期警告;(4) 分配是性能的隐形税——热点路径用 Span 零分配、用源生成干掉反射,GC 压力才是真瓶颈;(5) 依赖要注入而非 new——面向接口由容器组装,才能替换、测试、解耦;(6) 升级要渐进而非重写——Upgrade Assistant 加逐模块灰度,断崖式重写几乎必死。这 6 条哲学,是我们用 7 个 P0 事故和 87 天攻坚换来的集体共识。它们共同指向一个认知:.NET 现代化的价值不在于"升到了多新的版本号"这个动作本身,而在于把".NET 应用的跨平台、高并发、安全、高性能"从依赖运行时恰好没出事和工程师恰好没写错的运气,前移成了由现代特性(全异步、参数化 ORM、可空类型、源生成、内建 DI)结构性保障——会用现代 .NET 的团队,是在用语言和运行时的机制把一整类"线程耗尽、SQL 注入、空指针、GC 抖动、依赖死耦合"的问题从源头消除,而不只是在事后救火。
十一、重构收益的量化:7 个关键数字
7 数字:(1) 抗并发能力:同步阻塞线程池一打就穿 → 全异步后同样线程数扛起数倍并发;(2) SQL 注入面:拼接 SQL 处处隐患 → 全参数化后注入面归零;(3) 空指针生产事故:NullReferenceException 半夜炸 → 可空引用类型后编译期就拦住;(4) 序列化 CPU 开销:Newtonsoft 反射吃满 CPU → 源生成后吞吐大涨开销骤降;(5) GC 停顿:临时对象狂分配频繁 GC → Span 零分配后 GC 压力大降;(6) 部署形态:绑死 Windows/IIS → 跨平台容器后镜像几十 MB 跑 Linux;(7) 配置变更中断:改 web.config 要重启 → IOptions 热重载后无需重启。这些数字背后,是 87 天里 12 个人无数次的异步改造、参数化整改、可空标注、性能压测和逐模块灰度迁移,但每一个都实打实地转化成了并发能力、安全性、稳定性和性能的提升。当我们把这份数据汇报给管理层时,最有说服力的不是任何 .NET 名词,而是"促销洪峰再没把服务打到假死、再没有半夜被空指针叫醒、Windows 服务器授权成本随容器化大幅下降"这三条。
十二、留给后来者的最后一句话
87 天的 .NET 现代化战役,我们走过的不只是一条从 .NET Framework 到 .NET 8 跨平台、从同步阻塞到全异步、从拼 SQL 到参数化 ORM、从反射序列化到源生成、从 new 满天飞到内建 DI、从空指针裸奔到可空类型、从 web.config 到强类型配置、从断崖重写到渐进迁移的技术升级路,更是一次从"靠运行时恰好不出事、靠工程师恰好没写错的运气"到"靠语言运行时机制和编译期检查结构性兜底"的开发范式跃迁。当促销洪峰冲进来全异步的服务用同样的线程数稳稳扛住而不再线程池打穿假死、当每一条 SQL 都参数化后注入隐患从根上绝迹、当可空引用类型在编译期就把空指针危险点一个个标红、当源生成把序列化的反射开销和 GC 抖动统统削平、当依赖由容器优雅地注入组装让测试和替换变得轻而易举、当同一份代码打成几十兆的镜像跨平台跑在 Linux 容器里的那一刻,真正点燃我们的,不是升到了多新的版本号本身,而是".NET 应用的跨平台、高并发、安全和高性能,终于从依赖运行时不抽风和手感的运气,变成了由现代特性和编译器强制保障"的踏实与笃定。.NET 现代化没有银弹,关键是理解跨平台、async/await、EF Core、源生成、DI、可空类型、强类型配置各自解决什么问题、又各自带来什么代价,然后从全异步改造和开启可空类型的地基起步、用 Upgrade Assistant 加逐模块灰度可回退地落地——尤其要克制"图省事拼个 SQL、图省事来个 .Result 同步等一下、图省事 new 个依赖、图省事不开可空警告、图省事断崖式重写"的旧习惯,因为每一处拼接的 SQL、每一个同步阻塞点、每一次硬编码的 new、每一个被忽略的可空警告,都是在亲手埋下未来某次注入拖库、线程池死锁或深夜空指针的事故。愿每一位还在和线程耗尽、SQL 注入、空指针、GC 抖动搏斗的同行,都能早日让自己的 .NET 服务被现代特性和编译器稳稳地守护。共勉,后会有期。
—— 别看了 · 2026