当一个 .NET API 在预发布环境运行完美,却在真实生产负载下崩溃时,根本原因几乎从来不是单一的灾难性 Bug。它通常是一系列微小低效的累积——此处一个阻塞调用,那里一个未追踪的实体图,中间件管道配置不当——只有当并发请求量攀升至每分钟数千时才会显现。ASP.NET Core 性能调优的实践是系统地发现并消除这些低效,而不是在故障发生后被动应对。本文将依次介绍高流量 API 最关键的领域:异步正确性、缓存、数据访问、序列化、中间件排序、扩容,以及用于定位真正需要投入时间的诊断工具。
正确使用 Async/Await
生产环境 ASP.NET Core 应用中最常见的性能杀手是同步等待异步(sync-over-async)代码——在 Task 上调用 .Result 或 .Wait() 而不是 await。这会阻塞线程池线程,等待 I/O 绑定操作完成,在高负载下将引发线程池饥饿:线程池需要缓慢扩容,新请求排队等待空闲线程,导致整个应用延迟激增,而 CPU 使用率看似很低。根本解决方法是全程 await 调用栈,但这需要在整个代码库中保持纪律,包括开发者意想不到的地方,如静态构造函数、自定义中间件和后台服务循环。
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IOrderRepository _orders;
public OrdersController(IOrderRepository orders)
{
_orders = orders;
}
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> GetOrder(int id, CancellationToken cancellationToken)
{
var order = await _orders.GetByIdAsync(id, cancellationToken);
if (order is null)
{
return NotFound();
}
return Ok(order.ToDto());
}
}
Enter fullscreen mode Exit fullscreen mode
注意 CancellationToken 参数,ASP.NET Core 会自动将其连接到请求的取消信号。将它贯穿到数据访问和下游 HTTP 调用中,意味着当客户端断开连接或请求超时时,工作会立即停止,而不是继续消耗线程池和数据库资源来处理无人接收的响应。同时还要注意事件处理器之外的 async void 方法——其中抛出的异常无法被调用者捕获,会导致进程崩溃,这在高负载下是特别难以调试的故障模式。
响应缓存与输出缓存策略
并非每个请求都需要经过应用逻辑和数据库。对于在短时间内向多个调用者返回相同数据的端点——产品目录、配置数据、公共内容——输出缓存可以完全消除大部分后端工作。
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Catalog", policy =>
policy.Expire(TimeSpan.FromSeconds(60))
.Tag("catalog"));
});
var app = builder.Build();
app.UseOutputCache();
app.MapGet("/api/products", async (ICatalogService catalog) =>
{
var products = await catalog.GetAllAsync();
return Results.Ok(products);
})
.CacheOutput("Catalog");
app.Run();
Enter fullscreen mode Exit fullscreen mode
上文展示的基于标签的失效机制很重要:当产品更新时,你可以只清除 catalog 标签,而不是清空整个缓存或等待固定过期时间,从而在保持数据新鲜度的同时不牺牲命中率。即使不使用服务端输出缓存,正确设置响应缓存头(Cache-Control、ETag)也值得,因为这能让浏览器、移动客户端和 CDN 完全避免往返请求。
Minimal API 与 Controller:开销考量
Minimal API 的引入是为了减少传统 MVC Controller 管道带来的仪式和每次请求的开销。Controller 会经历模型绑定、筛选器执行和操作调用机制,虽然灵活,但与直接将路由映射到委托的 Minimal API 端点相比,确实存在可测量的开销。
不过,对于典型业务端点——数据库调用或下游 HTTP 请求占主导——这种差异远小于人们的预期。更务实的做法是:保留 Controller 在模型绑定验证、一致筛选器管道、Swagger 集成、共享基类等方面的价值, selectively 在真正热点路径或需要减少仪式的新轻量服务中使用 Minimal API。架构决策应基于实际测量的瓶颈,而不是“某种风格总是更快”的普遍信念。
连接池与数据库查询优化
数据库访问是大多数高流量 ASP.NET Core API 真正花费时间的地方。反复出现的两个问题是:只读查询上不必要的变更跟踪,以及 N+1 查询模式——单个逻辑请求悄无声息地向数据库发出数十甚至数百次往返。
Entity Framework Core 默认跟踪实体以便检测并持久化变更。对于大多数 API 的主要流量——只读查询——这种跟踪开销纯粹是浪费。调用 AsNoTracking() 可告知 EF Core 跳过构建变更跟踪快照。
public async Task<List<OrderSummaryDto>> GetRecentOrdersAsync(
int customerId, CancellationToken cancellationToken)
{
return await _dbContext.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId).OrderByDescending(o => o.CreatedAt).Take(20).Select(o => new OrderSummaryDto
{
Id = o.Id,
Total = o.Total,
Status = o.Status
})
.ToListAsync(cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode
如上所示,使用 Select() 直接投影到 DTO,除了避免跟踪开销外,还有额外好处:EF Core 可以将投影翻译成仅选择实际所需列的 SQL 查询,而不是拉回整个实体行。N+1 问题更微妙,在本地小数据集测试中往往不可见——它发生在代码加载一组父实体后,再为每个实体延迟触发单独查询以加载相关子数据。解决方法是使用 Include() 在单个查询中急切加载相关数据,或更优地仅投影所需字段。
在连接池方面,请确保连接字符串和 DbContext 生命周期针对工作负载进行了合理配置。AddDbContextPool 可跨请求重用 DbContext 实例,而不是每次都分配新实例,从而降低高请求量下的分配压力。
响应压缩
对于返回任何有意义大小 JSON 负载的 API,启用响应压缩可减少网络传输字节。ASP.NET Core 的响应压缩中间件同时支持 Gzip 和 Brotli,对于典型 JSON 内容,Brotli 通常在可比或更好的压缩速度下产生更小的负载。
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
options.Level = System.IO.Compression.CompressionLevel.Fastest;
});
Enter fullscreen mode Exit fullscreen mode
压缩级别很重要:更高的压缩级别可挤出更多字节,但每次请求消耗更多 CPU,在高并发下可能成为瓶颈。如果已经在反向代理或 CDN 层终止 TLS 并进行压缩,请仔细检查是否进行了双重压缩。
高效 JSON 序列化
序列化几乎发生在 API 处理的每个请求上,因此是调优的重要目标。System.Text.Json 是 ASP.NET Core 的默认序列化器,开箱即用很快,但其默认配置并不总是针对特定应用的负载形状进行调优。
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
options.SerializerOptions.DefaultIgnoreCondition =
System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
options.SerializerOptions.PropertyNamingPolicy =
System.Text.Json.JsonNamingPolicy.CamelCase;
options.SerializerOptions.NumberHandling =
System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString;
});
Enter fullscreen mode Exit fullscreen mode
如上配置省略空属性,既能缩小负载大小,又能减少具有许多可选字段的对象的序列化工作。对于极高吞吐场景,可考虑通过 JsonSerializerContext 使用源生成序列化,它在编译时生成序列化代码,而非依赖运行时反射。还值得直接审计 DTO 而非序列化完整 EF Core 实体:实体常携带导航属性和额外字段,会使负载膨胀,并可能在序列化过程中意外触发延迟加载查询。
中间件管道排序及其性能影响
ASP.NET Core 中的每个请求都按注册顺序穿过中间件管道,该顺序不仅影响正确性,还具有实际性能后果。管道早期注册的中间件会在每个请求上运行,包括那些最终会被后续中间件拒绝或短路的请求,因此过早放置昂贵中间件会浪费本不需要的请求的工作。身份验证和授权中间件通常应在昂贵业务逻辑中间件之前运行,以便以低成本、早拒绝未认证请求。输出缓存通常应放置得足够早,以便在缓存命中时完全短路管道。
健康检查与负载均衡器考量
在水平扩展部署中,负载均衡器或编排器依赖健康检查端点决定是否继续向实例发送流量。
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database").AddCheck<DependencyHealthCheck>("downstream-api");
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = _ => true
});
Enter fullscreen mode Exit fullscreen mode
如上所示,分离存活与就绪检查在规模化时很重要:存活检查应极其廉价,仅确认进程正在运行并响应,因为编排器可能每隔几秒轮询一次以决定是否重启实例。就绪检查可以验证下游依赖,但如果该检查本身很慢,在许多实例上频繁轮询时,它本身就会成为负载来源。
水平扩展与无状态设计
无论进程内调优做到何种程度,都无法取代流量增长时添加更多实例的能力,但水平扩展只有在应用真正无状态时才能顺利工作。会话状态、跨实例未同步的内存缓存,以及持有请求特定可变状态的单例服务,都会破坏“任何实例都能处理任何请求”的假设。在扩展前,请审计这些模式:会话数据应存放在 Redis 等分布式存储中,而非进程内内存;必须恰好运行一次的后台工作应使用分布式锁或专用工作进程,而非依赖在每个实例上冗余触发的进程内计时器。
分析与诊断:定位瓶颈而非猜测
上述每项技术只有在应用于实际测量的瓶颈时才有价值。dotnet-trace 是需要了解运行中 ASP.NET Core 进程在负载下实际行为时值得首先使用的工具——它捕获低开销事件跟踪,尤其擅长揭示线程池饥饿、垃圾回收压力以及特定方法调用花费的时间。Application Insights 或等效 APM 工具扮演不同且互补的角色:跨完整请求生命周期的持续、低开销监控,包括到数据库和下游服务的依赖调用。对于微观问题——使用 AsNoTracking() 是否让此方法更快,此序列化配置是否真的减少分配——BenchmarkDotNet 是正确工具,因为它正确处理了准确 .NET 基准测试的棘手机制:JIT 预热、迭代间的垃圾回收隔离,以及区分真实差异与噪声的统计报告。
综合 ASP.NET Core 性能调优
这些技术并非孤立存在,在未进行分析的代码库上同时应用所有技术并不是好策略。正确的方法是迭代式:使用真实诊断工具在代表性负载下测量,识别实际瓶颈——最可能是数据库访问或同步等待异步模式,而非 JSON 序列化或中间件排序——修复该具体问题,然后再次测量。缓存、压缩和 Minimal API 采用是真实杠杆,但只有在异步正确性和高效数据访问等基础已经就位后才最有价值;在 N+1 查询问题之上叠加缓存,只是意味着缓存了一个慢响应而非修复它。
如果团队面临扩展现有 API 的压力,且内部没有深入掌握这些模式,聘请经验丰富的 ASP.NET Core 开发者进行专注的性能调优,通常比通过生产事故学习更快且风险更低。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.