第 22 章 生产可观测性:日志、指标与链路追踪(让别人能救你的服务)

凌晨三点,告警群又炸了。订单服务 P99 从 200ms 飙到 3 秒,但这次不是你一个人的服务——调用链上挂着订单、库存、优惠、支付、用户中心五个服务。你打开日志,五个服务的日志各自为政,没有一行能把”同一次请求”串起来。你在订单服务的机器上 grep 了半小时,又在库存服务的机器上 grep 了半小时,最后发现是优惠服务里一个慢查询把整条链拖死了。从头到尾,你花了大半天。

如果当时每一行日志都带着同一个 traceId,你在 Kibana 里敲一次 traceId=xxx,五个服务的日志按时间排好队,慢的那一跳一眼可见——五分钟定位,不是大半天。这一章就讲这套”让别人(也包括半夜的你自己)能救你服务”的三件套:日志、指标、链路追踪。


22.1 三支柱:为什么单体靠 print,分布式必须靠三件套

先把这个词钉死:可观测性(Observability)。它说的是——你的系统跑在生产环境里,你不用改代码、不用重启,就能从外部输出推断内部状态。它有三根柱子:

  • 日志(Log):一条条带时间戳、带上下文的文本/结构化记录,回答”发生了什么具体的事”。
  • 指标(Metric):可聚合的数值时间序列,回答”系统整体现在是什么状态、趋势如何”(QPS、RT、错误率、线程数)。
  • 链路追踪(Trace):一次请求跨多个服务/多线程的完整路径,回答”这一次慢请求,到底慢在哪一跳”。

问题 1: 你写 Go 那五年,不就是一个 log.Printf + 偶尔 pprof 就过来了?为什么到了 Java 分布式,print 不够?

因为单体的世界里,“一次请求”和”一个进程”是绑定的。你 grep 一个 requestId,所有相关日志都在这一个进程的日志文件里,从头到尾一条线。但分布式下,一次用户请求会被拆成 N 次 RPC 散落到 N 个服务、N 台机器上。第 16 章你学过”一次请求在 Spring MVC 里过了几道门”——那只是在一个服务内部的路径。当你有五个服务,一次请求在外部还要再过四道”服务间跳转”,每一跳都是一次新的 HTTP/gRPC 调用、一个新的线程、一个新的日志上下文。

没有 traceId 把这几段”接起来”,你面对的不是一条线,而是一地把”时间戳大概接近”的日志碎片。你能做的只有靠时间猜”这段可能对应那段”——这正是大半天排查的本质:你在用人的脑子做数据库 JOIN,而 JOIN 的 key 根本不存在。

【思考】既然 traceId 是把跨服务日志串起来的”JOIN key”,那这个 key 应该由谁生成、又该往哪塞,才能让五个服务都用同一个值?

参考答案

直接答案:由”链路的入口服务”在最前面生成一次 traceId,然后通过每一次跨进程调用(HTTP header、MQ 消息头、gRPC metadata)把它透传下去;下游服务收到后,从请求里把 traceId 取出来,放进自己的日志上下文(MDC,22.2 讲)继续打日志。关键是”只生成一次,全程透传”,而不是每个服务各生成一个。

为什么必须入口生成、透传,而不是各自生成:如果每个服务对自己收到的请求都新生成一个 traceId,那五个服务会有五个互不相干的 id,你还是串不起来。JOIN key 要能 JOIN,前提是”同一次请求在所有服务里是同一个值”。所以规则是——第一个接过这个外部请求的服务生成 traceId,之后每一跳都把它原样往下传。

往哪塞才能透传:跨进程时,它必须离开你的进程内存、搭上”请求”这趟车走。HTTP 场景就是塞进请求头。早期 Spring Cloud Sleuth 用 B3 头(X-B3-TraceId / X-B3-SpanId),现在主流是 W3C 标准头 traceparent。链路追踪框架(22.4)会自动帮你从进来的请求头里取、再往出去的请求头里写——你不用手写,但你要知道它在哪。

代码锚点——哪怕是入口自己生成,也不是你手写 UUID:入口服务通常由 tracing 框架的 Filter(呼应 16 章 Filter 门)在请求进来时自动建 traceId 并放进 MDC,你业务代码里只要 log.info("处理订单") 就行,框架会把 MDC 里的 traceId 自动拼进每一行日志。你感知不到”生成”这个动作,这正是门面/自动装配的红利。

更深一层:traceId 这个设计,本质是给”分布式系统里本不存在的全局事务号”造了一个。单体里”一个请求=一个进程内的调用栈”是天然的;分布式里调用栈被网络切成了一段段,traceId 就是人为把这段段重新缝起来的线。你从 Go 过来,Go 里如果你也搞微服务,同样的问题一模一样——Go 的 context.Context 能在进程内把 trace 信息顺着 ctx 传下去,但跨进程照样得靠 header 透传(OpenTelemetry Go SDK 干的也是这事)。所以”三件套”不是 Java 的特产,是分布式系统的共性难题;只是 Java 生态用门面(SLF4J / Micrometer / Micrometer Tracing)把这事标准化了。


22.2 日志:门面 + 实现 + MDC + 异步 + 结构化

先纠正一个你大概率会犯的直觉:直接 import org.apache.log4j.Logger 然后在代码里写日志。别。Java 日志的第一课是门面(Facade)。

门面:为什么你的业务代码不该直接依赖某个日志实现

Java 日志的演进史是一地鸡毛:Log4j 1.x → java.util.logging(JUL,JDK 自带)→ Logback → Log4j2。历史上不同的库各自用了不同的日志框架,你的项目里同时出现 Log4j、JUL、Logback 是常态(这叫”日志框架分裂”)。于是有人造了个门面:SLF4J(Simple Logging Facade for Java)。你的业务代码只依赖 org.slf4j.Logger 这个接口,真正干活的实现(Logback 或 Log4j2)在运行时通过 classpath 上的绑定 jar 接上。

