← 返回文章列表

可观测性三支柱:日志、指标与链路追踪的分工

2026年8月14日可观测性
可观测性日志PrometheusOpenTelemetry链路追踪

笔者在稳定性治理上花时间最多的地方,其实是让问题能被看见。系统没出事的时候,监控大屏只是背景;真出事的那半小时,能不能在十分钟内说出「哪一层慢了、慢在哪个调用」,决定了后面是修故障还是猜故障。

这几篇东西本来是分三次记的。一份讲日志怎么打,一份是 Prometheus 的随手记,还有一份是 tracing 的笔记,夹在别的草稿里。整理的时候才发现,三份笔记其实都在回同一个问题:出问题的时候,笔者手上有什么证据。日志是最细的证据,指标是最早的证据,链路追踪是唯一能把两者对上号的证据。既然绕不开,不如合起来写一次,顺便把几处当时没想明白的地方重新想一遍。

先放一张对照表,后面几节都在展开它:

指标链路追踪日志
记录的粒度固定间隔的聚合值一次请求的调用树一条条离散事件
典型的可见延迟秒级(受抓取间隔限制)分钟级(要看采样和批处理)秒级到分钟级(看采集链路)
单位数据的成本最低中到高(取决于采样率)最高
最擅长发现趋势、毛刺、容量拐点跨服务的耗时归属单次调用的具体因果
天生看不见个体(哪一个请求)未被采样的那批请求全局分布(要现算)
查询方式PromQL 聚合TraceId 查整条链路全文检索加字段过滤

这张表不能替你决定先上哪一层,但能解释一件事:为什么三样东西都得有——每一层的盲区,恰好是另一层的强项。

指标:先知道「出事了」,再问「谁出事了」

指标的强项是便宜和快。一个数字加上时间戳,采样间隔固定,存储成本低到可以全量留着;查询是聚合,几百万个数据点加起来也就几十毫秒。Prometheus(开源监控系统,2016 年加入 CNCF)的数据模型很朴素:指标名加一组标签构成一条时间序列,形如 http_requests_total{service="im-gateway",method="POST",code="500"},值永远是浮点数,后面跟一个毫秒时间戳。

它的采集方式是拉取(pull):服务暴露一个 /metrics 端点,Prometheus 周期性去抓,而不是服务主动上报。这个选择有个容易被忽略的后果——目标列表本身就是一份存活清单,抓取失败(up == 0)是第一个能拿到的事实;代价是短命任务来不及被抓到,得靠 Pushgateway 之类的中转来补。目标列表从哪来,取决于部署方式:Kubernetes 里用服务发现加 relabel 规则自动生成,物理机或者虚机集群上则常用文件发现,配一份机器清单让 Prometheus 定期重读,加机器时只需要改这个文件,不用重启进程。

指标值有四种类型,这个分类决定了你能对它算什么:

类型语义典型用途能做的运算
Counter只增不减请求总数、错误数rate() / increase()
Gauge可增可减CPU、内存、队列长度、在线连接数直接取值、比较、求平均
Histogram按桶累计计数请求耗时、响应体大小histogram_quantile() 估分位数
Summary客户端算好的分位数需要精确分位数、且不跨实例聚合直接读,不能跨实例相加

笔记里当时把 Counter 拿去统计请求数、把 Gauge 拿去统计 CPU 和内存,这两个用法都是对的,Counter 的语义就是「累计发生过多少次」。真正容易踩的是后两种的取舍:Summary 的分位数是每个实例在本地算完再暴露的,几个实例的 P99 直接求平均没有意义;Histogram 用桶把分布记下来,服务端可以跨实例合并,代价是分位数只是估算,精度取决于桶边界。

rate()irate() 的差别,笔者也是混过好几次才理清。rate() 取区间内首尾两个样本算平均增长率,曲线平滑,适合看趋势和做告警;irate() 只取区间内最后两个样本,反应快,适合看瞬时尖刺,但它对采集抖动同样敏感,拿它做告警容易被打点噪声牵着走。还有个老问题:抓取间隔 15 秒,PromQL 区间却写 rate(x[1m]),一个区间里只有四个点,出现一次抓取失败就会让曲线掉一块——区间至少要是抓取间隔的四倍,这是 Prometheus 文档里反复提醒的事。

