.NET 可观测性选型:Metrics、ActivitySource、EventSource 到底该用哪个

很多人把 Meter 当成"新版 EventSource",把 ActivitySource 当成"新版 DiagnosticSource",然后在生产环境里发现:指标查不到、链路断了、开销还不小。这三个 API 解决的是三个不同维度的问题,混用一定会踩坑。

结论先行

  • **Meter / Instrument(System.Diagnostics.Metrics)只负责"数",不负责"事"。**它是聚合器,适合计数器、直方图、当前值;不适合承载"这次请求发生了什么"。
  • **ActivitySource 负责"事"和"链路"。**一个 Activity 就是一段有开始/结束、有父子关系的工作单元,是分布式追踪的唯一载体。
  • **EventSource 是给 EventPipe / ETW / dotnet-trace 用的诊断通道。**排查偶发问题(GC、线程池、异常栈)时用它,做业务监控别用它。
  • **三者可以共存,但不要互相替代。**典型服务:Meter 出 QPS 与延迟直方图,ActivitySource 出跨服务调用链,EventSource 在需要深挖时按需打开。
  • 它们全都是"零监听者近乎零成本"的设计 :没有 MeterListener / ActivityListener 订阅时,库里的打点基本就是几次分支判断。真正的开销来自你自己写错的参数求值和标签基数。

机制:三套 API 各自的边界

Meter:Instrument 才是主角

Meter 本身只是个命名容器,真正做事的是它创建的 Instrument:

Instrument 语义 典型用途
Counter<T> 只增不减的累计量 请求数、错误数、发送字节数
Histogram<T> 分布,落进桶 延迟、载荷大小
UpDownCounter<T> 可增可减的瞬时量 队列长度、并发连接数
ObservableGauge<T> 采集时回调取值 内存占用、缓存条目数

前三个是同步打点(你主动 Add / Record),最后一个是异步观测(采集器每隔一段时间回调你的委托)。这个区别很关键:同步 Instrument 由你的代码决定何时写,异步 Instrument 由采集器决定何时读。

写入路径是:Instrument.Add → MeterListener.SetMeasurementEventCallback 注册的回调 → 导出器(OpenTelemetry、Prometheus 抓取端点等)。中间没有队列、没有后台线程,回调是同步跑在你的调用线程上的。所以导出器的耗时会计入你的请求延迟------这是很多人"加了监控反而变慢"的真正原因。

IMeterFactory:别自己 new Meter

.NET 8 起 DI 容器提供 IMeterFactory,注册方式是一行 services.AddMetrics()。它做两件你自己做不到的事:按名字缓存 Meter 实例,以及在容器释放时统一 Dispose。

自己 new Meter("MyApp", "1.0") 放在静态字段里通常没问题;但如果放在服务构造函数里,每个实例一个 Meter,监听器会被反复注册、Instrument 数量随请求数增长,最终表现为内存缓慢上涨和标签重复。这是最容易被忽略的静默泄漏。

ActivitySource:StartActivity 可能返回 null

csharp 复制代码
using System.Diagnostics;

static readonly ActivitySource Source = new("MyApp.Orders", "1.0");

public async Task<Order> LoadAsync(int id, CancellationToken ct)
{
    using var activity = Source.StartActivity("order.load", ActivityKind.Internal);
    activity?.SetTag("order.id", id);

    var order = await _repo.GetAsync(id, ct);

    if (activity?.IsAllDataRequested == true)
    {
        activity.SetTag("order.items", order.Items.Count);
    }
    return order;
}

StartActivity 在没有监听者、或监听者 ShouldListenTo 返回 false、或采样判定为 drop 时返回 null。所以所有后续操作必须能接受 activity 为 null。

IsAllDataRequested 与 Recorded 是两回事:前者表示"监听者想要全部细节",后者表示"这条 trace 被采样保留"。需要付出较高代价才能拿到的标签,用 IsAllDataRequested 守卫;纯粹的轻量标签直接写。