// 业务代码里只依赖 SLF4J 门面,不依赖任何具体实现
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
 
@RestController
public class OrderController {
    // 永远用门面拿 Logger,编译期只认 slf4j 这一个类型
    private static final Logger log = LoggerFactory.getLogger(OrderController.class);
 
    public Order get(Long id) {
        log.info("查询订单 id={}", id);   // 占位符,避免无谓的字符串拼接
        return service.findById(id);
    }
}

问题 2: 为什么要绕这一道门面?我直接 new LogbackLogger() 不香吗,少一层抽象。

香不香看代价。直接依赖实现,等于你的业务代码和”某个具体日志框架的 API”焊死了——哪天团队要从 Log4j2 迁到 Logback(真发生过,Log4j 1.x 停更、Log4j2 出漏洞),你得改几百个 import。用了门面,实现是运行时插进去的,换实现只换 classpath 上一个 jar,业务代码一行不动。这和你第 14 章学的”依赖倒置、面向接口编程”是同一个道理:门面就是日志这个领域的接口,业务代码依赖接口而非实现。 呼应分层思想——上层不该知道下层是谁。

【思考】你从 Go 过来,Go 的标准库 log 没有门面这回事,大家直接 log.Println。为什么 Java 非得有 SLF4J 这种门面,而 Go 不需要?

参考答案

直接答案:根因是生态成熟度与”历史包袱”的差异。Go 的日志生态长期没有事实标准的”多实现之争”,标准库 log 足够简单、第三方库大多也直接用它或 log/slog(Go 1.21+),所以不需要一个门面去隔离”实现”。Java 这边是”上古就有多个日志框架并存、且互相不兼容”,门面是被迫出现的”适配层”,用来让业务代码不被某个具体框架绑架。

Java 为什么必须门面:Log4j / JUL / Logback / Log4j2 的 API 完全不兼容,而你的项目会间接引入一堆用了不同框架的第三方库(Hibernate 用 JUL、早期 Spring 用 commons-logging、Kafka 客户端用 slf4j……)。没有门面,你的日志会散成几个框架各自的输出、格式还不一样。SLF4J + jul-to-slf4j / log4j-over-slf4j 这些”桥接包”把所有框架的日志都收编到 SLF4J 门下,再统一交给一个实现(Logback)输出。这是 Java 的”多框架历史”逼出来的解法。

Go 为什么不用:Go 没有这段”多框架混战”的历史。log 包从第一天就在标准库,大家默认用它;需要结构化时上 slog(标准库、统一接口)。第三方库也都顺着用 log/slog,没有”几十个互不兼容的日志框架”这个前提,自然不需要门面来调停。

更深一层:门面在 Java 里无处不在(SLF4J 之于日志、JDBC 之于数据库驱动、JCL 之于缓存、甚至你第 15 章看的自动配置——@ConditionalOnClass 探测的就是”门面背后有没有具体实现”)。它反映的是 Java 生态”先有碎片、再用抽象层收编”的演化路径。Go 的演化路径相反——先有标准库定调、碎片少。所以你到 Java 世界会发现”门面”是一种生活方式:见到 xxx-api + xxx-impl 这种成对出现的依赖,大概率是门面模式。别绕过门面直接依赖 impl,否则换实现时你会哭。

MDC:把 traceId/用户ID 注进每一行日志

门面之上,SLF4J 给了个杀手锏:MDC(Mapped Diagnostic Context,映射诊断上下文)。它本质是一个和”当前线程”绑定的 Map<String, String>(底层是 ThreadLocal,呼应你第 10 章学的 ThreadLocal)。你往 MDC 里 put("traceId", xxx),之后这同一个线程里打的每一行日志,都会自动带上 traceId=xxx——不用你在每条 log.info 里手写。

import org.slf4j.MDC;
 
// 在 Filter 门(呼应 16 章)里,请求一进来就把 traceId 放进 MDC
MDC.put("traceId", tracer.currentTraceId());   // 链路追踪框架提供
MDC.put("userId", currentUser.getId());        // 用户身份也注进去
 
try {
    chain.doFilter(req, res);                   // 业务代码里随便 log,自动带 traceId
} finally {
    MDC.clear();                                // 必须清!线程池复用会串号(见 22.4)
}

MDC 是”日志可观测”从”能看”到”能查”的质变:没有它,你打了日志但查不到;有了它,一行 traceId=xxx 就能把所有相关日志捞出来。

异步 Appender:别让打日志阻塞业务线程

日志默认是同步写磁盘的。高并发下,每一行日志都 write 一次磁盘,业务线程得等 IO。Logback 给了 AsyncAppender——它把日志事件丢进一个内存队列,由一个后台线程负责真正落盘,业务线程不阻塞。

<!-- logback-spring.xml:用 AsyncAppender 包住真正的落盘 Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>2048</queueSize>              <!-- 队列容量,满则按策略丢弃 -->
    <discardingThreshold>0</discardingThreshold> <!-- 0=队列满才丢,默认会先丢 INFO -->
    <appender-ref ref="FILE"/>               <!-- 真正的落盘 Appender -->
</appender>

代价要清楚:异步意味着”进程崩了,队列里没落盘的那几行日志可能丢”。所以对审计/资金类关键日志,要么同步写,要么接受这个风险。别为了”快”把所有日志异步化而丢了排查用的关键行——这是取舍,不是免费午餐。

结构化日志:JsonLayout,方便采集