这里还藏着指标最容易翻车的一处:标签基数(cardinality)。statusmethodservice 这类取值有限的标签可以放心用;一旦有人顺手把 user_idorder_id 或者完整的请求路径塞进标签,时间序列的数量就是用户数乘以接口数,Prometheus 的内存和查询耗时会一起起飞。**判断标准很简单:这个标签的取值集合是有限的枚举,还是随业务无限增长?**前者能进标签,后者只能进日志或者链路的属性。指标命名也一样,笔记里说的「用下划线分隔」是约定的一半,另一半是名字要自带单位与语义——http_request_duration_secondsapi_latency 有用得多,_total 结尾则明示它是一个 Counter。这里有个最近的变化值得记一笔:Prometheus 3.0(2024 年底发布)放开了指标名和标签名的字符限制,UTF-8 名字要用引号写法,OpenMetrics 那套命名约束不再是硬性门槛;同时它也能直接接收 OTLP 数据,也就是 OpenTelemetry 的 SDK 采集的指标可以不走 Prometheus 客户端库。这两件事的方向是一样的——Prometheus 在向 OpenTelemetry 的生态靠,两边原来各自一套的命名和采集方式迟早要合并。笔者手上的笔记写在 3.0 之前,所以还按老规矩记的,真要动手配的时候得先确认版本。

采集这些指标的活儿大多不写在自己的代码里。node_exporter 负责机器层面的 CPU、内存、磁盘、网卡;blackbox_exporter 做拨测,从外部看端口能不能连上、HTTP 能不能通、证书还有几天过期——它测的是「用户视角能不能到达」,这和进程内部的指标是两回事;剩下的按中间件各配一个,redis_exportermysqld_exporter 之类。业务指标才需要自己埋,笔记里那句提醒是对的:不要在业务代码里直接调 Prometheus 的 client,包一层,否则哪天换后端或者要加统一标签,就得全仓改。

告警是 Alertmanager 在处理,规则用 YAML 描述,expr 是一段 PromQL,for 表示条件持续多久才真正触发。for 这个字段看着不起眼,实际它决定了半夜有多少条消息会响:没有它,一次抖动、一次发布重启、一次抓取超时都会变成告警,连着响几周之后,真的那条就没人看了。抑制(inhibition)和静默(silence)是另外两个救命的功能——前者让「机器下线」压住「这台机器上的所有服务异常」,后者给变更窗口留出安静时间。

一份最小的告警规则大概长这样,字段不多,但每个都得填对:

- alert: APIErrorRateHigh
  expr: |
    sum(rate(http_requests_total{code=~"5.."}[5m])) by (service)
      / sum(rate(http_requests_total[5m])) by (service) > 0.01
  for: 10m
  labels:
    severity: warning
  annotations:
    summary: "{{ $labels.service }} 5xx 占比超过 1%"

这里 for: 10m 是关键的一行。删掉它,一次发布重启带来的瞬时 5xx 就会把值班的人叫起来;留得太久,真正的故障又要等十分钟才浮出水面。labels 里那个 severity 决定了这条告警走哪条通知渠道——这也是抑制规则能生效的前提,没有标签就没法说「P0 压制 P2」。

关于告警规则,笔记里那句话笔者原样同意:不要让告警太频繁,重要告警才配,不然就变成狼来了。它还值得再往下推一层。一条告警要成立,至少得同时满足三件事:有明确的触发阈值、有人能对它采取动作、以及它指向的是用户能感受到的东西。少任何一条,它都会慢慢退化成噪音。拿常见的「CPU 使用率超过 80%」来说,如果这台机器的容量本来就跑在 80% 附近,这条规则每天都会响,而收到的人除了关掉它做不了别的;换成「5xx 占比超过 1% 且持续十分钟」,可动作性就完全不同了——要么回滚,要么扩容,要么去查依赖。

