项目中全链路跟踪实践与落地

一句话概括:APM 不是”上个监控系统”,而是回答四个递进的问题——系统健康吗(Metrics)、慢在哪一跳(Traces)、当时到底发生了什么(Logs)、哪里是系统性瓶颈(三者聚合)。这四层缺一层,排查就会退化成人肉翻日志。而把它们串起来的那根线,只有一个东西:一个全局唯一的 traceId


一、先还原一次真实的排查现场

线上告警来了,某个接口开始报错。你会怎么查?

我把我们团队过去的真实动作拆出来,是这样一条路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
告警响了


① 打开配置中心 ──── 这个服务当前是什么配置?连的哪个环境?


② 打开注册中心 ──── 这个服务注册了几个实例?哪些还活着?


③ 打开容器平台 ──── 找到对应的 Deployment


④ 进到 Pod 里 ───── kubectl exec,一个一个进


⑤ grep / less ───── 在几万行日志里翻,凭时间戳猜哪条是这次请求


⑥ 发现是 RPC 报错 ── 下游服务返回了 error


⑦ 换到下游系统 ───── ①②③④⑤ 再走一遍 ← 死循环从这里开始

举个具体例子。一次报错的完整链路是:

1
api-gateway  →  query-service  →  queryByThirdParty  →  error

三跳。为了知道 error 出在第三跳,你要把上面那套动作做三遍

这套流程的平均耗时:15~30 分钟。

注意,这 15~30 分钟里,你一行代码都没改,全部花在”定位问题在哪”上。真正修问题可能只要 2 分钟。

更糟的是,这个耗时是不可压缩的——它不取决于你多熟练,而取决于链路有多长。链路上多一个系统,就多一轮 15 分钟。


二、为什么这么慢:拆出四个根因

慢的表象是”要翻很多地方”。但往下拆,是四个独立的能力缺失:

根因一:系统流量无法统计、无法区分。

谁在调我的接口?调的是哪几个接口?QPS 多少?哪个接口最慢?——全都不知道。没有这层数据,你连”这次故障影响面有多大”都答不上来,只能靠用户投诉量估。

根因二:缺乏请求唯一标识,无法识别一次请求的上下文。

这是最致命的一条。日志里每一行都是孤立的,你没法把”属于同一次请求”的那几十行日志挑出来。所以你只能靠时间戳附近瞎猜,猜错了就再翻一遍。

根因三:日志分散在多个 Pod,且磁盘会打满导致日志丢失。

服务多副本部署,一次请求只落在其中一个 Pod 上。你不知道是哪个,就得每个都进去翻。而且——磁盘满了之后,日志根本没写进去,你翻也翻不到。

根因四:系统整体性能如何、风险点在哪,不知道。

哪个接口是慢查询大户?哪个下游是最脆弱的依赖?容量还能撑多久?没有数据支撑,全靠拍脑袋。

结论:基础建设太差,工具不成体系,人工运维成本大,效率低。

请注意”不成体系“这四个字。我们当时并不是完全没有工具——配置中心有、注册中心有、容器平台有、日志文件也在。问题是它们互相之间没有一根线串起来,每个工具只能回答自己那一小块,跨工具的关联全靠人脑去拼。


三、APM 到底解决什么:四个递进的问题

APM = Application Performance Monitoring,应用性能监控。

但”应用性能监控”这个翻译其实误导性很强,容易让人以为它就是个”看 CPU 内存曲线的看板”。

它真正的价值在于:它是一套分层递进的能力。

层次 要回答的问题 对应能力 数据基础
① 发现 系统现在健康吗? 告警、健康看板 Metrics
② 定位 这次慢/错发生在哪一跳?哪个服务、哪行代码? 链路瀑布图、调用拓扑、依赖分析 Traces
③ 解释 当时具体发生了什么?参数是什么?为什么走了这个分支? 异常堆栈、上下文日志、业务参数 Logs
④ 优化 哪里是系统性瓶颈?容量还能撑多久? 慢 SQL 治理、性能剖析、容量规划 三者聚合分析

这张表是整套方案的骨架,值得多看两眼。