传统日志是一行文本,人能读、机器难解析。上了 ELK / Loki 这类采集系统,你得让每一行日志是一段 JSON,traceId、level、userId 都是字段,才能被检索、聚合、做仪表盘。Logback 用 JsonLayout(来自 logstash-logback-encoder 这套生态):

<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
        <providers>
            <timestamp/><!-- 时间字段 -->
            <logLevel/><!-- level 字段 -->
            <mdc/><!-- 把 MDC 里的 traceId/userId 自动展开成 JSON 字段 -->
            <message/><!-- 日志正文 -->
        </providers>
    </encoder>
</appender>

mdc 这个 provider 会自动把 MDC 里的 traceId 展开成 JSON 的一个字段——这就把上一节的 MDC 和这一节的结构化接上了:MDC 负责”按线程注入”,JsonLayout 负责”把注入的值输出成可检索字段”。

日志级别:warn 和 error 别乱打

ERROR 是”出事了、需要人来看”;WARN 是”有风险、但还能跑”。很多人把所有异常都 log.error,结果告警风暴,真正该看的 error 淹没在噪音里。一个朴素的纪律:业务可自愈的瞬时失败(如一次超时后重试成功)打 warn;需要人工介入的(如数据库连接彻底断了)才打 error。 你的 error 数量应该和告警数量同量级,否则告警就废了。


22.3 指标:Micrometer 门面 + Counter/Gauge/Timer

日志回答”发生了什么”,指标回答”现在整体怎么样、趋势如何”。如果日志是显微镜,指标就是仪表盘。

Java 的指标也有门面,叫 Micrometer。它和 SLF4J 是同一个套路:你的代码依赖 io.micrometer.core.instrument.MeterRegistry 这个门面接口,真正往哪个后端(Prometheus、Datadog、InfluxDB)报,由运行时接的实现决定。Spring Boot 自动配好了 MeterRegistry,你基本无感。

// 业务代码只依赖门面 MeterRegistry,不直接依赖 Prometheus 客户端
@Autowired
private MeterRegistry registry;
 
// 三种最核心的仪表(Meter)
Counter counter = registry.counter("order.created");   // 计数:只增不减
counter.increment();                                    // 订单创建数 +1
 
Gauge.builder("order.active", orderService, s -> s.activeCount())  // 瞬时值
     .register(registry);                               // 如当前活跃订单数
 
Timer timer = registry.timer("order.create.time");     // 耗时分布
timer.record(() -> createOrder(...));                   // 包一段逻辑,自动记耗时+分位数

三种仪表的区别,必须刻进肌肉记忆:

仪表语义典型用途能不能减
Counter单调递增的计数请求总数、错误总数、订单创建数只增
Gauge某一刻的瞬时值连接池活跃连接数、内存占用、队列长度可增可减
Timer耗时分布(含 count + 分位数)接口 RT、SQL 耗时、RPC 耗时—

问题 3: 连接池”当前活跃连接数”该用 Counter 还是 Gauge?如果答错会怎样?

用 Gauge。因为它是一个一直在变的瞬时值,这秒 5 个、下秒 8 个。如果你错误地用 Counter 去 increment / decrement 模拟,Counter 的语义是”累计”,你的监控图会变成一条只增不减的斜线,根本看不出”此刻有多少连接”。更隐蔽的是:Counter 重置(进程重启)会让”累计值”归零,导致你算”环比增长率”时得到一个假峰值。Counter 只用于”发生了多少次”,Gauge 才用于”现在是多少”。 这条界线划错,指标就失去了意义。

【思考】接口耗时你用 Timer 记录,它除了”平均耗时”还给了你什么?为什么”平均 RT”在排错时常常是个陷阱?

参考答案

直接答案:Timer 记录的不只是平均值,它天然产生分位数(p50/p95/p99)和采样分布**。而”平均 RT”在排错时是陷阱,因为平均值会把少数极慢的请求”平均”掉——100 次请求里 99 次 10ms、1 次 3000ms,平均才 39ms,看着很健康,但那 1 次 3 秒的超时可能正是用户投诉的来源。看 p99 才能看见”最慢的那批请求有多慢”。**

为什么 Timer 能给你分位数:Micrometer 的 Timer 底层不是简单累加求和再除,而是维护一个滚动直方图(或基于 HdrHistogram 的高动态范围直方图),把耗时按桶(bucket)统计。所以你能查 order.create.time.max、order.create.time.p99 这种分位数指标,而不是只有平均。Prometheus 侧也能用 histogram_quantile 按 bucket 算分位。

代码锚点——与其手写平均,不如交给 Timer:

Timer timer = registry.timer("http.server.requests", "uri", "/order");
// ✅ 框架(WebMvcMetricsFilter)其实已经自动给每个接口建了 Timer,你通常不用手写
// 业务里要计时自己的一段逻辑时:
timer.record(() -> heavyLogic());   // 自动记 count + 总耗时 + 分位

更深一层:平均值的陷阱本质是”分布被压成一个点,信息丢失”。指标世界里这个道理处处成立——看 QPS 要看”有没有突刺”而不是只看均值;看错误率要看”分业务维度”而不是总数。你从 Go 过来,prometheus/client_golang 的 Histogram/Summary 也是同样的语义(Go 那边叫 Summary 直接算分位、Histogram 靠 bucket),所以这套”别只看平均、看分位”的纪律是跨语言的。排错时第一眼永远看 p99,不是 avg。

指标后端:Prometheus 的拉模型

你的 MeterRegistry 把指标攒在内存里。谁来取?如果用 Prometheus,答案是Prometheus 主动来”拉”(pull)。Spring Boot 把指标暴露成一个端点 /actuator/prometheus,Prometheus 服务器按固定间隔(如 15s)来抓一次。

