☰
基于.NET Core自研轻量级诊断工具:链路追踪与Activity实战
2026/11/3 3:17:41 网站建设 项目流程

搞 .NET 这么多年,我一直觉得诊断和链路追踪是“得自己动手才有底气”的事。前阵子线上一个报表接口偶尔卡到 8 秒,业务日志打印得满满当当,却看不到任何异常,直到我临时在关键代码段塞了 Stopwatch 才定位到是某个第三方数据服务超时。那次之后我就下定决心,基于 .NET 自研一套轻量级诊断工具,并且彻底弄懂链路追踪原理。这篇文章会从开发诊断工具的选型思路、链路追踪的核心数据模型,一直到 DiagnosticSource 和 Activity 的实际使用、采集器的落地实现、以及我踩过的大量坑,最后再讲讲性能优化。内容会有点长,适合有一定 .NET 基础、想在项目里落地可观测性的同行,也欢迎刚接触 Activity 的读者一起讨论。

1. 为什么最终选择自研诊断工具,而不是直接套 APM

很多人一听到诊断工具,第一反应是“直接用 APM 不就完事了吗,为什么还要自己写?”说实话,我在前几个项目里也试过商业 APM 和开源的 OpenTelemetry,效果不能说不好,但到了业务深入定制的时候,总有一层纸捅不破。

1.1 内置诊断能力很强,但总差“业务最后一公里”

.NET 生态其实已经提供了非常完整的基础设施诊断能力,比如 EventSource、EventCounter、DiagnosticSource、Activity、Metrics API,这些机制理论上能覆盖 CPU、内存、GC、HTTP 路由、数据库连接池等基础设施级别的问题。但我在实际项目里发现,默认监控只能告诉你“这个接口慢了”,却没法告诉你“慢的这一批请求是不是来源于某个特定商户”,更没法告诉你“耗时集中在链路中的哪个业务阶段”。

要搞清楚这些,就必须做业务级埋点,也就是把订单号、用户 ID、商户 ID、业务分支名称等塞进链路上下文。商业 APM 当然支持自定义 tag,可它带来的问题是:你在每个服务里都要引入 SDK,配合探针还能做到很多自动化,但整套体系的部署、升级、权限控制都很重。加上很多商用产品按请求数或带宽收费,在高 QPS 的场景下,成本曲线非常吓人。所以很多团队做到一定程度,会开始考虑基于 .NET 自己封装一个可控的 Agent,把 trace、metric、log 统一到一套模型里。

1.2 自研能解决什么问题:低侵入、可插拔、可定制

在中文搜索热词里经常能看到“.NET Core 开发 agent”,这其实说明了不少团队都在走自研 Agent 这条路。我自己的实践经验是,Agent 这种形态最适合做诊断工具的载体:它既可以作为独立进程旁路部署,也常以 NuGet 包的形式嵌进应用进程内。订阅 DiagnosticSource、监听 EventSource,把采集到的 Span 和指标推给 Collector,这就是一个自治的小 Agent。

我之前给一个老旧的资产管理系统和一个开源商城项目做过接入,这两个项目都比较封闭,不可能为了可观测性做大范围代码重构。但通过引入一个自研 Agent 包,在不改业务代码的前提下,还是能拿到关键链路信息。这也是我推崇自研的核心原因:可以按需裁剪,不绑架业务代码。如果你让我用表格来选型,我大概会这么总结:

方案侵入性一次性成本长期运维成本定制能力落地难度
自研 Agent低,打一个包即可中,需要团队有原理认知低,可控最强,业务字段随便加中高,需要踩坑
OpenTelemetry 原生 SDK中低,需引入自动埋点包低,社区方案成熟中,组件版本兼容需维护中,主要通过 Attributes 扩展中,但概念较多
商用 APM低,探针部署即可高,按量计费中高,绑定厂商受产品能力限制低,但个性化弱

看完这个表你会发现,自研并不是最省钱省力的方案,它的核心价值是“可控”。当你真的碰到需要把企业内部的流程 ID、工单状态、缓存 Key 关联到请求链路上时,你会特别庆幸当初选择了自研。

1.3 自研的边界:别把该用现成工具的部分也造了