关键在”递进“两个字——这四层不是四个并列的功能,而是一条有严格先后顺序的链

  • 没有 ① 你不知道要查(问题发生了 20 分钟你还不知道)
  • 没有 ② 你不知道去哪查(知道有问题,但不知道在哪一跳)
  • 没有 ③ 你查到了也看不懂(知道是这个方法慢,但不知道当时参数是什么)
  • 没有 ④ 你永远在救火,不会变好(每次都修一个点,系统性风险从不消除)

很多团队上 APM 只做了 ①,配了一堆 CPU、内存告警,然后发现”告警很响,但故障还是要查半小时”——因为 ②③ 缺位,① 产出的只是一个更快的”你有麻烦了”通知,并没有缩短定位时间。

下面按这四层依次说落地。


四、第一层 Metrics:先知道”健不健康”

Metrics 是聚合后的数值,特点是数据量小、查询快、适合做告警和趋势。

指标与维度是两件事

这里有个很多人会混的概念:指标(Metric)维度(Dimension / Label)

  • 指标:被测量的那个数值。请求量、错误数、耗时、连接数。
  • 维度:给这个数值切片的标签。服务名、接口名、状态码、实例 IP、环境。
1
2
3
4
5
6
7
指标: http_server_requests_seconds_count  = 12843

├── 维度 service = query-service
├── 维度 uri = /api/v1/query/{id}
├── 维度 method = GET
├── 维度 status = 200
└── 维度 instance = 10.0.3.17:8080

有了维度,”系统健康吗”才能细化成”query-service 的 /api/v1/query 接口,在生产环境,5xx 比例是多少“。这正好回答了根因一里的”谁在用、用哪些接口”。

选指标不用自己发明,业界有成熟方法论直接抄:

方法论 关注 具体指标
RED 服务对外表现 Rate(请求速率)、Errors(错误率)、Duration(耗时分布)
USE 资源饱和度 Utilization(使用率)、Saturation(饱和度)、Errors
四个黄金信号 Google SRE 提法 延迟、流量、错误、饱和度

对业务服务,先把 RED 三个指标铺满,就已经能解决 80% 的”系统健康吗”。

⚠️ 维度必须低基数。这是 Metrics 最容易踩的坑:绝对不能把用户 ID、订单号、时间戳这类值放进维度。每个不同取值都会生成一条独立时间序列,几万个用户 ID 就是几万条序列,存储和查询会直接爆掉。这个坑在 Traces 的 span 命名里也一样存在,后面还会再提一次。

打点打在哪:统一网关是性价比最高的位置

铺 Metrics 最现实的问题是:几十个服务,难道要一个一个改代码埋点?

不用。流量入口的统一网关,是投入产出比最高的打点位置

1
2
3
4
5
6
7
8
9
10
11
                 ┌───────────────────────┐
所有外部流量 ───▶ │ 统一网关 │───┐
│ 在这里统一打点: │ ├──▶ service-A
│ · 请求量 / 错误率 │ ├──▶ service-B
│ · 耗时 P50/P95/P99 │ └──▶ service-C
│ · 调用方身份 app-id │
│ · 生成 traceId ★ │
└───────────┬───────────┘


Prometheus ──▶ Grafana

在网关这一层做三件事,一次性解决多个问题:

  1. 统一打点 —— 不改任何业务代码,所有接口的 RED 指标全部拿到
  2. 识别调用方 —— 通过 app-id 之类的身份标识,直接回答”谁在用我的接口”
  3. 生成 traceId(这条最关键)—— 网关是全链路的第一跳,在这里生成 traceId 并往下传,整条链路才有统一的标识

第 3 点是承上启下的:它既是 Metrics 的一部分,也是下一层 Traces 的起点。

想直接看效果长什么样,不必自己搭:Grafana 官方提供了公开的演示实例 play.grafana.org,里面有现成的服务监控看板可以随便点。


五、第二层 Traces:定位”慢在哪一跳”

Metrics 告警了,你知道 /api/v1/query 错误率涨到 5%。然后呢?

Metrics 只能告诉你”哪个接口有问题”,永远不能告诉你”为什么”——因为它是聚合值,聚合的过程中,单次请求的信息已经被抹掉了。

回到根因二:没有唯一标识,就无法识别一次请求的上下文,分析难度大大增加。

