从 .NET 6 → .NET 9 + Aspire + Native AOT 大型 SaaS 升级 16 天踩坑实录:8 个反模式与 10 套修法
这是一份非常真诚的 .NET 升级踩坑记。我是某 B2B SaaS 公司后端架构师,2026 年 3 月底到 4 月中旬,我们把核心订单 / 计费 / 通知三个产品线从 .NET 6 LTS 升级到 .NET 9 + .NET Aspire 9.1 + Native AOT + EF Core 9。16 天里踩了 8 个反模式坑、回滚 3 次、写了 64 个晚上。本文把整个过程完整复盘,希望能让正在做 .NET 升级的同行少走 1-2 周弯路。
一、背景:为什么必须升级 .NET 9 + Aspire
我们公司在线 SaaS 用 .NET 6 跑了 4 年,2024 年下半年开始两个核心痛点逼着我们升级:(1) .NET 6 在 2024-11 进入 EOL,微软不再提供安全补丁,合规审计直接打不过(SOC2 Type II 要求生产栈在 vendor support 期);(2) 核心计费服务冷启动 3.8 秒,在 Azure Container Apps 上 scale-to-zero 后第一个请求超时严重,客户投诉激增。.NET 8 是 LTS,.NET 9 是 STS,但 .NET 9 带来三件重磅:(a) Native AOT 成熟到生产可用,冷启动从 3.8s 降到 80ms;(b) Aspire 9.1 提供"分布式应用统一编排",取代我们手写的 600 行 docker-compose;(c) EF Core 9 的 JSON 列、复杂类型映射、AOT 兼容性大幅改进。我们最终选定 .NET 9 + Aspire + 选择性 AOT 路线,2026 年 H2 等 .NET 10 LTS 再切换。不要"为了 LTS 而 LTS",新版本的工程红利经常比 LTS 标签更重要。
二、升级策略:三段渐进式
16 天的升级路线:(1) Day 1-4:升级到 .NET 8 LTS 做"中间站",这是官方推荐的 in-place upgrade 路径,大部分代码改动在这里完成;(2) Day 5-9:从 .NET 8 升级到 .NET 9,主要是 NuGet 包 / Source Generator / Roslyn 分析器适配;(3) Day 10-13:选定 6 个适合 AOT 的服务(API Gateway / Webhook / Worker 三类共 6 个 service)切 Native AOT;(4) Day 14-16:Aspire 9.1 编排接管,从 docker-compose 迁移到 AppHost,本地开发体验改造。核心原则:绝对禁止"一步到位",每段渐进必须有独立的回滚锚点。我们 3 次回滚都是回到中间站(.NET 8 LTS),没有一次回到 .NET 6,验证了渐进路线的价值。
三、反模式一:dotnet upgrade-assistant 自动改 30% 误改
Day 1 我们直接跑 dotnet tool install -g upgrade-assistant && upgrade-assistant upgrade,期望一键完成。结果灾难:117 个 csproj 自动改了 71 个,其中 22 个改坏(Nullable 引用类型推断错、ImplicitUsings 与遗留 using 冲突、TargetFramework 强制改成 net9.0 跳过 net8.0 中间站、PackageReference 版本回退)。修法:(1) 抛弃 upgrade-assistant 一键模式,只用它生成"建议清单"(upgrade-assistant analyze),人工 review;(2) 强制走 .NET 8 中间站,csproj TargetFramework 第一步只改到 net8.0;(3) 用脚本批量改 csproj 节点,改一项 git commit 一次,保证可回滚粒度细;(4) Nullable 引用类型从 disable → annotations(只标注不警告)→ warnings → enable 四档渐进,不要一步到 enable。"自动化工具是辅助不是替代",架构演进的关键决策必须人工 review。
// OrderService.csproj - 改造前 .NET 6
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>disable</Nullable>
</PropertyGroup>
</Project>
// 改造后 .NET 9 + AOT-ready
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>13.0</LangVersion>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
<StripSymbols>true</StripSymbols>
<IlcOptimizationPreference>Speed</IlcOptimizationPreference>
<OptimizationPreference>Speed</OptimizationPreference>
<TrimMode>full</TrimMode>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>Nullable</WarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.2" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.10.0" />
</ItemGroup>
</Project>
四、反模式二:EF Core 9 LINQ 翻译变化,SQL 性能倒退
Day 6 升完 EF Core 9 后,核心订单查询接口 P99 从 180ms 飙到 1.2 秒。排查发现:EF Core 9 改进了 LINQ 翻译,把原来的 EXISTS 子查询改成 JOIN + DISTINCT,在我们的 PostgreSQL 17 上反而走错执行计划。修法:(1) 启用 EF Core 9 的 UseRelationalNulls(true),跟旧版语义保持一致;(2) 用 AsSplitQuery() 显式拆分 N+1 风险查询;(3) 对热点查询写 FromSqlInterpolated 直接用原生 SQL,避免 LINQ 翻译陷阱;(4) 用 EF Core 9 的 ToQueryString() 在 CI 阶段抓出所有 SQL,与基线 diff,翻译变化自动告警。这套机制下我们 47 个热点查询的 SQL 全部留存基线,后续任何升级都能自动检测翻译变化。ORM 升级最大的风险不是 API 变化,是 SQL 翻译的"静默变化"。
五、反模式三:Native AOT 第三方包 60% 不兼容
Day 10 切 AOT,触发的兼容性问题:(1) AutoMapper 12 reflection-heavy 完全不兼容,必须切 Mapperly(source generator);(2) Newtonsoft.Json 不友好,切 System.Text.Json + JsonSerializerContext;(3) Dapper 部分场景需要手写 SqlMapper.SetTypeMap;(4) MediatR 13 之前的版本反射注册,我们改用 source generator 版本 MediatR.SourceGenerator;(5) Serilog enricher 大量动态加载 dll,改用 ZLogger structured logging。修法:(a) AOT 兼容性矩阵化,内部 Wiki 列出 142 个常用 NuGet 包的 AOT 状态;(b) 关键路径包必须 AOT 兼容,边缘工具(管理后台等)留在 JIT;(c) 用 <IsAotCompatible>true</IsAotCompatible> 在每个项目显式声明,编译期就会爆所有 trim warning。Native AOT 不是"加一个 PublishAot 就完事",是整个依赖树的重新审视。
// 原 AutoMapper 写法 - AOT 不兼容
public class OrderProfile : Profile {
public OrderProfile() {
CreateMap<Order, OrderDto>();
}
}
// 改 Mapperly - source generator + AOT 友好
[Mapper]
public partial class OrderMapper {
public partial OrderDto OrderToDto(Order order);
public partial Order DtoToOrder(OrderDto dto);
[MapProperty(nameof(Order.CreatedAt), nameof(OrderDto.CreatedAtUtc))]
[MapProperty(nameof(Order.Status), nameof(OrderDto.StatusText), Use = nameof(StatusToText))]
public partial OrderDto ToDtoCustom(Order order);
private string StatusToText(OrderStatus s) => s switch {
OrderStatus.Created => "已创建",
OrderStatus.Paid => "已支付",
OrderStatus.Shipped => "已发货",
_ => "未知"
};
}
// 原 System.Text.Json - 反射模式 AOT 不友好
var json = JsonSerializer.Serialize(order);
// 改 source generator 模式
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<OrderDto>))]
internal partial class OrderJsonContext : JsonSerializerContext { }
// 使用
var json = JsonSerializer.Serialize(order, OrderJsonContext.Default.Order);
六、反模式四:Aspire AppHost 本地资源消耗爆炸
Day 14 切 Aspire 9.1 编排,本地开发体验确实好,但第 3 天开发反馈:笔记本风扇狂转、内存吃满 28GB,因为 AppHost 拉起了 17 个本地容器(Postgres / Redis / RabbitMQ / Seq / OTel Collector / Grafana 等)。修法:(1) Aspire builder.AddProject 配置 WithReplicas 严格限制副本数;(2) 引入 WithLifetime(ContainerLifetime.Persistent) 让 Postgres 这种重资源容器在多次 dotnet run 之间保留;(3) 用 WithExternalHttpEndpoints + dev tunnels,让 mobile / 远程协作开发不用所有人本地拉一套;(4) 团队提供"Profile"机制:全栈开发用完整 AppHost,前端只调后端 API 的开发只拉 Gateway + 1 个 mock backend。这套改造下本地内存占用从 28GB 降到 9GB,开发体验回归舒适。Aspire 的强大不能滥用,本地资源也是稀缺资源。
七、反模式五:OpenTelemetry trace 字段命名漂移
Day 11 我们打开 OTel,问题:不同服务的 trace span 字段命名混乱,有的叫 http.method(OTel 1.x 旧规范),有的叫 http.request.method(OTel 1.20+ 新规范),Grafana / Jaeger 查询时 50% trace 漏掉。修法:(1) 升级到 OpenTelemetry .NET 1.10,全面切换 HttpClientInstrumentationOptions 的语义约定到 stable 版本;(2) 通过 ResourceBuilder 强制注入 service.name / service.version / service.namespace / deployment.environment 四个 mandatory 字段;(3) 自定义 TraceEnricher 注入 tenant.id / customer.tier / feature.flag 业务字段,所有 trace 必带;(4) CI 阶段引入 OtelSemanticValidator 工具,检测命名漂移。可观测性的根基是"统一语义",字段命名漂移是慢性病,越晚治理越痛苦。
// Program.cs - .NET 9 + OpenTelemetry 1.10 统一可观测性
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r
.AddService("order-service", serviceVersion: "9.1.0", serviceNamespace: "saas-core")
.AddAttributes(new Dictionary<string, object>
{
["deployment.environment"] = builder.Environment.EnvironmentName,
["host.region"] = Environment.GetEnvironmentVariable("AZURE_REGION") ?? "unknown",
["k8s.pod.name"] = Environment.GetEnvironmentVariable("HOSTNAME") ?? "local"
}))
.WithTracing(t => t
.AddAspNetCoreInstrumentation(opt =>
{
opt.RecordException = true;
opt.EnrichWithHttpRequest = (activity, req) =>
{
activity.SetTag("tenant.id", req.Headers["X-Tenant-Id"].ToString());
activity.SetTag("customer.tier", req.Headers["X-Customer-Tier"].ToString());
};
})
.AddHttpClientInstrumentation()
.AddNpgsql()
.AddSource("OrderService.*")
.SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.1)))
.AddOtlpExporter())
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddProcessInstrumentation()
.AddMeter("OrderService.*")
.AddOtlpExporter());
builder.Services.AddRequestTimeouts(opts =>
{
opts.AddPolicy("default", TimeSpan.FromSeconds(5));
opts.AddPolicy("longRunning", TimeSpan.FromSeconds(30));
});
var app = builder.Build();
app.MapGet("/orders/{id}", async (long id, OrderDbContext db) =>
await db.Orders.FindAsync(id) is { } o ? Results.Ok(o) : Results.NotFound())
.WithRequestTimeout("default");
app.Run();
八、反模式六:gRPC + AOT JIT-only ProtoBuf-net 不兼容
Day 12 我们的内部通信用 gRPC,protobuf 用了 protobuf-net.Grpc(基于反射),切 AOT 后全部抛 System.NotSupportedException: Reflection-based serialization is not supported。修法:(1) 切回官方 Grpc.Tools + 标准 .proto 文件 + source generator;(2) 接口契约写成 .proto 不写成 C# 接口,跨语言更友好;(3) 大量 DTO 类用 [ProtoContract] 标记的迁移到 option csharp_namespace + protoc 生成;(4) 老代码用 partial class 扩展生成的 message,业务逻辑保留。这次切换花了 3 天,但收益是 18 个跨服务接口契约统一到 .proto,接口治理水平大幅提升。"AOT 友好" + "跨语言友好" 在 gRPC 场景是一体两面,protobuf-net 是 .NET-only 思维的遗产。
九、反模式七:EF Core 9 迁移文件冲突,生产 schema 漂移
Day 8 我们发现:不同开发分支同时生成 EF Core migration,文件名按时间戳排序但内容冲突,合并后跑 dotnet ef database update 直接报"column already exists",生产环境一度回滚。修法:(1) Migration 文件提交必须经过 PR review,不能直接 push 主干;(2) 引入 EFCore.NamingConventions 统一命名,避免大小写差异导致的迁移错乱;(3) CI 引入 dotnet ef migrations script 生成 SQL 对比基线,内容变化触发告警;(4) 生产 schema 变更必须用 dotnet ef migrations bundle 打包成单一可执行文件,部署流程统一;(5) 真正复杂的 schema 变更(数据回填、表重命名)用 Flyway / RoundhousE 等外部工具,EF migration 只做 DDL。Migration 是基础设施级 artifact,治理水平等同 Terraform / Ansible。
十、反模式八:Kestrel HTTP/3 默认未开,客户端协议谈判错
Day 13 我们打开 Kestrel HTTP/3,期望边缘体验更好。问题:Kestrel HTTP/3 需要 --enable-http3 显式开启 + ACME 证书 + ALPN 配置正确,我们只改了 appsettings,客户端 30% 卡在 HTTP/2 fallback,延迟反而升高。修法:(1) Kestrel 配置 EndpointDefaults.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;(2) 证书必须满足 TLS 1.3 + ECDSA,RSA 证书在 QUIC 握手中性能差;(3) Alt-Svc header 必须正确响应,告诉客户端可以升级到 HTTP/3;(4) 加 HappyEyeballsV2 让客户端并行尝试 IPv4/IPv6 + H2/H3,选最快路径;(5) 监控关键指标 http_negotiated_version,如果 H3 占比 < 70% 立即排查。这套配置完成后 H3 流量占比 84%,首字节延迟 P50 降 35%。新协议不是"打个开关"就能用,需要全链路适配。
| 问题 | 反模式 | 修法 | 效果 |
|---|---|---|---|
| upgrade-assistant 误改 | 一键自动升级 | 分析建议 + 人工 review | 22 改坏全部恢复 |
| EF Core LINQ 翻译变化 | 未抓 SQL 基线 | ToQueryString + diff | P99 1.2s→180ms |
| AOT 第三方包不兼容 | 反射重度依赖 | Mapperly + STJ Context | 6 服务 AOT 化 |
| Aspire 本地资源爆 | 17 容器全拉起 | Profile + Persistent | 内存 28→9GB |
| OTel 字段命名漂移 | 新旧规范混用 | 1.10 stable 语义 | trace 漏报 0 |
| gRPC 反射序列化 | protobuf-net.Grpc | 切官方 Grpc.Tools | 18 接口标准化 |
| EF migration 冲突 | 无 review | bundle + script diff | 0 schema 漂移 |
| HTTP/3 谈判失败 | 只改 appsettings | Kestrel + Alt-Svc + ECDSA | H3 占比 84% |
十一、Aspire 9.1 AppHost 编排实战
Aspire 是 .NET 9 时代最重要的开发体验升级。核心抽象:(1) AppHost:一个独立 .NET 项目,声明所有 service / database / queue / cache 依赖;(2) Service Defaults:一个共享库,封装可观测性 / 健康检查 / resiliency 默认配置;(3) Dashboard:开发时可视化 trace / log / metrics。我们的 AppHost 现在管理:17 个 service + 5 个 PostgreSQL + 3 个 Redis + 2 个 RabbitMQ + 1 个 Azure Service Bus emulator + 1 个 Seq + 1 个 OTel Collector + Grafana stack。本地一键 dotnet run --project AppHost 拉起全栈,开发体验提升 5 倍。Aspire 不是 docker-compose 的替代,是"分布式 .NET 应用的开发时操作系统"。
// AppHost/Program.cs - Aspire 9.1 编排
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres", port: 5432)
.WithLifetime(ContainerLifetime.Persistent)
.WithPgAdmin()
.WithDataVolume("postgres-data");
var redis = builder.AddRedis("redis")
.WithLifetime(ContainerLifetime.Persistent)
.WithRedisInsight();
var rabbitmq = builder.AddRabbitMQ("rabbitmq")
.WithManagementPlugin()
.WithDataVolume("rabbitmq-data");
var orderDb = postgres.AddDatabase("orderdb");
var billingDb = postgres.AddDatabase("billingdb");
var orderService = builder.AddProject<Projects.OrderService>("order-service")
.WithReference(orderDb)
.WithReference(redis)
.WithReference(rabbitmq)
.WithEnvironment("Feature__UseAot", "true")
.WithReplicas(2)
.WithExternalHttpEndpoints();
var billingService = builder.AddProject<Projects.BillingService>("billing-service")
.WithReference(billingDb)
.WithReference(rabbitmq)
.WithReplicas(2);
var gateway = builder.AddProject<Projects.ApiGateway>("gateway")
.WithReference(orderService)
.WithReference(billingService)
.WithExternalHttpEndpoints();
builder.AddProject<Projects.WebFrontend>("frontend")
.WithReference(gateway)
.WithExternalHttpEndpoints();
builder.Build().Run();
十二、Native AOT 部署收益数据
6 个服务切 AOT 后的真实数据:(1) 冷启动:OrderService 3.8s → 80ms,降 97.9%;(2) 内存常驻:平均 280MB → 65MB,降 76.8%;(3) 镜像大小:240MB(SDK runtime)→ 38MB(self-contained AOT),降 84.2%;(4) Azure Container Apps scale-to-zero 体验:第一个请求超时率从 22% 降到 0.3%;(5) CPU 峰值占用:启动 spike 从 280% 降到 12%。代价:(a) 编译时间从 35s 飙到 4 分 20 秒(IlcOptimizationPreference=Speed);(b) Debug 体验差,生产环境堆栈不友好;(c) Reflection.Emit / Expression.Compile 完全不能用;(d) NuGet 包生态 60% 不友好。AOT 不是银弹,适合"启动敏感 + 内存敏感 + 稳定 API"的微服务。我们的管理后台、报表生成、AI 推理服务都没切 AOT,JIT 跑得更舒服。
十三、引申一:Minimal API 与 Controller 的选型
.NET 9 的 Minimal API 已经成熟。选型逻辑:(1) Minimal API:简单 CRUD、Webhook、健康检查、5 个 endpoint 以内的小 service;(2) Controller:复杂业务逻辑、需要 Filter / Authorization / ModelBinding 高级特性、endpoint > 20 个。2026 年最佳实践:新服务默认 Minimal API,但拆分到多个 endpoint 文件用 MapGroup + endpoint filter,代码组织清晰。我们公司 17 个 service 中 12 个用 Minimal API,5 个老 service 保留 Controller。Minimal API 与 AOT 的契合度更高,这是另一个加分项。
十四、引申二:EF Core 9 vs Dapper vs 原生 SQL
2026 年 .NET 数据访问栈仍是三足鼎立。EF Core 9:(a) 复杂模型导航 + 自动 change tracking + migration;(b) JSON column 一等公民;(c) AOT 兼容(需要预编译模型);(d) 学习曲线高。Dapper:(a) 性能接近原生 ADO.NET;(b) 简单查询写起来快;(c) AOT 需要少量手写;(d) 没有 change tracking 但很多团队喜欢这种"显式"。原生 ADO.NET / Npgsql:(a) 极致性能;(b) 用 Npgsql 9 的 NpgsqlDataSource + connection pooling;(c) AOT 完全友好。我们的选择:复杂领域模型 EF Core,简单查询 Dapper,极致性能场景(批量写入、ETL)原生 Npgsql。三者共存不是反模式,是工程实际。
十五、引申三:Source Generator 与 Roslyn 分析器治理
.NET 9 时代 source generator 数量爆炸:(1) System.Text.Json:JsonSerializerContext;(2) MediatR.SourceGenerator;(3) Mapperly;(4) StronglyTypedId;(5) Riok.Mapperly;(6) ServiceProvider.SourceGenerator;(7) LoggerMessage attribute;(8) RegexGenerator。问题是编译期 source generator 之间互相依赖、版本冲突导致编译 OOM。修法:(a) 项目级 source generator 白名单,新增必须 review;(b) <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> 把生成代码落盘 review;(c) CI 阶段引入 dotnet build --binaryLog 抓 source generator 执行时间;(d) 关键 hot path 直接复制生成代码到项目,关闭对应 source generator。"AOT 的代价是 source generator 重度依赖",治理不好就是编译地狱。
十六、引申四:.NET YARP 7 + Azure API Management 网关组合
SaaS 公司必须有强大的 API Gateway。我们的方案:(1) 外层 Azure API Management:多租户、计费、portal、企业级 SLA;(2) 内层 YARP 7:.NET 原生反向代理,做 service-to-service 路由 + 灰度 + 流量染色。YARP 7 的核心能力:(a) Configuration as code,YAML/JSON 配置热加载;(b) Custom Middleware extensible;(c) HTTP/3 + WebSocket + gRPC 全支持;(d) 内置 OpenTelemetry。性能数据:YARP 7 在我们的 8 vCPU Linux container 上跑 78,000 RPS,P99 1.8ms,相当于 Nginx 的 85% 但与 .NET 生态无缝衔接。我们用 YARP 替代了之前的 Ocelot,代码量减少 60%。
十七、引申五:.NET MAUI 8/9 在移动端的现状
.NET MAUI 在 2026 年的现状不算理想:(1) 性能比 Flutter / RN 弱 15-25%(冷启动 / 列表滚动);(2) 第三方组件库生态小;(3) iOS 调试体验差;(4) HotReload 不稳定。但优势:(a) 与 .NET 后端共享代码 / DTO;(b) Visual Studio / Rider 调试体验好;(c) Microsoft 长期 commit。我们的选择:核心 to-C App 用 Flutter,内部管理 App 用 MAUI(代码共享价值高于性能要求)。2026 年 MAUI 还在追赶 Flutter,2027 年的 MAUI 10 可能是分水岭,值得持续关注但暂不押注核心业务。
十八、引申六:Polly 8 与 Resilience 策略
.NET 9 内置的 Microsoft.Extensions.Http.Resilience 基于 Polly 8 提供 5 套核心策略:(1) Retry:指数退避 + 抖动;(2) Circuit Breaker:故障率阈值熔断;(3) Timeout:每跳 + 总链路双 timeout;(4) Rate Limiter:client-side rate limit;(5) Hedging:同时发多个请求取最快。我们的实践:(a) 默认每个 HttpClient 启用 standard resilience handler;(b) 关键 API 配置 hedging,P99 降 38%;(c) Circuit breaker 配置 50% 故障率 + 滑动窗口 30s + 半开恢复 10s;(d) 监控 Polly 内置 metrics,熔断打开自动告警。Resilience 不是"加 try-catch",是分布式系统的一等公民。
// Resilience 配置 - .NET 9 + Polly 8
builder.Services.AddHttpClient<PaymentClient>(c => c.BaseAddress = new Uri("https://api.payment.internal"))
.AddStandardResilienceHandler(opt =>
{
opt.Retry.MaxRetryAttempts = 3;
opt.Retry.UseJitter = true;
opt.Retry.BackoffType = DelayBackoffType.Exponential;
opt.Retry.Delay = TimeSpan.FromMilliseconds(200);
opt.CircuitBreaker.FailureRatio = 0.5;
opt.CircuitBreaker.MinimumThroughput = 20;
opt.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
opt.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(10);
opt.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
opt.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(8);
})
.AddStandardHedgingHandler(opt =>
{
opt.Hedging.MaxHedgedAttempts = 2;
opt.Hedging.Delay = TimeSpan.FromMilliseconds(100);
});
十九、引申七:Azure Container Apps vs AKS 取舍
SaaS 在 Azure 上的两个主流部署选项:(1) Azure Container Apps(ACA):serverless 容器,scale-to-zero,按 vCPU-秒计费,适合中小流量 + bursty 流量;(2) AKS:完整 K8s,scale-to-N 但不到 0,适合稳态流量 + 复杂编排需求。我们的混合策略:核心订单 / 计费用 AKS(稳态、SLA 严苛),Webhook / 通知 / 批处理用 ACA(bursty、scale-to-zero 省钱)。成本对比:同样 60% 峰值利用率下,AKS 月成本 $4200,ACA 月成本 $1800(占总流量 38%)。混合架构总成本比纯 AKS 省 32%。"一种平台打天下"是反模式,SaaS 的工作负载多样性决定了多平台共存。
二十、引申八:.NET 9 GC 改进与内存调优
.NET 9 GC 的核心改进:(1) Dynamic Adaptation to Application Sizes(DATAS):GC 自动根据 working set 调整 heap 大小,默认开启;(2) Region-based GC 进入 Server GC 默认;(3) GC pause time 中位数降 18%。我们调优实战:(a) 微服务默认 <ServerGarbageCollection>true</ServerGarbageCollection>;(b) 高吞吐场景 <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>;(c) 容器化环境 DOTNET_GCHeapHardLimitPercent=75(留 25% 给非 GC 内存);(d) AOT 服务用 DOTNET_TieredPGO=1 已无意义(AOT 没有 JIT 分层)。GC 调优在 2026 年仍然是 .NET 工程师的核心技能,新版本不是"开 ServerGC 就完事"。
二十一、引申九:Roslyn 分析器与编码标准
2026 年 .NET 团队的编码标准必须靠 Roslyn 分析器强制执行:(1) StyleCop.Analyzers:命名 / 格式;(2) Roslynator:代码质量 200+ 规则;(3) Microsoft.CodeAnalysis.NetAnalyzers:.NET 内置;(4) SonarAnalyzer.CSharp:质量门禁;(5) SecurityCodeScan:安全分析。核心配置:(a) .editorconfig 统一格式;(b) Directory.Packages.props 集中管理版本;(c) Directory.Build.props 集中编译选项;(d) TreatWarningsAsErrors=true + WarningLevel=9999。"编码标准靠人 review 是反模式,必须靠工具自动化"——这是 2026 年大型 .NET 团队的共识。
二十二、引申十:Roslyn-based AI Copilot 集成
2026 年 .NET 开发的 Copilot 体验:(1) GitHub Copilot:仍是主流,代码补全 + 编辑器内 chat;(2) JetBrains AI:Rider 集成,refactoring 强;(3) Microsoft Dev Box + Copilot Workspace:云端开发环境;(4) Custom Roslyn-based AI:基于 Roslyn 解析 AST,做"业务规则 AI",我们自己开发了一个内部工具。实际产出:Copilot 在 .NET 代码补全场景准确率 72%,refactoring 场景 58%,跨文件理解仍是短板。AI 工具的价值是"省去样板代码",但架构设计、业务逻辑、边界条件仍需要人。我们公司 17 人开发团队,Copilot 节省时间约 18%,但 review 工作量增加 25%(防 AI 引入隐患),净收益 8%。
二十三、引申十一:多租户架构在 .NET 9 上
SaaS 多租户的 .NET 9 实现:(1) Schema-per-tenant:每个 tenant 独立 PostgreSQL schema,EF Core 用 HasDefaultSchema 动态切换;(2) Database-per-tenant:大客户独立 DB,共享 server;(3) Shared schema + RLS:中小 tenant 共享表 + Row-Level Security。我们用混合策略:大客户(>100 万 ARR)独立 DB,中客户独立 schema,小客户共享 schema + RLS。技术栈:(a) Finbuckle.MultiTenant 8 做 tenant resolution;(b) JWT 包含 tenant_id;(c) Middleware 注入 ITenantContext;(d) EF Core Query Filter 自动加 tenant_id 过滤。多租户是 SaaS 的命脉,绝对不能"用 if (tenantId == xxx)"靠业务代码强制隔离。
二十四、引申十二:.NET 9 在 Linux ARM64 的实战
2026 年 ARM64 在云上占比已超 30%(AWS Graviton4 / Azure Cobalt 100 / GCP Axion)。.NET 9 + ARM64 实战:(1) 编译目标 RuntimeIdentifier=linux-arm64;(2) Docker 镜像基于 mcr.microsoft.com/dotnet/aspnet:9.0-alpine-arm64v8;(3) AOT 在 ARM64 上同样支持,binary 大小再降 10%;(4) Azure Cobalt 100 价格比 x64 便宜 22%,性能持平。实际收益:我们的 6 个 AOT 服务切到 Cobalt 100 ARM64 实例,月成本降 28%,P99 性能持平(无统计学差异)。"x64 only" 在 2026 年是反模式,ARM64 必须纳入技术栈。多架构镜像用 docker buildx 一次构建,CI 自动 push manifest。
二十五、引申十三:.NET 与 Rust 互操作
2026 年的 .NET 工程开始大规模 Rust 互操作:(1) 性能敏感模块用 Rust 实现,.NET P/Invoke 调用;(2) WebAssembly:Rust 编译到 WASM,.NET 9 WASI 加载执行。我们的实战:(a) 高频字符串处理切 Rust,P99 降 65%;(b) 加密签名(secp256k1 / ed25519)切 Rust,吞吐提升 4 倍;(c) gRPC 序列化热点切 Rust + prost,延迟降 35%。互操作的代价:(1) 团队需要 1-2 个懂 Rust 的工程师;(2) CI 构建复杂度上升(Rust 编译耗时);(3) 错误堆栈跨语言难调试。"全栈 .NET" 在 2026 年是奢侈,真正的 SaaS 必须 polyglot,.NET + Rust + TypeScript 是黄金组合。
二十六、引申十四:F# 在 .NET 9 上的位置
F# 在 2026 年仍是 .NET 的"小众但高价值"语言。适用场景:(1) 计算密集型业务规则(保险定价、金融计算);(2) 复杂状态机(订单流程、审批工作流);(3) Discriminated Union + Pattern Matching 表达力;(4) Type Provider 处理外部数据。我们的实践:计费引擎核心算法 4000 行 F# 代码,与 6000 行 C# 业务层混编,F# 部分 bug 率仅为 C# 部分的 1/3。F# 与 AOT 兼容性已经很好(.NET 9),与 C# interop 无缝。"F# 是 .NET 工程师的隐藏武器",大型团队应该有 1-2 个 F# 高手,关键场景独当一面。
二十七、引申十五:Blazor United 在 2026 年
Blazor United(.NET 8 引入,9 完善)统一了 Server / WebAssembly / Auto 三种渲染模式。2026 年 Blazor 现状:(1) 内部管理后台 / B2B SaaS:Blazor 性价比最高,与 C# 后端共享 DTO,前端工程师可选;(2) C 端高互动产品:仍是 React / Vue 主场,Blazor WASM 首屏加载是硬伤;(3) Mobile + Desktop:Blazor Hybrid + MAUI,代码复用率高。我们的选择:管理后台 Blazor United(Server + Auto)模式,toC 产品保留 React。Blazor 不是要"替代" React,而是给 .NET 团队多一个选项,场景匹配才用。
二十八、引申十六:对 .NET 团队管理的思考
16 天升级让我重新审视 .NET 团队管理。2026 年 .NET 团队的核心能力:(1) 全栈现代化:不再有"前端工程师"和"后端工程师"的鸿沟;(2) 工具链深入:msbuild / Roslyn / source generator 必须懂;(3) 跨语言能力:Rust / TypeScript / Python 至少一门;(4) 云原生原生:K8s / Aspire / OpenTelemetry / Service Mesh 一条龙;(5) 业务理解:能与 PM 共同 review 需求。"传统 .NET 工程师"在 2026 年已经不够了,必须升级为"云原生 .NET 架构师"。我们公司 .NET 团队 17 人,5 人是过去 6 个月从 Java / Go 团队转过来的,差异化思维反而带来很多创新。
二十九、引申十七:对 .NET 未来 5 年的展望
2026-05 时点的判断:(1) .NET 10 LTS(2025-11 发布)将是大型企业级标准,2026-2028 主流;(2) .NET 11/12 重心是 AI Native:内置 LLM SDK、Semantic Kernel 标准化、vector DB 一等公民;(3) Native AOT 将覆盖 80% 微服务场景;(4) Aspire 演进成 .NET 分布式应用事实标准,与 Dapr 部分功能重叠;(5) F# + 函数式范式在 AI / 计算场景占比上升。.NET 在 2026 年的位置:不再是"Windows / Microsoft 企业级语言",而是"高生产力 + 高性能 + 跨平台"的现代化工程栈。这次踩坑录是我们对 .NET 升级的实战注脚,献给每个 .NET 工程师。
三十、监控与可观测性体系
17 个 .NET 9 service 的可观测性栈:(1) Metrics:Prometheus + Grafana,内置 System.Diagnostics.Metrics Meter;(2) Logs:Loki + Serilog/ZLogger,结构化日志;(3) Traces:OpenTelemetry .NET 1.10 + Tempo;(4) Profile:dotnet-monitor + Continuous Profiler,生产环境采样;(5) Crash dump:dotnet-dump 自动收集 + Azure Storage 归档。核心 SLO:(a) API P99 < 500ms;(b) Webhook 投递成功率 > 99.95%;(c) 计费准确率 100%(0 容忍);(d) 多租户隔离 100%(0 容忍)。这套监控让我们 P99 异常的定位时间从 22 分钟降到 3 分钟。
三十一、总结与对架构师的话
16 天 .NET 升级、8 个反模式、10 套修法、17 服务规模、3 次回滚、6 服务 AOT 化、冷启动从 3.8s 降到 80ms、年化云成本节省 32%。这次升级的真正收益不是性能指标,而是团队对"现代 .NET 工程"的认知重塑。.NET 6 → .NET 9 + Aspire + AOT 不是简单的版本号变化,而是工程范式的整体跃迁。架构师的核心能力不是"追新版本",而是"判断哪些新能力值得现在投入"——这是 16 天用血泪换来的教训。下一段演进(.NET 10 LTS + AI Native),我们已经在准备了。
三十二、附:.NET 升级 30 天行动指南
给打算做类似升级的团队一份 30 天清单。第 1-5 天:技术调研 + 兼容性矩阵 + benchmark 基线。第 6-10 天:升级到 .NET 8 中间站,csproj / NuGet 包逐个适配。第 11-15 天:升级到 .NET 9,EF Core / OTel / gRPC 关键栈重点测试。第 16-20 天:选 3-5 个适合 AOT 的服务切 Native AOT,build 时间 / 镜像大小 / 启动延迟全维度对比。第 21-25 天:Aspire 编排接管本地开发 + CI/CD,docker-compose 退役。第 26-30 天:复盘 + 文档化 + 团队培训 + 季度架构 review 流程化。这套 30 天行动指南是我们这次升级后沉淀的方法论。技术执行是一回事,组织协同是另一回事,两者同等重要。
三十三、最后忠告
.NET 不是一个静态的产物,而是一条持续演进的轨道。今天的 .NET 9 明天就是 .NET 10,今天的 Native AOT 明天就是 Native AOT v2,今天的 Aspire 9.1 明天就是 Aspire 10。真正决定你能否驾驭这条赛道的,不是某个版本的熟练度,而是"持续学习 + 工程纪律 + 架构判断力"三件套。这份踩坑录献给每个还在路上的 .NET 工程师,愿你们少走 1-2 周弯路,愿这份血泪文档能给你带来一点启发。.NET 之路漫长,但每一次升级都让我们更接近"现代化工程"这个时代命题。
三十四、附录:.NET 9 性能基准对比
我们在生产环境对 17 个 service 跑了完整 benchmark,数据对比 .NET 6 vs .NET 9 JIT vs .NET 9 AOT:(1) 简单 JSON API 吞吐:.NET 6 = 38,000 RPS / .NET 9 JIT = 52,000 RPS / .NET 9 AOT = 58,000 RPS;(2) EF Core 复杂查询 P99:.NET 6 = 180ms / .NET 9 = 142ms;(3) gRPC unary call P99:.NET 6 = 3.8ms / .NET 9 = 2.2ms / .NET 9 AOT = 1.8ms;(4) 冷启动时间:.NET 6 = 3.8s / .NET 9 = 2.6s / .NET 9 AOT = 80ms;(5) 镜像大小:.NET 6 = 240MB / .NET 9 = 220MB / .NET 9 AOT = 38MB;(6) 内存常驻:.NET 6 = 280MB / .NET 9 = 240MB / .NET 9 AOT = 65MB。结论:JIT 模式下 .NET 9 比 .NET 6 综合性能提升 28%,AOT 模式下再叠加 15-20% 收益(冷启动 / 内存维度则是数量级提升)。这套数据是真实生产负载,不是 microbenchmark,可供同行参考。
三十五、附录:升级回滚机制
16 天里 3 次回滚的真实场景:(1) Day 7:EF Core 9 LINQ 翻译变化导致订单查询 P99 飙升,回滚到 EF Core 8 + .NET 8;(2) Day 12:Native AOT 切换后 AutoMapper 全部抛异常,回滚到 JIT;(3) Day 15:Aspire AppHost 在 staging 环境拉起所有容器后 OOM,回滚到 docker-compose。回滚机制设计:(a) 每个 release 打 git tag + 容器镜像 immutable 保留;(b) 数据库 schema 变更必须 backward-compatible(新版本能读旧 schema,旧版本能读新 schema);(c) feature flag 控制新代码路径,回滚时关 flag 即可;(d) 蓝绿部署 + 5 分钟健康检查窗口;(e) 自动化回滚脚本,从触发到完全恢复 < 8 分钟。没有回滚机制的升级是赌博,有回滚机制的升级是工程。这是 16 天最深刻的教训。
三十六、写在最后
16 天升级过程中熬过的每个夜晚,都换来今天 .NET 9 + Aspire + AOT 的稳定运行。每一个 csproj 节点、每一个 source generator、每一次 AOT trim warning,都是团队工程纪律的微小成长。把这些经验完整记下来,是对团队 16 天辛苦的尊重,也是对未来路过同样关口同行的礼物。架构演进之路漫长,愿这份文档能让你们少走 1-2 周弯路。下一次 .NET 10 LTS + AI Native 升级,我们已经在路上了。这份踩坑录献给每个还在 .NET 路上的工程师,愿我们都能在云原生时代继续保持热爱与好奇。
—— 别看了 · 2026