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 并注意其内存与磁盘开销;若必须做条件判断,优先使用端点元数据而非字符串匹配。

中间件管道顺序与短路优化示意 请求进入 按注册顺序执行(洋葱模型) ① 异常捕获 UseExceptionHandler 覆盖全部请求 ② 静态文件 / 健康检查 命中即短路 ③ 路由 UseRouting 确定端点 ④ 认证 / 授权 仅保护端点 ⑤ 业务中间件 / 端点过滤器 成本最高 ⑥ 端点执行 MapControllers 最终处理 短路返回 响应返回 原则:影响面广且廉价者前置,影响面窄且昂贵者后置;命中即短路

可落地的顺序检查清单

在调整管道时,可以按下面的顺序逐项核对,避免遗漏或误置:

检查项推荐位置判断依据
异常捕获最前需覆盖后续所有中间件抛出的异常
静态文件 / 健康检查认证之前命中即短路,无需认证与业务
UseRouting认证之前后续中间件需读取端点元数据
认证 / 授权路由之后、端点之前依赖端点元数据决定是否执行
限流 / 压缩按流量特征分支仅对特定路径生效,避免全局开销
业务中间件末端成本最高,影响面应最小
优化顺序时,先用 dotnet-counters 或 Application Insights 观察各中间件的耗时占比,再决定移动谁。盲目调整顺序可能破坏认证、CORS 或异常处理语义,导致功能回归。
短路中间件必须确保响应已完整写入并正确设置状态码与 Content-Type,否则客户端可能收到不完整响应。同时,短路会跳过后续中间件,若后续包含审计、指标采集等必要逻辑,需评估是否接受这一取舍。

综上,中间件管道优化的核心不是“减少中间件数量”,而是让每个请求只经过它真正需要的处理。通过合理排序、短路、条件分支和端点路由,把昂贵操作限制在最小影响面内,就能在几乎不改变业务代码的前提下,显著降低平均延迟与 P99 延迟,并提升单位时间内的请求吞吐能力。