日志长这样的时候,你是没法分析的:

1
2
3
4
5
10:23:41.201 [http-nio-8080-exec-3] INFO  QueryService - 开始查询
10:23:41.203 [http-nio-8080-exec-7] INFO QueryService - 开始查询
10:23:41.288 [http-nio-8080-exec-3] INFO RpcClient - 调用下游
10:23:41.290 [http-nio-8080-exec-9] ERROR QueryService - 查询失败
10:23:41.301 [http-nio-8080-exec-7] INFO RpcClient - 调用下游

哪一行和哪一行属于同一次请求?靠线程名勉强能猜,但只要中间过了线程池、过了异步、过了另一个服务,线索立刻断掉。

所以第一件事是:需要一个唯一标识,来标记同一次请求产生的所有日志。

数据模型:整个链路追踪只有两个概念

链路追踪听起来复杂,但它的数据模型极简,只有两个基本概念:

  • Trace:一次完整的请求。由一个全局唯一的 trace_id 标识。
  • Span:请求中的一个工作单元——一次 HTTP 调用、一次 SQL 查询、一次 Redis 操作、一个方法执行。由 span_id 标识。

Span 之间通过 parent_span_id 建立父子关系,于是一个 Trace 内的所有 Span 就构成了一棵调用树。后端拿到这棵树,按开始时间和耗时渲染出来,就是我们熟悉的耗时瀑布图

1
2
3
4
5
6
7
8
Trace: trace_id = 4bf92f3577b34da6a3ce929d0e0e4736

Span A api-gateway GET /api/v1/query/{id} ├───────────────────────────┤ 820ms
Span B ├─ query-svc queryById ···├──────────────────────┤ 790ms
Span C │ ├─ MySQL SELECT ... ······├──┤ 45ms
Span D │ └─ 3rd queryByThirdParty ·········├──────────────────┤ 700ms ← 元凶
Span E └─ Redis GET cache:query:8823 ···├─┤ 8ms
└──── 时间轴 ────────────────▶

看这张图,结论是秒级得出的:总耗时 820ms,其中 700ms 花在第三方调用上

对比第一节那个 15~30 分钟的流程——这就是 Traces 的全部价值。

Span 里都有什么:字段决定了你能查什么

一个 Span 具体带哪些字段,直接决定了你排查时能拿到多少信息:

字段 含义 排查时的作用
trace_id 全链路唯一标识 聚合的依据;也是日志关联的钥匙
span_id / parent_span_id 自身标识与父节点指针 还原调用树,判断是谁调用了谁
name 操作名,如 GET /order/{id} 聚合统计的维度。注意必须是低基数(用路由模板,不能带具体 ID)
kind SERVER / CLIENT / PRODUCER / CONSUMER / INTERNAL 决定拓扑图的方向;也用于区分”服务端耗时”与”客户端观测耗时”
start / end_time 纳秒级时间戳 算耗时;父子耗时之差即为未被观测的时间(网络、序列化、排队)
status UNSET / OK / ERROR 尾部采样的核心依据;错误率统计的来源
attributes 键值对:db.statementhttp.status_code 定位到具体 SQL、具体下游地址。最有信息量的部分
events 时间点事件,如异常堆栈 异常发生的精确时刻与完整堆栈

三个字段值得单独强调:

name 必须低基数。 GET /order/{id} 是对的,GET /order/12345 是错的。后者会让每个订单号都生成一个独立的操作名,聚合统计彻底失效——和 Metrics 维度的坑完全同源。

父子耗时之差是个金矿。 父 Span 800ms,子 Span 加起来只有 300ms,那剩下 500ms 去哪了?答案通常是:网络传输、序列化/反序列化、线程池排队、GC 停顿。这类”没有任何代码在执行但时间在流逝”的问题,只有靠这个差值才能发现。

status 决定采样策略。 全量存 Trace 成本太高,必然要采样。而采样分两种:

采样方式 决策时机 优点 代价
头部采样(Head) Span 创建时就决定 实现简单、开销低 决策时还不知道这次请求会不会失败,可能把出错的链路丢掉
尾部采样(Tail) 整个 Trace 结束后决定 可以”只保留出错和慢的链路” 同一 Trace 的所有 Span 必须汇聚到同一个采集节点,且需要缓冲