判断标准可以更狠一点:如果一条告警响了,值班的人点开看一眼然后关掉,这条规则就该删掉或者改阈值。告警的价值不在于覆盖面,在于每一条响起来都有人愿意当真。至于 for 到底该填 5 分钟还是 10 分钟,笔者自己的做法是先设宽一点,等真实故障跑几轮之后再往下压——这比一开始就调得很灵敏、然后被噪音逼着全部静音要好。

存储这一段笔记里记得也不算错。Prometheus 用自己的时序数据库(TSDB,Time Series Database),最新两个小时的数据先在内存里,之后落到磁盘块上;默认保留期是 15 天,想留得更久就配远程写入(remote write),把这批数据转发到 Thanos、VictoriaMetrics 或者 Grafana 的 Mimir,长期存储和查询交给它们。15 天这件事的实际后果是:一个季度才复发一次的慢查询,等你想起去查的时候,数据已经没了。

笔记里说的那个坑,笔者认为比它看起来更值得展开:Histogram 的桶配错了,几乎所有请求都落进 +Inf 这个桶,分位数就完全看不出分布。原因是桶边界默认是 0.00510 秒那一串,如果业务接口的正常耗时本来就在 200 毫秒以上,绝大部分样本会集中在少数几个桶里;反过来,如果接口是微秒级的内网调用,样本又会全挤在最小的那个桶。改法不复杂,把 le 的边界按自己的耗时量级重设一遍就行,但前提是你得先知道自己的耗时大概在哪个量级——很多人是画不出分位数曲线之后才回头看的。

指标这一层的盲区也就在这里:它是一个聚合结果,天生不带个体。http_requests_total{code="500"} 涨了,你知道过去五分钟有 37 次失败,但不知道这 37 次分别属于哪个用户、打的是哪个群、失败信息是什么。真去查现场,仍然得回到日志或者链路——这也是为什么三类信号最后一定要能对上号,而不是各自建一套。

缓存命中率就是被这层盲区盖住的一个典型:整体数字一直好看,回源却集中在少数几个 key 上,等到曲线上能看出问题时数据库已经被打过了。缓存穿透与击穿:回源的两种形态整篇讲的就是这种「告警是真的,指标也是真的,两边就是对不上」的局面。

链路追踪:把一次请求拆成看得见的时间

跨服务调用多了之后,日志和指标都会露出同一个缺口。指标告诉你「下单接口慢了」,日志告诉你「这一次的写库语句报错了」,但「慢在哪一跳」这个信息,两边都没有。微服务里一个请求可能经过十几个服务,中间隔着 RPC、消息队列、缓存和定时任务,没有一个横跨它们的标识,因果关系就断了。

分布式追踪的做法是给一次请求分配一个 TraceId,请求每经过一个服务或一段操作,就生成一个 Span 记录起止时间、父节点、状态和若干属性。SpanContext 负责把这些标识随请求传下去,包含 TraceIdSpanId 和采样标记。有了父子关系之后,同一时刻哪一层在等谁,就能画成一条有向的瀑布图,而不是一堆散落的时间戳。

OpenTelemetry(缩写 OTel,CNCF 项目)现在是这件事的事实标准。它把可观测性拆成三个信号——链路追踪、指标、日志——用一套 API 和 SDK 全部覆盖,这也是前面提的「三份笔记是同一件事」的源头:以前 OpenTracing 管链路、OpenCensus 管指标和链路、各家厂商再各自加一套 Agent,2019 年这两个项目合并成 OpenTelemetry(首版 1.0 在 2021 年发布)。它的架构分四层:API 定义接口,SDK 提供具体实现,Collector 负责接收、批处理、过滤、再导出,Exporter 把数据发给后端。Collector 这一层的价值经常被低估:应用的 SDK 只需要指向本地的 Collector,后端换成 Jaeger、Tempo 还是别的,改的是 Collector 的配置,不用重新发版。

标识怎么跨进程传,有独立的标准管着:W3C 的 Trace Context 规定了 HTTP 头 traceparent 的格式,把版本、TraceIdSpanId 和采样标志编码成一个字符串。这一层是跨厂商、跨语言能对上的基础——Go 服务写进去的 traceparent,Java 服务按同一个规范读出来,链路才连得上。如果中间夹了一个自己实现透传的网关,头名少写一个字符,链路就从那里断开了。