# prometheus.yml 里配 scrape 目标,Prometheus 主动来抓
scrape_configs:
  - job_name: 'order-service'
    static_configs:
      - targets: ['order-service:8080']   # 来抓 /actuator/prometheus

拉模型 vs 推模型:拉模型下,采集端(Prometheus)控制节奏、自己决定抓谁、挂了也不影响业务(业务只管暴露);推模型(如 StatsD、或 Prometheus 的 Pushgateway 用于短时任务)是业务主动把数据推给收集端,收集端挂了业务得自己缓冲。K8s 里拉模型特别顺:Prometheus 通过 Service Discovery 自动发现新 Pod,新实例起来就被抓,不用业务去”注册自己”。


22.4 链路追踪:traceId/spanId 的传播,以及异步线程池的坑

指标告诉你”整体慢了”,日志告诉你”具体错了什么”,但谁告诉你”慢在哪一跳、错在哪个服务”? 答案是链路追踪。

一个 trace(一次完整请求)由多个 span(每一跳/每一阶段)组成。每个 span 有自己的 spanId,同一 trace 下的所有 span 共享同一个 traceId——这就是 22.1 那个 JOIN key 的技术实现。

生态现状:老项目用 Spring Cloud Sleuth(已停止主线发展,转入维护);新项目用 Micrometer Tracing(io.micrometer.tracing)作为门面,底层可接 Brave 或 OpenTelemetry(OTel) 两种实现。和 SLF4J、Micrometer 一脉相承:又是”门面 + 可换实现”。

问题 4: traceId 怎么从”订单服务”走到”库存服务”?它不在同一个 JVM 里,ThreadLocal 够用吗?

不够。跨进程时,trace 信息必须离开内存、搭上请求本身走。标准做法是通过协议头透传:HTTP 场景写入 traceparent(W3C 标准)或旧的 X-B3-TraceId;MQ 场景写进消息头;gRPC 写进 metadata。框架会自动帮你做”进来的请求取头、出去的请求写头”,你业务代码无感。

// 跨进程透传由框架(Client/Server 拦截器)自动做,你只需理解它在哪
// 出去的 HTTP 调用,框架往 header 塞:
// traceparent: 00-<32位traceId>-<16位spanId>-01
// 进来的服务从 header 取出 traceId,放进自己的 MDC,继续打日志

高频真实坑:异步线程池里 context 断了

这是本章最该刻进骨头的一个坑。你第 10 章学了 ThreadLocal 是线程私有的;MDC 底层就是 ThreadLocal;trace 的当前上下文(Brave 的 CurrentTraceContext)底层也是 ThreadLocal。

那么问题来了:当你把一段逻辑 executor.submit(() -> ...) 丢进线程池,这段逻辑跑在另一个线程上。那个线程的 ThreadLocal 是空的——它既没有你的 traceId,也没有你的 userId。结果:线程池里打的日志,traceId 字段空了;链路追踪里,这一跳的 span 和上游”失联”,调用链从这儿断开。

ExecutorService pool = Executors.newFixedThreadPool(8);
 
// ❌ 错误:直接 submit,新线程没有父线程的 MDC/trace 上下文
pool.submit(() -> {
    log.info("异步处理订单");   // 这行的 traceId 是空的,查不到!
});

根因一句话:ThreadLocal 不跨线程,而线程池恰恰把活从一个线程搬到了另一个线程。 你第 11 章学的线程池”复用线程、业务和调度解耦”,在这里变成了”上下文丢失”的温床。

修复——手动把上下文快照搬过去:

// ✅ 正确:提交前取父线程 MDC 快照,在新线程里恢复,跑完清空
ExecutorService pool = Executors.newFixedThreadPool(8);
 
void submitWithContext(Runnable task) {
    Map<String, String> ctx = MDC.getCopyOfContextMap();   // 取父线程快照
    pool.submit(() -> {
        if (ctx != null) MDC.setContextMap(ctx);           // 恢复到新线程
        try {
            task.run();
        } finally {
            MDC.clear();    // 必须清,否则线程被复用时会串到下一次任务
        }
    });
}

链路追踪同理——Brave 提供 CurrentTraceContext.wrap(executor) 把执行器包一层,自动做”取父上下文→在新线程恢复→跑完清”;Sleuth 有 LazyTraceThreadPoolTaskExecutor / TraceableExecutorService。本质都一样:把父线程的上下文快照塞进子线程。 你要记住的不是某个具体类名,而是这条原则:凡是跨线程(线程池、@Async、CompletableFuture、MQ 消费线程),MDC/ThreadLocal 必丢,必手动或靠框架包装传递。


22.5 Actuator:把”内部状态”变成”可抓取端点”

前面三件套(日志/指标/链路)讲怎么产生数据。Spring Boot Actuator 是”把这些数据暴露出来、并且让你看得到应用自身健康”的闸门。它来自你第 15 章见过的 spring-boot-starter-actuator。

常用端点:

端点干嘛用
/actuator/health健康检查,K8s 的 liveness/readiness 探针就打它
/actuator/metrics所有指标的列表+单指标的当前值(人看的)
/actuator/prometheus给 Prometheus 抓的纯文本指标(机器看的)
/actuator/conditions第 15 章用过:哪些自动配置生效/没生效、为什么
# 默认只开 health。要暴露指标和链路,显式列出来
management.endpoints.web.exposure.include=health,metrics,prometheus
management.endpoint.health.probes.enabled=true   # 暴露 /health/liveness 和 /health/readiness

health 端点为什么重要:K8s 靠它判断”这个 Pod 该不该接流量、该不该重启”。readiness(就绪)= 能接流量了吗;liveness(存活)= 还活着吗,不该被杀吗。你的 DB 连不上了,健康检查就该返回 DOWN,K8s 把流量切走——这就是”可观测性接上编排系统”的落点。