生产环境的常见组合:正常链路按比例头部采样(比如 1%),status=ERROR 或耗时超阈值的链路 100% 保留。这样既控住成本,又不会丢掉真正想看的那些。


六、把 traceId 落到日志里:MDC 与 ThreadLocal

有了 trace_id,还差最后一步——必须让它出现在每一行日志里。否则 Traces 和 Logs 依然是两座孤岛。

日志格式

改造后的日志 pattern:

1
[%d{yyyy-MM-dd HH:mm:ss:SSS}]|%X{traceId}|%X{x-app-id}|%X{clientIp}|%X{uri}|[%level]|%logger{1}|%msg%n

拆开看每一段的用意:

片段 作用
%d{...:SSS} 毫秒级时间戳,和 Trace 的时间轴对齐
%X{traceId} 核心,从 MDC 取出的链路 ID
%X{x-app-id} 调用方身份,回答”谁在用”
%X{clientIp} 来源 IP
%X{uri} 请求路径
%level %logger{1} %msg 常规日志三要素

| 做分隔符而不是空格,是为了让日志平台能直接按固定列切分,不用写复杂正则。

效果是这样的——同一次请求的所有日志,前缀完全一致:

1
2
3
[2026-09-03 10:23:41:201]|4bf92f35...4736|order-web|10.0.2.5|/api/v1/query/8823|[INFO]|QueryService|开始查询
[2026-09-03 10:23:41:288]|4bf92f35...4736|order-web|10.0.2.5|/api/v1/query/8823|[INFO]|RpcClient|调用下游 third-party
[2026-09-03 10:23:41:988]|4bf92f35...4736|order-web|10.0.2.5|/api/v1/query/8823|[ERROR]|QueryService|查询失败 timeout

拿着 trace_id 一搜,一次请求的完整故事就全出来了。 第一节那个”凭时间戳猜哪条日志”的动作,到这里彻底消失。

%X{} 从哪来:MDC 的底层是 ThreadLocal

%X{traceId} 里的 %X 是 logback 的 MDC(Mapped Diagnostic Context)取值语法。而 MDC 的实现,本质就是一个 ThreadLocal<Map>

理解 ThreadLocal 是理解这套机制的关键,它的特点一句话说完:

哪个线程 set,哪个线程才能 get。

1
2
3
4
5
6
7
8
9
10
11
12
13
     线程 A                            线程 B
│ │
set(traceId=X) set(traceId=Y)
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ ThreadLocalMap│ │ ThreadLocalMap│
│ traceId → X │ │ traceId → Y │
└───────┬───────┘ └───────┬───────┘
│ │
get() → X get() → Y
│ │
└────── 互不干扰,各自隔离 ─────────┘

每个 Thread 对象内部持有一个 ThreadLocalMap,key 是 ThreadLocal 实例本身,value 是你存的值。所以天然隔离,不需要加锁。

这里带出两个必须搞清楚的基础问题,它们直接关系到会不会写出内存泄漏。

问题一:Java 有哪几种引用类型?

引用类型 回收时机 典型用途
强引用 Strong 永不回收(只要可达) Object o = new Object()
软引用 Soft 内存不足时回收 内存敏感的缓存
弱引用 Weak 下次 GC 必回收 ThreadLocalMap 的 key
虚引用 Phantom 随时可能被回收,只用于回收通知 堆外内存清理

ThreadLocalMapkey 是弱引用(指向 ThreadLocal 实例),但 value 是强引用。这个设计不对称,正是泄漏的根源:

外部对 ThreadLocal 的强引用消失后,key 会在下次 GC 被回收变成 null,但 value 还被 Entry 强引用着。如果这个线程是线程池里的常驻线程,它永远不销毁,这个 value 就永远回收不掉。

结论:用完必须 remove(),通常放在拦截器的 finallyafterCompletion 里。

问题二:OOM 和 Memory Leak 有什么区别?

这两个概念经常被混为一谈,但它们完全不是一回事:

Memory Leak(内存泄漏) OOM(内存溢出)
是什么 对象已经没用了,但仍被引用,GC 回收不掉 申请内存时可用内存不足,JVM 抛 OutOfMemoryError
关系 原因 结果(之一)
表现 内存缓慢增长,Full GC 后也降不下来 服务直接崩溃
必然性 泄漏量小可能一直不 OOM 一次性申请过大也会 OOM,未必有泄漏

一句话:内存泄漏是慢性病,OOM 是猝死。泄漏积累到超过堆上限就表现为 OOM;但 OOM 不一定由泄漏导致(比如一次性 new 一个巨大数组)。

ThreadLocal 忘记 remove() 加上线程池常驻线程,是 Java Web 应用里最经典的泄漏组合之一。而 traceId 恰好就是用 ThreadLocal 传的,所以这个坑必须提前知道。

集成 logback

落地只有两步。

第一步:在请求入口把 traceId 放进 MDC,出口清掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class TraceIdInterceptor implements HandlerInterceptor {

private static final String TRACE_ID = "traceId";

@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
// 优先复用上游传来的 traceId,保证跨系统是同一条链路
String traceId = req.getHeader("X-Trace-Id");
if (traceId == null || traceId.isEmpty()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put(TRACE_ID, traceId);
MDC.put("x-app-id", req.getHeader("X-App-Id"));
MDC.put("clientIp", getClientIp(req));
MDC.put("uri", req.getRequestURI());
return true;
}

@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
Object handler, Exception ex) {
// 必须清理!否则线程池复用线程时会串号 + 内存泄漏
MDC.clear();
}
}

第二步:在 logback-spring.xml 里用 %X{} 引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<configuration>
<property name="LOG_PATTERN"
value="[%d{yyyy-MM-dd HH:mm:ss:SSS}]|%X{traceId}|%X{x-app-id}|%X{clientIp}|%X{uri}|[%level]|%logger{1}|%msg%n"/>

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/data/logs/app.log</file>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/data/logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
</appender>

<root level="INFO">
<appender-ref ref="FILE"/>
</root>
</configuration>

totalSizeCap 这一行很重要,原因见第九节。

⚠️ 异步场景必踩的坑:因为 ThreadLocal 是”哪个线程 set 哪个线程才能 get”,一旦业务代码里出现 @Async、线程池、CompletableFuture、并行流,新线程的 MDC 是空的,traceId 就断了。解决办法是包装线程池,在任务提交时把父线程的 MDC 快照传过去(父线程 MDC.getCopyOfContextMap() → 子线程 MDC.setContextMap()),或者直接用 TransmittableThreadLocal 这类现成方案。


七、跨系统统一 traceId:从自研走向 OpenTelemetry

到这一步,单个系统内部的链路已经串起来了。但回到第一节的死循环——问题恰恰出在跨系统那一跳

每个系统各自封闭,且链路长,需要逐一排查,耗时长。

如果 A 系统用 X-Trace-Id,B 系统用 X-Request-Id,C 系统压根不透传,那么链路会在每个边界断掉:

1
2
3
4
5
  A 系统                B 系统                C 系统
traceId=X ──▶ traceId=Y ──▶ traceId=Z
│ │ │
└───── 三段独立的链路,无法拼接 ──────────┘
依然要逐个系统排查

所以必须跨系统统一 traceId。 而”统一”意味着要有一个各方都认的标准,不能各写各的。

标准:W3C Trace Context

这件事业界已经标准化了。W3C Trace Context 定义了两个 HTTP Header,目前已是 W3C Recommendation:

1
2
3
4
5
6
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ │ │ └─ trace-flags:01 = 已采样
│ │ └─ parent-id:调用方的 span-id,8 字节 16 位十六进制
│ └─ trace-id:全链路唯一,16 字节 32 位十六进制
└─ version:固定 00

traceparent 在 version 00 下长度恰好是 55 个 ASCII 字节,格式严格。另有一个可选的 tracestate 头,供各厂商附加自己的数据。

在这个标准之前,各家用的是自己的私有头,互不兼容:

体系 使用的 Header
Zipkin X-B3-TraceId / X-B3-SpanId / X-B3-Sampled
Jaeger uber-trace-id
AWS X-Ray X-Amzn-Trace-Id
W3C 标准 traceparent / tracestate