埋点分成两种,两者不冲突。自动埋点(auto-instrumentation)挂到 HTTP 客户端、gRPC 框架、数据库驱动这些标准库上,代码零侵入就能拿到跨度;手动埋点留给业务语义,比如「创建群组」这一步内部做了什么、耗时多久、失败在哪个校验上——这些标准库不可能替你命名。全自动埋点的问题不是覆盖不够,是所有的 span 都叫 POST /api/...,点开一看全一样,看不出业务上的因果关系。

采样是绕不过去的:全量记录每个请求的链路,数据量和存储成本会先崩。头部采样(head sampling)在请求开始时按固定比例决定记不记,比如 10%,实现简单、开销低,但决定下得太早,一个后面会报错的请求可能一开始就被丢掉;尾部采样(tail sampling)等请求结束、看到完整状态之后再决定,可以做到「正常的少留、出错的都留」,代价是 Collector 必须先把这条链路上的所有 span 缓存住——延迟高了、内存也高了。

尾部采样还有个实现上的前置条件:同一 TraceId 的所有 span 必须落到同一个 Collector 实例上,否则每个实例只看到链路的一小段,谁也没法做完整判断。常见做法是按 TraceId 做一致性哈希,再给决策结果加一层缓存。这两处哪一处配错,表现都不是报错,而是「链路缺了几段」——比报错更难发现。

这里要回应笔记里标为「错误观点」的那句。当时记的是「OpenTelemetry 只支持 HTTP」,后面自己划掉了。可以确定的是这个说法不对:OTLP 作为 OTel 的传输协议,标准形态是 gRPC 和 HTTP,HTTP 上再分 protobuf 与 JSON 两种编码。笔记里补的「还支持 UDP」也不能套到 OTLP 上:OTLP 规范定义了 gRPC 和 HTTP 两种传输方式,没有定义 UDP 传输。

后端选型笔记里列了四个,判断基本可用:Jaeger 是老牌,功能全,官方已在推 OpenTelemetry SDK,老的 Jaeger 客户端处于维护收尾状态;Zipkin 更早,新项目基本不再选它;Grafana Tempo 把数据直接放对象存储,不需要单独维护一套索引集群,成本和 Grafana 的集成是它的卖点;拿 ELK 硬做 tracing 也不是不行,但查询模型并不贴合瀑布图,用起来别扭。笔者现在的倾向是 Tempo 或 Jaeger 二选一,取决于团队已有的存储和可视化栈,而不是取决于哪个 benchmark 快。

日志:最后还是要看具体那一笔

日志的证据粒度最细,也最贵。指标是一次聚合,链路是一条时间轴,日志是这两者底下那些原始事件:谁在什么时候对哪个对象做了什么,返回值是什么。

笔记里那句「线上几百万条日志,查问题像大海捞针」,问题其实不在数量,在于这批日志当时是按「给人看」的格式写的:2026-09-14 10:23:41 ERROR 创建群组失败: group already exists。人眼读得懂,机器读不了——时间戳格式一变、字段顺序一换,正则就失效。结构化的做法是让每条日志本身就是一条 JSON,字段固定:时间、级别、服务名、trace_id、消息、以及这次操作相关的业务键。

{"level":"error","ts":"2026-09-14T10:23:41.512+08:00","service":"im-group",
 "trace_id":"4f1c9a7d2b8e5306","span_id":"a91c07f4","user_id":"10231",
 "msg":"create group failed","reason":"group already exists","group_id":"8891"}

代价也很实在,笔记里那句「开发的时候看 JSON 日志真的很累眼睛」是实话。折中办法是在本地开发时让输出走一个可读的 console encoder,线上再切回 JSON,用 zap(Uber 出的 Go 日志库)或者 zerolog 都支持这种双编码器切换。

笔记里还记了「zap 比标准库快 4~5 倍」,这个数字当时就自己标了「记得可能不太准」——性能倍数取决于打点场景、字段数量和是否结构化,官方 benchmark 的口径和线上真实负载差得远。**可以确定的是 zap 和 zerolog 走的是零分配的设计路径:字段值尽量不强转成 interface{},减少逃逸和 GC 压力,比 log 标准库快是共识;那个具体倍数没能核实,不当结论用。**真要在生产里选,比倍数更值得看的是结构化字段怎么传、能不能挂全局字段、日志采样怎么配。

日志规范里最硬的几条,笔记总结得很干净,值得原样留在这里:日志里不能出现密码、令牌、身份证号这类敏感信息;异常日志必须带堆栈,只写一句「调用失败」等于没写;日志级别不能乱用,把正常业务信息打成 error 的后果是半夜的告警响个不停;不要在循环体里打日志,一秒几万条不是夸张,是很容易发生的事。

笔记里没有正面回答的是开头那个「日志到底要怎么打」。笔者现在的分法比笔记里细一点,方向是一样的:error 只留给需要人介入的事,它应该和告警一一对应;warn 留给「这次没成功但系统自己能兜住」的情况,比如重试一次后成功、降级到缓存返回了旧值;info 记录状态迁移和关键业务动作,用来复盘「这个群是什么时候建的、什么时候解散的」;debug 在线上默认关掉,需要时按服务或按请求动态打开——这也是为什么日志框架要支持动态调级别,而不是重新发一次版。判断 error 用得对不对有个简单办法:看值班的人会不会为它起床。不会的话,它大概只该是 warn

这套分法里隐含着一件事:info 那一层的价值是事后才显现的。error 日志当场就有用,info 日志平时看着像废话,直到某次需要回答「这个失败是个例还是所有请求都这样」——只看 error,你只知道失败发生了,不知道失败之前这个用户做过什么、走到了哪一步。所以「线上几百万条日志」这件事里,有相当一部分不是浪费,是暂时没用上的证据;要砍的是重复打印和循环里的日志,不是这一类。

至于「不能打敏感信息」这条,落到实现上有两个低成本的做法:在日志库里注册一个统一的结构化字段做脱敏,或者给日志的 encoder 加一层钩子,在落盘前按 key 把手机号、令牌、身份证号替换成掩码。靠写日志的人自觉很容易漏,靠一层拦截会稳得多。

采集链路上,笔记记的是 Filebeat 采集、Logstash 处理、Elasticsearch 存储、Kibana 展示这套经典组合。真要挑毛病,问题出在中间那一环:Logstash 是 JVM 写的,一条日志要过一堆 filter 插件,资源占用和启动时间都不轻。常见做法是拿掉它——轻量的场景用 Filebeat 直接写 ES,需要解析和路由的用 Fluentd 或 Vector 顶上,Vector 是 Rust 写的,同样吞吐下 CPU 和内存都更省。保留策略用 ES 的索引生命周期管理(ILM,Index Lifecycle Management)来做:索引按天滚动,热数据 7 天留在 SSD 上,温数据留到 30 天,再往前的归档到对象存储。索引按天滚动这件事不只是省钱——单个索引涨到几百 GB 之后,一次查询要扫的分片数量会让 Kibana 直接等到超时。

现在来处理笔记里那个「一个疑惑」:到底要不要把日志打到 Elasticsearch 里?笔记里写的是「有人说 ES 不是为日志设计的,ClickHouse 更适合,但 ClickHouse 的生态没有 ES 完善,这个得再调研一下」。

笔者目前的理解是这样,未必对。 这句话里的分歧其实来自两个不同的负载。ES 建在 Lucene 上,为倒排索引和全文检索优化,擅长「在几百万条里找出包含某个关键词的那几条」,配上 Kibana 的开箱即用和成熟的告警、权限体系,是日志检索最省事的选择;代价是写入放大的压缩开销和集群运维成本。ClickHouse 是列存,为聚合扫描优化,擅长「按服务和错误码分组,统计每小时的失败率」这类分析查询,压缩比通常也更好,但全文检索和「关键词模糊匹配」不是它的强项,周边生态(采集、可视化、权限)也确实不如 ES 那一套齐。

