大多数关于 .NET API 反模式的文章把所有坏实践铺成一张清单,统一标注”不要这样做”。这没什么用。有些反模式会在凌晨两点崩溃生产,另一些只会让你的冲刺速度慢 5%。把它们等同对待,才是为什么很多团队花了一个季度重命名变量,同时放任一个 new HttpClient() 悄悄耗尽生产的套接字。
本文梳理了 10 个在真实 .NET 10 代码库中反复出现的反模式,每一个都有破坏半径评级、真实失败模式,以及修复方法。
快速结论:如果你只能修三个,就修这三个:
async void(会杀死进程)、每次请求new HttpClient()(会耗尽套接字)、单例注入作用域服务(会污染跨请求状态)。
什么是反模式
反模式是一种对常见问题反复出现的”解决方案”,看起来合理,实则弊大于利。关键词是”反复”:开发者不断伸手去拿它,因为它看起来对,然后不断为它付账。
这和 Bug(一处具体的错误行为)不同,也和代码坏味道(可能有问题但不一定是问题的症状)不同。一个 Bug 崩溃一个功能;一个坏味道让一个文件难以阅读;一个反模式会在规模化时降级整个代码库——同一个错误形状被复制到各个服务,失败模式到处都是。
在 .NET 10 API 开发中,反模式集中在三个家族:异步正确性(语言让你容易走错路)、依赖管理(DI 给了你绳子)、以及架构懒惰(短期便利积累成长期维护负债)。
严重性矩阵
不是每个反模式都一样重要。以下是按破坏半径的分级:
| 严重性 | 含义 | 本文对应的反模式 |
|---|---|---|
| 🔴 杀死生产 | 应用崩溃、死锁或在真实负载下返回错误数据 | #1 async void、#2 sync-over-async、#3 new HttpClient()、#8 captive dependency |
| 🟠 损耗金钱 | 应用正常运行,但消耗了不必要的 CPU、内存或云费用 | #7 IEnumerable<T>返回异步动作、#9 AOT项目中的运行时反射映射器、#10 恐慌采购商业库 |
| 🟡 拖慢速度 | 代码运行正常,但每个新功能都比应该的慢 | #5 胖控制器、#6 在简单CRUD上套 Repository Pattern |
| ⚪ 外观问题 | 代码运行正常,但略难阅读 | #4 throw ex |
矩阵放在最前面,因为后面每一节都会引用它。如果你的团队带宽有限,从上往下修。一个请求处理函数里的 new HttpClient() 比重构胖控制器优先级更高。
反模式 #1:事件处理器以外使用 async void
严重性:🔴 杀死生产
C# 编译器允许你写 async void 方法,语言设计者也明确警告不要这样做。然而我在 API 代码库中反复见到它,通常出现在即发即忘的后台工作中。
反例:
app.MapPost("/orders", async (CreateOrderRequest req, IOrderService orders) =>
{
SendConfirmationEmailAsync(req.CustomerEmail); // 即发即忘
var id = await orders.CreateAsync(req);
return Results.Created($"/orders/{id}", new { id });
});
static async void SendConfirmationEmailAsync(string email)
{
await Task.Delay(100);
throw new InvalidOperationException("SMTP down");
}
失败现场: 在 async void 方法中抛出的异常无法被调用代码捕获。它会传播到捕获该方法的 SynchronizationContext——而在 ASP.NET Core 中默认没有同步上下文,所以异常作为 AppDomain.UnhandledException 出现,进而终止进程。一个本应返回 201 Created 的请求反而把整个 API 实例崩溃了。
作者在生产中调试过这个确切的场景:一次 SMTP 故障触发了一波 async void 后台发送,主机挂了,负载均衡器轮换到各实例,几分钟内每个实例都以同样的方式崩溃。修复花了 10 分钟,找到原因花了 4 小时,因为所有堆栈跟踪都指向 [GC] 而不是邮件代码。
修复:
app.MapPost("/orders", async (CreateOrderRequest req, IOrderService orders, ILogger<Program> log) =>
{
var id = await orders.CreateAsync(req);
_ = Task.Run(async () =>
{
try { await SendConfirmationEmailAsync(req.CustomerEmail); }
catch (Exception ex) { log.LogError(ex, "Confirmation email failed for {Email}", req.CustomerEmail); }
});
return Results.Created($"/orders/{id}", new { id });
});
static async Task SendConfirmationEmailAsync(string email)
{
await Task.Delay(100);
throw new InvalidOperationException("SMTP down");
}
async Task 返回类型强制异常在 Task 上浮现,用 Task.Run 包裹加 try/catch 实现即发即忘而不崩溃主机。更干净的方案是通过托管服务或后台队列发送邮件——但最低要求是永远不要在事件处理器以外使用 async void。
反模式 #2:对异步代码使用 .Result 和 .Wait()(同步覆盖异步)
严重性:🔴 杀死生产
同步方法需要调用异步方法时,很容易的反应是在调用上拍 .Result 或 .Wait() 了事。在控制台应用里也许能侥幸过关;在 ASP.NET Core 里,不行。
反例:
app.MapGet("/products/{id}", (int id, IProductService products) =>
{
var product = products.GetByIdAsync(id).Result; // 危险!
return Results.Ok(product);
});
失败现场: 在任何有意义的负载下(约50-100并发请求),.Result 会导致线程池耗尽。线程池 worker 同步等待一个会在另一个线程池 worker 上恢复的 Task——如果所有 worker 都在阻塞等待,没有线程剩下来完成工作,请求开始超时。典型症状:API 在负载测试中表现正常直到 N 个并发用户,然后在 N+1 时断崖式下跌。
这是生产代码审查中最常见的”应用变慢”原因。团队增加 Pod、扩展数据库、更换缓存提供商——实际瓶颈是一个热处理程序里的一个 .Result 调用。
修复: 端到端使用 async/await。
app.MapGet("/products/{id}", async (int id, IProductService products) =>
{
var product = await products.GetByIdAsync(id);
return Results.Ok(product);
});
如果你在构造函数或其他不允许 await 的上下文中,重新设计 API。异步工作属于 InitializeAsync 方法或工厂,不属于 .Result。ASP.NET Core 的 Minimal API 端点、MVC Action 和中间件都原生支持异步——.NET 10 中没有需要 .Result 的场景。
反模式 #3:每次请求创建 new HttpClient()
严重性:🔴 杀死生产
HttpClient 实现了 IDisposable,所以自然反应是在每个需要它的方法里写 using var http = new HttpClient()。这个直觉是错的——而且这个错误自 .NET Core 2.x 就已是众所周知的问题。
反例:
app.MapGet("/weather/{city}", async (string city) =>
{
using var http = new HttpClient();
var response = await http.GetStringAsync($"https://api.example.com/weather/{city}");
return Results.Content(response, "application/json");
});
失败现场: HttpClient 持有一个池化 TCP 套接字的 HttpClientHandler。Dispose 客户端时,套接字不会立即关闭——它们以 TIME_WAIT 状态保留 240 秒(Windows 默认值)。在持续负载下,你会耗尽临时端口,每个新请求都触发 SocketException: only one usage of each socket address is normally permitted。
作者见过这个问题破坏一个平均 50 RPS 的服务。团队的直觉是扩容,而真正的修复是把 HttpClient 一行重构为 IHttpClientFactory。
修复:
builder.Services.AddHttpClient("weather", c =>
c.BaseAddress = new Uri("https://api.example.com"));
app.MapGet("/weather/{city}", async (string city, IHttpClientFactory factory) =>
{
var http = factory.CreateClient("weather");
var response = await http.GetStringAsync($"/weather/{city}");
return Results.Content(response, "application/json");
});
IHttpClientFactory 池化底层 handler 并定期轮换以避免 DNS 过期——两个问题一行注册全解决。对于需要与多个第三方通信的服务,为每个依赖定义一个 Typed HttpClient。
反模式 #4:用 throw ex 捕获并重新抛出
严重性:⚪ 外观问题,但调试时代价高昂
C# 有两种重新抛出捕获异常的方式:throw 和 throw ex。看起来一样,实则不然。
反例:
try
{
await _repository.SaveAsync(entity);
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "Save failed");
throw ex; // 危险!
}
失败现场: throw ex 重置异常的堆栈跟踪到重新抛出那一行,而不是保留原始的。你的错误日志说异常来自 SomeService.cs 的第47行——那是 throw ex 那行。异常实际起源的地方(深入 EF Core 三帧之下)已经消失了。你花一个小时读 EF Core 源码来弄清楚是哪个 SaveChangesAsync 失败的,因为堆栈跟踪说谎了。
这是矩阵里代价最低的生产问题——它不会真正破坏什么。但它在团队中积累的调试时间不可小觑。
修复:
try
{
await _repository.SaveAsync(entity);
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "Save failed");
throw; // 正确:保留原始堆栈跟踪
}
去掉 ex。裸的 throw 保留原始堆栈跟踪。更好的方式是不要捕获你无法处理的内容——大多数情况下正确答案是让异常冒泡到全局异常处理器。
反模式 #5:胖控制器(或胖 Minimal API Handler)
严重性:🟡 拖慢速度
一个做验证、调数据库、转换数据、写审计日志、发邮件、返回响应的控制器或端点,在技术上没有崩溃。但它扩展慢——每个新功能都碰同一个 200 行的方法。
反例(胖 handler):
app.MapPost("/orders", async (
CreateOrderRequest req,
AppDbContext db,
IEmailService email,
IAuditLog audit,
ILogger<Program> log) =>
{
if (string.IsNullOrEmpty(req.CustomerEmail)) return Results.BadRequest("Email required");
if (req.Items.Count == 0) return Results.BadRequest("At least one item required");
var customer = await db.Customers.FirstOrDefaultAsync(c => c.Email == req.CustomerEmail);
// ... 查询、创建、保存、发邮件、记审计 ... 全在这里
return Results.Created($"/orders/{order.Id}", order.ToResponse());
});
失败现场: 不是生产崩溃——是组织崩溃。三个工程师无法并行在这个端点上工作而不发生合并冲突。验证逻辑无法被另一个创建流复用。邮件发送阻塞响应。审计日志和订单保存不在事务中。每个新需求都给同一个方法加一个 await。
修复: 把业务逻辑移出去。使用薄 handler/命令,或 CQRS 分发器。端点变成 HTTP 和领域之间的翻译层:
app.MapPost("/orders", async (CreateOrderRequest req, IDispatcher dispatcher) =>
{
var result = await dispatcher.Send(new CreateOrderCommand(req));
return result.IsSuccess
? Results.Created($"/orders/{result.Value.Id}", result.Value)
: Results.BadRequest(result.Error);
});
验证移入 validator,订单创建移入命令 handler,邮件发送移入集成事件或后台队列。端点只负责一件事:HTTP 到领域的翻译。
反模式 #6:在简单 CRUD 上套 Repository Pattern
严重性:🟡 拖慢速度
Repository Pattern 在 EF 4 时代有意义——那时 ObjectContext 难以 mock,Unit of Work 也没有内置。在 EF Core 10 中,DbContext 本身已经是 Unit of Work,DbSet<T> 本身已经是 Repository。再用你自己的 IProductRepository 包一层,是两个接口、零价值。
反例:
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id, CancellationToken ct);
Task<List<Product>> GetAllAsync(CancellationToken ct);
Task AddAsync(Product product, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}
// 实现只是把 DbContext 调用转发出去
失败现场: 不是失败——是杂乱。每个 CRUD 操作现在需要更新两个文件:接口和实现。Repository 永远无法使用 EF Core 功能(Include、projection、AsSplitQuery)而不通过接口泄漏它们。测试看起来更容易,但实践中你得到的是脆弱的 mock,它们会偏离真实 EF 行为。
作者的观点:如果你的聚合根适合一张表,完全跳过 Repository Pattern。在命令/查询 handler 中直接使用 DbContext。Repository 只有在你有真正的聚合(DDD 风格需要封装规则)或需要在运行时切换数据源时才值得。
修复:
public sealed class CreateProductHandler(AppDbContext db)
{
public async Task<int> Handle(CreateProductCommand cmd, CancellationToken ct)
{
var product = new Product { Name = cmd.Name, Price = cmd.Price };
db.Products.Add(product);
await db.SaveChangesAsync(ct);
return product.Id;
}
}
没有接口,没有 mock,没有 IProductRepository。用 Testcontainers 的集成测试覆盖真实 EF Core 路径和真实数据库。这么薄的代码不需要单元测试。
反模式 #7:从异步 Action 返回 IEnumerable<T>
严重性:🟠 损耗金钱
从异步端点返回 IEnumerable<T> 看起来符合人体工学,实则在性能上微妙地错了。
反例:
app.MapGet("/products", async (AppDbContext db) =>
{
IEnumerable<Product> products = await db.Products.ToListAsync();
return products.Where(p => p.IsActive).Select(p => p.ToResponse()); // 危险!
});
失败现场: Where 和 Select 在 JSON 序列化期间同步执行,在请求线程上,逐项处理。对于10,000条产品的列表,这意味着10,000次延迟 LINQ 评估与 JSON writer 交织。内存压力和 GC 暂停上升。在负载下,P99 延迟从 80ms 涨到 250ms。
修复: 在返回前物化,并把过滤和映射推入 IQueryable 让 EF Core 翻译成 SQL。
app.MapGet("/products", async (AppDbContext db) =>
{
var products = await db.Products
.Where(p => p.IsActive)
.Select(p => p.ToResponse())
.ToListAsync();
return products;
});
JSON 序列化器现在迭代一个完全物化的 List<T>,没有延迟工作。相同的逻辑结果,截然不同的内存profile。
反模式 #8:单例注入作用域服务(Captive Dependency)
严重性:🔴 杀死生产
ASP.NET Core 的 DI 容器有三种生命周期:Transient、Scoped、Singleton。混用不当会创造captive dependency——生命周期更长的服务持有生命周期更短的服务。
反例:
public sealed class CacheService(AppDbContext db) // AppDbContext 是 Scoped
{
public async Task<Product?> GetAsync(int id, CancellationToken ct)
{
// 使用被捕获的 DbContext
}
}
builder.Services.AddSingleton<CacheService>(); // 危险:捕获了 Scoped DbContext
失败现场: 第一个请求把 CacheService 解析为单例时,容器把第一个请求的 AppDbContext 注入进去。此后每一个请求——跨越每一个连接、每一个用户——都使用那同一个 DbContext。EF Core 的 DbContext 不是线程安全的。在任何并发下你都会得到 InvalidOperationException: A second operation was started on this context 错误。更糟的是:一个用户的数据可能泄漏到另一个用户的响应中,因为他们共享同一个变更追踪器。
作者在一次生产审计中见过这个确切的 Bug:团队的”缓存”服务持有第一个幸运请求解析的 DbContext;在接下来的 24 小时里,所有读取都经过那一个 context,不断积累被跟踪的实体,直到进程最终 OOM。
修复: 注入 IServiceScopeFactory 并在每次缓存未命中时创建作用域。或者更好——使用 HybridCache,这是 .NET 10 对这个确切场景的官方答案。
public sealed class CacheService(HybridCache cache, IServiceScopeFactory scopeFactory)
{
public ValueTask<Product?> GetAsync(int id, CancellationToken ct) =>
cache.GetOrCreateAsync($"product:{id}", async (ct) =>
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.Products.FindAsync([id], ct);
}, cancellationToken: ct);
}
还有一个防护手段:在生产主机中开启 ValidateScopes = true,让 DI 容器在启动时就捕获 captive dependency,而不是等到生产中:
builder.Host.UseDefaultServiceProvider(opts => opts.ValidateScopes = true);
这一行会在应用首次启动时暴露每一个 captive dependency。它在 Development 模式默认开启,但在 Production 模式默认关闭——而这恰恰是需要捕获真实 Bug 的地方。把它打开。
反模式 #9:在 Native AOT 项目中使用运行时反射映射器
严重性:🟠 损耗金钱(AOT 破坏或运行时失败)
这是一个 2026 年特有的反模式——在 .NET 8 使 Native AOT 成为真正的生产选项之前它不存在。错误:在有 <PublishAot>true</PublishAot> 的项目中添加 AutoMapper 或默认模式的 Mapster。
反例:
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="14.0.0" />
</ItemGroup>
builder.Services.AddAutoMapper(cfg => cfg.AddProfile<ProductProfile>());
失败现场(两条路,都很糟):
- 发布命令输出大量关于不安全反射、动态代码和裁剪警告的 AOT 分析器警告。构建仍然成功,但团队发布了一个在生产中首次运行映射时就抛
InvalidOperationException: dynamic code is not supported的二进制文件。 - 构建裁剪掉了反射元数据,映射器对第一个它见到的类型”正常工作”,然后对构建时没有被执行的下一个类型静默失败。
作者在一次 Native AOT 迁移中遇到过这个确切的案例:团队迁移到 AOT 是为了冷启动性能,构建是绿的,冒烟测试本地通过——然后第一个需要不常用映射的生产请求崩了,抛出 MissingMetadataException。
修复: 使用源生成映射器如 Mapperly,或用扩展方法手写映射。两者都能在 PublishAot = true 下干净工作。
<ItemGroup>
<PackageReference Include="Riok.Mapperly" Version="4.2.1" />
</ItemGroup>
[Mapper]
public partial class ProductMapper
{
public partial ProductResponse ToResponse(Product product);
}
反模式 #10:在检查免费层之前恐慌采购商业库
严重性:🟠 损耗金钱(真实的金钱)
当 MediatR 在 2025 年开始商业化、AutoMapper 紧随其后时,.NET 社区有三种反应:立即付款、恐慌迁移、或写一页冷静的备忘录。后两种中有一种是错的。
失败现场: 周一读到公告,周五签采购单,从未检查团队的年总收入是否在免费社区层之内。真实的金钱代价:AutoMapper 商业定价从最小层约 $489/年开始;MediatR 在同一范围。对于年总收入低于 $500 万的组织,两个产品都提供免费社区许可证,涵盖商业用途。没有检查层级就付费的团队,付了不需要付的钱。
另一个失败模式是反过来的:在检查是否需要迁移之前就恐慌迁移到不够成熟的替代品。把现有 AutoMapper Profile 迁移到 Mapperly 的成本不大(每30个 DTO 约2-4小时),但在公告第一天就做,没有备忘录,是团队如何发布一个拖了一个季度的半成品迁移的原因。
修复: 读到公告当天就执行这四个问题清单,然后等一周:
- 根据新许可证,我们符合免费层吗?
- 最后一个免费版本能安全使用(安全公告)吗?
- 采用替代方案的成本是什么?
- 替代方案能给我们旧库做不到的东西吗?
对于大多数收入低于 $500 万的团队,第1题的答案是”是”——恐慌是不必要的。对于超过门槛的团队,第4题的答案有时是”是”(Mapperly 的 Native AOT 支持,自定义 CQRS 分发器的4倍性能),迁移就成为一个有计划的工程决策,而不是救火演习。
严重性汇总
| # | 反模式 | 严重性 | 修复成本 | 现在修? |
|---|---|---|---|---|
| 1 | async void 用在事件处理器外 | 🔴 杀死生产 | 低(搜索+替换) | 是 |
| 2 | .Result/.Wait() 同步覆盖异步 | 🔴 杀死生产 | 低-中(async传播) | 是 |
| 3 | 每次请求 new HttpClient() | 🔴 杀死生产 | 低(AddHttpClient注册) | 是 |
| 4 | throw ex 而不是 throw | ⚪ 外观 | 微不足道 | 见到就改 |
| 5 | 胖控制器 | 🟡 拖慢速度 | 中(重构到 handler) | 下个冲刺 |
| 6 | 在简单CRUD上套 Repository | 🟡 拖慢速度 | 中(删代码为主) | 下个冲刺 |
| 7 | 从异步 Action 返回 IEnumerable<T> | 🟠 损耗金钱 | 微不足道(ToListAsync) | 本冲刺 |
| 8 | 单例注入作用域服务 | 🔴 杀死生产 | 低(ValidateScopes = true) | 是 |
| 9 | AOT 项目中的运行时反射映射器 | 🟠 损耗金钱 | 中(迁移到 Mapperly) | AOT发布前 |
| 10 | 恐慌采购商业库 | 🟠 损耗金钱(真实金钱) | 免费(执行4个问题) | 采购前 |
关键结论
- 反模式是一种”看起来合理”却在规模化时造成危害的方案。不是所有反模式都一样——有些凌晨两点崩溃生产,其他的只是拖慢速度。
- 2026年的4个生产级杀手:
async void用于事件处理器外,.Result/.Wait()同步覆盖异步,每次请求new HttpClient(),以及单例持有作用域服务(captive dependency)。 async void的异常无法被捕获——异常会终止进程。使用async Task加Task.Run+ try/catch,或使用托管服务。- 每次请求
new HttpClient()通过TIME_WAIT耗尽套接字。改用IHttpClientFactory.CreateClient()——它池化 handler 并轮换 DNS。 - 在生产中开启
ValidateScopes = true,在启动时而不是生产中捕获 captive dependency。微软默认在 Development 开启,在 Production 关闭——这是捕获真实 Bug 的错误默认值。 - 在 EF Core 10 中,简单 CRUD 上的 Repository Pattern 是死重。
DbContext已经是 Unit of Work,DbSet<T>已经是 Repository。 - 2026年的新挑战:在 Native AOT 项目中使用运行时反射映射器会导致
MissingMetadataException;在检查免费社区层之前恐慌采购商业库是浪费预算。