历史包袱重的环境可以同时注册多个 propagator(W3C + B3)过渡,新系统直接上 W3C 就行。

实现:OpenTelemetry

OpenTelemetry(简称 Otel) 是 CNCF 旗下项目,目前已是厂商中立的事实标准。它把 Metrics、Traces、Logs 三件事统一成了一套 API + SDK + 协议(OTLP),默认的传播格式就是 W3C Trace Context。

它的价值在于解耦:应用只依赖 OTel API 埋点,后端想换 Jaeger、Tempo 还是商业 APM,都不用改一行业务代码。

整体架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌──────────┐  ┌──────────┐  ┌──────────┐
│ A 系统 │ │ B 系统 │ │ C 系统 │ ← 各自接入 OTel SDK
│ OTel SDK │ │ OTel SDK │ │ OTel SDK │ (Java 可用 javaagent 零代码接入)
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ traceparent 透传 │
└─────────────┬──────────────┘
│ OTLP 协议

┌────────────────────────┐
│ OTel Collector │ ← 统一做尾部采样、K8s 属性补全、
│ (agent + gateway) │ 脱敏、批量导出
└───────┬────────┬───────┘
│ │
┌────────▼──┐ ┌──▼─────────┐
│ Traces │ │ Metrics │
│ Jaeger / │ │ Prometheus │
│ Tempo │ │ │
└───────────┘ └────────────┘

Collector 这一层是精髓——采样策略、属性补全、脱敏这些横切逻辑,全部收敛在这里,不污染业务代码。前面说的尾部采样”必须让同一 Trace 的所有 Span 汇聚到同一节点”,也是在 Collector 的 gateway 层解决的。

落地节奏上建议先测试环境跑通再上生产:测试环境验证 SDK 版本兼容性、埋点覆盖度、性能开销(通常在个位数百分比的 CPU 增量);生产环境再按核心链路灰度接入。

想看真实效果,不需要自己搭环境,官方公开 demo 直接可用:

  • Jaeger HotROD —— Jaeger 官方教学 demo,模拟打车下单链路,能直接看到瀑布图和调用拓扑
  • OpenTelemetry Demo —— opentelemetry.io/docs/demo,一个完整的微服务电商系统,十几种语言混合,Metrics / Traces / Logs 全打通
  • Grafana Play —— play.grafana.org,公开的 Grafana 实例

顺带一句,分布式追踪这套东西的源头是 Google 2010 年的 Dapper 论文,Trace / Span / 采样这些概念都出自那篇文章。后来的 Zipkin(Twitter)、Jaeger(Uber)都是它的开源实现。想理解设计取舍,读原论文比读任何二手文章都快。


八、第三层 Logs:解释”到底发生了什么”

Traces 告诉你 queryByThirdParty 这一跳花了 700ms。但为什么慢?当时传的什么参数?走了哪个分支?返回了什么错误码?

这些只有日志能回答。Trace 给的是骨架,Log 给的是血肉

回到根因三:日志分散到多个 Pod,定位困难。

1
2
3
4
5
6
7
8
┌────────┐  ┌────────┐         ┌────────┐
│ Pod1 │ │ Pod2 │ ... │ PodN │
│ app.log│ │ app.log│ │ app.log│
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
└───────────┴──────────────────┘
一次请求只落在其中一个,但你不知道是哪个
→ 逐个 kubectl exec 进去 grep

所以要把日志收集起来,集中存储、集中检索。

外部可选方案很多,能力上大同小异:

方案 组合 特点
ELK Filebeat + Elasticsearch + Kibana 生态最成熟,全文检索强,资源占用偏高
Grafana Loki Promtail / Alloy + Loki + Grafana 只索引标签不索引全文,成本低很多,和 Grafana 天然一体
云厂商日志服务 各家日志类产品 免运维,按量付费

不管选哪个,架构都是这四段:

1)采集:Filebeat + Sidecar 打通 Pod 日志

容器里的日志怎么拿出来?主流做法是 Sidecar 模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌──────────────── Pod ─────────────────┐
│ │
│ ┌────────────┐ ┌─────────────┐ │
│ │ 业务容器 │ │ Filebeat │ │
│ │ │ │ (Sidecar) │ │
│ │ 写日志 ──▶│ │ ──▶ 读日志 │ │
│ └─────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ └───── emptyDir ───┘ │ ← 共享卷,两个容器都能访问
│ /data/logs │
└──────────────────┬───────────────────┘
│ 输出

日志平台(Kafka / ES / Loki)

关键点是共享卷emptyDir):业务容器往 /data/logs 写,Filebeat 挂载同一个卷来读。业务容器完全不用感知采集这件事,也不用引任何 SDK。

另一种是 DaemonSet 模式——每个 Node 上跑一个采集器,读所有容器的 stdout。更省资源,但灵活性差一些。

2)接入管理:先把元信息规范掉

采集器配好了,还要在平台侧登记:这个应用叫什么、属于哪个业务、日志在哪个路径、怎么切分字段、保留多久、谁有权限查。

这一步看着琐碎,但直接决定后面能不能查得动。字段没切对,traceId 就成了一坨文本中间的字符串,只能全文模糊匹配,慢且不准。前面日志格式用 | 分隔,好处在这里体现——按固定分隔符切列,配置一行就够,不用写正则。

3)存储:冷热分层控成本

日志是典型的”写多读少、越新越常读”。所以务必分层:

层级 保留 存储介质 典型用途
3~7 天 SSD / ES 热节点 日常排查,全字段可检索
7~30 天 HDD / ES 温节点 追溯近期问题
30 天以上 对象存储 审计、合规、安全事故追溯

冷层这一档特别容易被忽略,但它往往是最后的救命稻草——安全事故排查经常要翻一两个月前的日志。

4)查询检索:以 traceId 为入口

前面所有工作,都是为了让这一步变成一次搜索:

1
2
3
4
搜索框: traceId = "4bf92f3577b34da6a3ce929d0e0e4736"


返回:这次请求在【所有服务、所有 Pod】上产生的全部日志,按时间排序

至此,第一节那个 15~30 分钟的流程被压缩成:

1
2
告警 → 点开 Trace 看瀑布图(定位到哪一跳)→ 用 trace_id 搜日志(看到当时的参数和堆栈)
⏱ 分钟级

这才是 APM 的真正交付物:不是几张看板,而是排查路径从”翻 N 个系统”变成”两次点击”。


九、一个血的教训:磁盘打满,日志丢了

这一节单独拎出来,因为它是我们真实踩过的坑,而且代价很大

背景:一次安全事故需要排查,运维去翻日志——发现日志没有。磁盘早就打满了,日志根本没写进去。

这个场景有多糟糕:你花了大量精力建可观测体系,结果最需要它的那一刻,数据是空的。

原因通常是三个叠加:

  1. 磁盘容量按”正常量”估的,没算异常情况。一旦某个接口疯狂报错,ERROR 日志加堆栈能在几小时内打满几十 G
  2. 没配日志滚动上限。只配了按天切分,没配 totalSizeCap,文件只增不减
  3. 没有磁盘水位告警。满了都不知道,直到要用时才发现

解决方案是两件事同时做:扩大磁盘 + 修正 log 配置。

扩容是止血,配置才是治本。核心是三个参数(对应第六节 logback 配置里的那几行):

配置项 作用 建议
maxFileSize 单文件大小上限 100~200MB,太大不好传输和检索
maxHistory 保留多少个历史文件 7 天(更久的靠日志平台的冷存储)
totalSizeCap 所有日志文件总大小上限 必配,超出自动删最老的

totalSizeCap 是最关键的一条——它是磁盘不被打满的最后一道防线。很多团队只配前两个,结果 maxHistory 按天算,遇到日志暴涨的一天,单天就能吃掉整块盘。

另外三条一并做掉:

  • 磁盘水位告警:80% 就告警,别等 100%
  • 日志分级DEBUG 生产环境关掉;高频接口的 INFO 考虑采样打印
  • 本地只做缓冲:日志被采集走之后,本地文件的价值就下降了,本地保留策略可以更激进

一个观念上的修正:日志的可靠性本身是一个需要设计的工程问题,不是”配个 logback 就完事了”。日志平台建得再好,采集不到就是零。


十、第四层 优化:从”救火”到”治本”