所以这个问题的答案取决于你主要在问哪种问题。日志检索和日志分析是两件事:前者是运维现场翻日志,后者是做容量规划和错误率趋势。没能验证的部分:笔记里提到的 ES 与 ClickHouse 在日志场景下的具体对比基准、以及各自在真实写入量下的压缩比与成本,笔者没有查到可靠的一手数据,这里只能给出负载层面的判断,不给数字。

三者怎么串起来

前面三节按信号分开了,实战里它们是串行的,而且方向是反的:指标告诉你「有异常」,链路追踪告诉你「哪一段」,日志告诉你「具体哪一笔」。反过来,如果你从一条用户反馈的日志出发,也能顺着 trace_id 往上找到那条链路,再对着时间窗口去查对应的指标曲线。

同一场故障在三个视角下的表现:指标发现异常、链路定位服务、日志确认具体调用,以及各自的盲区

三者能串起来,靠的是一条共享的 trace_id。这是整篇里最便宜、收益最大的一件事:请求进网关时生成 TraceId,中间件把它放进日志上下文,SDK 把它放进 SpanContext,日志和链路就在这一列字段上对齐了。对齐之后,从某个错误率的毛刺跳到某一次失败调用,中间不再需要靠时间戳猜。

串不上的情况也很典型,而且原因通常不在工具上。异步任务、消息消费、线程池里换了线程、context 没往下传,链路就断在那里;trace_id 只写进了接入层的 access log,业务代码的日志里没有它,对齐就无从谈起。一个很常见的具体形态是:入口处手动 go func() 起了个协程去写审计,忘了把带 SpanContext 的 context 传进去,于是审计日志里的 trace_id 是空的——日志本身一条不少,出问题的时候就是接不上。

按这个顺序,一次典型的排查会走成这样:告警是 sum(rate(http_requests_total{code=~"5.."}[5m])) by (service) / sum(rate(http_requests_total[5m])) by (service) > 0.01 持续十分钟触发的,指标先告诉你出问题的是接入层;打开这个服务在该时间窗内的链路列表,按耗时排序,看到瀑布图上一段 group.create 的 span 占掉了绝大部分时间,而它下面的数据库调用其实很快——问题不在数据库,在这段业务逻辑自己;拿到那条 span 的 trace_id 回日志里过滤,才看到同一时刻的 error 日志写着「锁等待超时」。三步各出一半证据,缺任何一步都还得靠猜——只有指标,你只知道接入层的失败率在涨;只有日志,你得先在几百万条里捞到对的那几条。

顺带说一个容易忽略的方向:链路数据本身也能反过来喂指标。有些团队直接从 span 里按服务、按接口、按状态码聚合出请求量、错误率、耗时分布这组「黄金指标」,好处是口径和应用里埋的那套完全一致,不用维护两份打点;代价是这笔聚合依赖采样率——只有 10% 的请求被记录,聚合出来的绝对值和真实请求量就差了一个量级,只能看趋势,不能当容量数字用。指标和链路的重叠部分怎么分工,是笔者觉得值得单独想清楚的一件事。

如果只能按顺序补,笔者会这么排:先把 trace_id 的生成和透传打通,再把指标口径和服务分级对齐,然后才做链路的采样策略和后端选型,日志的采集与保留放在最后。理由是前两步的收益立刻能兑现——透传打通当天,日志就能按请求串起来;而采样策略和存储选型一旦定错,返工要动的是整条数据管道。

写到这里,三份笔记算是合成一份了。至于尾部采样在生产环境到底该怎么配、Collector 需要多大的内存才扛得住,笔者现在只有概念上的理解,没有跑过量——这里再挖一个坑,等真按这个方案搭起来之后再回来补。

真到出事的时候,这个顺序还会被迫加速。值班的人手上通常只有几分钟:先看大盘确认影响面,再挑一条出错或超时的链路点开,最后才去翻那一条日志的上下文。这三步里任何一步的数据缺口,都会让排查退化成猜——指标口径不对,就是照着错的曲线在判断;链路断了,就得靠时间戳去凑;日志里没有 trace_id,就得在几百万条里靠关键词碰运气。笔者在稳定性治理上花的时间,很大一部分就是花在补这三个缺口上,而不是花在加新的监控面板上。

返回博客列表