从 IApplicationBuilder 到 RequestDelegate:ASP.NET Core 请求管线的性能与可观测性实战

很多团队做性能优化时,第一反应是改 SQL、加缓存、扩机器。结果接口还是慢,而且慢得不稳定。

这类问题里,有一部分根因并不在业务代码,而在请求进入业务之前就已经产生了: 中间件顺序、重复序列化、过重日志、异常处理位置不当,都会把每个请求的固定成本悄悄抬高。

这篇文章我们不讲抽象概念,直接从一个真实工程场景出发,拆开 ASP.NET Core 请求管线,回答三个问题:

  • 请求管线到底是怎么执行的
  • 哪些中间件写法会稳定拉低吞吐
  • 如何在不牺牲可观测性的前提下,把链路成本控制住

1. 问题背景: 为什么明明 CPU 不高,RT 却在抖

先看一个常见现象:

  • 峰值时段 P95 从 35ms 涨到 90ms
  • CPU 只到 45%
  • 数据库监控正常
  • 线程池没有明显爆满

像商场收银台排队: 收银员速度没变,库存系统也没卡,但每位顾客在真正结账前都要先填两张表、复印一次小票、走一段绕路。单人多花 10 秒,队伍就会在高峰时段整体失控。

在 Web 服务里,这段"真正结账前的绕路"就是请求管线上的固定开销。

典型问题包括:

  • 将高成本日志中间件放在链路最前面,且对所有请求都做完整 Body 记录
  • 鉴权、异常处理、路由等中间件顺序错误,导致重复执行或额外分支判断
  • 在中间件中做同步阻塞 I/O
  • 将一些本该按采样写出的指标,变成了每请求都完整打点

2. 原理解析: IApplicationBuilder 如何变成 RequestDelegate

ASP.NET Core 启动时,IApplicationBuilder 会把你注册的中间件构造成一个 RequestDelegate 链。

关键点只有两个,但经常被忽略:

  1. 中间件按"注册顺序"进入,按"逆序"包裹执行。每个中间件把后续链路作为自己的 next,形成嵌套闭包。
  2. 任意中间件都可以不调用 next(),从而短路后续链路。

一个简化模型如下:

csharp 复制代码
RequestDelegate app = context => Task.CompletedTask;

app = MiddlewareC(app);
app = MiddlewareB(app);
app = MiddlewareA(app);

// 实际执行顺序: A -> B -> C -> Endpoint -> C -> B -> A

这意味着:

  • 前置中间件越重,所有请求都要付出这笔成本
  • 末端短路逻辑的位置决定了多少中间件能被跳过
  • 可观测性埋点放在不同层,看到的是不同粒度与成本

常见顺序误区

  • UseRouting() 之前做基于 Endpoint 元数据的判断: 信息还没解析出来
  • 在全局异常处理中间件之后再包一层局部 try/catch: 导致异常路径重复记录
  • 在静态资源请求也走完整业务日志链路: 无效开销

3. 示例代码: 从"能跑"到"跑得稳"

下面先看一个"看起来没问题,但成本偏高"的写法。

csharp 复制代码
using System.Diagnostics;
using Microsoft.AspNetCore.HttpLogging;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpLogging(options =>
{
    options.LoggingFields = HttpLoggingFields.All;
});

var app = builder.Build();

app.UseHttpLogging(); // 对所有请求做重日志,静态文件也不例外
app.Use(async (ctx, next) =>
{
    var sw = Stopwatch.StartNew();
    await next();
    sw.Stop();

    // 每请求都写详细日志,高并发下会有明显写放大
    app.Logger.LogInformation("{Path} took {Elapsed}ms", ctx.Request.Path, sw.Elapsed.TotalMilliseconds);
});

app.UseRouting();
app.MapGet("/ping", () => Results.Ok("pong"));

app.Run();

再看一版更适合线上场景的写法。

csharp 复制代码
using System.Diagnostics;
using Microsoft.AspNetCore.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("api", limiter =>
    {
        limiter.Window = TimeSpan.FromSeconds(1);
        limiter.PermitLimit = 200;
        limiter.QueueLimit = 100;
        limiter.AutoReplenishment = true;
    });
});

var app = builder.Build();

app.UseExceptionHandler("/error");
app.UseRouting();
app.UseRateLimiter();

