楼层: 首页/ 软件技术/ Spring Cloud 微服务/ 可观测性三件套落地:日志、指标、链路追踪
十四

可观测性三件套落地:日志、指标、链路追踪

Observability in Practice · Micrometer Tracing / Prometheus / Loki / SkyWalking

微服务最难的不是写代码,是出事之后定位不到问题。第 7 章我们讲了链路追踪的原理,这一章讲怎么真的把它落地到生产:Spring Boot 3 之后 Sleuth 已经归档,得换成 Micrometer Tracing;追踪信息怎么和日志串起来;指标怎么变成告警;SkyWalking 和 OpenTelemetry 两条路线到底怎么选。目标很明确——当线上报警响起时,你能在三分钟内说清"哪个服务、哪个接口、慢在哪一步",而不是翻十台机器的日志。

三件套分别回答什么问题

先把分工搞清楚,不然后面配置起来你会不知道每一项是为了什么。三件套不是"三个都上就更稳",而是三个不同维度的问题,缺一个就会有盲区。

支柱回答的问题典型数据形态典型工具
日志 Logging"这一次请求到底发生了什么?"离散的文本事件,量大、成本高ELK / Loki / ClickHouse;SLF4J + Logback
指标 Metrics"整体健康吗?现在比昨天差多少?"可聚合的数值时间序列,成本低Micrometer + Prometheus + Grafana
追踪 Tracing"这个慢请求,时间花在哪一步?"一次请求的调用树(trace + span)Micrometer Tracing + Brave/OTel,SkyWalking

论为什么只靠日志不够

① 日志没有全局视角:一次下单请求穿过网关、订单、库存、账户四个服务。你只有一堆各自为战的日志文件,"这次请求"在哪台机器上发生了什么都不确定,只能靠时间戳肉眼对齐。

② 指标知道"坏了"但不知道"为什么坏":告警告诉你 P99 从 200ms 涨到 3s,但它不会告诉你是数据库慢、还是某个下游超时。指标是体温计,不是 CT。

③ 排查路径应该是这样的:指标发现异常 → 追查找出是哪条链路的哪一步变慢 → 日志看那一步具体报了什么错。三件套的价值就是"从聚合到个体"的三级下钻,缺任何一级,下钻都会断。

链路追踪:Micrometer Tracing 与 traceId 的传播

Spring Cloud Sleuth 已于 2023 年归档(迁移到 Micrometer Tracing),所以 Spring Boot 3.x 的正确选择是 Micrometer Tracing。它本身是一层门面(facade),底层可以桥接到 Brave(Zipkin 生态,轻量)或 OpenTelemetry(CNCF 标准,生态更大)。

论traceId / spanId 是怎么"跟着请求走"的

① 一个 traceId 代表一整次请求,一个 spanId 代表链路中的一段。Gateway 收到请求时先生成一个 traceId 和一个根 spanId,然后把这些 ID 塞进 HTTP 请求头往下游发。

② 下游收到头之后不新建 traceId,只新建自己的 spanId,并把"父 spanId"记下来。于是所有服务上报的数据靠 traceId 就能拼成一棵树。

③ 默认用的是 W3C 标准头:traceparent(含 traceId、parent spanId、采样标记)。Brave/Zipkin 生态传统上用的是 X-B3-TraceId / X-B3-SpanId 系列。混用两套标准是排查不出来的常见原因之一。

④ 跨消息也要传播:走 MQ 时 traceId 要放进消息头(RocketMQ 的 user property、Kafka 的 header),消费端取出来继续接。否则"下单服务 → MQ → 积分服务"这一段就断了,追踪树会缺一块。

Micrometer Tracing + Brave + Zipkin 的最小可运行配置