安全:别裸奔。 Actuator 端点默认只开 health,这是对的。如果你图省事写 management.endpoints.web.exposure.include=*,等于把 /actuator/env(可能含密码)、/actuator/beans(全容器 Bean)、/actuator/threaddump 全暴露到公网——这是真实被攻破过的路径。/actuator/prometheus 至少应该只在集群内网被 Prometheus 抓,别挂公网域名。

【思考】/actuator/health 返回 UP 就真的安全吗?一个”DB 连接池全被慢查询占满、但连接还能建”的服务,health 会报 DOWN 吗?

参考答案

直接答案:不一定,而且很可能不会。/health 默认只检查”组件在不在”(如 DataSource 这个 Bean 存在、能建一次连接),不检查”连接池是不是快被占满、RT 是不是已经爆了”。所以一个连接池被慢查询占满、但还能勉强建出新连接的服务,health 照样 UP,K8s 照样给它灌流量,然后它继续雪崩。Health 是”死活”探针,不是”健康度”探针。

为什么默认不报:Spring Boot 的健康指示器(HealthIndicator)默认做”轻量检查”。DataSourceHealthIndicator 通常是发一条极轻的 SELECT 1 验证连通性,不关心连接池水位、不关心慢查询。所以”能 SELECT 1” ≠ “能扛住业务流量”。

代码锚点——自己加一个连接池水位健康指示:

@Component
public class PoolHealthIndicator implements HealthIndicator {
    private final HikariPool pool;          // 注入你的连接池
    @Override
    public Health health() {
        int active = pool.getActiveConnections();
        int max = pool.getMaximumPoolSize();
        if (active >= max * 0.9) {          // 水位超 90% 就报不健康
            return Health.down()
                .withDetail("active", active).withDetail("max", max).build();
        }
        return Health.up().withDetail("active", active).build();
    }
}

更深一层:/health 的粒度由你定义。默认它只防”组件挂了”这种硬故障;软故障(连接池满、线程池满、本地磁盘快满、下游依赖变慢)它看不见,得你自己写 HealthIndicator 把”业务相关的健康度”暴露出去。这是 Actuator 设计的一个隐含责任:它给你能力,但不替你定义”什么叫不健康”。 你从 Go 过来,Go 的 /healthz /readyz 也是同样道理——k8s 的探针只认你返回的状态码,至于你怎么算”健康”,得你自己写。别以为挂了 Actuator 就自动”啥病都能查出来”,那是错觉。


22.6 排错闭环:指标 → 链路 → 日志 → 08 章方法收口

三件套不是三个孤立工具,是一条排错流水线。把它和你的第 8 章排错法(先定位后修复、二分法、相信证据)拼起来,长这样:

  1. 指标发现异常:监控大盘上 order.create.time.p99 从 200ms 涨到 3s,或错误率 order.error 突增。指标是”第一发现者”——它告诉你”出事了、大概哪类指标异常”。
  2. 链路定位慢跳:进 APM(如接了 OTel 的后端),按 traceId 看这次请求的 span 树,一眼找到”慢在优惠服务的 calcDiscount 这一跳”或”错在库存服务的某个 span”。链路是”精确定位到哪一跳”。
  3. 日志看具体错误:拿到那个 span 的 traceId,去日志系统 traceId=xxx,把优惠服务那几行日志按时间排好,看到具体异常栈和参数。日志是”看清到底错在哪一行”。
  4. 08 章方法收口:带着”优惠服务 calcDiscount 慢/错”这个已定位的结论,回头用第 8 章的”先定位后修复、二分法、证据优先”去挖根因——是慢 SQL?是缓存击穿?是线程池满了?

注意顺序:指标负责”广撒网发现”,链路负责”缩到一跳”,日志负责”看清细节”,排错法负责”从细节到根因”。 没有 traceId,第 2 步就断了,你只能在第 1 步和第 3 步之间靠时间猜——也就是本章开头那个”大半天”的惨剧。三件套串起来,才是 08 章方法论在生产环境里的”放大器”:08 章教你怎么科学猜,这一章教你怎么让”猜”变成”不用猜”。


22.7 三个真实事故

案例一:日志没有 traceId,跨 5 服务排查超时花了大半天

现象:订单服务 P99 飙到 3 秒,告警群炸。五个服务(订单/库存/优惠/支付/用户中心)各自的日志里只有普通的 log.info("处理xxx"),没有统一 traceId。

排查过程:

  1. 在订单服务机器 grep 超时请求的 orderId,找到订单服务的日志,看到它调了优惠服务。
  2. 切到优惠服务机器,按 orderId 再 grep,找到优惠服务的日志,看到它查了库存。
  3. 再切库存服务……如此在五台机器间来回跳,靠”时间戳接近 + orderId”手动对,花了大半天。
  4. 最后定位:优惠服务一个 calcDiscount 里的慢 SQL,没走索引,全表扫描。

根因:日志没有跨服务的 traceId,一次请求被切成五段互不相干的日志碎片,人肉做 JOIN。

修复:接入链路追踪(Micrometer Tracing + Brave),Filter 层把 traceId 注入 MDC,日志 JsonLayout 把 traceId 输出成字段。之后同样的故障:在 APM 按 RT 找到慢 span → 拿 traceId 在日志系统一次 traceId=xxx 捞出五个服务全部相关日志,5 分钟定位。

教训:traceId 不是”锦上添花”,是分布式系统排查的”基础设施”。没有它,服务越多排查成本越接近线性爆炸。你从单体 Go 过来最容易低估这点——单体里 grep 一个 id 就够了,微服务里必须有 traceId 当 JOIN key。呼应 22.1。

案例二:异步线程池里开了新线程,traceId 断了

