目录
一、可观测性的困境与破局
(一)三套系统各自为战的排障之痛
(二)从监控到可观测性的演进
(三)OpenTelemetry 的定位与价值
二、核心概念与数据模型
(一)Trace、Span 与上下文传播
(二)Resource:三大信号的共同身份证
(三)指标与日志的数据模型要点
(四)Collector 的四类组件与管道模型
三、整体架构设计
(一)技术选型与拓扑
(二)Collector 关键配置
四、请求关联:让每条日志都能找到它的链路
(一)把 trace_id 写进每一条日志
(二)双向跳转:在 Grafana 里缝合日志与链路
1、从日志跳转到链路
2、从链路跳转到日志
(三)日志采集管道与降噪治理
五、跨服务追踪的工程落地
(一)自动埋点与手工埋点的分工
(二)异步与消息队列场景的断链修复
1、线程池切换
2、消息队列
3、定时任务与补偿逻辑
(三)采样策略:头部采样与尾部采样
(四)服务依赖拓扑:链路数据的第二重价值
六、业务指标体系:从请求量到经营数据
(一)用 spanmetrics 白拿一套 RED 指标
(二)业务自定义指标与 Exemplar 关联
(三)指标体系的分层治理
七、告警体系:从阈值告警到错误预算
(一)SLI、SLO 与错误预算
(二)多窗口燃烧率告警的落地
1、多窗口多燃烧率规则设计
2、分级路由与降噪
(三)告警与三信号的闭环,以及对采集链路自身的监控
八、一次完整的排障演练:五分钟走完全链路
九、经验总结与避坑指南
(一)分阶段推进路线
(二)高频踩坑清单
(三)写在最后
参考链接
干货分享,感谢您的阅读!
凌晨两点半,你面前并排开着三个标签页。Prometheus 一脸笃定地告诉你延迟涨了,但坚决不说为什么;ELK 热情地捧出一万两千条日志,可惜没有一条记得自己属于哪次请求;Jaeger 最有诚意,认认真真采样了 1%,只可惜出事的那次刚好在剩下的 99% 里。三个系统像三位目击证人:一个只记得几点钟,一个只记得说了什么,还有一个压根没到现场。于是你开始在三块屏幕之间复制粘贴时间戳,用肉眼做 join——这大概是当代软件工程中最昂贵的一种手工连表查询,而且没有索引。
建立请求关联、跨服务追踪、业务指标和告警体系,把三套割裂的监控系统整合成一条可以来回穿梭的排障高速路。
一、可观测性的困境与破局
(一)三套系统各自为战的排障之痛
凌晨两点,值班手机响了:订单服务 P99 延迟从 200 毫秒飙到 3 秒。你打开 Prometheus 看指标,确认延迟确实涨了,但指标只能告诉你"发生了什么",不能告诉你"为什么"。于是你切到 Jaeger 想找一条慢请求的调用链,却发现采样率只有 1%,那条最慢的请求大概率根本没被采到。最后只能登上 ELK,用时间范围加服务名做全文检索,在几十万条日志里靠肉眼找异常——而日志里没有任何字段能和刚才看到的那条指标曲线、那次请求对应起来。
这是许多团队的真实写照:Prometheus 管指标、ELK 管日志、Jaeger 或 SkyWalking 管链路,三套系统三套数据模型、三套查询语言、三个浏览器标签页。它们各自都能工作,但彼此之间没有共同语言。排障过程变成了"人肉关联"——工程师凭借时间戳、服务名和经验,在三个界面之间反复横跳,把线索一点点拼起来。故障恢复时间(MTTR)里,真正修复问题往往只占两成,剩下八成都耗在了定位上。
割裂的代价还不止于排障效率。三套系统意味着三份埋点代码、三套 SDK 依赖需要升级维护,意味着新服务接入要重复对接三次,也意味着每个系统各有一套服务命名和标签体系——同一个服务在指标里叫 order-svc、在日志里叫 orderService、在链路里叫 order-service-prod 的情况屡见不鲜,任何试图做自动化关联的平台工程都会在这种命名沼泽里搁浅。更隐蔽的是供应商锁定:埋点代码直接调用某个特定后端的 SDK,意味着未来任何后端迁移都要动业务代码,成本高到基本等于不可能。
问题的根源不在于工具不够好,而在于三类遥测数据在采集源头就没有建立关联。日志里没有链路标识,指标样本无法回指到具体请求,链路数据又常常因为采样而残缺。数据孤岛一旦在源头形成,后端再怎么建设平台也只能做时间维度的模糊对齐,无法做到请求级的精确关联。
(二)从监控到可观测性的演进
监控(Monitoring)和可观测性(Observability)经常被混用,但两者的出发点不同。监控回答的是"预定义的问题":CPU 是否超过 80%、接口错误率是否超标——你必须提前知道要问什么。可观测性回答的是"未曾预料的问题":系统对外暴露足够丰富、足够关联的遥测数据,使得工程师面对一个从未见过的故障时,也能通过数据的层层下钻还原出内部状态。
支撑可观测性的三大信号(也称三大支柱)分别是:
- 链路追踪(Traces):记录一次请求在分布式系统中流经的完整路径,由一组带父子关系的 Span 构成,擅长回答"慢在哪一跳、错在哪一环"。
- 指标(Metrics):对系统行为的数值化聚合,如 QPS、延迟分位数、错误率,存储成本低、适合长期趋势和告警。
- 日志(Logs):带时间戳的离散事件记录,信息密度最高,擅长呈现单次事件的完整细节。
三者各有所长也各有盲区:指标看得远但看不细,日志看得细但看不远,链路看得清结构但受制于采样。真正的可观测性不是把三者堆在一起,而是让它们共享同一套身份标识——当一次请求的链路标识(trace_id)同时出现在它产生的每条日志、并作为示例样本(Exemplar)挂在它贡献的每个指标数据点上时,三大信号才真正"打通",工程师才能从任意一个信号出发,无损地跳转到另外两个。
(三)OpenTelemetry 的定位与价值
OpenTelemetry(下文简称 OTel)是 CNCF 旗下仅次于 Kubernetes 的第二大活跃项目,由 OpenTracing 与 OpenCensus 合并而来。它的野心不是再造一个监控后端,而是统一遥测数据的"生产标准":定义三大信号的数据模型与语义约定,提供各主流语言的 SDK 与自动埋点能力,规定传输协议 OTLP,并提供一个可插拔的数据管道组件——OpenTelemetry Collector。
这带来三个直接价值。第一是关联性内建:OTel 的日志数据模型原生包含 TraceId 与 SpanId 字段,指标支持 Exemplar,三大信号共享同一份资源属性(Resource),关联不再依赖后期的字符串解析。第二是厂商中立:应用只对接 OTLP 协议,后端可以是 Jaeger、Tempo、Prometheus、Loki,也可以是任何商业平台,切换后端不需要改一行业务代码。第三是采集与处理统一:Collector 作为遥测数据的"路由器",统一完成过滤、脱敏、批处理、采样和多路分发,把运维复杂度从每个应用收敛到一个基础设施组件。
二、核心概念与数据模型
(一)Trace、Span 与上下文传播
一条链路(Trace)由若干跨度(Span)组成。每个 Span 描述一个具备明确起止时间的操作单元——一次 HTTP 处理、一次数据库查询、一次消息消费——并携带名称、时间戳、状态码、属性键值对和事件列表。Span 之间通过 parent_span_id 建立父子关系,整条链路共享一个全局唯一的 trace_id。
跨服务的关联依靠上下文传播(Context Propagation)。W3C Trace Context 规范定义了标准 HTTP 头traceparent,格式为:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 │ └────────── trace-id ──────────┘ └─ parent-id ──┘ │ 版本 32位十六进制 16位十六进制 采样标志上游服务发起调用时,SDK 自动把当前 Span 的上下文序列化进traceparent头;下游服务收到请求后解析该头,创建的新 Span 便自动挂到同一条链路上。整个过程对业务代码透明,这正是"请求关联"的技术基石:只要上下文不断链,一次用户请求无论穿过多少服务、多少线程、多少消息队列,都能被同一个 trace_id 串起来。
与traceparent配套的还有tracestate头,用于在链路上携带厂商或平台自定义的键值对(例如上游的采样率、租户标识),以及更通用的 Baggage 机制——它允许业务在链路上下文中传递任意业务键值(如灰度分组、渠道来源),下游服务和遥测数据都能读取。Baggage 很强大但要克制使用:它随每一次网络调用传输,塞入过多内容会实质性增加所有请求的头部开销,敏感信息更是绝对禁止放入。
(二)Resource:三大信号的共同身份证
Resource(资源)描述遥测数据的产生者,是一组遵循语义约定(Semantic Conventions)的属性,例如service.name、service.version、deployment.environment、k8s.pod.name。OTel 要求三大信号都必须携带 Resource,这一点看似平淡,实则是信号关联的另一半地基:trace_id 解决"同一次请求"的横向关联,Resource 解决"同一个实例"的纵向关联。当你在链路里看到某个 Pod 上的 Span 异常时,可以直接用完全相同的属性名去检索该 Pod 同时段的日志与指标,不存在"链路里叫 svc-a、日志里叫 service_a"的对齐难题。
在 Kubernetes 环境中,Collector 的 k8sattributes 处理器可以自动为流经的所有信号补齐 Pod、Namespace、Deployment 等属性,保证三大信号的资源标签逐字节一致,这是靠各应用自觉打标签永远做不到的。
(三)指标与日志的数据模型要点
指标信号的数据模型比表面看起来更讲究。OTel 定义了几类核心仪表(Instrument):单调递增的计数器(Counter)适合累计量,如请求总数;可增可减的上下计数器(UpDownCounter)适合瞬时量,如活跃连接数;直方图(Histogram)记录数值分布,是延迟类指标的标准选择;异步观测仪(Gauge / Observable)用于周期性采样外部状态,如队列深度、内存水位。选错仪表类型是新手常犯的错误——用 Gauge 记请求数会丢失累计语义,用 Counter 记连接数则无法表达下降。
直方图还有一个进阶选项:指数直方图(Exponential Histogram)。传统显式边界直方图需要预先设定桶边界,边界设窄了高延迟全挤在最后一个桶里失去分辨率,设宽了低延迟区间又浪费桶位;指数直方图按对数尺度自动分桶,以极小的存储代价覆盖从微秒到分钟的完整量程,对延迟这类跨多个数量级的度量尤其合适。
日志信号方面,OTel 的日志数据模型(LogRecord)与传统文本日志最大的区别在于结构化字段是一等公民:时间戳、严重级别(SeverityNumber)、消息体(Body)、属性(Attributes)、TraceId、SpanId、Resource 各自独立成字段,不需要靠正则从一行文本里"抠"出来。OTel 对存量日志生态的策略是桥接而非替换——各语言提供日志框架的桥接器(Appender/Handler),把 Logback、Log4j、zap、winston 等框架产出的日志记录翻译成 LogRecord 模型发送,业务代码的打日志习惯完全不用改。这种设计极大降低了迁移阻力:日志是三大信号中存量包袱最重的一个,任何要求"换日志框架"的方案在大型组织里都寸步难行。
(四)Collector 的四类组件与管道模型
Collector 内部由四类组件组装成若干条管道(Pipeline):
- 接收器(Receiver):数据入口,支持 OTLP、Prometheus 抓取、Zipkin、Kafka、文件日志等多种协议;
- 处理器(Processor):串行加工数据,常用的有内存限制(memory_limiter)、批处理(batch)、属性增删改(attributes/transform)、过滤(filter)、尾部采样(tail_sampling);
- 导出器(Exporter):数据出口,负责把数据写往后端,内置重试与发送队列;
- 连接器(Connector):同时充当一条管道的出口和另一条管道的入口,实现信号之间的派生转换,典型代表是把链路数据聚合成指标的 spanmetrics。
三种信号各建独立管道是生产环境的最佳实践:链路管道故障不会波及指标管道,失败域彼此隔离。管道内处理器的排列顺序也有讲究——memory_limiter 必须放在第一位充当熔断闸门,batch 通常放在最后一位以合并网络请求。
三、整体架构设计
(一)技术选型与拓扑
本文以一套完全开源、自托管的组合为例:Grafana 作统一查询与可视化门户,Tempo 存链路、Prometheus 存指标、Loki 存日志,应用侧使用 OTel SDK 或 Java Agent 完成埋点,所有数据经 OTLP 汇入 Collector。这套组合的每一个后端都可以独立替换——比如把 Tempo 换成 Jaeger、把 Loki 换成 Elasticsearch——应用侧无感知。
选择这套组合的理由有三点。其一,Tempo 与 Loki 都采用"对象存储 + 索引极简"的架构:Tempo 只按 trace_id 索引,Loki 只索引标签不索引日志内容,两者的存储成本都远低于全文索引方案,非常契合"链路和日志主要靠 trace_id 精确定位、而非全文搜索"的新排障范式。其二,Grafana 对三个数据源的互跳能力是产品级内建的,不需要二次开发。其三,三个后端的运维模型相似,一个平台小组即可维护。当然,如果团队已有成熟的 Elasticsearch 或商业 APM 资产,OTel 体系同样兼容——这正是标准化采集层的价值:后端的选择变成了纯粹的成本与偏好问题,不再是绑架架构的决定。
部署形态采用两级架构:
- 代理层(Agent):以 DaemonSet(Kubernetes)或本机守护进程方式随节点部署,就近接收本节点应用的 OTLP 数据,补齐主机与 Pod 级资源属性后转发。配置极简,几乎不做重计算。
- 网关层(Gateway):以无状态 Deployment 集中部署,承担重活——尾部采样、属性脱敏、指标派生、多后端路由、重试与削峰。需要横向扩容时只扩这一层。
两级分离的好处是职责清晰:代理层追求轻和稳,网关层集中演进策略。当引入尾部采样后,代理层到网关层之间还需要一个按 trace_id 做一致性哈希的负载均衡导出器(loadbalancing exporter),确保同一条链路的所有 Span 落到同一个网关实例——这是尾部采样正确工作的硬性前提。
(二)Collector 关键配置
下面是网关层 Collector 的骨架配置,体现了三管道分离、熔断前置、批量后置、队列与重试兜底的原则:
receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: memory_limiter: # 永远放在处理器链第一位 check_interval: 1s limit_mib: 3500 # 约为容器内存上限的 85% spike_limit_mib: 700 k8sattributes: {} # 自动补齐 Pod / Namespace 等资源属性 batch: send_batch_size: 8192 timeout: 5s exporters: otlp/tempo: endpoint: tempo:4317 tls: { insecure: true } sending_queue: { enabled: true, num_consumers: 4, queue_size: 5000 } retry_on_failure: { enabled: true, initial_interval: 1s, max_elapsed_time: 300s } prometheusremotewrite: endpoint: http://prometheus:9090/api/v1/write loki: endpoint: http://loki:3100/loki/api/v1/push service: pipelines: traces: receivers: [otlp] processors: [memory_limiter, k8sattributes, batch] exporters: [otlp/tempo] metrics: receivers: [otlp] processors: [memory_limiter, k8sattributes, batch] exporters: [prometheusremotewrite] logs: receivers: [otlp] processors: [memory_limiter, k8sattributes, batch] exporters: [loki]有几条来自生产事故的教训值得写进注释里:不配 sending_queue 与 retry_on_failure,后端一次抖动就会丢一批数据;不配 memory_limiter,流量尖峰会直接把 Collector 打出 OOM;调试用的 debug 导出器忘了下线,会把全量遥测数据刷进标准输出撑爆磁盘。
四、请求关联:让每条日志都能找到它的链路
请求关联是三信号打通的第一战役,目标很具体:任何一条业务日志,都必须能反查到产生它的那次请求的完整调用链;反之,拿到一条链路,也必须能捞出这条链路途经的所有服务的所有日志。
(一)把 trace_id 写进每一条日志
以 Java 技术栈为例,挂载 OTel Java Agent 后,Agent 会自动把当前活跃 Span 的上下文注入日志框架的 MDC(Mapped Diagnostic Context),键名为trace_id、span_id、trace_flags。应用只需在日志输出格式中引用它们。推荐直接输出结构化 JSON 日志,避免后续正则解析的脆弱性:
<!-- logback-spring.xml:输出携带链路标识的 JSON 日志 --> <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <includeMdcKeyName>trace_id</includeMdcKeyName> <includeMdcKeyName>span_id</includeMdcKeyName> <customFields>{"service_name":"order-service","env":"prod"}</customFields> </encoder> </appender>启动命令中挂载 Agent 并声明资源属性:
java -javaagent:opentelemetry-javaagent.jar \ -Dotel.service.name=order-service \ -Dotel.resource.attributes=deployment.environment=prod \ -Dotel.exporter.otlp.endpoint=http://otel-agent:4317 \ -jar order-service.jar此后每条日志形如:
{"@timestamp":"2026-09-11T02:13:45.372+08:00","level":"ERROR", "logger":"c.e.order.PaymentClient","message":"支付网关调用超时", "trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7", "service_name":"order-service","env":"prod"}更进一步,可以绕开"落盘再采集"的传统路径,让日志框架通过 OTel 的日志桥接器(Logback Appender / Log4j Appender)直接以 OTLP 协议发往 Collector。OTel 的日志数据模型里 TraceId 与 SpanId 是一等公民字段,结构化程度和传输效率都优于文本日志再解析的方案。两种方式可以并存:本地保留文本日志兜底,OTLP 通道供平台消费。
(二)双向跳转:在 Grafana 里缝合日志与链路
数据带上标识只是第一步,还要让查询界面"认识"这些标识。Grafana 通过数据源配置实现双向跳转。
1、从日志跳转到链路
在 Loki 数据源上配置派生字段(derivedFields),用正则从日志行中提取 trace_id 并生成指向 Tempo 的内部链接:
- name: Loki type: loki url: http://loki:3100 jsonData: derivedFields: - datasourceUid: tempo matcherRegex: '"trace_id":"(\w+)"' name: TraceID url: '$${__value.raw}'2、从链路跳转到日志
在 Tempo 数据源上配置 tracesToLogsV2,点击链路中任意 Span 即可按服务名与时间窗过滤出对应日志:
- name: Tempo type: tempo url: http://tempo:3200 jsonData: tracesToLogsV2: datasourceUid: loki spanStartTimeShift: '-5m' spanEndTimeShift: '5m' filterByTraceID: true tags: [{ key: 'service.name', value: 'service_name' }]配置完成后的体验是质变的:在日志面板看到一条错误日志,点一下 TraceID 链接,右侧立刻展开这条请求的完整调用瀑布图;在瀑布图里点任何一个 Span 的"查看日志",又能立刻看到该服务在该时间窗内、属于该 trace_id 的全部日志。过去需要跨三个系统人肉拼接的证据链,现在是两次鼠标点击。
(三)日志采集管道与降噪治理
如果暂时无法全面切换到 OTLP 直发日志,Collector 的 filelog 接收器可以承接传统的落盘日志路径:以 DaemonSet 方式挂载节点日志目录,按容器日志格式解析、提取 JSON 字段、把 trace_id 提升为记录级字段后再进入统一管道。这条路径与 OTLP 直发可以长期并存,迁移节奏由各团队自行掌握。
日志的成本问题比链路和指标更尖锐,因为它的量级往往大一个数量级。几条被反复验证有效的降噪手段值得在管道层统一实施:其一,健康检查与探活日志在 Collector 的 filter 处理器直接丢弃,这类日志常占总量的两三成却几乎没有排障价值;其二,对调试级别日志实施"链路联动保留"——仅当所属链路被尾部采样命中时保留 DEBUG 日志,其余降级丢弃,让最详细的日志恰好留在最值得看的请求上;其三,为不同服务设定差异化保留期,核心交易服务保留三十天,边缘服务七天,归档需求走对象存储冷层。降噪不是一次性项目而是持续治理,建议把"每服务每日日志量"本身做成指标并设置环比暴涨告警,防止某次发布把日志级别误设为 DEBUG 之后静默烧钱。
五、跨服务追踪的工程落地
(一)自动埋点与手工埋点的分工
自动埋点(零代码方式)应当是绝对主力。Java Agent、Python/Node 的自动注入能力已覆盖主流框架:HTTP 服务端与客户端、gRPC、JDBC、Redis、Kafka、RabbitMQ 等,接入成本仅为一个启动参数,且埋点质量与语义约定的一致性远高于手写。经验法则是:框架边界交给自动埋点,业务语义交给手工埋点。手工埋点聚焦两件事——为关键业务操作创建自定义 Span(如"风控评估"、"库存扣减"),以及为 Span 附加业务属性(订单金额区间、商户等级、优惠券类型),让链路数据具备业务检索维度:
private static final Tracer tracer = GlobalOpenTelemetry.getTracer("com.example.order"); public void deductInventory(String skuId, int count) { Span span = tracer.spanBuilder("库存扣减").startSpan(); try (Scope ignored = span.makeCurrent()) { span.setAttribute("biz.sku_id", skuId); span.setAttribute("biz.count", count); doDeduct(skuId, count); } catch (Exception e) { span.recordException(e); span.setStatus(StatusCode.ERROR, "库存扣减失败"); throw e; } finally { span.end(); } }注意给 Span 属性设定命名空间前缀(如biz.),并克制属性数量——链路属性不受基数约束,但每个属性都有存储成本。
(二)异步与消息队列场景的断链修复
同步 HTTP 调用的上下文传播由自动埋点全权处理,真正容易断链的是异步场景。三个高频断点及对策如下。
1、线程池切换
上下文默认绑定在 ThreadLocal,任务提交到线程池后随线程切换而丢失。Java Agent 已自动包装 JUC 标准线程池;自建执行器或使用第三方异步框架时,需用Context.current().wrap(runnable)显式包装任务,或在框架层统一封装提交入口,避免每个调用点各自处理。
2、消息队列
生产者需把上下文注入消息头(Kafka Headers、RocketMQ UserProperty),消费者从消息头恢复。主流客户端已被自动埋点覆盖,但要注意消费侧语义:批量消费时一批消息来自多条链路,正确做法是为每条消息创建独立的消费 Span,并通过 Span Link(而非父子关系)关联到各自的生产链路,否则整批消息会被错误地拼成一条超长链路。
3、定时任务与补偿逻辑
这类"无源头"的执行本身应作为新链路的根。若它处理的数据关联到历史请求(例如对账任务修复某笔历史订单),同样用 Span Link 建立弱关联,避免把不相干的执行强行拼进旧链路,污染原链路的耗时统计。
(三)采样策略:头部采样与尾部采样
全量存储链路在中高流量下成本不可承受,采样是必答题。两种范式的本质区别在于决策时机:
头部采样(Head Sampling)在链路诞生的第一个 Span 处做决定,典型配置是父级优先的比例采样(parentbased_traceidratio)。它实现简单、开销恒定、天然保证整条链路决策一致,缺点是"闭着眼睛抽签"——1% 的采样率意味着 99% 的错误链路和慢链路同样被丢弃,恰恰丢掉了排障最需要的样本。
尾部采样(Tail Sampling)把决策推迟到链路结束之后:Collector 把同一 trace_id 的 Span 缓存在内存里,等待 decision_wait 窗口结束,基于整条链路的特征(是否有错误、总耗时是否超阈值)再决定去留。典型策略组合:
processors: tail_sampling: decision_wait: 15s # 建议取 P99 延迟的 3 倍左右 num_traces: 50000 # 内存中最多缓存的链路数 policies: - name: errors-policy # 错误链路 100% 保留 type: status_code status_code: { status_codes: [ERROR] } - name: slow-policy # 超过 2 秒的慢链路 100% 保留 type: latency latency: { threshold_ms: 2000 } - name: baseline-policy # 其余按 10% 随机保底 type: probabilistic probabilistic: { sampling_percentage: 10 }尾部采样的代价是有状态:内存占用约等于 TPS × decision_wait × 平均 Span 数 × 单 Span 大小,必须配合三件套——前置 memory_limiter、按 trace_id 哈希的负载均衡层、以及对sampling_trace_dropped_too_early指标的监控(该指标上涨说明链路还没等到完整就被挤出缓存,需要加大 num_traces 或缩短 decision_wait)。另有一个易被忽略的统计陷阱:采样之后,后端基于链路算出的吞吐、错误数不再等于真实全量,因此度量应以指标信号为准,链路只负责解释原因;若确需从链路侧还原总量,需在 Span 上附带采样率元数据供后端加权。
(四)服务依赖拓扑:链路数据的第二重价值
链路数据除了支撑单请求排障,还能聚合出全局的服务依赖拓扑。Collector 的 servicegraph 连接器观察 Span 之间的客户端-服务端配对关系,持续产出"谁调用谁、调用量多少、失败率多少、延迟多少"的边级指标,Grafana 或 Jaeger 可以据此渲染实时的依赖关系图。它的价值在三个场景尤为突出:新人理解系统全貌不再依赖过期的架构文档;故障时快速判断爆炸半径——下游挂了会波及哪些上游;架构治理时识别不该存在的调用(如边缘服务直连核心库、循环依赖)。使用时注意它与尾部采样一样是有状态组件,需要保证同一链路的 Span 对到达同一实例,并合理设置配对缓存的容量与过期时间,避免高峰期配对失败导致拓扑图上出现大量"未连接"的悬空边。
六、业务指标体系:从请求量到经营数据
(一)用 spanmetrics 白拿一套 RED 指标
RED 方法论——请求速率(Rate)、错误数(Errors)、时延分布(Duration)——是服务健康度的最小完备刻画。传统做法要求每个服务各自埋点上报,而在 OTel 体系里,这套指标可以从链路数据中"免费"派生:Collector 的 spanmetrics 连接器消费链路管道的 Span 流,按服务名、Span 名称、状态码等维度聚合出调用次数与耗时直方图,再作为指标管道的源头输出:
connectors: spanmetrics: histogram: explicit: buckets: [5ms, 25ms, 100ms, 250ms, 500ms, 1s, 2s, 5s] dimensions: - name: http.method - name: http.status_code exemplars: enabled: true # 关键:让指标数据点携带 trace_id 样本 max_per_data_point: 3 service: pipelines: traces: receivers: [otlp] processors: [memory_limiter, batch] exporters: [otlp/tempo, spanmetrics] # 链路同时喂给后端与连接器 metrics: receivers: [otlp, spanmetrics] # 连接器作为指标源 processors: [memory_limiter, batch] exporters: [prometheusremotewrite]这意味着任何一个新服务只要接入了链路追踪,无需写一行指标代码,就自动获得标准化的 RED 大盘与告警基础。这种"链路先行、指标派生"的模式还带来一个隐性收益:指标口径全公司天然统一。过去各团队自埋的请求计数器,有的算重试、有的不算,有的在过滤器前打点、有的在业务逻辑里打点,跨服务对比时口径之争没完没了;由同一个连接器从同一套 Span 语义中派生,口径分歧从机制上消失了。
需要警惕的是维度基数:dimensions 里绝不能加入 trace_id、user_id 这类高基数或时变字段,只应选择稳定、全量存在、有区分意义的属性(如环境、区域、方法名),否则时间序列数量会爆炸式增长,拖垮指标后端。同时要遵循指标数据模型的单一写入者原则——多个 Collector 实例向同一后端写入时,务必通过实例标识或分片配置错开指标流,避免不同实例写入同一条时间序列引发数据冲突与丢点。
有一点必须强调:若启用了尾部采样,spanmetrics 应当接在采样之前的全量 Span 流上(即在代理层或采样处理器之前派生),否则 RED 指标会被采样偏差污染——错误链路被 100% 保留会让错误率看起来远高于真实值。
(二)业务自定义指标与 Exemplar 关联
技术指标之上是业务指标:下单量、支付成功率、优惠券核销金额。这类指标只能由业务代码主动记录。Java 生态可用 Micrometer(经 OTLP registry 桥接)或直接使用 OTel Metrics API:
private static final Meter meter = GlobalOpenTelemetry.getMeter("com.example.order"); private static final LongCounter orderCounter = meter .counterBuilder("biz.order.created") .setDescription("订单创建总数") .setUnit("{order}") .build(); private static final DoubleHistogram payLatency = meter .histogramBuilder("biz.payment.duration") .setUnit("ms") .build(); public void onOrderCreated(Order order) { // 在活跃 Span 上下文内记录,SDK 自动附加 Exemplar(trace_id) orderCounter.add(1, Attributes.of( AttributeKey.stringKey("channel"), order.getChannel(), AttributeKey.stringKey("tier"), order.getMerchantTier())); }关键机制是Exemplar(示例样本):当指标测量发生在一个被采样的活跃 Span 内时,SDK 会在该数据点上附着当前的 trace_id。OTel 默认的 trace_based 过滤器只在采样命中的 Span 内记录 Exemplar,天然控制了存储开销。Prometheus 侧需开启 exemplar-storage 特性,Grafana 面板中开启 Exemplar 显示后,延迟直方图上会浮现一颗颗菱形样本点——每一颗都是一次真实请求的传送门,点击即跳转 Tempo 中对应的完整链路。至此,"指标发现异常 → 链路定位环节 → 日志还原细节"的最后一块拼图完成。
(三)指标体系的分层治理
落地中建议把指标分为三层管理,避免"什么都采、什么都看不懂":
- 资源层:节点、容器的 CPU、内存、磁盘、网络,来自基础设施采集(node-exporter、kubelet),用于容量与异常兜底;
- 服务层:RED 指标与运行时指标(JVM 堆、GC 停顿、连接池水位),其中 RED 由 spanmetrics 统一派生,保证全公司口径一致;
- 业务层:由领域团队定义的经营与体验指标,命名统一加
biz.前缀,必须附带 owner 与告警预案说明。
三层共用同一套 Resource 标签体系(服务名、环境、版本),这样任何一层的异常都能横向对齐到其他层——比如版本标签让"新版本发布"与"错误率上涨"在同一张面板上直接对照,金丝雀分析不再需要人工对时间轴。
基数治理需要制度与工具双管齐下。制度上,新增指标维度走轻量评审:申请人须说明该维度的取值集合大小与增长趋势,取值无上界(用户标识、请求标识、随机值)的维度一票否决。工具上,利用指标后端的基数分析能力(如 Prometheus 的 TSDB 状态接口)定期产出"序列数 Top 榜",对单指标序列数超过阈值的自动告警;Collector 层还可以用 transform 处理器对失控维度做兜底截断或删除,把爆炸半径限制在采集层而不是等存储层雪崩。经验数字是:单个服务的自定义指标序列总数控制在几千的量级是健康的,冲到几十万则几乎必然存在维度误用。
另一个实践细节是指标命名与单位规范。OTel 语义约定建议指标名用点分层级(如biz.order.created)、单位放进元数据而非名称、直方图默认以秒为单位。团队内部无论选择何种风格,关键是全局一致并配备静态检查——命名混乱的指标体系会让 PromQL 查询和告警规则的维护成本随时间线性上涨,而这类债务几乎无法事后偿还,只能在源头拦截。
七、告警体系:从阈值告警到错误预算
(一)SLI、SLO 与错误预算
阈值告警("错误率大于 1% 就报警")的问题在于既吵又钝:偶发抖动触发大量无效呼叫,而缓慢恶化的故障又可能长期低于阈值不被发现。更工程化的方法是 Google SRE 提出的 SLO 体系:
- SLI(服务水平指标):可测量的服务质量指标,如"成功且延迟低于 200 毫秒的请求占比";
- SLO(服务水平目标):对 SLI 的承诺,如"30 天窗口内 99.9%";
- 错误预算(Error Budget):1 − SLO,即 30 天里允许 0.1% 的请求不达标。预算花完,停止发布新功能,优先还稳定性的债。
错误预算把"要不要告警"转换成一个经济学问题:预算烧得有多快?燃烧率(Burn Rate)定义为当前不达标速率与预算允许速率的比值——燃烧率为 1 意味着恰好在 30 天末刚好烧完预算;燃烧率 14.4 意味着 2 天内预算清零,必须立刻有人处理。
SLI 的选取比公式本身更需要业务判断。原则是站在用户视角度量,而不是站在组件视角:用户不关心某台机器的 CPU,只关心下单是否成功、页面是否秒开。因此 SLI 应优先定义在服务边界的入口处(网关或核心接口),把"成功"定义得贴近业务语义——HTTP 200 但业务码失败的响应必须算作不达标,否则 SLO 会系统性虚高。延迟类 SLI 建议采用"低于阈值的请求占比"而非"P99 数值",前者可以和错误率合并进同一套预算框架,且对流量波动更稳健。一个服务起步阶段定义两三个 SLI 足矣:可用性一个、延迟一个,成熟后再考虑数据新鲜度、吞吐承诺等扩展维度,贪多只会稀释注意力。
(二)多窗口燃烧率告警的落地
1、多窗口多燃烧率规则设计
单窗口燃烧率仍会误报(短暂尖峰)或迟报(窗口太长)。生产级方案是多窗口多燃烧率:同时要求长窗口和短窗口都超过阈值才触发——长窗口保证问题确实持续存在,短窗口保证问题当下仍在发生(故障已自愈则不再打扰人)。基于 spanmetrics 派生指标的 Prometheus 告警规则示例:
groups: - name: slo-order-service rules: - alert: OrderServiceFastBurn # 紧急:2天烧完预算 expr: | ( 1 - sum(rate(traces_span_metrics_calls_total{service_name="order-service",status_code!="STATUS_CODE_ERROR"}[1h])) / sum(rate(traces_span_metrics_calls_total{service_name="order-service"}[1h])) ) / (1 - 0.999) > 14.4 and ( 1 - sum(rate(traces_span_metrics_calls_total{service_name="order-service",status_code!="STATUS_CODE_ERROR"}[5m])) / sum(rate(traces_span_metrics_calls_total{service_name="order-service"}[5m])) ) / (1 - 0.999) > 14.4 for: 2m labels: { severity: p0, team: trade } annotations: summary: "订单服务错误预算快速燃烧(燃烧率>14.4)" runbook: "https://wiki.example.com/runbook/order-slo" - alert: OrderServiceSlowBurn # 提醒:5天级别的缓慢恶化 expr: | (同上,窗口改为 6h 与 30m) > 6 for: 15m labels: { severity: p2, team: trade }2、分级路由与降噪
配套的 Alertmanager 按 severity 分级路由:P0 走电话与即时通讯群高亮,P1 走值班群,P2 只进工单池;同级告警按服务分组合并,并用抑制规则避免"根因告警与衍生告警齐飞"。每条告警必须携带 runbook 链接——告警的终点不是把人吵醒,而是把人直接送到处置路径的起点。
(三)告警与三信号的闭环,以及对采集链路自身的监控
告警体系的价值最终体现在响应动作的效率上。理想的告警消息应携带三个直达链接:指标面板(看趋势与波及面)、Exemplar 链路(看典型病例)、日志查询(看具体报错)。这依赖前几章打通的关联能力——告警只是入口,三信号联动才是破案现场。
另外一个常被遗忘的角落:遥测管道自身也需要被监控。Collector 暴露的自监控指标至少要盯住四个——接收量(otelcol_receiver_accepted_spans,对基线骤降告警,防止"静默断流")、拒绝量(otelcol_receiver_refused_spans,通常意味着队列打满)、导出失败量(otelcol_exporter_send_failed_spans)、尾采样早退量(sampling_trace_dropped_too_early)。可观测性系统失明比业务故障更危险,因为它让所有其他故障都变成了暗故障。
告警体系还需要一条负反馈回路来对抗告警疲劳。每月统计三个数字:告警总量、其中真正触发人工处置的比例、以及从告警到确认的平均响应时长。真实处置率低于三成,说明阈值过敏或分级失当,该降级的降级、该合并的合并;确认时长持续上涨,往往意味着值班同学已经开始条件反射式地忽略某些告警——这比没有告警更危险,因为它制造了"有人在看"的错觉。SLO 体系的优势在这里再次体现:燃烧率告警天然自带优先级语义(烧得越快越紧急),比几十条互相独立的阈值规则更容易维持一个可信的告警秩序。
八、一次完整的排障演练:五分钟走完全链路
把前面所有建设串起来,看一次典型故障的处置流。场景设定:一套典型的电商微服务系统,用户下单请求依次经过接入网关、订单服务、库存服务、支付服务,全链路已按前文方案完成接入——自动埋点开启、日志携带链路标识、spanmetrics 派生 RED 指标并附带 Exemplar、核心接口配置了 99.9% 的可用性 SLO 与多窗口燃烧率告警。以下时间线来自一次真实故障的复盘记录,细节做了脱敏与简化,但每一步的跳转路径与判断依据都保持原样,可以作为同类体系建成后排障体验的参照基准。
第一分钟,告警触达。值班群收到 P0 告警:"订单服务错误预算快速燃烧,燃烧率 22,1 小时窗口错误率 2.4%"。消息里带着三个链接。点开指标面板:错误率曲线在 14:02 陡增,按版本标签拆分后发现异常全部集中在 10 分钟前刚发布的 v2.7.3 实例上——但发布回滚不是无脑选项,先看根因。
第二分钟,从指标跳进链路。在延迟直方图上,14:02 之后的高延迟桶浮出一串 Exemplar 菱形点。点击其中一颗,Grafana 右侧滑出 Tempo 瀑布图:这条请求总耗时 3.2 秒,30 个 Span 里有一个红色异常 Span——订单服务调用库存服务的 gRPC 请求,耗时 3 秒后以 DEADLINE_EXCEEDED 告终。
第三分钟,从链路跳进日志。点击该 Span 的日志按钮,Loki 自动按 trace_id 过滤出两端日志:库存服务侧连续打印"库存缓存未命中,回源数据库"与"获取连接超时:连接池已满(active=50, max=50)"。同时段翻几条其他 trace_id 的日志,报错一致——不是孤例,是系统性资源耗尽。
第四分钟,横向确认与定性。回到指标层,调出库存服务的连接池水位与缓存命中率面板:v2.7.3 发布后缓存命中率从 98% 跌到 31%。对照发布 diff,新版本改动了缓存键的序列化格式,导致全量缓存失效,回源流量瞬间打满数据库连接池。
第五分钟,处置与复盘挂钩。回滚 v2.7.3,缓存命中率回升,燃烧率告警自动恢复。整个过程没有登录任何一台服务器,没有一次全文检索,证据链完整可回放——事后复盘时,把这条 trace_id 贴进故障报告,任何人都能重走一遍现场。
这就是三信号打通的终极意义:指标负责发现与量化,链路负责定位与归因,日志负责细节与实证,而 trace_id 与统一资源标签是贯穿始终的线索。
值得注意的是这次演练中几个"恰好起作用"的前置建设:告警之所以在两分钟内触达而不是等用户投诉,靠的是燃烧率规则对错误预算的敏感;Exemplar 之所以恰好指向一条典型慢请求,靠的是尾部采样对慢链路的百分百保留;日志之所以能按 trace_id 秒级过滤,靠的是结构化注入而非事后正则。可观测性体系的每一分投入,都是在为未来某次故障的每一分钟提前买单——而故障从不预约。同样重要的是把演练常态化:每季度用混沌注入制造一次可控故障,检验从告警触发到根因定位的全流程是否仍然顺畅,工具在演进、人员在轮换,未经演练的排障路径会悄悄生锈。
九、经验总结与避坑指南
(一)分阶段推进路线
一步到位地铺开全部能力容易失控,推荐按依赖关系分四阶段推进:
- 第一阶段(1~2 周):核心服务挂自动埋点,Collector 单层部署,链路先通、先看得见调用拓扑;
- 第二阶段(2~4 周):日志注入 trace_id 并结构化,Grafana 配好双向跳转,请求关联能力上线,此时排障效率已有肉眼可见的改善;
- 第三阶段(1~2 月):启用 spanmetrics 与 Exemplar,统一 RED 大盘;业务指标规范发布,重点域接入;
- 第四阶段(持续):核心服务定义 SLO 并上线燃烧率告警;流量上规模后引入两级 Collector 与尾部采样;建立遥测数据的成本与基数治理机制。
分阶段的关键在于每一阶段都独立产生可感知的收益,而不是憋一个大招。第二阶段结束时就应该组织一次"排障演练":挑一个历史故障,让团队分别用旧方式和新方式各走一遍定位流程,用计时对比说话——这种可量化的对比是争取后续阶段资源投入最有力的论据。另一个组织层面的经验是设立"可观测性接入验收清单":链路完整性(一条压测请求穿越所有异步边界不断链)、日志关联性(任意错误日志可跳转链路)、资源属性规范性(服务名、环境、版本三件套齐全)三项全过才算接入完成,防止出现"接了但没接好"的半吊子状态长期潜伏。
(二)高频踩坑清单
- 资源属性不统一:各服务自报的 service.name 大小写、连字符风格不一,后期关联全部失效。对策:命名规范先行,Collector 层用 transform 处理器兜底归一。
- 高基数指标标签:把 user_id、订单号放进指标维度,序列数量指数膨胀。对策:高基数信息属于链路属性与日志字段,指标只留低基数维度。
- 尾部采样前置条件缺失:没有按 trace_id 的负载均衡层,同一链路的 Span 分散到不同网关实例,采样决策基于残缺链路。对策:loadbalancing exporter 按 traceID 路由是硬前提。
- spanmetrics 接在采样之后:RED 指标被采样偏差污染。对策:指标派生永远基于全量 Span 流。
- 异步断链:自建线程池、跨进程消息未包装上下文。对策:接入验收时用一条穿越所有异步边界的压测请求验证链路完整性。
- 只建设不治理:遥测数据量半年翻十倍,存储账单失控。对策:从第一天就监控"每服务每信号的数据量",把降噪(过滤健康检查、压缩重复日志)做成常态。
- 告警缺 runbook:告警只会吵醒人,不会指路。对策:无处置文档的告警不允许上线 P0/P1 级别。
(三)写在最后
展望方向上,OTel 生态仍在快速演进:性能剖析(Profiling)正在成为第四个标准信号,届时从慢 Span 直接下钻到火焰图的函数级热点将成为标配;基于 eBPF 的零侵入采集让未挂探针的存量服务也能进入统一视图;而遥测数据与大模型结合的智能根因分析,正把"五分钟排障"进一步压向"告警即结论"。这些新能力都建立在同一个前提上——数据已经标准化、已经关联。今天打通三信号的每一分投入,都是在为下一代能力铺设地基。
可观测性建设的本质不是堆工具,而是在数据的生产源头建立统一的身份体系——trace_id 关联同一次请求的所有足迹,Resource 关联同一个实例的所有信号。OpenTelemetry 把这套身份体系变成了跨语言、跨厂商的开放标准,使得"从告警到根因五分钟"不再依赖个别专家的肌肉记忆,而成为平台沉淀下来的组织能力。当你的团队不再为"日志和链路对不上"争论,而是在同一条证据链上讨论根因时,这套体系的投入就已经收回了成本。
参考链接
- OpenTelemetry 官方文档
- OpenTelemetry 日志规范:与链路、资源的关联设计
- W3C Trace Context 规范
- OpenTelemetry Collector 架构说明
- 使用 OpenTelemetry Collector 实现尾部采样
- spanmetrics 连接器官方说明
- Grafana Alloy:spanmetrics 组件参考
- OpenTelemetry 可观测性实战:统一 Metrics、Logs、Traces
- 在 Kubernetes 中从零打造可观测性
- OpenTelemetry 对接 Grafana 的日志链路打通实践
- 日志、链路、指标关联:SRE 实践指南
- OpenTelemetry 生产环境完整部署指南
- OpenTelemetry 监控:链路、指标与日志
- 稳定性监控与告警体系:SLI/SLO/SLA 实践
- Google SRE Workbook:基于 SLO 的告警