这里必须泼一盆冷水:自研不是事无巨细全部从零开始。底层的数据采集标准,比如 W3C Trace Context、OTLP 导出协议,建议直接兼容,该用 OpenTelemetry 的地方就用,该包 ActivitySource 的地方就包 ActivitySource。我见过强行自研一套完整 trace 协议,最后连不同服务之间的 Header 都解析不了的例子。自研是在标准之上做裁剪和增强,而不是推翻标准。理解清楚这一点,后面的路会顺很多。

2. 链路追踪的三个基石:Trace、Span、Context

要开发诊断工具,不把链路追踪原理吃透肯定是不行的。很多人概念背得很熟,但一写代码就不知道从哪里取 TraceId、为什么下游服务能看到同一个 TraceId、采样标志又是怎么回事。这里我用最容易理解的“一棵树”来拆开讲。

2.1 一条请求的一生:从 TraceId 构建 Span 树

一次完整的业务请求,通常会产生一次链路追踪,这条链路里会包含多个 Span。举个例子,用户在下单页面点击“提交订单”,订单服务接收到请求后需要执行三个动作:查询订单、扣减库存、发送消息。这三个动作每个都可以作为一个子 Span,它们共同挂在同一个 Trace 下面。它们的父子关系是这样的:

  • Trace(一次完整调用链)
    • Span:POST /api/order/apply(根 Span)
      • Span:查询用户账户
      • Span:扣减库存
      • Span:发送 MQ 消息

在这个结构里,最重要的三个字段就是 TraceId、SpanId、ParentSpanId。TraceId 是整个调用链的唯一标识,SpanId 是当前 Span 的唯一标识,ParentSpanId 指向当前 Span 的父节点。后端展示系统拿到这三个字段后,才能把零散的 Span 拼成一棵完整的调用树。如果服务 A 在调用服务 B 时没有传递 TraceId 和 ParentSpanId,那么 B 产生的 Span 就会变成另一棵树,两个服务之间的调用关系就断了。

2.2 Context 为什么要随着调用“流动”

在 .NET 中,Activity 对象内部会通过 AsyncLocal 保存当前上下文,它包含 TraceId、SpanId、Sampling 标记、Baggage 等信息。上下文不流动,链路就断了。流动的方式取决于调用的传播载体:如果是 HTTP 调用,通常注入到 Header 里;如果是消息队列,就放到消息的自定义 Header;如果是跨进程调用,还要考虑本地协议。

现在业界已经统一了 W3C Trace Context 标准,里面定义了traceparent和tracestate两个 Header。traceparent是核心,格式很简单,举个例子:

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01

这段 Header 按-分割成四个部分:版本号、TraceId、SpanId、Flags。其中 TraceId 是 32 位十六进制,SpanId 是 16 位十六进制,最后的01表示这条链路是被采样的。2019 年之后,.NET 的 Activity 默认就支持 W3C 格式,ASP.NET Core 和 HttpClient 也会自动进行上下文的注入和提取,所以你不手动写解析代码也能跑通。但如果你想做自己的诊断工具,必须搞明白里面的字段含义,否则你从 Header 里摘出来的 SpanId 可能是错的。

2.3 为什么推荐直接使用 Activity 的 Id 生成规则

刚开始自研的时候,我一度想自己封装一个 Context 类,自己生成 TraceId 和 SpanId。后来发现这是自找麻烦。.NET 从 5.0 开始,Activity 的默认 IdFormat 已经是ActivityIdFormat.W3C,它生成的 TraceId、SpanId 布局是标准化的,和 OpenTelemetry、商业 APM 都能对齐。你手动生成的 Id 很容易出现长度、大小写、字符集不一致的问题。

需要特别注意的是,Activity.Current存储的是当前异步上下文中的 Activity。每当你调用HttpClient发送请求时,ASP.NET Core 的自动埋点会读取Activity.Current的信息,然后把traceparent写进请求头。如果你在请求开始时把这个 Activity 搞丢了,那下游收到的就是新的 TraceId。后面我会专门讲这个坑。所以我的建议是:直接让 Activity 帮你生成 Id,不要自己造轮子,除非你要实现非常精细的 ID 分配策略。