现象:接了链路追踪后,大部分请求能串起来,但有一类”异步发券”的请求,调用链在”订单服务把任务丢进线程池”之后就断了——下游券服务的 span 和上游没有父子关系,像凭空冒出来的。

排查过程:

  1. 看代码,发券逻辑是 executor.submit(() -> sendCoupon(...)) 异步执行的。
  2. 回忆 22.4:线程池里的活跑在新线程上,MDC/ThreadLocal 不跨线程。
  3. 验证:在线程池任务里打一行 log.info("traceId={}", MDC.get("traceId")),确实是 null。

根因:executor.submit 直接丢任务,新线程没有父线程的 trace 上下文,MDC 为空、Brave 的 CurrentTraceContext 也为空,于是链路追踪认为这是一条”新 trace”,调用链断开。

修复:用 CurrentTraceContext.wrap(executor) 或 LazyTraceThreadPoolTaskExecutor 包装线程池(本质是 22.4 那个”取快照→恢复→清空”的封装);MDC 侧同理包装 Runnable。修复后,异步发券的 span 正确挂回原 trace 下,调用链完整。

教训:只要代码里出现 executor.submit / @Async / CompletableFuture / MQ 消费线程,就默认”上下文会丢”,主动用包装执行器。 这是 Java 的 ThreadLocal/MDC 模型在异步场景下的结构性代价(呼应第 10/11 章)。Go 的 context.Context 因为是显式传参,反而没这个坑——22.8 细说。

案例三:指标维度爆炸,Prometheus 内存打爆

现象:某服务接入指标后,Prometheus 内存持续上涨、查询越来越慢,最后 OOM 重启。监控上这个服务的 series(时间序列)数量是其他服务的几百倍。

排查过程:

  1. 看指标定义,发现有人写了 registry.counter("order.by.user", "userId", user.getId())——把 userId 当成了标签(tag)。
  2. 每个不同 userId 都会生成一个独立的时间序列。用户量十万级,于是凭空产生十万+ 条 series。
  3. Prometheus 是 TSDB,每条 series 都有固定内存开销,十万低基不对,是高基 series 直接把内存吃爆。

根因:高基维度(high-cardinality)标签。标签(tag/label)的取值基数必须低(如 uri、status、region,几十到几百种);一旦把 userId、orderId、requestId 这种”每个请求都不同”的值当标签,series 数量爆炸。

修复:去掉 userId 标签,只保留业务关键、低基的维度(如 uri、method、status)。需要按用户维度统计?那是日志/链路的事(用 traceId+userId 在日志里查),不是指标的事。指标只回答”整体趋势”,不该回答”某个具体用户”。

教训:指标的标签是”分组维度”,不是”查询条件”。能用有限的几个值分组的才配当标签;无限增殖的值(用户 id、订单 id、trace id)永远是日志的领地,别塞进指标。这是 Micrometer/Prometheus 使用里最贵的一个坑,一次就能打爆内存。


22.8 Go ↔ Java 对照:可观测性的两套哲学

先看一张总表,把这一章的”概念|Go|Java”钉死:

维度GoJava(Spring Boot)
日志门面标准库 log / log/slog(无门面之争)SLF4J(门面)+ Logback / Log4j2(实现)
结构化日志slog + JSON handler(Go 1.21+)Logback JsonLayout / logstash encoder
上下文注入日志context.Context 显式传,日志时从 ctx 取MDC(ThreadLocal)隐式,自动带进每行
指标门面prometheus/client_golang(直接对接 Prometheus)Micrometer(门面)+ 可接 Prometheus/OTel 等
指标类型Counter / Gauge / Histogram / SummaryCounter / Gauge / Timer(等价)
链路追踪OpenTelemetry Go SDK / go.opentelemetry.ioMicrometer Tracing + Brave / OTel
trace 跨线程传递ctx 显式传给新 goroutine,天然不丢ThreadLocal/MDC 隐式,线程池必丢,需包装
健康检查端点自己写 /healthz /readyzActuator /actuator/health(+探针)

最该想透的一点:Go 的 context.Context vs Java 的 MDC/ThreadLocal,是两种”上下文传递模型”的本质区别。 你第 10 章学过 ThreadLocal 是”线程私有变量、靠线程身份取”;Java 把 trace/用户身份塞进 ThreadLocal(MDC),于是”谁打日志、谁就自动带上下文”。但代价是:上下文跟着”线程”走,一旦跨线程(线程池、异步)就丢——22.4 那个坑的根。

Go 反过来:context.Context 是显式参数,你要 func handle(ctx context.Context, ...) 把 ctx 一路当参数传下去。新开 goroutine 时,你也得显式把 ctx 传进去:go worker(ctx, job)。于是 trace 信息跟着 ctx 这个值走,而不是跟着”线程”走——跨 goroutine 不丢,因为 ctx 是你自己递过去的。

并排看这两段等价逻辑(都是”在子任务里打带 traceId 的日志”):

// Go:ctx 显式传参,自然带进子 goroutine
func handle(ctx context.Context, orderId string) {
    ctx = context.WithValue(ctx, "traceId", newTraceId())  // 把 traceId 塞进 ctx
    go worker(ctx, orderId)                                // ✅ ctx 显式传给 goroutine
}
 
func worker(ctx context.Context, orderId string) {
    tid := ctx.Value("traceId")                            // 子 goroutine 里从 ctx 取,不丢
    slog.Info("处理订单", "traceId", tid)
}
// Java:MDC 靠 ThreadLocal 隐式,跨线程池必丢
void handle(String orderId) {
    MDC.put("traceId", newTraceId());          // 塞进当前线程的 ThreadLocal
    pool.submit(() -> {                        // ❌ 新线程,ThreadLocal 为空
        log.info("处理订单");                  // traceId 是空的
    });
}
// ✅ 必须手动/靠框架把 MDC 快照搬进子线程(见 22.4 修复代码)

问题 5: 既然 Go 的 ctx 模型在异步传递上更顺,那 Java 的 MDC 模型是不是”设计错误”?

不是设计错误,是各自生态的取舍。Java 的 MDC 让你”业务代码里完全不碰 traceId,框架在 Filter 层注入、日志自动带”——隐性但省事,代价是异步/线程池处必丢、需框架补救。Go 的 ctx 让你”每一层都显式传来传去”——显性但啰嗦,代价是样板代码多、但跨 goroutine 天然不丢。这又是全书反复出现的”显式 vs 隐式”:Go 用显式换”上下文传递可靠”;Java 用隐式换”业务代码干净”,代价是异步处要额外操心。你从 Go 过来,最容易在 Java 的线程池里踩这个坑,因为你的直觉是”ctx 传了就有”,而 Java 这边的等价物 MDC 是”线程传了才有”——线程池恰恰换了线程。


22.9 本章核心结论

如果这一章你只看这一段:

  1. 可观测性三支柱 = 日志(发生了什么)+ 指标(整体怎样)+ 链路(慢在哪一跳)。分布式系统里没有 traceId 就不可能把跨服务日志串起来,排查成本随服务数近似线性爆炸。
  2. 日志用 SLF4J 门面 + Logback/Log4j2 实现,换实现不碰业务代码——和 JDBC、Micrometer 同属”门面模式”,呼应分层与依赖倒置。
  3. MDC 把 traceId / userId 注进每行日志,底层是 ThreadLocal;结构化日志(JsonLayout)把它输出成可检索字段,二者接上才”能查”。
  4. 异步 Appender 不阻塞业务线程,但进程崩会丢队列里的日志;ERROR 只用于需人工介入的故障,否则告警风暴会让真问题淹没。
  5. 指标用 Micrometer 门面,三种仪表:Counter(累计次数)/Gauge(瞬时值)/Timer(耗时+分位);看排错看 p99 不是 avg,平均值会掩盖长尾慢请求。
  6. Prometheus 是拉模型:/actuator/prometheus 暴露、Prometheus 主动来抓,K8s 里天然适配;指标标签必须低基,高基维度(如 userId)会打爆内存。
  7. 链路追踪里 traceId/spanId 跨进程靠协议头(traceparent / X-B3-*)透传;跨线程(线程池/@Async)必丢,因为 ThreadLocal 不跨线程,必须包装执行器传递快照。
  8. Actuator 把状态变端点:/health(K8s 探针)、/metrics、/prometheus(给 Prometheus)、/conditions(第 15 章用过);别 include=* 裸奔,且 /health 只查”死活”不查”健康度”,软故障得自己写 HealthIndicator。

22.10 深度思考题

题 1:如果项目里完全不接链路追踪,只靠”在日志里手动打 requestId”,能替代 traceId 吗?

参考答案

直接答案:能解决”串日志”这 60%,但解决不了”自动画出调用链、自动算每一跳耗时”那 40%。手动 requestId 让你用 grep requestId=xxx 把五个服务的日志捞出来,但每一跳的父子关系、每一跳的 RT、跨线程的自动透传,都得你自己手写且极易漏——链路追踪框架的价值是”自动建 span 树 + 自动算耗时 + 自动跨进程/跨线程透传”。

为什么 requestId 不够:手动 requestId 你要在每个服务入口生成、在每次 RPC 调用时手动塞进 header、在下游手动取出来打日志。五服务、十几个 RPC 调用点,任何一处忘了透传,链就断。而链路追踪框架用 Client/Server 拦截器自动做透传,你业务代码零改动。更关键的是:requestId 只是个字符串,它不携带”我是谁的子调用、我花了多久”这种结构——APM 里那棵 span 树、那个 p99 瀑布图,是框架按 span 的父子关系自动算出来的,手动 requestId 给不了。

更深一层:手动 requestId 是”穷人版 traceId”,适合小项目救急;一旦服务多了、调用深了,框架化链路追踪的 ROI 急剧上升。你从 Go 过来,Go 项目也面临同样选择——context.Context 里塞个 requestId 能救急,但要 span 树和耗时分布,还是得上 OpenTelemetry Go SDK。本质是”字符串 id” vs “带结构的追踪数据”的区别。

题 2:链路追踪会往每个请求头里塞 trace 信息,并且每个 span 都要上报,这对性能有多大影响?什么情况下不该开?

参考答案

直接答案:采样(sampling)机制把开销压到可控范围——默认不是”全量上报每个 span”,而是按采样率(如 10%)上报,且 span 的创建/传播本身是很轻的 in-memory 操作(改 ThreadLocal、写一个 id 到 header),不是每次都网络发包。真正开销在网络上报和存储。所以”全量采样 + 高 QPS”才是危险组合;生产常用”头采样 + 关键链路全采”。

为什么开销可控:trace 的透传(往 header 写个 id)几乎是零成本;span 的创建就是内存里建个对象。成本大头在”把 span 批量上报给收集端(OTel Collector / Jaeger)“和”收集端存储”。所以框架默认采样率不是 100%,而是按比例抽。Spring Boot 配 management.tracing.sampling.probability=0.1 就是 10% 采样。

什么时候谨慎:超高 QPS(十万级 QPS)且全量采样,上报流量和存储都会涨;这时候用”按特征采样”(出错必采、慢请求必采、其余抽采)比无脑全量聪明。另外 span 里别塞大体积属性(如把整个请求 body 当 span 的 tag),那会撑爆存储——body 该进日志,不该进 trace。

