遥测接入:指标、追踪、Collector 与一次真实排障
"接入了 Micrometer"不等于"能定位故障":指标上去了、span 却断了线程、Collector 收到一半、PromQL 里查不到——四类断点各自有不同的证据。本页用一条真实链路(Boot 4.0.9 服务 → OTLP → OpenTelemetry Collector → Prometheus)走完接入与排障:指标类型与标签基数、直方图与 histogram_quantile、采样与盲区、Context 跨线程、Collector 管道,最后注入一次下游 503,从 rate 异常 → 502 span → 拒绝日志完成定位。
方法论(为什么观测、基数预算、分位语义、告警燃烧率)归 T26 六篇:可观测性从问题和用户影响开始、高基数、采样与遥测成本、分位、延迟和正确的聚合、从遥测语义到可操作告警;端点暴露/健康就绪归 Actuator;Feign/R4j 指标语义归 远程客户端。本页只讲 Java 侧接入的 API、默认值与失败形态。
固定版本:JDK 21(Liberica 21.0.11+11)、Boot 4.0.9 / Framework 7.0.10、Micrometer 1.16.8、micrometer-tracing 1.6.8、OTel SDK 1.55.0、context-propagation 1.2.1(Boot BOM);OpenTelemetry Collector core 0.164.0 与 Prometheus 3.15.1 为官方发行版二进制(本机直接运行,不用 Docker)。全部观察来自 telemetry 实验 的一次完整公开运行(20 项断言全部通过、exit 0),证据在 labs/telemetry/evidence/u64-2026-10-28.json。
一、指标:类型、单位与不可变口径
Micrometer 的四类 meter 各自承诺一种语义,选错类型比不埋点更糟:
| 类型 | 语义 | 错误用法 |
|---|---|---|
| Counter | 只增的累计量(_total 由 Prometheus 导出器补全) | 存"当前积压数"——重置后 rate 会算出负值 |
| Gauge | 某一时刻的当前值 | 存"累计请求数"——聚合求 avg 毫无意义 |
| Timer | 次数 + 耗时分布 | 只 record 成功路径——失败路径的耗时不可见 |
| DistributionSummary | 耗时以外量的分布 | 用 Gauge 记每次响应大小 |
实验的真实断言(MeterSemanticsTest):
U64 counterCumulative=PASS count=3 timerCount=3 within50ms=2
U64 gaugeReflectsCurrent=PASS value=3
U64 filterDeniesTags=PASS meters=1 denied=2Timer 的 SLO 桶行为值得单独说:配置 serviceLevelObjectives(Duration.ofMillis(50)) 后,10/40/80ms 三次调用在 ≤50ms 桶恰好累计 2 次、80ms 落到更高桶。桶边界决定你能回答的问题——histogram_quantile 只能在已有桶之间插值,桶设 1s/10s 两档就永远答不出"P95 是否超过 400ms"。
二、标签与基数:MeterFilter 是闸门,不是事后清理
order.id 这类高基数值放进标签,序列数会随业务量持续增长(T26 的成本模型在 Java 侧的对应物就是它;拒绝发生在注册层,由单测与导出端对照验证)。实验做了正反对照:
U64 highcard-request [200, 200, 200] # /api/lab/highcard/{id} 故意带 order.id 标签
U64 highcard-filtered lab_highcard series=0 # MeterFilter.deny 后 /actuator/prometheus 无该序列基数成本:序列数是遥测的"行数"
被拒绝的不只是"乱标签",是一笔真实的存储与查询成本:每个标签组合都是一条独立时间序列,Prometheus 的内存、WAL 与查询延迟都按序列数计。T26 的成本模型(高基数、采样与遥测成本)讲预算怎么定,Java 侧的落点就是本节的 MeterFilter——预算在注册层被执行,而不是靠导出端过滤或事后清理。
MeterFilter.deny(id -> id.getTag("order.id") != null) 在注册时刻拒绝 meter(实验应用只对 lab.highcard.orders 生效,生产应按指标名集合精确匹配)——不是"导出时过滤",而是 SimpleMeterRegistry/Prometheus registry 里根本没有这个 meter(单测断言 registry.getMeters() 只有未带标签的那一个)。这给出两条工程纪律:基数治理写在 MeterFilter 里集中管理(配置类见实验 MetricsConfig),而不是散落在每个 Counter.builder 调用点;请求级 ID 走日志或 trace 属性,不进指标标签(本实验 /api/orders/{id} 把 orderId 只放进响应体与 span 属性)。
三、直方图与分位:客户端桶 vs 服务端聚合
实验服务对 lab.downstream.latency 开启 publishPercentileHistogram + 固定 SLO 桶(25/50/100/250ms),指标经两跳到达 Prometheus:应用 /actuator/prometheus → Collector prometheus receiver 抓取 → Collector prometheus exporter 暴露 → Prometheus 抓取。真实查询:
U64 histogram-quantile p50=0.00258… # histogram_quantile(0.5, sum by (le) (rate(...bucket[5m])))
U64 promql-no-data empty vector for unknown metric # 不存在的指标返回空向量,不是 0三条必须记住的边界:客户端 percentile(publishPercentiles(0.95))不可跨实例聚合(各实例各自插值,无法求和);histogram 才可聚合(桶计数可加,histogram_quantile 对聚合后的桶估算);分位数只在被保留的请求上成立——采样或超时剔除会系统性偏低。无数据语义的实测:查询不存在的指标得到空向量,PromQL 不会替你填 0;把空当 0 就会在采集断流时显示"一切正常"(T26 告警页的反例在这里有了真实载体)。
四、追踪:Observation、采样与盲区
Observation.createNotStarted("lab.downstream.call", observations).observe(...) 同时驱动指标与 span;HTTP 服务端 span 由 Boot 自动产生。采样是 SdkTracerProvider 层的开关,Boot 暴露为 management.tracing.sampling.probability。实验的两个真实对照:
U64 otlp-traces-exported serverSpans=46(probability=1.0)
U64 sampling-zero-blindspot before=59 after=59(probability=0.0,8 个请求后 trace 文件零新增)采样=0 的盲区是双向的:业务正常时你看不到任何东西(省了钱),业务出错时也没有 trace 可查(丢了排障证据;本实验只实测了成功路径下 0.0 的零新增,错误路径下的缺失是机制推论)。错误感知只能靠指标与日志兜底——这正是 T26"采样盲区"的机制层含义。0.0 与 1.0 都是极端配置;生产取值要按流量与错误预算重放校准,本实验不给出"推荐值"。
Resource 属性:service.name 从哪来
Resource 属性(service.name 等)来自 Boot 的自动配置:spring.application.name=telemetry-lab 映射为 service.name,management.opentelemetry.resource-attributes.* 补充自定义属性。实测导出的 resource:
U64 otel-resource service.name=['telemetry-lab']默认不设置时是 unknown_service——Collector/后端按 service.name 分组,这个默认值等于把所有服务混成一堆。
五、Context 跨线程:ThreadLocal 断链与显式包装
实测同一请求内三个位置的 traceId:
U64 context-propagation plainEmpty=True wrappedMatches=True
{"requestTraceId":"7f67c3b6…","plainThreadTraceId":"","wrappedThreadTraceId":"7f67c3b6…"}裸 Executors.newSingleThreadExecutor() 里的任务 traceId 为空串——ThreadLocal 在线程边界不复制;ContextExecutorService.wrap(executor, ContextSnapshotFactory…)(micrometer context-propagation 1.2.1)在提交时刻捕获快照、执行时恢复,wrapped 线程拿到的 traceId 与请求一致。机制细节:快照在 submit 时拍(不是执行时),所以"先提交、后改 ThreadLocal"不会生效;源码上 submit 一律经过 capture().wrap(task):
@Override
public <T> Future<T> submit(Callable<T> task) {
return this.executorService.submit(capture().wrap(task));
}
@Override
public <T> Future<T> submit(Runnable task, T result) {
return this.executorService.submit(capture().wrap(task), result);
}工程结论:每次跨线程都要显式选择"传播"还是"不传播"——@Async、线程池、消息发送是三个最常见的断链点;不包装的池永远拿不到 trace。Reactor 的 Context 是另一套载体(U26 已讲),与本节的 ThreadLocal 快照互不替代。
六、Collector:管道、配置校验与数据落地
Collector 的价值是把"多来源 → 统一处理 → 多去向"拆成管道:receiver(otlp http、prometheus 抓取)→ processor(memory_limiter、batch)→ exporter(file、prometheus)。实验配置经 otelcol validate 预检后启动:
U64 collector-config-valid两个值得记住的实测细节:prometheus receiver 的 metrics_path 必须指向 /actuator/prometheus(开发期首轮用默认 /metrics 得到 404 后修正;留档运行都带正确路径),抓取成功后目标 up;经 Collector 转发后 Prometheus 里的标签变了——job 变成 collector 自己的 collector,原始 job/instance 挪到 exported_job/exported_instance(honor_labels 语义),所以 PromQL 选择器必须写 {job="collector",exported_job="telemetry-lab"} 而不是原始标签。"在 Grafana 里查不到指标"的第一 suspicion 应该是这条重标签规则,不是"导出失败"。
导出失败可见性:Collector 停止后应用照常服务(下一节实测);file exporter 落盘 JSON 是本轮的证据形态——不部署 Tempo/Jaeger 也能核对 span 内容,生产追踪后端的选择与容量归 U81/后续票。
七、PromQL 聚合与无数据
真实查询链(全部对着本机 Prometheus 3.15.1 执行):
U64 promql-rate-via-collector rate=0.02242… # rate(lab_orders_accepted_total{job="collector"}[5m])
U64 collector-scraped-total total=12.0 # sum(...) 达到 >=12 阈值(抓取有 1–2s 滞后)
U64 promql-rejected-rate rateRejected=0.013… # 故障注入后的拒绝速率 > 0聚合的正确姿势:rate() 先于 sum()(先算每序列速率再聚合),选择器走 sum by (le) 给 histogram_quantile。计数器语义(只增、重置由 rate 处理)与 Gauge 的"当前值"在 PromQL 里对应不同函数——rate 对 Gauge 无意义。无数据与零是两个答案:空向量在 sum() 后是空、在 Grafana 里显示 No data,聚合函数不会把它变成 0;告警规则必须显式处理"查询无结果"(T26 的语义契约在本轮有了可执行验证)。
八、排障链实测:注入下游 503,从指标到日志
实验用 mock_downstream.py --fail(恒定 503)注入故障,观察到的完整证据链:
U64 downstream-failure-502 [502, 502, 502, 502] # 上游看到 502(LabErrorAdvice 映射)
U64 rejected-counter rejected=4.0 # lab_orders_rejected_total 计数器 +4
U64 promql-rejected-rate rateRejected=0.0137 # PromQL 里拒绝速率 > 0
U64 troubleshoot-chain errorSpans=4 rejectLogLines=4 # span 属性 status=502 + 日志 order rejected reason=downstream status 503定位次序是从便宜到昂贵:1)rate(lab_orders_rejected_total) 跳升——知道"在拒绝";2)按 traceId 找到 server span,span 属性 status=502、下游 span 的 OTel status 为 ERROR、message 是 downstream status 503——知道"哪个调用、哪一跳";3)日志 order rejected id=fail-0 reason=downstream status 503 给出业务上下文。日志与 trace 的关联键就是 traceId(Logback pattern 注入),这比"按时间戳对齐"可靠两个数量级。
一个容易踩的机制点:http_server_requests_seconds_count 是随应用进程存在的 Dial 计数器,按 Prometheus 的 staleness 机制,实例停止后其序列会过期消失——事后取证不能依赖已停止实例的进程内指标,业务计数器(如 lab_orders_rejected_total)经 Collector 仍有最后值可用。本实验的故障注入阶段在应用停止前完成查询,这条 staleness 行为是机制说明而非本轮实测。
失败映射的语义:下游 5xx 统一映射为上游 502(@RestControllerAdvice),拒绝路径计入 lab.orders.rejected——"上游 502"与"下游 503"是两个事实,都进 span 属性;把二者混成一个"错误数"会丢掉排障链第一跳。
九、导出失败的可见性:不阻塞业务是底线
Collector 停止后启动应用(OTLP 端点不可达),本地端点仍然 200:
U64 export-failure-does-not-block elapsed=0.004s遥测故障不能升级为业务故障:OTLP exporter 的失败是异步批处理,阻塞在请求线程上等于把可观测性变成新的故障域。代价是静默丢数据——Collector 长期不可达时你不会从应用侧收到失败,只能靠"指标断流"告警(对 Collector 自身的 up/心跳监控)发现。本轮断连场景只验证了"业务不阻塞";"数据确实没导出"由该阶段无新增 trace 文件佐证,不是单独的计数器对照检查。
十、证明边界与架构师变化条件
- 已运行:真实 otelcol 0.164.0 + Prometheus 3.15.1 二进制、Boot 4.0.9 服务(真实 jar、随机端口)、OTLP 追踪导出与 file 落盘、prometheus receiver 抓取、真实 PromQL 聚合查询、采样 1.0/0.0 对照、跨线程 Context 对照、下游 503 注入与三信号定位、Collector 断连下业务不阻塞。
- 未运行:Prometheus HA/长期存储(Thanos/Mimir)、Grafana 与告警路由、Tempo/Jaeger 追踪后端、Loki/ELK 日志后端、Kubernetes sidecar/Operator、TLS/mTLS 与鉴权头、Collector contrib 发行版、跨主机时钟偏差、容量压测与事故恢复演练(U81)。
- 版本组合是本机教学选型(Boot BOM 锁定的 Micrometer/OTel + 官方发行版二进制),不构成生产推荐;升级任何一侧都要重跑本链路。
架构师变化条件:把 file exporter 换成 Tempo 后,排障链第三步从"读 JSON"变成"查 UI",要重新验证 trace 关联键与保留期;把 Collector 从单进程换成网关+Agent 两层后,断连语义分成两段(Agent→网关、网关→后端),导出失败可见性要分别演练;把采样从固定 probability 换成 tail-based(错误优先保留),盲区问题缓解但 Collector 内存与延迟上升——每个变化都重跑本链路的四个对照(计数一致性、分位、采样盲区、断连),不能沿用旧结论。
闭卷验收
本页 14 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。