前三层都是被动响应——出了问题快速定位。第四层是主动发现:在故障发生之前,找出系统性瓶颈。

这一层的特点是:它不依赖新数据,而是把前三层的数据聚合起来分析

分析方向 数据来源 能发现什么
慢 SQL 治理 Span 的 db.statement 属性聚合 哪些 SQL 是慢查询大户、缺索引、有全表扫描
依赖脆弱性分析 调用拓扑 + 各下游的错误率 / 耗时 哪个下游最不稳定、哪里缺熔断降级
未观测时间分析 父子 Span 耗时之差 序列化开销、线程池排队、GC 停顿、网络延迟
性能剖析 Profiling CPU / 内存火焰图 具体哪个方法吃 CPU、哪里在频繁分配对象
容量规划 Metrics 长期趋势 + 压测数据 当前配置还能撑多少 QPS、什么时候该扩容

举个最典型的收益路径:把所有 Span 的 db.statement 按耗时 P99 排序,Top 10 慢 SQL 立刻浮出水面。这些 SQL 平时不会告警(还没到超时),但它们就是系统的定时炸弹——流量一涨就集体劣化。

这一层是 APM 从”运维工具”变成”架构治理工具”的分界线。 前三层让你不再手忙脚乱,第四层才让系统真正变好。


十一、总结与落地节奏

回头看整条线,其实就一句话:

日志 + 全链路 traceId 统一。

这两件事做成了,第一节那个 15~30 分钟的死循环就被拆掉了。其他能力都是在这个基础上叠加。

四层能力和它们解决的根因,对应关系是完全闭合的:

层次 数据 解决的根因
① 发现 Metrics 根因一:流量无法统计区分
② 定位 Traces 根因二:缺乏请求唯一标识
③ 解释 Logs 根因三:日志分散、丢失
④ 优化 三者聚合 根因四:性能瓶颈未知

建议的落地顺序

不要想着一次性铺完,四层同时上必然烂尾。按投入产出比排序:

第一步:traceId 打通(收益最大,成本最低)
在统一网关生成 traceId,透传格式直接用 W3C traceparent,各系统接入 MDC 打进日志。这一步不需要任何平台,改造量小,但立刻能把”猜日志”变成”搜日志”

第二步:日志集中 + 磁盘兜底
接一个日志平台(ELK 或 Loki),同时把 totalSizeCap 和磁盘告警配上。第九节那个坑,越早堵越好。

第三步:Metrics 铺 RED
网关层统一打点,先把请求量、错误率、耗时分布铺满,配基础告警。

第四步:Traces 接 OpenTelemetry
Java 服务优先用 javaagent 零代码接入,先测试环境验证,再生产灰度。Collector 上尾部采样。

第五步:聚合分析与治理
前四步的数据攒够了,再做慢 SQL 治理、容量规划、Profiling。

落地清单

真正开工时,这些是容易漏的检查项:

  • 统一网关生成 traceId,且优先复用上游传来的(否则跨系统还是断)
  • 透传格式统一为 W3C traceparent,不要自创 Header
  • Span 的 name、Metrics 的维度,全部检查低基数
  • 线程池 / @Async / CompletableFuture 场景的 MDC 传递
  • MDC 在拦截器 finally 里清理,防止串号与内存泄漏
  • 日志 totalSizeCap + 磁盘水位告警
  • 采样策略:正常链路按比例,ERROR 与慢链路全留
  • 冷存储保留 30 天以上,为安全事故追溯留后路
  • 敏感字段在 Collector 层脱敏,不要进存储

最后说一句我自己的体会。

可观测性建设最大的阻力,从来不是技术难度,而是”它不产出业务价值”这个印象。 埋点、接 SDK、配采集,这些活儿在需求排期里永远排在最后。

但换个算法:一次故障排查 20 分钟,一周 5 次,一个月就是 400 分钟——将近 7 个小时纯粹浪费在”找问题在哪”上。而这 7 小时里,你的系统正在带病运行,用户正在受影响

这笔账,任何时候算都是划算的。

我们自己的落地还在推进中:配置管理那块已经相对完善,核心业务链路还在往下铺。这篇算是把踩过的坑和想清楚的思路先沉淀下来——先有 traceId,再谈可观测