更深一层:可观测性三件套都有”开销 vs 收益”的旋钮。日志别开 DEBUG 全量;指标标签别高基(案例三);链路别全量采样。三者的纪律一脉相承:采集要带采样/降维,不然观测系统自己先被自己压垮。

题 3:Micrometer 和 SLF4J 都是”门面”,那能不能只用一个门面统一日志和指标?为什么现实里是两个?

参考答案

直接答案:不能,因为日志和指标的”使用姿势”根本不同,门面要暴露的接口也不同。日志门面(SLF4J)暴露的是 log.info/error 这种”打一条带级别文本”的接口;指标门面(Micrometer)暴露的是 counter/gauge/timer 这种”注册一个可聚合数值源”的接口。两者抽象的是两类完全不同的动作,强行合一只会让接口四不像。

为什么是两个:日志是”事件流”(一次一条、非结构化→结构化文本);指标是”数值源”(持续存在、被周期性抓取、可聚合)。SLF4J 的 Logger 接口里没有”counter”的概念,Micrometer 的 MeterRegistry 里也没有”打一段 error 文本”的概念。它们的生产者语义、消费者语义、存储后端(日志进 ES/Loki、指标进 Prometheus/TSDB)全不一样,所以门面天然是两个。

更深一层:Java 生态的门面化是”按领域切”的——日志(SLF4J)、指标(Micrometer)、JDBC(数据库驱动)、JCache(缓存)、连 tracing 也门面化(Micrometer Tracing)。门面的本质是”在某一类东西里隔离实现”,而”类”是按领域划的,不是按”都是输出”划的。你从 Go 过来,Go 这边没有 SLF4J 这种日志门面(因为不需要),但有 database/sql(数据库门面)、prometheus/client_golang(指标,但它直接对接 Prometheus、不算广义门面)。所以”门面”在 Java 是普遍习惯,在 Go 只在确实需要多实现的领域出现——又是”历史碎片 vs 标准库定调”的差异(呼应 22.2 的【思考】)。

题 4(开放题,无标准答案):你刚接手一个没有任何可观测性基建的 Go 微服务,现在要照着这一章给它”补课”,第一笔该花在日志、指标、还是链路?

【思考】

这道题没有标准答案,但给你一个判断锚点:

  • 如果团队现在最痛的是”出了问题完全不知道哪错了、只能盲猜”——先补日志:统一 slog + 结构化 + 把 requestId/traceId 注进去。性价比最高、改动最小、立刻能查。
  • 如果最痛的是”服务慢了但不知道整体 RT 趋势、不知道是不是量上来了”——先补指标:接 prometheus/client_golang,曝光 QPS/RT/错误率/资源。
  • 如果服务已经多个、最痛的是”一个超时请求不知道卡在哪一跳”——先补链路:上 OpenTelemetry Go SDK。
  • 现实里多数团队的顺序是 日志 → 指标 → 链路,因为日志改动最小、链路接入最重。你从 Java 这章学到的”三件套串成闭环”,在 Go 那边一样成立,只是工具换成 Go 的。

想清楚你的第一笔该花在哪,取决于”你现在最常被哪种问题半夜叫醒”,而不是”哪个最时髦”。

参考答案

直接答案:绝大多数团队的第一笔该花在日志(结构化 + requestId/traceId 注入),因为它改动最小、立刻能查、且是后两者的地基。链路追踪的前提就是”日志里有个能查的 id”,所以日志先行、指标次之、链路最后,是成本和收益最顺的梯度。但具体顺序由”你最常被哪种故障叫醒”决定,不是由时髦程度决定。

为什么日志先行:接入结构化日志 + 在入口注入 requestId、在日志里输出它,通常只是改一处 logger 初始化 + 一个 middleware,业务代码几乎不动。但它直接把”盲猜”变成”能查”,ROI 最高。而且指标和链路最终都要靠这个 id 把人从”哪一跳”引导到”哪一行日志”——没有日志里的 id,链路的 span 树只是棵没叶子的树。

为什么链路最后:链路接入最重(要改 RPC 客户端/服务端拦截器、要起收集端、要管采样),且它的价值建立在”服务多了、调用深了”之上。单体或两三个服务时,日志的 requestId 够用,上链路是过度投资。等服务真多到”跨 5 服务排查半天”,链路的 ROI 才爆发——正是本章案例一的场景。

更深一层:可观测性基建的顺序,是”先补最痛的那块”,不是”按教材顺序全上”。你从 Go 过来,Go 的微服务补课和 Java 完全同构——工具名不同(slog 不是 SLF4J、Prometheus Go client 不是 Micrometer、OTel Go 不是 Micrometer Tracing),但”三件套 + 闭环 + 采样/降维纪律”是一套放之四海皆准的方法。这一章教的不是 Java API,是”分布式系统怎么被观测”这件事本身。


下一章预告

第 23 章是 综合实战:把全套拼起来并给进阶地图。前面 22 章你分别学了 Web 层(第 16 章)、数据库与事务(第 17 章)、MyBatis(第 18 章)、Redis 缓存(第 19 章)、消息队列 Kafka(规划的第 20 章)、源码阅读(规划的第 21 章)、以及这一章的生产可观测性——它们是散的零件。第 23 章让你亲手把它们拼成一个能跑、能构建、能上线的真实服务:从 pom.xml 依赖、自动配置(第 15 章)、一次请求的完整生命周期(第 16 章)、到 DB/缓存/MQ 的协作,再接上这一章的日志/指标/链路三件套,最后用 Actuator + K8s 探针让它能活在生产里。它还会收尾全书:给你一张”从 Go 工程师到能扛起 Java 服务的工程师”的进阶地图,告诉你哪些已经扎实、哪些该继续深挖(JVM 调优、响应式、云原生可观测性的下一步)。学了一路,最后一章让你亲手把它们拼起来,并知道接下来往哪走。