尧图精选

基于.NET Core自研轻量级诊断工具:链路追踪与Activity实战

🕒 发布时间:2026/9/8 20:20:20 📁 来源:尧图网络
搞 .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一次完整调用链SpanPOST /api/order/apply根 SpanSpan查询用户账户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 ObserverDiagnosticListener(onNext: l { if (l.Name HttpHandlerDiagnosticListener || l.Name Microsoft.AspNetCore) { l.Subscribe(new ObserverKeyValuePairstring, 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 OrderIdid); 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 ConcurrentQueueSpanData _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 ListSpanData(); 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后端存储压力会很大。自研诊断工具最需要做的第一件事就是采样。我实际用的采样策略分两层固定采样率比如 10% 的请求要记录。实现方式很多最简单的就是对 TraceId 做哈希取模traceId.GetHashCode() % 100 sampleRate。但要注意GetHashCode()在 .NET Core 里对 string 做了随机化进程重启后结果会变跨进程判断是否采样时可能不一致。更稳妥的是解析 TraceId 的前几个字节做判断。慢请求必然采样如果接口耗时超过阈值比如 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.Runvar 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传给 BB 也能正常解析但最后在后端链路里发现 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.serviceredisdb.statementDECR 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阶段就决定要不要创建真正的 Activityusing var listener new ActivityListener { ShouldListenTo source source.Name.StartsWith(MyCompany.Diagnostic), SampleUsingParentId (ref ActivityCreationOptionsstring options) { var shouldSample Sampler.ShouldSample(options.TraceId); return shouldSample ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None; }, Sample (ref ActivityCreationOptionsActivityContext 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.enabledtrue/falsediagnostic.sampler.ratio10diagnostic.slow-threshold-ms1000当线上出现问题时不需要重启应用直接动态把采样率拉到 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 的标准机制上做封装后面会省很多事。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →