这次我们来看 .NET 技术栈里的一个实践问题:怎么在不改业务代码的前提下,把分布式链路追踪接进来。很多团队不是不想上链路追踪,而是担心改造量太大——给每个项目加请求头传递、手动创建 Span、处理跨服务上下文,听着就劝退。但实际上 .NET 生态里早就提供了几条接近零侵入的路径,从 ASP.NET Core 中间件到 HostingStartup 程序集注入,再到 DiagnosticSource 自动监听以及 OpenTelemetry 标准插桩,完全可以把 Trace 采集能力下沉到框架层,业务侧只负责写业务。
这篇文章会覆盖四条可落地的方案,给出代码示例、启动方式和验证方法,最后补充链路数据的查询、日志关联、性能观察和排错清单。文章针对两类读者:一类是还在维护 .NET Framework 老项目的,另一类是已经升级到 .NET 6/.NET 8 想快速补齐可观测性的。先说结论:新项目直接用 OpenTelemetry 自动插桩,老项目可以参考 DiagnosticSource 和中间件的组合思路。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 实现方式 | 中间件、HostingStartup 程序集注入、DiagnosticSource 事件监听、OpenTelemetry 自动插桩 |
| 侵入程度 | 业务代码基本零改动,仅需要一次性注册基础设施 |
| 适用平台 | .NET Framework 4.7.2+ 以及 .NET 6/.NET 8 等现代版本,具体看各方案支持度 |
| 依赖组件 | Microsoft.Extensions.DiagnosticAdapter、OpenTelemetry SDK、Jaeger/Zipkin 后端、Serilog 等 |
| 启动方式 | 配置环境变量或注册中间件后随应用启动 |
| 是否支持 API | 支持,Jaeger 和 Zipkin 都提供 Trace 查询接口 |
| 是否支持批量任务 | 支持,可通过队列或批次导出的方式上报 Span |
| 适合场景 | 微服务调用链排查、接口耗时分析、跨服务日志关联、生产故障定位 |
需要说明的是,不同方案的接入成本和采集深度差别很大。中间件方案适合快速看到单服务的请求耗时;HostingStartup 方案可以做到完全不动业务项目文件;DiagnosticSource 方案能捕获 HttpClient 发出的外部调用;OpenTelemetry 方案则是一套标准化产出,直接对接 Jaeger、Zipkin 或 SkyWalking 等后端。实际显存、内存、CPU 开销需要按本机环境和请求量测试,不能一概而论。
2. 无侵入链路追踪的选型思路
先理清楚“无侵入”在不同语境下的含义。很多文章说的无侵入,实际上指的是“业务逻辑里不用手动打点”,但需要你在启动类里添加几行注册代码。真正极致的无侵入,是通过程序集级特性或环境变量在应用启动时自动加载追踪组件,业务项目连 Startup 都不用动。
| 方案 | 需要改业务项目? | 采集范围 | 维护成本 |
|---|---|---|---|
| 自定义中间件 | 需要改 Program/Startup | 当前服务的请求耗时、状态码 | 低,逻辑简单可控 |
| HostingStartup 注入 | 业务项目零修改,用环境变量指定程序集 | 同中间件,但可以封装为独立 NuGet | 中,程序集加载机制需要理解 |
| DiagnosticSource 监听 | 业务项目零修改,独立项目订阅事件 | HttpClient 外部调用、ASP.NET Core 请求事件 | 中高,依赖框架内部事件名 |
| OpenTelemetry 自动插桩 | 需要改 Program 注册 TracerProvider | HTTP、数据库、Redis 等主流组件 | 低,官方插件覆盖广 |
选择时主要看两个约束:一是你能否升级到现代 .NET,如果能,优先 OpenTelemetry;二是业务项目是否允许改动,如果完全不允许,HostingStartup 是最合适的切入点。对 .NET Framework 项目来说,仅仅靠中间件不够,因为老框架没有原生 DiagnosticSource 集成到 HttpClient 的所有版本,需要借助 EventSource 监听或者第三方 APM 探针。这里要实际测试框架版本和运行时表现,不同小版本的监听行为有差异。
3. 环境准备与前置条件
无论选择哪条路线,都需要准备一套基础环境。如果只是验证链路打通,最简单的组合是:一个 .NET 8 API 项目 + Jaeger 容器 + 输出日志。下面是通用的检查清单。
# 检查 .NET SDK 版本 dotnet --info # 检查 Docker 是否可用 docker --version # 检查端口占用情况 netstat -ano | findstr "16686" netstat -ano | findstr "4317"Jaeger 是链路追踪最常见的可视化后端,可以直接用官方镜像启动。新版本 Jaeger 内置 OTLP 接收端口,链路数据通过 HTTP 上报后直接在 Web UI 查看。
docker run -d --name jaeger \ -p 16686:16686 \ -p 4317:4317 \ -p 4318:4318 \ jaegertracing/jaeger:latest启动后访问http://localhost:16686可以看到 Jaeger UI。如果网络条件受限,也可以使用 Zipkin,同样的容器方式:
docker run -d -p 9411:9411 openzipkin/zipkin接下来是项目准备。建议先建一个测试解决方案,包含两个项目:一个是普通的 WebAPI,另一个是被追踪的库项目。这样可以用最简单的场景验证链路数据是否贯通。安装 NuGet 包时不要凭记忆写版本号,直接在工程里通过dotnet add package安装最新稳定版,依赖版本不一致是 .NET 追踪组件最常见的坑。
4. 方案一:中间件实现轻量级追踪
中间件方案是最容易理解的无侵入思路:在请求管道最前面插入一段代码,自动读取或生成 TraceId,同时记录耗时。业务 Controller 里不需要写任何打点逻辑。
public class TraceContextMiddleware { private readonly RequestDelegate _next; private readonly ILogger<TraceContextMiddleware> _logger; public TraceContextMiddleware(RequestDelegate next, ILogger<TraceContextMiddleware> logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { string traceId = Activity.Current?.TraceId.ToString() ?? context.Request.Headers["X-Trace-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString("N"); if (!context.Request.Headers.ContainsKey("X-Trace-Id")) { context.Response.Headers["X-Trace-Id"] = traceId; } using (_logger.BeginScope(new Dictionary<string, object> { ["TraceId"] = traceId })) { var stopwatch = Stopwatch.StartNew(); try { await _next(context); } finally { stopwatch.Stop(); _logger.LogInformation( "请求完成 {Method} {Path} {StatusCode} {ElapsedMs}ms", context.Request.Method, context.Request.Path, context.Response.StatusCode, stopwatch.ElapsedMilliseconds); } } } }在 Program 里注册:
var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.UseMiddleware<TraceContextMiddleware>(); app.MapControllers(); app.Run();这个方案的优点是非常直白,任何 .NET 开发者都能维护。缺点是需要手动进入当前请求上下文的 TraceId 传递逻辑,如果希望调用 HttpClient 时把 TraceId 自动带出去,还需要在 HttpClient 管道里加 DelegatingHandler。现场验证时建议先打一个测试接口,观察响应头 X-Trace-Id 是否出现,再查看应用日志里的 TraceId 是否和响应头一致。如果日志里能看到但链路后端还没接入,说明中间件本身没有问题。
5. 方案二:HostingStartup 运行时注入实现真正无侵入
如果业务项目完全不能改代码,HostingStartup 是 .NET 提供的一个原生扩展点。它的原理是:Web 应用启动时,运行时读取环境变量中指定的程序集,加载程序集内的 HostingStartup 逻辑。你可以把追踪中间件封装成一个独立的 Debug/Infrastructure 包,通过环境变量按需激活。
首先建一个独立的类库项目,安装框架引用,然后编写启动类:
using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; [assembly: HostingStartup(typeof(TracingBootstrap.TracingHostingStartup))] namespace TracingBootstrap; public class TracingHostingStartup : IHostingStartup { public void Configure(IWebHostBuilder builder) { builder.ConfigureServices(services => { services.AddSingleton<IStartupFilter, TracingStartupFilter>(); }); } }注意,直接通过builder.Configure(app => app.UseMiddleware(...))的方式在部分版本中顺序不可控,更稳妥的做法是通过IStartupFilter把中间件插入到管道最前段:
public class TracingStartupFilter : IStartupFilter { public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) { return app => { app.UseMiddleware<TraceContextMiddleware>(); next(app); }; } }业务项目发布时,只需要设置环境变量:
set ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=TracingBootstrap dotnet MyApp.dll这里有几个关键点。第一,TracingBootstrap程序集必须能被业务项目加载,最简单的是放进同一个输出目录,或者安装为 NuGet 依赖。第二,如果同时有多个 HostingStartup 程序集,用分号分隔。第三,加了这个环境变量后,如果类库有异常,整个应用启动会失败,所以封装时一定要做充分的异常保护,比如在 Configure 逻辑里包一层 try-catch。实测习惯是先跑一个空类库只输出日志,确认环境变量加载生效,再逐步加入追踪逻辑。
6. 方案三:DiagnosticSource 自动监听采集跨服务调用
中间件方案能覆盖当前服务的请求链路,但不少场景要看的恰恰是“当前服务调了谁”“被谁调用”。在 .NET 中,HttpClient 和 ASP.NET Core 框架内部会通过 DiagnosticSource 对外广播诊断事件,我们可以写一个独立的监听器去消费这些事件,自动为每次 HTTP 调用创建 Activity,把调用信息串成 Span。
先创建一个通用的诊断订阅器:
public sealed class TracingDiagnosticObserver : IObserver<DiagnosticListener> { public void OnNext(DiagnosticListener listener) { if (listener.Name == "HttpHandlerDiagnosticListener") { listener.Subscribe(new HttpHandlerTracingSubscriber()); } else if (listener.Name == "Microsoft.AspNetCore") { listener.Subscribe(new AspNetCoreTracingSubscriber()); } } public void OnError(Exception error) { } public void OnCompleted() { } }订阅启动时,遍历当前已注册的 DiagnosticListener:
var observer = new TracingDiagnosticObserver(); DiagnosticListener.AllListeners.Subscribe(observer);在HttpHandlerTracingSubscriber里监听System.Net.Http.HttpRequestOut.Start和System.Net.Http.HttpRequestOut.Stop事件,前者拿到请求信息创建 Activity,后者写入状态码和耗时。这里要注意:事件名是框架内部约定的字符串,不同 .NET 版本可能有细微差异,需要实际验证。同时,如果多个监听器订阅同一个 DiagnosticSource,事件会被广播给所有订阅者,采集侧要做去重保护,避免重复创建 Span。
这个方案的最大好处是业务侧完全不感知,而且能抓到 SDK 内部自动发出的 HttpClient 调用,包括连接池、重试等细节。但它的学习曲线也最陡,需要理解 Activity、DiagnosticSource、W3C TraceContext 这几个概念。第一次做验证时,建议先只订阅 HttpHandlerDiagnosticListener 并输出日志,确认能看到外部调用事件,再逐步扩展。
7. 方案四:OpenTelemetry 自动插桩接入链路后端
如果你不需要自己造轮子,OpenTelemetry 是目前最标准的选择。它在 .NET 里提供了基于 Activity 的自动埋点,业务代码不用手动改,只需要在启动时注册 TracerProvider,并安装对应的 Instrumentation 包。
在 API 项目中安装基础包:
dotnet add package OpenTelemetry.Extensions.Hosting dotnet add package OpenTelemetry.Instrumentation.AspNetCore dotnet add package OpenTelemetry.Instrumentation.Http dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore dotnet add package OpenTelemetry.Exporter.Jaeger如果后端使用 Zipkin,最后一行替换为OpenTelemetry.Exporter.Zipkin。然后在 Program.cs 中注册:
using OpenTelemetry.Trace; builder.Services.AddOpenTelemetry() .WithTracing(tracing => { tracing.AddAspNetCoreInstrumentation(); tracing.AddHttpClientInstrumentation(); tracing.AddEntityFrameworkCoreInstrumentation(); tracing.SetSampler(new AlwaysOnSampler()); tracing.AddJaegerExporter(options => { options.AgentHost = "127.0.0.1"; options.AgentPort = 6831; }); });注意 Jaeger 的 UDP Agent 端口在新版本中可能默认关闭,如果上报失败,可以改用 OTLP 导出器直连 4318 端口。导出方式调整为:
tracing.AddOtlpExporter(options => { options.Endpoint = new Uri("http://127.0.0.1:4318"); });注册完成之后,启动应用并调用任意接口,打开 Jaeger UI,在 Service 下拉框里选择对应服务名,就能看到完整的 Trace 列表。每个请求会形成一个 Trace,包含 Server 端 Span 和客户端 HTTP Span,数据库 Span 也会在开启了AddEntityFrameworkCoreInstrumentation后出现。
这套方案最大的价值是标准化。Span 的命名、属性、状态码都由官方插件托管,团队后续接 SkyWalking 或 Grafana Tempo 都不需要改业务代码。缺点是插件版本需要跟随 .NET 版本更新,部分组件例如 StackExchange.Redis 的自动插桩在早期版本还不完善,实际测试时以插件文档为准。
8. 链路数据查询与日志关联
链路追踪不只是看一眼 UI 上有没有 Trace,更关键的是把 TraceId 和日志关联起来。在 Serilog 里可以直接通过配置把 TraceId 输出到日志字段。
Log.Logger = new LoggerConfiguration() .Enrich.WithProperty("Application", "OrderService") .Enrich.FromLogContext() .WriteTo.Console() .CreateLogger();中间件方案里已经用BeginScope把 TraceId 放进了日志上下文,Serilog 会自动把它作为一个字段输出。日志平台收集后,就可以用 TraceId 反向查询某一次请求的所有日志。
不管用 Jaeger 还是 Zipkin,链路数据都会通过 API 暴露出来,可以用于自动化巡检或故障追溯。以 Jaeger 为例:
# 按服务名查询最近 10 条 Trace curl "http://127.0.0.1:16686/api/traces?service=MyApi&limit=10" # 按 TraceId 查询完整 Trace curl "http://127.0.0.1:16686/api/traces/{traceId}"Zipkin 的查询接口是:
curl "http://127.0.0.1:9411/api/v2/trace/{traceId}"拿到这些数据后,可以写一个小脚本定期扫描调用链中的超时 Span,把超过阈值的请求自动汇总成日报,这类工作能让链路追踪从“出了事再看 UI”变成“提前发现风险”。如果内部有统一的可观测性平台,可以直接把 OTLP 上报地址指向平台网关,省掉自建 Jaeger。
9. 资源占用与性能观察
链路追踪是有成本的。每加入一项 Instrumentation,就多一次拦截和属性采集;每条请求都会创建 Activity,然后交给采样器决策是否导出。生产环境建议不要使用AlwaysOnSampler,而是按比例采样或根据 TraceId 哈希采样,减少后端存储压力。
tracing.SetSampler(new TraceIdRatioBasedSampler(0.1));观察资源占用,可以使用 dotnet-counters 工具查看运行时关键指标:
dotnet-counters monitor --process-id <应用PID> --counters System.Runtime这里重点观察内存增长和线程池队列。每次请求创建 Activity 后必须确保 Dispose,否则会导致监听器缓存增多。可以关注的几个指标包括 GC 堆大小、线程池待处理任务数、锁竞争次数。在压测环境下,可以先记录插桩前的基线,再打开链路追踪开关,对比同 QPS 下的耗时分布,一般建议把采样率降到满足分析需要的最低值。中间件方案里,Stopwatch 本身开销很小,但如果加了过多的日志输出、数据库语句参数记录,就会明显放大延迟。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 日志里有 TraceId,但 Jaeger 看不到链路 | 采样器丢弃了 Span,或导出器地址错误 | 查看应用日志中的导出错误,检查 Jaeger 端口连通性 | 临时换成 AlwaysOnSampler 验证,确认通后调低采样率 |
| HostingStartup 环境变量设置了但不生效 | 程序集名写错、类库没有被打入输出目录 | 检查启动日志中的程序集加载记录 | 确认程序集路径与 ASPNETCORE_HOSTINGSTARTUPASSEMBLIES 一致 |
| 跨服务调用只有部分 Span | 没有安装 HttpClient Instrumentation 或 TraceId 头未传递 | 用 curl 检查请求头中是否有 traceparent | 安装 Http 插件并确认AddHttpClientInstrumentation |
| 中间件没有正确插入管道 | 注册顺序问题,被异常中间件短路 | 查看启动日志与中间件顺序 | 通过 IStartupFilter 插入到管道最前段 |
| DiagnosticSource 没有触发订阅 | 监听器名称与当前框架版本不一致 | 打印所有 DiagnosticListener 名称 | 按实际版本调整订阅名称 |
| 导出 Jaeger UDP 报错 | 新版 Jaeger 默认未开启 Agent UDP | 检查容器端口与日志 | 改用 OTLP 导出器连接 4318 |
| 请求量上来后内存明显增加 | Activity 未释放或监听器重复订阅 | 查看内存计数器,检查订阅代码 | 确保只订阅一次,业务逻辑异常时 finally 中 Dispose |
如果应用启动直接失败,先看异常栈,判断是不是 HostingStartup 程序集内部的依赖没有被加载。很多情况是因为类库和主项目引用了不同版本的 OpenTelemetry 包,最好在类库中统一依赖版本。
11. 最佳实践与使用建议
上线链路追踪之前,先把目标定清楚:你是想解决“请求慢在哪一段”的定位问题,还是想解决“跨服务日志乱成一团”的关联问题。这两个目标对应的方案侧重点不同。前者要重点强化 HttpClient 和数据库 Span 的采集,后者要重点把 TraceId 注入到所有日志上下文。
具体建议是:
- 第一次先做最小闭环。单服务 + 中间件 + 控制台日志,确认 TraceId 生成和传递逻辑没问题,再接 Jaeger。
- 组件依赖版本要统一。用 Directory.Build.Props 统一 OpenTelemetry 包版本,避免依赖冲突。
- 日志字段命名规范统一。TraceId、SpanId、ServiceName 这些字段名称要提前约定,方便日志平台建索引。
- 链路数据要保留一定时间。Jaeger 的默认存储在大数据量下不够用,生产上尽量接 Elasticsearch 或对象存储。
- 涉及用户隐私的请求,比如包含身份证、手机号的接口,需要在插桩中关闭请求体采集,避免敏感信息进入链路数据。
从工程化角度看,链路追踪不是一次性配置,它是可观测性体系的一部分。每次新增一个外部依赖,比如 Redis、消息队列、gRPC,都要评估是否接入追踪,并且要在发布前做一次链路验证。
12. 总结与下一步
最值得先尝试的是中间件方案,因为它只改一个文件,跑通后能看到请求耗时。如果业务项目完全不允许改代码,下一个优先级是 HostingStartup 注入。新项目或团队想长期建设可观测性,直接上 OpenTelemetry 自动插桩,这一步能省掉之后很多手工埋点的工作。最容易踩的坑是版本和监听名称不一致,以及采样率设置过高导致存储压力变大。
下一步可以做的扩展方向是:把链路追踪覆盖到数据库查询、Redis 缓存调用和消息队列生产消费场景,再配合日志聚合平台和告警规则,形成一套完整的请求级别诊断闭环。建议收藏备用,实际部署时按照“最小闭环跑通再逐步扩展”的顺序来做。