// 仅对 API 路径做轻量计时,并且避免记录敏感/大体积内容
app.UseWhen(
    ctx => ctx.Request.Path.StartsWithSegments("/api"),
    branch =>
    {
        branch.Use(async (ctx, next) =>
        {
            var start = Stopwatch.GetTimestamp();
            await next();
            var elapsedMs = (Stopwatch.GetTimestamp() - start) * 1000d / Stopwatch.Frequency;

            if (elapsedMs > 50)
            {
                app.Logger.LogWarning(
                    "slow request {Method} {Path} {StatusCode} {ElapsedMs:F2}ms",
                    ctx.Request.Method,
                    ctx.Request.Path,
                    ctx.Response.StatusCode,
                    elapsedMs);
            }
        });
    });

app.MapGet("/error", () => Results.Problem("unexpected error"));

app.MapGroup("/api")
   .RequireRateLimiting("api")
   .MapGet("/orders/{id:int}", (int id) => Results.Ok(new { id, status = "Paid" }));

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

app.Run();

这版改动的核心不是"少写几个中间件",而是:

  • 明确将异常处理放在统一入口
  • 将高成本观测从"全量"调整到"有条件采样/告警"
  • 让非 API 请求不走完整业务观测链
  • 将限流作为入口保护,避免高峰把后端拖垮

4. 工程实践建议: 性能和可观测性不是二选一

4.1 给中间件分层,而不是平铺

建议按职责分为三层:

  • 入口治理层: 异常处理、限流、基础安全
  • 路由与授权层: 路由、认证、授权
  • 业务观测层: 业务日志、慢请求告警、特定埋点

这样做的好处是顺序稳定,审查成本低,新人也不容易"插错位置"。

4.2 指标全量,日志分级

  • 指标(如请求总量、P95、错误率)建议全量
  • 明细日志建议按状态码、耗时阈值、采样率输出

全量日志在中高流量场景会迅速放大 I/O 成本,最后变成"为了观测而损失性能"。

4.3 用工具验证,不靠体感

至少建立这套最小验证闭环:

  • 压测: bombardierwrk
  • 运行时计数器: dotnet-counters monitor --process-id <pid>
  • 分布式追踪: OpenTelemetry + Jaeger/Tempo

先拿到基线,再改顺序,再对比 P95/P99 和吞吐,不要只看平均值。

4.4 中间件评审清单(可直接落地)

每次新增中间件前,团队至少回答 4 个问题:

  • 是否必须作用于所有请求
  • 失败时是否会影响主链路可用性
  • 是否涉及同步阻塞 I/O
  • 观测收益是否大于新增成本

5. 总结

ASP.NET Core 请求管线的优化,本质上是控制"每个请求必须支付的固定成本"。

IApplicationBuilderRequestDelegate 的构建机制决定了中间件顺序就是性能策略。把顺序理顺、把观测做轻、把入口治理做实,通常比"盲目微优化业务代码"更快见效。

如果你线上也出现过"CPU 不高但接口发抖"的情况,建议先做两件事:

  • 把现有中间件按执行顺序画出来
  • 按慢请求阈值重新设计日志输出策略

很多时候,系统的稳定性拐点,就在这两步里。

相关推荐
gCode Teacher 格码致知21 小时前
Asp.net Mvc教学: Url.Encode及Html.Encode的区别和联系-由Deepseek产生
asp.net·mvc
步步为营DotNet1 天前
洞悉.NET 11:ASP.NET Core 10 在构建实时协作 Web 应用的技术实践
前端·asp.net·.net
无风听海2 天前
HttpContext.Connection 深度解析:从连接元数据到请求追踪与 mTLS
asp.net
无风听海3 天前
ASP.NET Core .NET 10 错误响应体系全景:从 BadRequest 到编译器基础设施
后端·asp.net·.net
无风听海4 天前
ASP.NET Core CORS 深度解析:从 AddCors 到 CSRF 防御
后端·asp.net·csrf
祀爱4 天前
ControllerBase 类将对象转换为 JSON 格式并返回前端的方法
前端·json·asp.net
剑锋所指,所向披靡!8 天前
计算机网络之应用层(HTTP)
计算机网络·http·asp.net
无风听海9 天前
深入理解 ASP.NET Core Authentication Scheme 体系
运维·云计算·asp.net
勿芮介9 天前
【开发技术】Asp.NetCore的管道和中间件
后端·asp.net
步步为营DotNet9 天前
深入.NET 11:ASP.NET Core 10 在构建高可用分布式系统的关键技术与实践
asp.net·.net·wpf