<!-- pom.xml:门面 + 桥接 + 上报,三个依赖缺一不可 --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-brave</artifactId> <!-- 桥接到 Brave --> </dependency> <dependency> <groupId>io.zipkin.reporter2</groupId> <artifactId>zipkin-reporter-brave</artifactId> <!-- 上报到 Zipkin --> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> <!-- 指标与健康端点 --> </dependency> # application.yml management: tracing: sampling: probability: 0.1 # 生产 10% 采样,别写 1.0(后面有专门一节讲为什么) zipkin: tracing: endpoint: http://localhost:9411/api/v2/spans endpoints: web: exposure: include: "health,info,prometheus" # 没暴露 prometheus 就抓不到指标 logging: pattern: level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]" # ↑ 关键一行:把 traceId/spanId 打进每行日志,日志和追踪才能对上

业务代码里主动打点和取追踪 ID

import io.micrometer.tracing.Tracer; import io.micrometer.tracing.Span; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @Service public class OrderService { private static final Logger log = LoggerFactory.getLogger(OrderService.class); private final Tracer tracer; // Micrometer Tracing 的门面 public OrderService(Tracer tracer) { this.tracer = tracer; } public void placeOrder(Long orderId) { // 手动创建一个 span,用来标记"这段业务逻辑耗时多少" Span span = tracer.nextSpan().name("place-order").start(); try (Tracer.SpanInScope ws = tracer.withSpan(span)) { log.info("开始下单 orderId={}", orderId); // 打 tag:在追踪界面上可以按订单号搜索这条链路 span.tag("orderId", String.valueOf(orderId)); doBusiness(orderId); } catch (Exception e) { span.error(e); // 打上错误标记,追踪界面会标红 throw e; } finally { span.end(); // 必须 end,否则 span 一直不结束,界面上看不到 } } // 从当前上下文里取 traceId(比如要塞进 MQ 消息头传给下游) public String currentTraceId() { return tracer.currentSpan() == null ? null : tracer.currentSpan().context().traceId(); } }

Sleuth 到 Micrometer Tracing 迁移对照

如果你的项目原来是 Spring Boot 2.x + Sleuth,升级到 3.x 时这一节就是"替换清单"。核心变化:Sleuth 的 API 从 org.springframework.cloud.sleuth 整体换成了 Micrometer 的 io.micrometer.tracing,概念没变,类名全变。

能力Sleuth(Spring Boot 2.x,已归档)Micrometer Tracing(Spring Boot 3.x)
依赖spring-cloud-starter-sleuthmicrometer-tracing-bridge-brave(或 otel 桥)
上报spring-cloud-sleuth-zipkinzipkin-reporter-brave / OTLP exporter
配置项spring.sleuth.sampler.probabilitymanagement.tracing.sampling.probability
取 tracer注入 org.springframework.cloud.sleuth.Tracer注入 io.micrometer.tracing.Tracer
创建 spantracer.nextSpan().name("x").start()形式类似,但返回 io.micrometer.tracing.Span,需配 withSpan 建立作用域
日志字段%X{X-B3-TraceId}%X{traceId} / %X{spanId}
注解打点@NewSpan / @ContinueSpan改用 @Observed(Observation API)
与指标的关系追踪和指标是两套独立体系统一到 Observation API:一次打点同时产出指标和追踪
迁移时最容易踩的:日志格式没跟着改

只换依赖、忘了改 logging.pattern.level,结果每行日志里 traceId 全是空的或者显示成 %X{X-B3-TraceId} 字面量。排查了半天以为是追踪没生效,其实只是占位符名字对不上。迁移后第一件事:随便发一个请求,看日志里有没有真实的 16 进制 traceId。有,说明链路通了;没有,先查日志格式再查依赖。

日志聚合:让 traceId 把日志串起来

日志分散在十几台机器上是不可能排查的,必须集中。做法是统一的:应用把日志输出到 stdout,采集器(Filebeat / Promtail / Fluent Bit)从 stdout 收集并推到日志后端,你在一个界面上按 traceId 搜索,一次请求跨所有服务的日志就全出来了。选型上,ELK 功能全但资源重,Loki 只索引标签、成本低。

方案索引方式优势代价 / 适合
ELK
(Elasticsearch + Logstash + Kibana)
全文索引,每个字段都可搜检索能力强,能做复杂聚合与可视化,生态成熟存储和内存开销大,集群运维成本高。适合日志量大、有专职运维的团队。
Loki
(+ Promtail + Grafana)
只索引标签,日志正文不索引成本低、部署简单,和 Grafana 指标在同一界面正文只能"过滤"不能"全文检索",复杂查询慢。适合中小团队、指标+日志一体化。
ClickHouse 方案列式存储 + SQL 查询写入吞吐极高,SQL 灵活,成本可控要自己搭采集与查询链路。适合日志量大又要便宜的场景。

Logback 结构化输出:让采集器能按 traceId 建索引

<!-- logback-spring.xml --> <configuration> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <!-- 用 JSON 编码,采集器解析零成本,字段稳定 --> <pattern>{"time":"%d{yyyy-MM-dd HH:mm:ss.SSS}","level":"%level", "service":"${spring.application.name}", "traceId":"%X{traceId:-}","spanId":"%X{spanId:-}", "thread":"%thread","logger":"%logger{36}","msg":"%msg"}</pattern> </encoder> </appender> <root level="INFO"><appender-ref ref="CONSOLE"/></root> </configuration> # Loki 查询:先按服务缩小范围,再按 traceId 精确捞出这次请求的全部日志 {service="order-service"} |= "traceId=8f2a1c9b3e4d5a6b" # Kibana 里等价的 KQL service: "order-service" and traceId: "8f2a1c9b3e4d5a6b" # 排查套路:从追踪界面拿到一个慢请求的 traceId → 粘到日志系统 → 全链路日志按时间排列
日志里没有 traceId,等于白做追踪

很多团队上了 Zipkin,界面上也能看到红色慢请求,但点进去只有 span 名字,没有业务信息——因为日志里没带 traceId,你在日志系统里搜不到那一次请求的任何业务输出。记住:追踪负责"定位到哪一步",日志负责"那一步发生了什么",两者的桥梁就是 traceId。所以配置顺序应该是:先把 traceId 打进日志格式,再配上追踪上报,最后才考虑采样率、告警这些优化。顺序反了,你会发现追踪"看着有了但没用"。

指标:Micrometer + Prometheus + 告警

指标是唯一成本低到可以"全量长期保留"的可观测性数据,也是告警的数据源。Spring Boot 3 的默认指标门面是 Micrometer,它把 JVM、HTTP、数据库连接池、线程池等的指标自动注册好,你只需要暴露 /actuator/prometheus 端点,让 Prometheus 定期来抓。

自定义三种最常用的指标:计数器、计时器、仪表盘值

import io.micrometer.core.instrument.*; import org.springframework.stereotype.Service; @Service public class BizMetrics { private final Counter orderCreated; // Counter:只增不减,适合"发生了多少次" private final Counter orderFailed; private final Timer payLatency; // Timer:耗时分布,自动产出 P50/P95/P99 private final AtomicInteger pendingQueue; private final MeterRegistry registry; // 动态标签时需要,故存成字段 public BizMetrics(MeterRegistry registry) { this.registry = registry; // 统一加公共标签,方便在 Grafana 上按服务/环境过滤 Tags tags = Tags.of("service", "order-service", "env", "prod"); this.orderCreated = Counter.builder("biz.order.created") .description("成功创建的订单数") .tags(tags).register(registry); this.orderFailed = Counter.builder("biz.order.failed") .tag("reason", "unknown") // 带维度的计数器,能按原因下钻 .register(registry); this.payLatency = Timer.builder("biz.pay.latency") .publishPercentiles(0.5, 0.95, 0.99) // 关键:只要百分位,别只看平均值 .register(registry); // Gauge:可增可减的瞬时值,注册时绑定一个会被读取的数值来源 this.pendingQueue = registry.gauge("biz.pending.queue", new AtomicInteger(0)); } public void onOrderCreated() { orderCreated.increment(); } public void onOrderFailed(String reason) { // 动态标签:同一指标按失败原因分别计数 Counter.builder("biz.order.failed").tag("reason", reason).register(meterRegistry).increment(); } public void recordPay(long millis) { payLatency.record(java.time.Duration.ofMillis(millis)); } } // 用 AOP 一次性给所有业务接口加耗时统计,比逐个手写划算 @Aspect @Component public class MetricsAspect { @Around("@annotation(org.springframework.web.bind.annotation.GetMapping)") public Object timed(ProceedingJoinPoint pjp) throws Throwable { return Timer.builder("api.request") .tag("method", pjp.getSignature().getName()) .register(registry) .recordCallable(pjp::proceed); // 异常也会被计入 } }

Prometheus 抓取配置 + Alertmanager 告警规则

# prometheus.yml:让 Prometheus 定期来拉数据 scrape_configs: - job_name: 'spring-services' metrics_path: '/actuator/prometheus' scrape_interval: 15s static_configs: - targets: ['order-service:8083', 'stock-service:8082'] # alert-rules.yml:把指标变成"人话告警" groups: - name: order-service-alerts rules: # 规则一:P99 延迟超过 1 秒,持续 5 分钟才报(避免毛刺误报) - alert: HighApiLatency expr: histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{job="spring-services"}[5m])) by (le, uri)) > 1 for: 5m labels: { severity: warning } annotations: summary: "接口 {{ $labels.uri }} P99 超过 1 秒" runbook: "先看追踪找慢在哪个 span,再看该服务日志" # 规则二:错误率超过 5% - alert: HighErrorRate expr: sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) by (uri) / sum(rate(http_server_requests_seconds_count[5m])) by (uri) > 0.05 for: 2m labels: { severity: critical } # 规则三:JVM 老年代使用率居高不下,往往是内存泄漏的前兆 - alert: HighOldGenUsage expr: jvm_memory_used_bytes{area="heap", id=~"G1 Old Gen"} / jvm_memory_max_bytes{area="heap", id=~"G1 Old Gen"} > 0.85 for: 10m

论为什么平均耗时是最没用的指标

① 平均值会掩盖长尾:100 个请求里 99 个 20ms、1 个 5 秒,平均值只有 70ms,看着很健康。但那个 5 秒的请求背后可能是一个用户投诉、一次超时重试风暴。

② 用户感受到的是 P99,不是平均值。稍微大一点的业务,1% 的请求可能就是每天几千次,足以形成舆情。

③ 所以告警要用百分位:histogram_quantile(0.99, ...),Grafana 面板上也同时看 P50/P95/P99 三条线。P99 涨而 P50 平,说明是局部问题(比如某个下游偶发超时);P50 也涨了,说明是整体容量问题。这一条判断能省你很多时间。

SkyWalking 与 OpenTelemetry:两条路线怎么选

Micrometer Tracing 是"应用侧埋点"的方案,还有一类是"探针自动埋点"的方案。SkyWalking 属于后者(Java Agent 无侵入),OpenTelemetry 是目前的事实标准(既支持 SDK 也支持 Agent)。这不是"谁更好"的问题,而是"你的约束是什么"的问题。

对比项Apache SkyWalkingOpenTelemetry (OTel)
接入方式Java Agent 挂载(-javaagent),代码零改动SDK 手动埋点,或用 OTel Agent 自动注入
接入成本极低。加启动参数即可,框架组件自动识别SDK 方式需要改代码;Agent 方式成本接近 SkyWalking
数据模型自有模型,与自家 UI/OAP 存储深度绑定厂商中立标准,trace/metrics/logs 三合一规范
后端选择官方 OAP + Elasticsearch/BanyanDB,一体化开箱即用自由:Jaeger、Tempo、Prometheus、Datadog、阿里云 ARMS 等都能接
可定制 / 可移植改造成本高,容易和 SkyWalking 生态绑定标准协议(OTLP),换后端不动代码,避免供应商锁定
国内生态中文文档与社区非常好,国内公司用得多大厂逐渐统一到 OTel,云厂商基本都支持 OTLP 接入
适合谁想快速见效、不想改代码、团队运维能力一般的团队有明确可观测性规划、想避免锁定、多语言混合技术栈的团队

SkyWalking 的接入:只改启动命令,不碰代码

# 下载 SkyWalking Agent 后,启动时挂上探针即可 java -javaagent:/opt/skywalking-agent/skywalking-agent.jar \ -Dskywalking.agent.service_name=order-service \ -Dskywalking.collector.backend_service=127.0.0.1:11800 \ -jar order-service.jar # 效果:Spring MVC、Feign、MySQL、Redis、RocketMQ 调用自动生成 span # 优点:老项目接入几乎零成本;缺点:想加自定义业务 span 还是得引 SDK # ---- 对比:OTel Agent 也是同样思路,但输出是标准 OTLP ---- java -javaagent:/opt/otel/opentelemetry-javaagent.jar \ -Dotel.service.name=order-service \ -Dotel.exporter.otlp.endpoint=http://127.0.0.1:4317 \ -jar order-service.jar

从 OTel Agent 演进到"标准协议 + 自由后端"的部署形态

# OTel Collector:应用只把数据发给它,它负责分发到多个后端 # 好处:以后想换后端、加采样、加脱敏,都只改 Collector,不动应用 receivers: otlp: protocols: grpc: { endpoint: 0.0.0.0:4317 } http: { endpoint: 0.0.0.0:4318 } processors: batch: {} # 尾部采样:正常请求只留 10%,但凡有 error 或慢请求 100% 保留 tail_sampling: policies: - name: keep-errors type: status_code status_code: { status_codes: [ERROR] } - name: keep-slow type: latency latency: { threshold_ms: 1000 } - name: baseline type: probabilistic probabilistic: { sampling_percentage: 10 } exporters: otlp/tempo: { endpoint: "tempo:4317" } prometheus: { endpoint: "0.0.0.0:8889" } service: pipelines: traces: { receivers: [otlp], processors: [tail_sampling, batch], exporters: [otlp/tempo] } metrics: { receivers: [otlp], processors: [batch], exporters: [prometheus] }

论怎么在这两条路线里做决定

① 看团队规模与运维能力:三五个人的团队、没人专门维护可观测性平台,SkyWalking 更省事,装上就有 UI 和拓扑图。

② 看是否需要避免锁定:如果未来可能上云、可能用云厂商的 APM,那从一开始就走 OTel 标准协议,换后端时不用改业务代码。

③ 看是否多语言:Java + Go + Node 混合的团队,OTel 的统一规范优势非常明显;纯 Java 团队 SkyWalking 的体验更顺。

④ 其实可以共存:两边都是 OTLP/自有协议,很多公司用 OTel Agent 采集、通过 Collector 同时喂给 SkyWalking 和 Tempo。不用把这道题做成单选题,关键是先把"统一用哪种传播头(建议 W3C traceparent)"定下来。

三个高频踩坑

坑一:采样率设成 1.0。这是新手最容易犯的错——为了"看得全"把生产采样率写成 1.0。结果每个请求都产生一个 span 上报,QPS 一万的服务每秒上报几万个 span,网络带宽、追踪后端存储、应用 CPU 全部被打爆,甚至会影响主业务。正确做法:生产用 1%~10% 采样,再配尾部采样把错误和慢请求全部保留——这样既省成本,又不丢关键链路。

坑二:traceId 没跨线程传递。主线程里有 traceId,一提交到线程池/异步任务里就变成空的。原因是 Micrometer/Brave 把追踪上下文放在 ThreadLocal 里,线程一换就丢了。解决:用 @Async 时确保执行器是 Spring 包装过的(会传递上下文),或者手动用 Tracer.withSpan() 在子线程里重建作用域,ContextExecutorService / TaskDecorator 也是常用手段。

坑三:只埋点不看。上完 Zipkin 和 Grafana 就结束了,没有任何告警规则、没有看板、没人值班时看。可观测性的价值在于"问题出现时有人被通知到",没有告警规则,再好的追踪也只是事后复盘用的工具。

记
这一章的核心

① 日志、指标、追踪各答一个问题,"聚合 → 定位 → 细节"三级下钻缺一不可。

② Spring Boot 3 起用 Micrometer Tracing 取代 Sleuth,日志格式要同步改成 %X{traceId},否则追踪形同虚设。

③ traceId 是日志与追踪之间的桥;跨服务靠 HTTP 头(推荐 W3C traceparent),跨 MQ 靠消息头。

④ 生产采样率绝不要写 1.0,用低比例 + 尾部采样保错误与慢请求。

⑤ 指标要看 P99 不看平均值;指标必须配 Alertmanager 规则,否则等于没上。

⑥ SkyWalking 胜在无侵入快见效,OTel 胜在标准中立不锁定,可共存,但要统一传播头。

本章面试题

面试快答 · 可观测性

Q1. 一次请求在微服务里跨了 4 个服务,你怎么把它的日志串起来?

参考答案

靠 traceId。链路:入口(网关)生成 traceId,通过 HTTP 头(W3C 的 traceparent 或 Brave 的 X-B3-*)逐跳传递,每个服务不从头上新建 traceId 只新建 spanId。日志侧:把 traceId 打进日志格式(%X{traceId}),日志统一采集到 ELK/Loki。使用:从追踪界面拿到慢请求的 traceId,粘到日志系统里一次搜出全链路日志,按时间排即可还原现场。跨 MQ 时要额外把 traceId 放进消息头。

Q2. 为什么生产环境不能把采样率设成 1.0?那怎么保证不丢关键链路的追踪?

参考答案

不能设 1.0 的原因:全量采样意味着每个请求都产生并上传 span。高 QPS 下这会让追踪后端存储爆炸、占用大量网络带宽、甚至反噬应用自身的 CPU 和延迟,属于典型的"用可观测性把系统搞挂了"。解决办法:用尾部采样(tail sampling)——先收集完整链路,再在 Collector 侧按策略决定保留哪些:所有带 error 的、耗时超过阈值的 100% 保留,正常请求按 1%~10% 概率保留。这样成本可控,又不会丢掉真正需要排查的那部分数据。

Q3. Spring Boot 2 的 Sleuth 升级到 3.x 要改哪些东西?

参考答案

四类改动:① 依赖,spring-cloud-starter-sleuth → micrometer-tracing-bridge-brave(或 otel 桥),上报换 zipkin-reporter-brave;② 配置项,spring.sleuth.sampler.probability → management.tracing.sampling.probability;③ API,注入的 Tracer 从 org.springframework.cloud.sleuth.Tracer 换成 io.micrometer.tracing.Tracer,注解从 @NewSpan 改为走 Observation API 的 @Observed;④ 日志格式,占位符从 %X{X-B3-TraceId} 改成 %X{traceId}。最后一条最容易被漏掉。

Q4. 接口的 P50 是 30ms,P99 是 2s,说明了什么?你应该怎么排查?

参考答案

说明:绝大多数请求很快,但有 1% 的请求被拖到 2 秒,长尾明显。这通常不是容量问题(否则 P50 也会涨),而是某个偶发因素——某个下游服务偶发超时、数据库偶发慢查询、GC 停顿、线程池排队、连接池不够用。排查:① 用追踪筛出耗时大于 1 秒的 trace,看时间花在哪个 span;② 对应服务看 GC 日志和 JVM 指标(有没有长停顿);③ 看下游的 P99 是否同步变差;④ 检查连接池/线程池的排队指标。优化方向:给下游加超时和熔断,避免一个慢下游拖住整条链路。

Q5. SkyWalking 和 OpenTelemetry 你会怎么选?

参考答案

不是好坏题,是约束题。选 SkyWalking:纯 Java 技术栈、团队运维人力少、想快速见效、不想改任何代码——Java Agent 挂上就有 UI 和拓扑图,中文资料也好。选 OTel:多语言混合栈、未来可能接入云厂商 APM、希望避免供应商锁定——OTLP 是事实标准,换后端不用动业务代码。实践中常见做法是两者共存:用 OTel Agent 采集,通过 Collector 分发到多个后端。无论选哪个,都要先把追踪上下文的传播格式统一(建议 W3C traceparent),否则跨服务链路一定是断的。