1. 项目概述:一套可观测性组合拳背后的真实需求
做后端和运维时间久了,大家基本都会遇到这么个场景:线上某个接口突然变慢,用户投诉已经进来一轮,你打开监控大盘看到CPU、内存全部正常,登录服务器翻日志,发现报错信息散落在十几个文件里,想要把一次请求从网关到数据库的完整路径拼出来,得挨个系统对时间戳,手动串半天——运气好十分钟定位,运气差折腾一整个下午,最后发现是某个下游服务连接池被打满了。
这套全链路可观测性方案,就是用来解决这个"串不起来、查不透彻"的痛点。它用 OpenTelemetry 统一生成Metrics、Logs、Traces三类数据,Prometheus负责指标存储与告警,Loki做日志聚合,Tempo做链路追踪,最后全部接入Grafana做统一可视化。我最早接触这套组合是在一个日活百万的电商中台项目里,当时系统拆分出几十个微服务,排查问题越来越吃力,从调研选型到完整落地用了大半个月,现在沉淀下来这套从零到可以上线运行的整体方案,真心建议所有被"排查慢、观测散"折磨过的团队都来参考。
这套方案的适用对象非常明确:微服务数量多但还没建立规范观测体系的团队、正在从"监控"往"可观测性"转型的技术团队、希望用一套开源技术栈避免厂商锁定的小组。它解决的核心问题有三个:让一次请求的前世今生有迹可循,让指标异常和日志报错、Trace断点能互相印证,让新同学也能借助统一面板迅速定位故障,而不是靠老师傅的经验。
我评估过不少同类方案,比如单独用ELK做日志,用Jaeger做追踪,用SkyWalking做APM,各有各的好处,但要同时覆盖指标、日志、追踪三类数据,且这几类数据之间还要能互相串联跳转,目前开源社区里最顺滑的组合就是OTel加PLGT这套。理由后面详细说,先记住这句话:可观测性的目标不是堆工具,而是让三类数据在同一个时间轴上互相对齐,快速还原故障现场。
2. 核心设计与技术选型拆解:为什么是OTel加PLGT这一套
2.1 可观测性的三个支柱,到底在说什么
刚开始接触可观测性时,很容易把Metrics、Logs、Traces理解为三种孤立的数据。其实它们是从三个不同维度描述同一个系统状态,互相补充。
打个比方,系统是一辆车。Metrics(指标)是仪表盘,告诉你车速多少、油量剩多少、水温是否正常——它是聚合后的数值,优点是可以长期低成本存储、设置阈值告警,缺点是它只告诉你"哪里不正常",不告诉你"为什么不正常"。Logs(日志)是行车记录仪,记录了每一个具体事件,比如"13:05:22 这条SQL执行了3秒"——它是离散的,信息量最大,但如果没有关联字段,很难把同一段旅程的事件按顺序串起来。Traces(链路追踪)是GPS轨迹,记录了一次请求从出发到结束经过的每一个节点和耗时——它是结构化的调用链,能还原一次请求的完整路径,但单条Trace日常存储成本不小,没人会为所有请求永久保留完整轨迹。
这套方案的核心思路就是用OpenTelemetry把三类数据的产生端统一起来,通过trace_id、service.name这些公共标签在存储端把它们关联起来。凡是打点埋过OTel的服务,Metrics里能看到服务健康度,Logs里能找到具体报错,Traces里能还原完整调用链,三者在Grafana里通过一个trace_id或者标签就能互相跳转。这套组合拳打通之后,排查效率提升是肉眼可见的。
2.2 技术选型对比:这套组合的生态协同优势
我在做选型时,拿这四款工具分别和主流替代品对比过,结论很清晰。
Prometheus和InfluxDB、VictoriaMetrics这类时序数据库相比,最大优势是生态标准。Prometheus的Exporter体系几乎覆盖了所有中间件,MySQL、Redis、Kafka、Nginx全有现成的采集器,告警规则通过PromQL表达也足够灵活。虽然VictoriaMetrics在超大规模存储上性能更强,但对于绝大多数团队单机Prometheus加上合理的数据保留周期已经非常够用。
Loki对标的自然是ELK。Elasticsearch全文检索能力强悍,但要为高可用部署多个节点,还要伺候索引生命周期。Loki的设计思路完全不同——它不为每条日志建立全文索引,而是像Prometheus一样为日志打标签,用标签过滤,再对查询范围内的日志做内容检索。这个设计让它对存储和内存的消耗大幅下降,和Grafana无缝集成,特别适合以Kubernetes为核心运维场景的中小团队。
Tempo和Jaeger对比也很有意思。Jaeger自带界面,功能完整,但需要单独部署一套UI,数据模型和OTel的交互也没有Tempo那么原生。Tempo的设计初衷就是做Grafana生态里的"Trace后端",本身不带界面,全部通过Grafana的Tempo数据源查询。这样用户访问入口只有一个——Grafana,学习成本和运维成本都低。
OpenTelemetry是这套组合的连接器。它是CNCF的标准化项目,目标是统一Metrics、Logs、Traces三类数据的产生和传输协议。选择它而不是直接用各家SDK,就等于告诉业务团队只需要学一套埋点规范,不用关心后端是Prometheus还是Jaeger,以后即使存储层要换,业务代码几乎不用动。
2.3 数据模型的串联:指标、日志、链路如何互相咬合
三类数据不是各自孤零零存着,而是通过统一资源模型串联起来的。OTel规范里定义了Resource这个核心概念,每个服务在产生数据时都会带上service.name、service.namespace、host.name、k8s.pod.name这类公共属性。
Prometheus指标里的标签可以记录job和instance;Loki日志里通过label提取出trace_id字段;Tempo链路里天然就是trace_id和span_id的树形结构。真正让它们互相咬合的钥匙就是trace_id。日志里打印了trace_id,就能在Grafana日志面板一键跳转到Tempo查看完整链路;指标异常时也能通过service.name下钻到对应的日志和最近的新Trace。
这套联动机制需要在落地时刻意设计,而不是默认就有的。我在下面实操部分会详细教大家怎么把trace_id从OTel SDK一路传递到日志系统里,这一步做通,整套系统的价值立刻翻倍。
3. 落地实操:从部署到接入埋点的完整过程
3.1 环境准备:docker-compose搭起一套全家桶
我建议第一阶段追求的是快速跑通全流程,不需要Kubernetes环境,一台服务器或者开发机用Docker Compose就能把整套系统搭起来。
需要准备的服务有:Prometheus、Grafana、Loki、Tempo、OpenTelemetry Collector,以及一个用来演示埋点的Demo应用。写一个docker-compose.yml,Prometheus挂载配置文件,Loki挂载配置目录,Tempo暴露端口接收OTLP数据,Grafana的 provisioning目录里自动配置好数据源。这个初始版全部使用默认配置,先把通路跑通。
几个关键配置值得说明一下。
Prometheus的配置文件里,除了抓取自身指标,最重要的是创建两个独立的job。一个job用来抓取OpenTelemetry Collector暴露的指标端口,端口一般是9464,路径是/metrics;另一个job用于抓取业务服务通过Prometheus exporter暴露的指标。如果你是首次使用Prometheus,建议先不用配太多告警规则,等数据源稳定了再慢慢加。
Tempo需要启用几个关键配置才能配合Grafana工作。最主要的是开启metrics_generator功能,它可以从Trace数据里自动生成服务拓扑和RED指标(Rate、Errors、Duration),这样即便服务没有单独暴露指标,Grafana的Tempo数据源也能展示服务之间的调用关系和错误率。接收OTLP数据的端口默认是4317(gRPC)和4318(HTTP),需要在Compose中映射出来。
Loki的配置在初期其实可以非常简洁,只需要指定存储路径和一个本地文件系统,不需要启用多租户模式,把需要对外的端口映射好。Grafana界面里创建Loki和Tempo数据源时,URL直接填服务名和端口即可。
3.2 应用接入埋点:优先推零代码的Agent方案
对于Java技术栈的团队,我强烈建议第一阶段优先使用OpenTelemetry Java Agent方式,真正零代码改动。直接在JVM启动参数里加javaagent,服务不用改任何业务代码,就可以自动完成HTTP调用、JDBC、Redis客户端、消息队列等常用框架的埋点采集。
启动命令大概是这样的:
java -javaagent:/opt/otel/opentelemetry-javaagent.jar \ -Dotel.service.name=order-service \ -Dotel.metrics.exporter=otlp \ -Dotel.logs.exporter=otlp \ -Dotel.traces.exporter=otlp \ -Dotel.exporter.otlp.endpoint=http://otel-collector:4318 \ -jar order-service.jar这里有一个很关键的点,日志的exporter也发往OTLP,很多人会忽略。OTel支持把日志往Collector发送,Collector可以再转发给Loki。但这里有个限制,Java Agent自动捕获日志时,日志必须通过SLF4J这类框架输出,Agent才能把日志和当前Trace关联起来。如果你的项目用的是Log4j2,需要确保采用SLF4J桥接,否则trace_id不会自动出现在日志上下文里。
对于非Java语言项目,比如Go或者Python,就没有Agent这种零侵入的方案了,需要用OTel SDK手动埋点。以Python为例,FastAPI服务通过OpenTelemetry Instrumentor可以自动创建Span,但手动埋点可以控制更细粒度。原理上都一样——在请求入口创建根Span,在调用下游的时候创建子Span,将父Span的context传递过去。
无论哪种语言,都要确保一个原则:在服务入口处生成的trace_id要能够被日志采集组件提取出来。Java Agent会默认把trace_id和span_id注入到日志的MDC中(如果日志格式里有对应占位符),但也需要你的日志pattern里包含%X{trace_id}和%X{span_id},并且日志必须输出了这些字段,Loki侧才能提取。
3.3 打通数据链路:Collector的配置是重中之重
在整套体系里,OTel Collector是一个数据中转站,也是最容易被低估的组件。它的作用可以理解为连接SDK和后端的"交通枢纽":接收SDK发来的OTLP数据,经过批处理、压缩、资源修改等步骤,再分发到Prometheus、Loki、Tempo不同的后端。
Collector的核心配置文件是config.yaml,分为receivers、processors、exporters、service四个大段。开发阶段可以简单配置,但生产环境有几个处理器值得优先安排。
第一个是batch处理器。没有它,SDK产生的每个Span和每个Metric都会单独发往后端,造成大量小请求,Collector和后端的CPU、内存都会浪费在协议解析上。batch处理器会积攒一段时间内的数据,合并成一个大批次发送,默认配置可以设置timeout为5秒、send_batch_size为10000条,效果立竿见影。
第二个是resource处理器。它可以给数据统一添加公共标签,比如从环境变量读取的环境名、机房、团队信息。这样在Grafana里可以按团队或者环境去筛选数据,不会有遗漏。
第三个是memory_limiter处理器,用于保护Collector自身。当下游后端(比如Loki或者Tempo)响应变慢或暂时不可用时,Collector内存会迅速攀升,如果不限制内存使用,Collector自己就会被打挂。配置内存上限为总内存的一半左右,并设置检查间隔,积累的数据持续达到上限时会触发降级丢弃数据。
Exporters这块,Tempo作为Trace后端直接配置endpoint指向Tempo的接收端口;Prometheus作为Metrics后端需要配置单独的exporter端点,让Prometheus定期来抓取;Loki作为日志后端需要配置认证信息和无证书模式。
这里有件事需要反复强调:发送给Loki的数据,最好在日志中保留原始级别。Loki目前对Trace的关联,是通过提取日志内容中的trace_id字段实现的。所以日志exporter必须保留Trace上下文中的trace_id和span_id字段,并在Loki的采集流水线里用正则提取成标签,这样Grafana中Loki日志和Tempo Trace才能实现自动跳转。
3.4 为日志和链路加上关联标签
在Loki中配置提取trace_id是在PromeLoki的采集配置里完成的。在docker-compose中部署Loki时,需要在Loki配置文件的scrape_configs或pipeline_stages中做正则提取。
以我常用的Loki配置为例:
scrape_configs: - job_name: otel-logs pipeline_stages: - regex: expression: 'trace_id=(?P<trace_id>[a-f0-9]{32})' - labels: trace_id: relabel_configs: - source_labels: ['__meta_kubernetes_pod_label_app'] target_label: service_name这里的regex表达式是专门匹配OTel生成的trace_id格式,默认是32位十六进制字符。如果你的SDK生成的trace_id格式不同(比如带横线),正则表达式要对应调整。
标签设计有一条铁律:用来筛选的字段才做成标签,用来检索的内容保留在日志正文里。Loki的索引就是标签,标签太多会拖慢查询,标签值变动频繁更是大忌。比如pod名称这种每秒都在变的字段,做成标签只会导致索引膨胀,查询变慢。标准的做法是把pod名称存储在日志正文里或者做成低基数的service.name标签。
Tempo侧也一样,Tempo按trace_id索引数据,所以Grafana的Tempo数据源配置时不需要额外做关联,只要Loki日志中能提取出trace_id,UI面板会提供从日志行到Trace详情的跳转链接。
3.5 指标链路的打通与核心参数设定
Metrics这部分是相对最成熟的,流程也最顺理成章。OTel SDK需要把指标导出到Collector,Collector通过prometheus exporter暴露一个HTTP端点,然后Prometheus去拉取。
配置Prometheus抓取Collector指标时,需要注意抓取间隔。我习惯把抓取间隔设为30秒或60秒,太长会丢细节,太短会对Collector和存储造成压力。Prometheus还有两个非常重要的参数:scrape_timeout必须小于scrape_interval,否则会报错;evaluation_interval决定告警规则的评估频率,一般设成scrape_interval的倍数。
对于业务指标的埋点,几个最常见的类型要区分清楚。Counter是只增不减的累计值,适合记录请求总数、错误总数;Gauge是可以上下浮动的瞬时值,适合记录当前连接数、队列长度;Histogram是值分布的累计统计,适合记录接口耗时分布,比如p50、p95、p99。一个合格的请求指标集至少要包含这三类数据:总请求数和错误数的Counter,处理耗时的Histogram,以及当前在途请求数的Gauge。
在SDK侧,我通常建议命名遵循Prometheus命名规范,以命名空间和单位结尾,如http.server.request.duration。这套规范在OpenTelemetry语义约定里写得很清楚,照着做就不会乱。
3.6 Grafana面板搭建:让三类数据出现在同一个界面
Grafana数据源配置好之后,最有价值的是创建几块典型面板。
推荐至少搭建四个面板。
第一个是服务总览面板。上方选时间范围,下面的核心指标图展示每秒请求数、错误率、p95耗时。这个面板基于Prometheus数据源,适合放在团队墙上的大屏里,一眼看清所有服务健康状态。
第二个是日志检索面板。使用Loki数据源,上方是LogQL查询输入框,可以输入{service_name="order-service"}过滤出某个服务的所有日志,再按时间实时刷新。配合Grafana的Trace to Logs特性,从日志行可以直接打开对应的Trace。
第三个是链路追踪面板。使用Tempo数据源,支持直接输入trace_id查询单条Trace,也支持按服务名和耗时列表从中筛选慢Trace。这个面板是排查慢请求的利器。
第四个是服务依赖拓扑图。通过Tempo的metrics_generator生成的服务图数据,支持在Grafana界面直接看到服务与服务的调用关系。谁调用了谁、哪个环节延迟高,在图上一目了然,非常直观。
4. 从0到可上线:推进路径与数据量预估
4.1 分阶段推进:先跑通再覆盖,先核心后边缘
我建议落地这套体系千万别想着一步到位,分三条路走最稳。
第一阶段是"可见":花费一到两天实现上面说的一整套Docker化部署,接入两个核心服务做Demo,在Grafana看到三类数据。这一阶段的验证目标是技术跑通,发现坑及时解决。
第二阶段是"可查":把Agent都接入到所有Java服务,让trace_id贯穿所有日志,完善日志标签和指标面板,沉淀出一套服务部署和配置标准。这一阶段的目标是让一线研发在排障时主动使用Grafana,而不是遇到问题直接翻服务器日志。
第三阶段是"可告警":基于采集到的历史指标,逐步添加Prometheus告警规则、告警分组和通知渠道。告警做在前面,使系统能够在用户发现问题之前就主动通知值班同学。
每阶段之间的间隔建议在一到两周。一套观测系统,先跑通比什么都强。
4.2 容量评估:一套小账本决定你的存储策略
开始大规模接入前,最常被忽视的是容量评估。有一件事业务同学不会关心,但你必须提前想明白:Trace数据比Metrics大一个数量级,越细的链路越占空间。
以某服务日均100万请求为例做估算。假设平均一个请求产生15个Span,每个Span序列化后约800字节到1KB,一天产生的Trace原始数据约为100万乘以15再乘以1KB,就是15GB。Loki日志存储的膨胀比例大约是1比1.3,Prometheus指标存储与抓取频率和标签数量强相关,一套规范的采集方案一天大概也只有几百MB。
这意味着如果不做采样,Trace存储每天增加至少15GB,一个月接近450GB,一年的成本相当可观。而且Trace数据是排障数据,保留太久并不划算。
因此我的建议是,Metrics保留30到90天,Loki日志保留15到30天,Trace只保留7到15天且必须做采样。Trace采样配置推荐使用尾部采样,在Collector端根据已经完成的链路决定哪些Trace送入后端存储,比如把耗时超过某阈值的慢请求100%保留,普通请求保留10%。这样既能覆盖排障高发场景,又能控制成本。
4.3 上线前必做的三项检查清单
上线前有几件事很容易被遗漏,专门列出来提醒一下。
第一项是检查所有服务的时区。OpenTelemetry默认用UTC时间,很多团队的日志系统用的是本地时区,如果时区不统一,会导致日志与Trace在时间轴上对不上,需要额外花很多时间对齐。
第二项是检查trace_id是否真的在日志里。用一个测试请求打一个小接口,到Loki里查对应日志,看trace_id字段是否存在、格式是否匹配。很多人推进到这一步就卡住,实际原因是日志框架的MDC功能没打开或pattern没配置。
第三项是检查Grafana的告警渠道通知是否走通。Prometheus告警默认会推送到Alertmanager,Alertmanager再通过webhook或者邮件发送到钉钉、企微这类渠道。大家很多时候配好了告警规则,结果消息发不出来,只能等到线上出状况才发现,这是最让人抓狂的情况。
5. 常见问题与排查技巧实录
5.1 数据不显示?按这三个位置逐层排查
接入完成后面板为空,这是第一周最常见的求助问题。排查顺序一定要遵循数据流向。
先看数据源状态。在Grafana的Configuration里检查Prometheus、Loki、Tempo三个数据源是否标为Healthy。如果数据源本身就是红色的,大概率是地址没填对或者端口没映射出去。Compose环境下可以用docker exec -it grafana curl http://loki:3100/ready验证网络连通性。
再看采集端。Prometheus界面Targets页面检查job的up状态,如果显示down就看endpoint是否可连通。Loki这里查看接受到的日志数可以看loki_ingester_received_chunks_total指标。
最后看查询语句和日志字段。如果面板为空但Targets是绿的,多半是查询的标签不匹配。Loki的标签是精确匹配,{service_name="order-service"}和{service_name="order-service "}(多了一个空格)完全是两个结果。在排查时我习惯先用{__name__=~".+"}全量看有没有数据,再逐步缩小标签范围。
5.2 Trace断链:链路总是在某一步中断
链路断掉是接入过程中仅次于数据不显示的帐篷级问题。常见的断链原因有三个。
第一个是异步线程没有传播Context。使用了线程池或者异步调用后,新线程里不会自动带上前一个线程的Trace上下文。这时需要显式调用Span.current().makeCurrent()或者使用io.opentelemetry.context.Context.current()传递。线上排查时,凡是看到多线程到异步之间的Span就断了,基本都是这个原因。
第二个是消息队列消费时没有看Consumer端是否接收了消息头里的Trace Context。SDK确实会自动解析消息队列的Header,但前提是消息生产者写入的消息Header中包含W3C Traceparent。如果业务代码在包装消息时手工修改了Header,可能会把Trace上下文丢失。
第三个是网关层没有透传HTTP Header。有的网关框架默认只透传业务自定义Header,不会自动透传traceparent。此处的解决办法是在网关层开启透传或者用SDK的Propagator注入,否则下游拿不到原始trace_id,链路上每个服务都自成一条链。
5.3 日志与Trace关联不上:先从格式与大小写查起
日志里有trace_id,但Grafana无法跳到Tempo,这个是Loki侧正则匹配失败造成的。
最常见的坑是SDK输出了32位小写十六进制,但日志格式里带上了双引号或者中括号,正则匹配没找到。用Loki的text查询先确认日志原貌,再根据原貌修正正则表达式。建议在正则表达式中预留灵活的空白符号,比如trace_id[ ="']*([a-f0-9]{32})。
另一个坑是大小写混用。W3C标准对trace-id字段大小写不敏感,但OTel Java Agent默认输出小写,如果用Python SDK生成了大写,Loki的正则表达式就得加上(?i)或者写成匹配大小写不敏感的模式。模板里统一全部转小写最保险。
5.4 告警噪音:怎么控制告警不打扰
告警规则上线后,铺天盖地的告警会让所有人麻木。核心要领是给告警分级和加持续时间。
比如"接口成功率低于90%"这个条件,如果只设了阈值没有任何持续时间,网络抖动一下就会触发告警。Prometheus里通过for字段设置至少持续5分钟才报警,就可以过滤掉大量瞬时抖动。持续1小时才报警的属于低级别,需要当天处理的属于中级,持续5分钟就需要立即响应的属于高级。
告警内容也可以做得更可操作。Alertmanager的通知模板里,建议把跳转到对应Grafana面板的URL拼接好,比如{{ .ExternalURL }}或者自定义grafana_host变量加上实际面板ID。这样值班同学点开链接就能看到可视化图表,而不是看到一个干巴巴的表达式。
5.5 验证一次全链路排查:慢接口问题实战还原
拿一个真实的故障场景讲一下三支柱是怎么协同工作的。
某次线上反馈订单查询接口变慢,用户页面等待转圈。你打开服务总览面板,发现order-service的p95耗时从200ms飙到3秒,错误率没有明显上升,初步判断是下游响应慢。
顺着面板点到日志面板,输入{service_name="order-service"} |= "query_order",看到若干条日志拖尾出现"redis timeout"字样,而且日志行里有trace_id。点击那条日志的Trace链接跳转到Tempo,完整链路展示出来,发现Redis查询的Span异常,直接显示Redis命令超时。
再回到Prometheus查Redis的Exporter指标,发现Redis慢查询数和连接数暴涨,定位到某个热点key,Redis集群锁争用严重。整个排查过程从发现异常到定位根因,大概十来分钟,三类数据多次互相跳转,基本没有来回翻系统的操作。
6. 落地过程中沉淀的经验与新探索
前面把全链路可观测性从前到后走了一遍,最后再补充一段我个人在落地过程中最深的一些体会。
第一点是:这套体系技术难点并不是部署和配置,而是规范和协作。部署、接入等等操作一天就能做完,但是"什么样的指标需要埋点、每个服务需要保留多少日志、Trace采样的比例怎么统一、告警分级的规则谁来定"这些问题,如果不在项目启动前和所有业务团队达成一致,推进过程中就会不断返工。我的建议是找一个核心团队先立标准,做一个简单的接入文档,里面写清楚Trace命名规范、日志字段约定、指标命名规则、数据保留周期这些硬性约束,让其他业务线照着执行。
第二点是:监控不是让你看到所有数据,而是让你看到"需要知道"的数据。任何时候,指标500个不如50个设计得合理、能指导决策的指标。特别是告警规则,少而准永远比多而杂强。我们的情况是,团队最先设计了几百条告警规则,上线头三天告警几乎刷屏;经过两轮精简,现在保留的告警不足最初的五分之一,但几乎每一条报警都能对应一个实际问题。
第三点是:可观测性建设是一个逐渐演进的过程,不必一次追求完美。这套PLGT组合的好处在于,基础设施搭好之后,后续接入新服务只是一个标准操作,按既有规范配置Agent和日志。随着接入的服务越来越多,面板和告警规则也会慢慢优化,这套体系会自动沉淀出团队的运维经验。
如果你所在团队正准备搞微服务可观测性,别犹豫了,从今天开始搭一套,跑通它,接上你自己的服务,用一段时间你就会理解,排障体验可以如此顺畅。