3. 动手挂钩应用:DiagnosticSource 与 Activity 实战

原理讲完了,接下来就到了诊断工具开发最核心的部分:怎么把应用内部的事件“钩”出来。.NET 里有一套事件管道叫 DiagnosticSource,另一套面向可观测性的高层模型叫 ActivitySource。这两者关系很紧密,我一般在自研 Agent 里会同时用。

3.1 从“监听”开始:使用 DiagnosticListener 订阅内部事件

DiagnosticSource 是一种生产者和消费者模式,.NET 框架内置组件(比如 HttpClient、ASP.NET Core、EF Core)在关键位置会写事件,外部诊断工具可以订阅这些事件名,从而拿到请求开始和结束的信息。

下面是最基本的一段订阅代码:

var listener = new DiagnosticListener("MyDiagnosticTool"); DiagnosticListener.AllListeners.Subscribe(new Observer<DiagnosticListener>(onNext: l => { if (l.Name == "HttpHandlerDiagnosticListener" || l.Name == "Microsoft.AspNetCore") { l.Subscribe(new Observer<KeyValuePair<string, object>>(onNext: kv => { if (kv.Key == "System.Net.Http.HttpRequestOut.Start") { // 拿到 HttpClient 开始请求的事件 var request = kv.Value.GetType().GetProperty("Request").GetValue(kv.Value); // 这里可以读取 request 对象,获取 URL、Method 等信息 } else if (kv.Key == "System.Net.Http.HttpRequestOut.Stop") { // 请求结束,这里能拿到 Response 和耗时 } })); } }));

这里有几个常见的坑。第一,AllListeners.Subscribe要在应用启动早期调用,否则会错过已经创建的 DiagnosticListener。第二,并不是所有 DiagnosticListener 都会回调你,你需要判断l.Name是否符合预期。第三,事件消息往往是一个对象,字段是通过反射拿的。反射频率高了会有性能问题,所以一般会做委托缓存。

3.2 自己写一个 Instrumentation:通过 ActivitySource 创建 Span

如果只是监听内置事件,那还不够业务化。我的做法是在自己开发的组件里用 ActivitySource 埋点。这样既不用直接依赖 DiagnosticSource 的细节,又能让链路追踪模型保持统一。

埋点代码非常简单:

private static readonly ActivitySource Source = new ActivitySource("MyCompany.Diagnostic.Database"); using (var activity = Source.StartActivity("QueryOrder")) { activity?.SetTag("db.system", "sqlserver"); activity?.SetTag("db.statement", "SELECT * FROM Orders WHERE OrderId=@id"); activity?.SetTag("peer.service", "order-db"); // 执行业务代码 var order = await _orderRepository.GetByIdAsync(id); }

Source.StartActivity在做两件事:如果当前活动上下文里已经有 TraceId,它会创建子 Span,自动设置 ParentSpanId;如果当前没有,它会创建一个新的根 Span。activity是一个可空对象,因为当没有任何监听者时,StartActivity会返回null,这不会影响业务代码,只是少了一次追踪。用using包裹是很重要的,因为 Activity 在 Dispose 时会记录结束时间,并触发监听器里的 Stop 回调。

3.3 监听不到事件时的排查路线

很多同行告诉我,说他们照着文档写订阅,但永远收不到事件。根据我的经验,常见原因基本是这几种:

  • 订阅太晚。比如放在某个后台任务的初始化里,而 HttpClient 的监听在服务启动时就注册了。
  • 事件名拼写不对。不同版本 .NET 的事件名可能有差异,需要先抓一下实际输出。
  • 订阅了 DiagnosticListener,但没有调用Subscribe。如果你只订阅了 AllListeners 却没有对某个 listener 调用 Subscribe,等于白干。
  • 目标框架版本不一致。同一个 HttpClient 在 .NET Framework 和 .NET/Core 里的监听名称会不一样。

排查思路很简单:先写一个小测试,在应用启动的最前端订阅所有 DiagnosticListener,然后原样输出事件名。看到实际事件名后,再针对性地处理。这个过程类似反编译工具看到内部实现再做适配,不要凭文档盲写。

4. 一个轻量级追踪采集器的落地实例

工具真正要落地,不能只停留在“能监听”。你得把 Span 数据收集起来,格式化好,导出到后端。这里我分享一套我在生产环境用过的轻量级采集器设计,不一定适合所有场景,但思路可以借鉴。

4.1 采集器核心结构:SpanQueue 与后台导出线程

直接在每个请求路径上写网络导出,是非常糟糕的设计,因为一次网络波动就能让业务线程卡住。我的方案是把所有结束的 Span 放到一个内存队列,由后台线程异步批量导出。

核心结构大概是这样的:

public sealed class SpanCollector : IDisposable { private readonly ConcurrentQueue<SpanData> _queue = new(); private readonly Timer _timer; public SpanCollector(TimeSpan exportInterval) { _timer = new Timer(_ => Flush(), null, TimeSpan.Zero, exportInterval); } public void Push(SpanData span) { if (span.Sampled) { _queue.Enqueue(span); } } private void Flush() { var batch = new List<SpanData>(); while (_queue.TryDequeue(out var span) && batch.Count < 1000) { batch.Add(span); } if (batch.Count == 0) { return; } // 这里把 batch 导出到后端,注意必须用异步,且不能阻塞当前线程 _ = ExportAsync(batch); } }

这个设计你可以理解成把“外卖订单”先放在柜台上,再由固定频率的配送员批量取走。好处是业务线程几乎不会被诊断工具拖慢,缺点是如果进程突然崩溃,内存里还没导出的 Span 会丢。对诊断工具来说,这个取舍通常是可以接受的。如果你需要更可靠,可以改为“每 N 条或每 T 秒”强刷,甚至可以持久化到本地文件再异步上传。

4.2 采样策略:别把所有请求都存下来

生产环境的流量是很猛的,哪怕一个小商城项目,每秒也有几百个请求。如果每个请求都产生 5、6 个 Span,后端存储压力会很大。自研诊断工具最需要做的第一件事就是采样。

我实际用的采样策略分两层:

  1. 固定采样率:比如 10% 的请求要记录。实现方式很多,最简单的就是对 TraceId 做哈希取模:traceId.GetHashCode() % 100 < sampleRate。但要注意,GetHashCode()在 .NET Core 里对 string 做了随机化,进程重启后结果会变,跨进程判断是否采样时可能不一致。更稳妥的是解析 TraceId 的前几个字节做判断。

  2. 慢请求必然采样:如果接口耗时超过阈值,比如 1000ms,不管采样率是多少,都记录下来。这个是为了故障排查。

示例代码片段:

private bool ShouldSample(Activity activity) { if (_onlySlowRequests) { return activity.Duration.TotalMilliseconds >= _slowThresholdMs; } // 根据 traceId 的散列值判断是否属于采样集 var traceIdBytes = activity.TraceId.ToByteArray(); var sampleValue = (traceIdBytes[0] << 8) | traceIdBytes[1]; return sampleValue % 100 < _samplePercent; }

4.3 如何导出到自己的后端或第三方

如果你只是自建一个简单后台,可以考虑用批次 JSON 格式直接 POST 到一个 Web API。比如把SpanData序列化成 JSON:

{ "traceId": "0af7651916cd43dd8448eb211c80319c", "spanId": "b7ad6b7169203331", "parentSpanId": "", "operation": "GET /api/order/apply", "startTime": "2025-01-12T21:00:00.123Z", "durationMs": 235.5, "tags": { "user_id": "10086", "http.status_code": "200" } }

后端接住后,存到 Elasticsearch、ClickHouse 或者随便什么数据库,再做一个简单的查询页面,就能看到整条链路。这往往够很多企业内部用。如果后续想走向更标准的可观测性体系,我建议在导出端兼容 OTLP 协议,也就是直接把SpanData转成 OpenTelemetry 的Activity数据再导出,这样 Jaeger、Grafana Tempo、SkyWalking 这些后端都能无缝接入。自研的核心逻辑放在采集和上下文中,导出方式保持可插拔就好。

5. 踩坑实录:上下文丢失、异步死锁和采样判断

任何说到可观测性的文章,都会强调“上下文传递”很重要,但没有真实踩过坑,很难意识到这里到底有多容易出问题。下面这几件事都是我实际遇到的。

5.1 AsyncLocal 并不万能:Task.Run 与线程池切换

在异步编程里,Activity.Current是通过AsyncLocal实现的,它会顺着异步执行流自动传递。但这并不意味着跨所有场景都能传递。最常见的丢失场景是Task.Run:

var parentActivity = Activity.Current; await Task.Run(() => { // 在某些情况下,这里的 Activity.Current 可能不是 parentActivity var current = Activity.Current; Console.WriteLine(current?.TraceId.ToString()); });

为什么?Task.Run会把任务扔到线程池执行,虽然它会捕获ExecutionContext并尝试恢复AsyncLocal,但在一些特殊场景下(比如存在自定义的 ExecutionContext 抑制、或者在旧版 .NET Framework 上运行),你会发现这里可能变成 null,或者拿到的是另一个 Activity。更麻烦的是,如果你在子任务里新建了 Activity,父级恢复后可能不会自动把子 Span 挂回正确父级。

解决思路有两种。第一种是显式传递 Activity 引用:进入子任务前,把Activity.Current存到变量,然后在子任务里用该变量作为 parent 创建一个新的 Activity。第二种,也是最稳妥的,就是尽量不要跨线程或跨任务创建子 Span,而是让调用方传入一个ActivityContext结构,再用ActivitySource.StartActivity(ActivityKind, parentContext)创建。

5.2 序列化传播的坑:SpanId 大小写与 TraceFlags

有一次我在排查一个跨语言调用场景:服务 A 是 .NET,服务 B 是 Java 或者 Go。A 把traceparent传给 B,B 也能正常解析,但最后在后端链路里发现 Trace 被“拆成两棵”。原因是在序列化时,.NET 的Activity.TraceId和Activity.SpanId是byte[]类型,转换成字符串时如果直接输出,可能输出为带连字符的大写格式。而 W3C 规范要求traceparent中的 id 必须是小写十六进制。

在自研传播逻辑时,一定要统一处理:

var traceId = activity.TraceId.ToHexString().ToLowerInvariant(); var spanId = activity.SpanId.ToHexString().ToLowerInvariant();

另外,traceparent的最后两位是 TraceFlags。如果是01,代表这条链路会被采样;如果是00,代表不采样,多数后端会把不采样的 Span 直接丢弃。当你的诊断工具决定“这个请求必须采样”时,不能只改自己的采集器状态,还需要在传播时把 flags 标记为01,不然下游会认为不需要记录。

5.3 防死锁:不要在导出回调里等网络响应

这个坑我印象非常深刻。某次我在采集器的导出代码里图省事,用.Wait()同步等待一个异步 HTTP 调用把 Span 数据发出去。刚开始流量小,没有异常。等流量上来之后,突然出现大量超时,连业务请求也被拖住。最后分析 dump 才发现,导出代码使用了同一个线程池,而.Wait()把线程池线程占住,等待异步 IO 返回,但异步 IO 的续延又需要线程池线程,形成了典型的线程池饥饿。

从那以后我给自己定了一条规矩:诊断工具的导出路径绝对不允许同步阻塞。任何网络导出都必须走async void或async Task,并且加上超时控制。如果采集器是独立进程,那么更要通过队列导入和批量写来降低锁竞争。

5.4 真实的排查链路案例:从订单超时到找到 Redis 异常

理论讲多了容易空,这里分享一个我在商城项目里用这套工具定位问题的完整链路。某天收到反馈:“创建订单接口偶尔超时,大概 10% 的请求会超过 3 秒。”

我打开自研诊断后台,查看最近一小时的慢请求 Trace,发现根因不在订单服务本身,而是在一个子 Span 上:操作名是“扣减库存”,耗时高达 2800ms。点开这个 Span 的 Tags,看到peer.service=redis,db.statement=DECR inventory:10086。因为把 Redis 的 Key 和命令都放到了 Tags 里,我很快意识到这是 Redis 网络延迟抖动,而不是业务代码问题。随后运维检查 Redis 实例,发现该实例因为备份操作导致频繁的 fork 停顿。如果没有链路追踪,这类问题靠日志几乎没法定位,因为你十几台机器上可能只看到“订单查询失败”,但根本不知道是 Redis 还是别的依赖。

这个案例给我最大的启发是:开发诊断工具时,一定要把业务字段塞进 Span 的 Tags。常见的组合包括user_id、order_id、db.statement、peer.service、http.url。这些字段平时看起来不起眼,但在排障时能省几个小时。

6. 性能优化与内存分配:诊断工具本身不能成为故障源

自研诊断工具很讽刺的一点是:它是用来帮忙定位性能问题的,但如果它自己写得很粗糙,反而会制造性能问题。所以最后这一章,我只说与开销和稳定性相关的内容。

6.1 高频埋点的分配压力

每一个ActivitySource.StartActivity都会创建Activity对象,还会可能触发字符串格式化和 Tag 设置。在高 QPS 场景下,这些分配会直接影响 GC 的频率。假设一个服务每秒处理 1 万个请求,每个请求在核心路径上产生 200 字节的 Activity 相关分配,那一秒就是 2MB,一分钟就是 120MB。虽然这些对象大多是短命对象,分配在 Gen0 里,GC 一趟也能回收,但分配量太大会让 Gen0 频繁 GC,进而影响整体吞吐。

解决思路不是“不分配”,而是“能省则省”。最常见的手段是给 ActivitySource 挂一个采样器,在ShouldStartActivity阶段就决定要不要创建真正的 Activity:

using var listener = new ActivityListener { ShouldListenTo = source => source.Name.StartsWith("MyCompany.Diagnostic"), SampleUsingParentId = (ref ActivityCreationOptions<string> options) => { var shouldSample = Sampler.ShouldSample(options.TraceId); return shouldSample ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None; }, Sample = (ref ActivityCreationOptions<ActivityContext> options) => { var shouldSample = Sampler.ShouldSample(options.TraceId); return shouldSample ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None; } }; ActivitySource.AddActivityListener(listener);

这个展示了两件事:通过ShouldListenTo控制只监听我们关心的源,通过Sample回调决定是否填充数据和标记为 Recorded。当返回None时,StartActivity大概率会返回 null,后续业务代码里的 Tag 设置也不会执行,这样分配开销就基本消除了。

6.2 用配置中心动态启停,别把采样率写死在代码里

自研诊断工具的可观测性本身也需要“观察”。我会在 Agent 里读取配置中心的开关,例如通过 Consul 或 AppSettings 下发:

  • diagnostic.enabled=true/false
  • diagnostic.sampler.ratio=10
  • diagnostic.slow-threshold-ms=1000

当线上出现问题时,不需要重启应用,直接动态把采样率拉到 100%,把慢请求阈值调低,就能更细粒度地观察链路。故障恢复后再把采样率降回去。这个话题也和研究热搜词里的“net core 商城 开源”很配,因为开源商城项目通常也希望能有一套可动态调整的监控方案,而不是接一个厚重的 APM。

6.3 日志与跟踪联动:用同一个 TraceId 贯穿始终

最后一条经验,也是我觉得提升排查效率最大的一点:把 TraceId 塞进日志上下文。你在 Serilog 或 NLog 里可以这样操作:

LogContext.PushProperty("TraceId", Activity.Current?.TraceId.ToString());

这样业务日志里的每一行都会带上一个 TraceId。当用户反馈某个请求出错时,你能从日志系统按 TraceId 找出当时的所有日志,再结合诊断后台的 Span 树一起看。线下调试时,很多人会把 TraceId 打印到响应头里,这样前端也可以直接提供一个 TraceId 给后端排查。这是衔接“日志”和“链路追踪”最重要的桥梁。

我实际使用这套方案时最大的感受是:一旦把 TraceId、SpanId、业务 Id 串起来,后端起一个查询面板,排障效率能得到接近数量级的提升。前面说的那些 AsyncLocal 丢失、大小写不一致、线程池饥饿问题,都是在开发过程中最容易绊倒人的地方。如果一开始就在 Activity 的标准机制上做封装,后面会省很多事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询