为什么这个API慢得离谱?从400ms到40ms的.NET 9性能优化实战

作者:微信公众号:【架构师老卢】
8-26 19:28
9

和其他“简单”的性能抱怨一样,这个故事始于一个看似普通的性能问题。我们的.NET 9 Minimal API拥有所有时髦的特性——轻量级、快速启动、简洁的端点。但在生产环境中?平均延迟高达400毫秒。

这还发生在热路径上。一个GET请求。甚至没有数据库调用。

作为长期使用C#的开发者,我能感觉到事情不对劲。于是我打开性能分析器,准备深入调查。随后便陷入了一系列“无害”中间件、不当使用的HttpClient和出乎意料的异步开销的迷宫。

经过两天残酷的优化,我们将这个API的中位延迟降低到了约40毫秒。本文记录了每一个关键的修复步骤、基准测试和代码调整。

如果你在生产环境运行.NET 9 API,这可能是你这周最有价值的10分钟阅读。

第一步:先分析,别猜测 在深入代码之前,重要提醒:不要盲目优化。我使用了以下工具:

  • dotnet-trace:追踪GC压力和方法级性能
  • dotnet-counters:监控CPU、分配率和请求吞吐量
  • JetBrains Rider Profiler:深入分析调用栈和慢端点

这是我立即发现的问题:

[400ms总延迟] └── 120ms: 中间件(自定义日志、CORS、指标收集) └── 80ms: JSON序列化 └── 60ms: HttpClient实例化(😬) └── 40ms: GC暂停(分配密集型代码) └── 100ms: 实际处理逻辑

现在我们来逐一解决。

第二步:无情削减中间件 我们喜欢可观测性,但在Minimal API中,中间件的成本是真实存在的。

原来代码:

app.Use(async (context, next) => {
    var sw = Stopwatch.StartNew();
    await next();
    logger.LogInformation($"Request took {sw.ElapsedMilliseconds}ms");
});

app.UseCors(...);
app.Use(async (context, next) => {
    metrics.Increment("api_requests");
    await next();
});

问题:每个Use都增加异步开销,Stopwatch增加每次请求的分配

✅ 修复:

  • 使用Middleware类替代内联中间件(减少lambda捕获)
  • 通过OpenTelemetry将日志和指标推送到ActivityListener
  • 内部API完全移除CORS

效果:节省约80ms

第三步:重用你的HttpClient 这个有点尴尬。在我们的处理程序中:

app.MapGet("/data", async () => {
    using var client = new HttpClient();
    var result = await client.GetStringAsync("https://internal-api/data");
    return Results.Ok(result);
});

经典新手错误:每次请求都销毁HttpClient会杀死socket复用

✅ 修复:

var httpClient = new HttpClient(new SocketsHttpHandler {
    PooledConnectionLifetime = TimeSpan.FromMinutes(5)
});

app.MapGet("/data", async () => {
    var result = await httpClient.GetStringAsync("https://internal-api/data");
    return Results.Ok(result);
});

或者更推荐使用IHttpClientFactory(如果需要策略)

效果:节省约60ms,负载下CPU降低12%

第四步:异步并不总是免费的 有个误区:异步=快速。并非总是如此。

如果你的端点不需要等待I/O(比如从内存读取),异步只会增加上下文切换和额外分配。

我们的“健康检查”端点原来是这样的:

app.MapGet("/health", async () => {
    return Results.Ok("Healthy");
});

✅ 修复: 直接改为同步

app.MapGet("/health", () => Results.Ok("Healthy"));

仅此一项就节省了约20ms(避免了异步状态机)

第五步:精简JSON System.Text.Json很快——但需要正确配置。

我们使用了默认设置,会序列化所有内容:包括null值和不需要的巨大DTO属性。

✅ 修复:

  • 使用[JsonIgnore]或创建精简DTO
  • 全局配置JSON:
builder.Services.Configure<JsonOptions>(options =>
{
    options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
    options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
  • 序列化大型已知结构时使用源生成器

效果:节省约30ms,响应大小减少18%

第六步:启用响应压缩(但非必需) GZip有帮助——除非你大规模压缩300字节的有效负载。

我们全局启用了压缩。这是个坏主意。对于小负载,这是CPU浪费。

✅ 修复:

builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[] {
        "application/json"
    });
});

然后在逻辑中:

app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/big"), builder =>
{
    builder.UseResponseCompression();
});

现在只压缩大型JSON,跳过其他内容

效果:平均节省15-20ms

额外技巧:预热JIT,缓存一切 这在负载测试前并不明显

  • 添加[PreJIT]路由或在启动时通过虚拟请求强制预热
  • 在单例或作用域服务中缓存查找和配置读取
  • 将静态数据移入内存——如枚举或只读参考数据
app.Lifetime.ApplicationStarted.Register(() =>
{
    _ = httpClient.GetStringAsync("https://internal-api/warmup");
});

这些小调整又额外节省了10-15ms

最终基准测试:优化前后 [图片:优化前后性能对比图表]

结语:性能不是偶然 Minimal API很快——但前提是你要像对待F1赛车而不是家用轿车那样对待它们。

每一层都很重要。每次分配都会累积。每个中间件、序列化器配置和异步调用都会引入摩擦。

事实上,这篇文章不是关于“巧妙技巧”,而是关于残酷的专注和对抽象成本的尊重。

如果你正在大规模部署API,性能分析不是可选的。优化不是过早的。延迟就是一个功能特性。

TL;DR 检查清单

  • 移除或合并中间件
  • 使用连接池重用HttpClient
  • 在不需要的地方避免异步
  • 精简JSON输出并使用源生成器
  • 选择性添加压缩
  • 预热JIT并积极缓存

如果这对你有帮助,请点赞并与你的团队分享。 如果你在.NET Minimal API中遇到性能瓶颈,我很想知道你是如何解决的——或者你仍然卡在哪里。

相关留言评论
昵称:
邮箱:
阅读排行