3. 中间件管道性能优化
ASP.NET Core 的请求处理管道本质上是一条按注册顺序依次执行的中间件链。每个请求从 Kestrel 进入后,会沿着 RequestDelegate 组成的委托链逐层向内传递,直到某个中间件终止处理或到达端点执行。理解这一点非常关键:中间件顺序不仅决定功能是否正确,也直接决定每个请求要付出多少额外开销。一个放在管道前端的日志、认证或异常捕获中间件,会对所有请求生效;而一个放在末端的中间件,可能只对少数命中端点的请求执行。
从延迟角度看,中间件带来的成本主要来自三部分:一是每个中间件自身的执行时间(如序列化、加解密、正则匹配);二是它是否强制读取请求体、响应体或进行缓冲;三是它是否让本可提前结束的请求继续向后传递。高并发场景下,单次请求多花 0.2ms,在 1 万 QPS 下就意味着每秒多消耗 2 秒的 CPU 时间,因此“少做无谓的事”往往比“把每件事做得更快”收益更直接。
顺序即成本:把昂贵且窄范围的中间件后置
管道遵循“先注册先执行、后注册后返回”的洋葱模型。请求阶段自外向内,响应阶段自内向外。因此,越靠前的中间件,覆盖的请求越多,其开销被放大的倍数也越大。优化的基本原则是:把影响面广、成本低的中间件前置;把影响面窄、成本高的中间件后置。
典型反例是把静态文件、健康检查、限流等本可快速命中的逻辑放在认证之后。假设认证中间件平均耗时 0.5ms,而 /health 探针每秒被调用 200 次、静态资源每秒 3000 次,如果这些请求都要先过认证,就白白增加了约 1.6ms/千次 的无效开销。正确做法是让静态文件、健康检查、短路响应尽量靠前,命中后立即返回,不再进入后续链路。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// 1. 最前置:异常捕获,覆盖全部请求
app.UseExceptionHandler("/error");
// 2. 静态文件与健康检查:命中即短路,避免进入认证与业务
app.UseStaticFiles();
app.MapGet("/health", () => Results.Ok("healthy"));
// 3. 路由:先确定端点,后续中间件才能按端点条件执行
app.UseRouting();
// 4. 认证与授权:只对需要保护的端点产生实际成本
app.UseAuthentication();
app.UseAuthorization();
// 5. 端点执行
app.MapControllers();
app.Run();
注意 UseRouting 的位置。在端点路由模型下,路由匹配会把选中的 Endpoint 写入 HttpContext,之后的授权、CORS、端点过滤器才能读取端点元数据。若把 UseRouting 放得太靠后,前面的中间件无法感知端点信息,只能对所有请求统一处理,失去按端点裁剪开销的机会。
短路中间件:让不该继续的请求尽早返回
短路(short-circuit)指中间件不调用 next,直接写入响应并结束管道。它是降低延迟最有效的手段之一,因为被短路的请求不再经过后续任何中间件。常见短路场景包括:健康检查、探针、静态资源、缓存命中、限流拒绝、鉴权失败、CORS 预检请求。
// 自定义短路中间件:命中缓存直接返回,不进入业务
app.Use(async (context, next) =>
{
if (context.Request.Path == "/ping")
{
context.Response.StatusCode = 200;
await context.Response.WriteAsync("pong");
return; // 关键:不调用 next,管道在此终止
}
await next(context);
});
短路的价值可以用一个简单模型估算。设管道共有 6 个中间件,平均每个 0.1ms,业务处理 2ms。若某类请求本可在第 2 个中间件返回,却走到了业务层,则单次多付出约 0.4ms 加 2ms。当这类请求占比 30%、QPS 为 5000 时,每秒浪费约 3.6 秒 CPU 时间。示例数据仅用于说明量级,实际应以压测为准。
条件分支:用 Map / MapWhen 隔离不同请求路径
并非所有请求都需要完整管道。对于 API、静态资源、WebSocket、管理后台等差异明显的流量,可以用 Map 或 MapWhen 建立分支,让每条分支只包含自己需要的中间件,避免“一刀切”的全局开销。
// 按路径前缀分支:/api 走轻量管道,/admin 走完整管道
app.Map("/api", apiApp =>
{
apiApp.UseRouting();
apiApp.UseEndpoints(e => e.MapControllers());
});
app.MapWhen(
ctx => ctx.Request.Path.StartsWithSegments("/admin"),
adminApp =>
{
adminApp.UseAuthentication();
adminApp.UseAuthorization();
adminApp.UseRouting();
adminApp.UseEndpoints(e => e.MapControllers());
});
分支的代价是配置复杂度上升,且分支内的中间件不会自动继承分支外的行为。因此应遵循“公共且廉价的前置,差异且昂贵的分支”原则:异常捕获、请求日志等可放在分支外统一处理;认证、限流、压缩等按流量特征放入对应分支。
端点路由与端点过滤器:把开销精确到端点
端点路由让中间件能够“看到”当前请求将命中哪个端点,从而支持按端点元数据做条件执行。相比全局中间件,端点过滤器(IEndpointFilter)和 RequireAuthorization 等元数据只作用于匹配的端点,天然避免了无关请求的开销。
// 仅对该端点启用授权与输出缓存,其他端点不受影响
app.MapGet("/products/{id:int}", async (int id, IProductService svc) =>
await svc.GetAsync(id))
.RequireAuthorization()
.CacheOutput(p => p.Expire(TimeSpan.FromSeconds(30)));
此外,应避免在中间件中做“重活”。例如在全局中间件里读取并缓冲整个请求体、对每个请求做正则路由匹配、同步阻塞调用数据库,都会显著抬高 P99 延迟。若必须读取请求体,应使用 EnableBuffering 并注意其内存与磁盘开销;若必须做条件判断,优先使用端点元数据而非字符串匹配。
可落地的顺序检查清单
在调整管道时,可以按下面的顺序逐项核对,避免遗漏或误置:
| 检查项 | 推荐位置 | 判断依据 |
|---|---|---|
| 异常捕获 | 最前 | 需覆盖后续所有中间件抛出的异常 |
| 静态文件 / 健康检查 | 认证之前 | 命中即短路,无需认证与业务 |
| UseRouting | 认证之前 | 后续中间件需读取端点元数据 |
| 认证 / 授权 | 路由之后、端点之前 | 依赖端点元数据决定是否执行 |
| 限流 / 压缩 | 按流量特征分支 | 仅对特定路径生效,避免全局开销 |
| 业务中间件 | 末端 | 成本最高,影响面应最小 |
dotnet-counters 或 Application Insights 观察各中间件的耗时占比,再决定移动谁。盲目调整顺序可能破坏认证、CORS 或异常处理语义,导致功能回归。综上,中间件管道优化的核心不是“减少中间件数量”,而是让每个请求只经过它真正需要的处理。通过合理排序、短路、条件分支和端点路由,把昂贵操作限制在最小影响面内,就能在几乎不改变业务代码的前提下,显著降低平均延迟与 P99 延迟,并提升单位时间内的请求吞吐能力。