和其他“简单”的性能抱怨一样,这个故事始于一个看似普通的性能问题。我们的.NET 9 Minimal API拥有所有时髦的特性——轻量级、快速启动、简洁的端点。但在生产环境中?平均延迟高达400毫秒。
这还发生在热路径上。一个GET请求。甚至没有数据库调用。
作为长期使用C#的开发者,我能感觉到事情不对劲。于是我打开性能分析器,准备深入调查。随后便陷入了一系列“无害”中间件、不当使用的HttpClient和出乎意料的异步开销的迷宫。
经过两天残酷的优化,我们将这个API的中位延迟降低到了约40毫秒。本文记录了每一个关键的修复步骤、基准测试和代码调整。
如果你在生产环境运行.NET 9 API,这可能是你这周最有价值的10分钟阅读。
第一步:先分析,别猜测 在深入代码之前,重要提醒:不要盲目优化。我使用了以下工具:
这是我立即发现的问题:
[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增加每次请求的分配
✅ 修复:
效果:节省约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属性。
✅ 修复:
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,缓存一切 这在负载测试前并不明显
app.Lifetime.ApplicationStarted.Register(() =>
{
_ = httpClient.GetStringAsync("https://internal-api/warmup");
});
这些小调整又额外节省了10-15ms
最终基准测试:优化前后 [图片:优化前后性能对比图表]
结语:性能不是偶然 Minimal API很快——但前提是你要像对待F1赛车而不是家用轿车那样对待它们。
每一层都很重要。每次分配都会累积。每个中间件、序列化器配置和异步调用都会引入摩擦。
事实上,这篇文章不是关于“巧妙技巧”,而是关于残酷的专注和对抽象成本的尊重。
如果你正在大规模部署API,性能分析不是可选的。优化不是过早的。延迟就是一个功能特性。
TL;DR 检查清单
如果这对你有帮助,请点赞并与你的团队分享。 如果你在.NET Minimal API中遇到性能瓶颈,我很想知道你是如何解决的——或者你仍然卡在哪里。