OpenTelemetry OBI 指标为什么会在 TTL 之后还在 store 里保留最长达两倍时长,Prometheus 导出器会不会在每次 scrape 时都删掉同一组 label?
答案取决于用的是哪个导出器。OBI 在 OpenTelemetry 导出器和 Prometheus 导出器里都用同一个 TTL 来限制每个 label 集合的指标基数,但两者删除的时机不同。OTEL 导出器是惰性删除:只有当记录路径再次触到导出器时才会触发一次清扫,而且这次清扫本身被限制为「最多每 TTL 一次」,所以一个死掉的 label 集合在最后一次更新后最多可以再存活两个 TTL。Prometheus 导出器则是每次 scrape 都删:它的 collector 在 Collect 里直接调用过期清扫,所以一个死掉的 label 集合会在 TTL 之后落地的第一次 scrape 上消失。两者默认都是同一个 5 分钟 TTL。
一条指标序列活得比产生它的 label 集合还久,是基数泄漏,不是正确性 bug:值本身没问题,问题只是这条死序列多占了多少时间的内存和 scrape 负载。
机制
-
同一个过期存储,按 label 集合建键。
pkg/export/expire/expiry_map.go定义了ExpiryMap,一个带同步锁的从 label 集合 key 到条目的 map,条目里存值和一个lastAccess时间。热路径上唯一的写入口是GetOrCreate:未命中就插入新条目,命中就刷新lastAccess。DeleteExpired扫一遍 map,把所有lastAccess严格早于now - ttl的条目(判定式是now.Sub(e.lastAccess) > ex.ttl,也就是安静满一个 TTL 之后立刻到期)连同它们的 label 集合一起返回;而ttl == 0直接关闭过期功能。key 由LabelsKey生成,把每个 label 值编码成「长度 + 值」,因此["a", "bc"]和["ab", "c"]不会互相碰撞。 -
OTEL 过期器由记录驱动,且被限制为每 TTL 最多一次。
pkg/export/otel/expirer.go在ExpiryMap[attribute.Set]外面包了一层,持有一个lastExpiration时钟。删除发生在ForRecord里:在返回数据点之前,它先检查now.Sub(ex.lastExpiration) >= ex.ttl,只有满足条件才调用removeOutdated,扫出每个过期的 attribute 集合,并对每个调用metric.Remove以该集合作为删除选项,真正从 SDK 指标上摘掉这个数据点。由于清扫被限制为「距上次清扫不足一个 TTL 就跳过」,一个 TTL 前就过期的条目只可能被下一次清扫发现,而那次清扫最早也要再过 TTL 才发生——所以总寿命最长可达2*TTL,这正是代码注释里写的那句话:「一个过期指标在最后一次更新后可能继续可见 2*TTL」。清扫不是后台定时器:它跑在那些调用ForRecord的记录路径上,也就是pkg/export/otel/metrics.go里的 RED 时延与大小指标、以及pkg/export/otel/metrics_svc_graph.go里的 service-graph 汇总。RemoveAllMetrics是显式的关闭钩子,会立即删掉所有条目。 -
Prometheus 过期器由 scrape 驱动。
pkg/export/prom/expirer.go用同一个ExpiryMap包住一个prometheus.MetricVec,并以Collector形式暴露。它的Collect先调用ex.entries.DeleteExpired(),再用DeleteLabelValues把返回的每个 label 集合从包住的向量里删掉,最后才把剩下的输出。这里没有lastExpiration门控:每次 scrape 都是一次清扫,所以最大滞留时间是一个 TTL 加一个 scrape 间隔。过期器上的DeleteLabelValues提供对单个 label 集合的即时手动删除,prom_nodejs.go这类按进程的 runtime 指标封装在 Node.js 进程消失时就是用它的。 -
TTL 的取值与来源。
pkg/obi/config.go里defaultMetricsTTL = 5 * time.Minute,同时赋给OTELMetrics.TTL和Prometheus.TTL。OTEL 这个旋钮是 OTEL metrics 配置上的TTL字段,对应 YAML 里的ttl、环境变量OTEL_EBPF_METRICS_TTL;Prometheus 的旋钮是同名的 YAMLttl、环境变量OTEL_EBPF_PROMETHEUS_TTL。一个 TTL 值,两套删除节奏。
验证与排障路径
-
先确认配的是哪个导出器,再读生效的 TTL。 OTEL 导出器读
OTEL_EBPF_METRICS_TTL(OTEL metrics 块下的 YAMLttl);Prometheus 导出器读OTEL_EBPF_PROMETHEUS_TTL(Prometheus 块下的 YAMLttl)。哪边没设都会回落到 5 分钟默认,所以一条「看起来陈旧」的序列如果比 5 分钟还年轻,那就还不是配置问题。 -
直接在 Prometheus 端点上观察差异。 对一组不再产生流量的 label 集合(一条冷掉的路由或一个停掉的服务),Prometheus 的
/metrics输出会在安静满一个 TTL 后的第一次 scrape 上丢掉它,下一次 scrape 已经干净了。而在 OTEL collector 上,同一条序列可能再多撑两次 scrape 才消失,因为触发删除的那次清扫只有在「某条」label 集合的记录再次路过导出器时才会发生。如果 OTEL 指标看起来总在「边界附近才过期」而 Prometheus 端点看起来收得很紧,那就是这个预期形状,不是 bug。 -
看 debug 日志。 两个过期器都有 debug 日志:OTEL 过期器在删时会打
deleting old OTEL metric并带 attribute 集合,Prometheus 过期器打deleting old Prometheus metric并带 label 值,GetOrCreate打storing new metric label set。把其中一侧导出器的日志级别调到 debug,清扫边界就能直接在时间线上读出来。 -
约束最坏情况,而不是干等。 当基数很高(大量短命路由或进程)时,OTEL 的「两个 TTL 尾巴」加上 scrape 间隔就是死序列的实际留存时间。缩短 TTL 是两侧唯一能由操作者控制的旋钮;那个门控本身不可配置。
限制
- OTEL 过期器的清扫门控挂在记录路径上,而不是挂在墙钟定时器上:当导出器安静时,清扫就不会跑,所以「最多两个 TTL」是上界,不是保证。一条完全空闲的 OTEL 管道会一直保留它最后一批 label 集合,因为没有任何记录会调用
ForRecord来触发清扫。 - 两个导出器对同一个 TTL 值没有统一的语义:OTEL 的边界是安静
2*TTL,Prometheus 的边界是TTL加 scrape 间隔。只拿一个数去理解两边的操作者会误判一个死掉的服务留下的是哪批序列。 ttl: 0会彻底关闭被设为 0 的那一侧的过期功能,把基数泄漏换成 label 集合的无界增长;这是显式的可选项,不是默认。RemoveAllMetrics只存在于 OTEL 过期器上,是单个服务实例 reporter 的关闭路径;它不在普通安静期触发,不能替代 TTL。- 本回答描述的是 OBI
main分支行为。ExpiryMap的形状、每 TTL 一次的门控、以及 scrape 驱动的Collect清扫,都是 OBI 的实现细节;「label 集合应在一段安静期后过期」这个语义是通用的基数管理,任何按 label 的指标存储都会面对。
参考资料
- OBI
pkg/export/expire/expiry_map.go-ExpiryMap存储:GetOrCreate在命中时刷新最后访问时间,DeleteExpired在now.Sub(lastAccess) > ttl时切割,LabelsKey使 label 集合 key 无歧义。 - OBI
pkg/export/otel/expirer.go-ForRecord的「每 TTL 一次」清扫门控及其「最多 2*TTL」注释、removeOutdated调用metric.Remove、以及用于 reporter 关闭的RemoveAllMetrics。 - OBI
pkg/export/prom/expirer.go- Prometheus 过期器:Collect在每次 scrape 调用DeleteExpired,并把返回的 label 集合从包住的MetricVec中删掉。 - OBI
pkg/obi/config.go-defaultMetricsTTL = 5 * time.Minute同时赋给OTELMetrics.TTL与Prometheus.TTL。 - OBI
pkg/export/prom/prom.go- Prometheus 配置的TTL字段,对应 YAMLttl、环境变量OTEL_EBPF_PROMETHEUS_TTL。
今日社区讨论
今日没有可供总结的社区讨论:本次运行所覆盖的监控窗口一条消息都没有返回——两个 opt-in 归档在 24 小时窗口内返回了 0 条消息(result=ok,零条)——所以本条回答的是上文公开一手资料里一个长存的问题,而不是某条在线讨论,因此不附匿名的讨论摘要。
本次运行的频道覆盖:两个 opt-in 归档频道在监控窗口内均返回零条消息。可见浏览器专属来源——eunomia-bpf 与 sched-ext 的 Discord 服务器、bpf 邮件列表、以及 r/eBPF——本次因没有可用的可见浏览器会话而无法审阅,故标记为「未覆盖」而非「安静」。
继续阅读
返回索引
eBPF 问答
这里每天收录一个关于 eBPF、Linux 可观测性、性能分析、运行时扩展或安全问题的回答。每篇回答都以公开的一手资料为依据,并在末尾附上匿名化的当日社区讨论摘要。
上一篇 / 上一页
OBI 的 HTTP span 为什么会出现高基数的名称,OpenTelemetry 语义约定又如何限制 span 名称的基数?
span 名称是追踪系统里基数最高的单一字段,所以 OpenTelemetry 的 HTTP 语义约定把名称固定为 {method} {target},并要求 target 必须是低基数的:服务端用 http.route,客户端用 url.template。该约定明确说明,一个 instrumentation 不得把原始 URI 路径作为 target 的默认值。OpenTelemetry eBPF 探针(OBI)构造 HTTP
下一篇 / 下一页
OpenTelemetry OBI 的 Collector 接收器为什么保持独立模块和独立版本线,为什么普通的 go get 更新不会把接收器拉到 RC 标签?
有两道相互独立的屏障。OBI 把 Collector 接收器拆成了一个嵌套 Go 模块——collector/ 子目录有自己的 go.mod,import 路径保持不变——而这个嵌套模块走自己的 v0 版本线,标签打在 collector/v0.15.2;核心模块则打不带前缀的 v0.15.2 和 v1.0.0-rc.2。普通的 go get -u 到不了 RC,原因有二:Go 的 latest 与 upgrade
- 最后更新
- 2026年10月21日
- 首次发布
- 2026年10月21日
- 贡献者
- Yunwei 123
这个页面有帮助吗?