Skip to content
Go back

10 个会让 .NET 10 API 崩溃生产的反模式(以及如何修复)

大多数关于 .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 套接字的 HttpClientHandlerDispose 客户端时,套接字不会立即关闭——它们以 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# 有两种重新抛出捕获异常的方式:throwthrow 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()); // 危险!
});

失败现场: WhereSelect 在 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>());

失败现场(两条路,都很糟):

  1. 发布命令输出大量关于不安全反射、动态代码和裁剪警告的 AOT 分析器警告。构建仍然成功,但团队发布了一个在生产中首次运行映射时就抛 InvalidOperationException: dynamic code is not supported 的二进制文件。
  2. 构建裁剪掉了反射元数据,映射器对第一个它见到的类型”正常工作”,然后对构建时没有被执行的下一个类型静默失败。

作者在一次 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小时),但在公告第一天就做,没有备忘录,是团队如何发布一个拖了一个季度的半成品迁移的原因。

修复: 读到公告当天就执行这四个问题清单,然后等一周

  1. 根据新许可证,我们符合免费层吗?
  2. 最后一个免费版本能安全使用(安全公告)吗?
  3. 采用替代方案的成本是什么?
  4. 替代方案能给我们旧库做不到的东西吗?

对于大多数收入低于 $500 万的团队,第1题的答案是”是”——恐慌是不必要的。对于超过门槛的团队,第4题的答案有时是”是”(Mapperly 的 Native AOT 支持,自定义 CQRS 分发器的4倍性能),迁移就成为一个有计划的工程决策,而不是救火演习。

严重性汇总

#反模式严重性修复成本现在修?
1async void 用在事件处理器外🔴 杀死生产低(搜索+替换)
2.Result/.Wait() 同步覆盖异步🔴 杀死生产低-中(async传播)
3每次请求 new HttpClient()🔴 杀死生产低(AddHttpClient注册)
4throw ex 而不是 throw⚪ 外观微不足道见到就改
5胖控制器🟡 拖慢速度中(重构到 handler)下个冲刺
6在简单CRUD上套 Repository🟡 拖慢速度中(删代码为主)下个冲刺
7从异步 Action 返回 IEnumerable<T>🟠 损耗金钱微不足道(ToListAsync)本冲刺
8单例注入作用域服务🔴 杀死生产低(ValidateScopes = true)
9AOT 项目中的运行时反射映射器🟠 损耗金钱中(迁移到 Mapperly)AOT发布前
10恐慌采购商业库🟠 损耗金钱(真实金钱)免费(执行4个问题)采购前

关键结论

参考


Tags


Next

C# 反射:.NET 10 完全指南