跨进程传播靠 W3C traceparent 头,由 DistributedContextPropagator.Current 负责注入与提取,HTTP 客户端和 ASP.NET Core 已默认接好,不需要手写。

四个容易静默失效的坑

**1. ?. 挡不住实参求值。**下面这行看着很安全,实际上 BuildDetail() 一定会执行:

csharp 复制代码
activity?.SetTag("payload", BuildDetail());  // BuildDetail 照样被调用

?. 只短路了方法调用,实参在调用前就已经算完。要真正省掉开销,必须写成 if (activity?.IsAllDataRequested == true)。

**2. 直方图的默认桶是毫秒量级。**默认边界大致按 0、5、10、25、50... 这类数值递增设计。如果你的操作是微秒级的(内存缓存命中、纯计算),几乎所有样本都会落进最低那一个桶,你得到的 P99 就是一个毫无信息量的常量。给 Histogram 显式指定 unit 和自定义桶,或者干脆换用 Counter 统计分档命中次数。

**3. 标签基数爆炸。**把 userId、完整 URL、异常消息塞进标签,是监控系统被打爆的头号原因。每多一个唯一标签值就多一条时间序列,而这条序列会一直存在。规则很简单:标签值必须来自有限枚举集合;无限集合用日志或 trace 属性,不要用指标标签。

4. 观测回调里抛异常会静默吞掉。 CreateObservableGauge 传进去的委托在采集线程上执行,异常不会被你的请求上下文捕获,表现是"这个指标时有时无"。回调内部必须自己 try/catch 并返回一个明确的兜底值。

边界与不适用场景

  • **不要用 Meter 记录单次事件。**指标是聚合的,丢了就没了;需要事后追查某一次请求,只有 trace 和日志能用。
  • **不要用 Activity 统计总量。**采样率一开,计数就不准了。计数用 Counter。
  • **不要为了"以后可能要看"就全量打点。**每个 Instrument 都会增加导出器负担和后端成本,没有告警或看板消费方的指标就该删掉。
  • 库作者特别注意 :不要在库内部初始化导出器或注册监听器,只定义 Meter / ActivitySource 并公开名字,让宿主决定订阅什么。硬编码导出是侵入式设计。

一句话选型

问自己"我想知道什么":

  • 想知道多少 / 多快 / 多大 → Meter + Counter / Histogram。
  • 想知道这一次请求经过了哪些环节、慢在哪一跳 → ActivitySource。
  • 想知道运行时内部发生了什么(GC、JIT、锁竞争、异常) → EventSource + dotnet-trace / dotnet-counters。

三者叠加才是完整的可观测性;任何单一 API 想包打天下,最后都会变成"数据有了,但答不上问题"。

相关推荐
CSharp精选营1 小时前
我把 Ant Design Blazor 翻了一遍:Blazor 中后台组件库到底能不能用
.net·blazor·ant design blazor·中后台系统·mudblazor
IT规划师1 小时前
Redis 数据类型全解 - ZSet:两个编码,七种玩法(五)
redis·.net
吃一碗粉只付一碗钱12 小时前
16-SqlSugar多库ConfigId与按实体选连接
sqlserver·c#
rockey62713 小时前
AScript之字符串模板
c#·.net·script·expression·动态脚本
will_net14 小时前
Redis集群部署+Redis 链接地址放入Aollo +c#项目接入Redis
redis·c#·bootstrap
BuHuaX21 小时前
Lua 与 ToLua:运行机制及热更新原理
开发语言·unity·junit·c#·lua
asdzx671 天前
C# 操作 PDF 元数据:从属性写入到字段提取的完整流程
前端·pdf·c#
玩泥巴的1 天前
源生成与 AOT 就绪的微信生态 .NET SDK:结构、能力边界与 API 定位方法
ai·微信·.net·agent·aot·roslyn