项目中全链路跟踪实践与落地
一句话概括:APM 不是”上个监控系统”,而是回答四个递进的问题——系统健康吗(Metrics)、慢在哪一跳(Traces)、当时到底发生了什么(Logs)、哪里是系统性瓶颈(三者聚合)。这四层缺一层,排查就会退化成人肉翻日志。而把它们串起来的那根线,只有一个东西:一个全局唯一的 traceId。
一、先还原一次真实的排查现场
线上告警来了,某个接口开始报错。你会怎么查?
我把我们团队过去的真实动作拆出来,是这样一条路径:
1 | 告警响了 |
举个具体例子。一次报错的完整链路是:
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 | 指标: http_server_requests_seconds_count = 12843 |
有了维度,”系统健康吗”才能细化成”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 | ┌───────────────────────┐ |
在网关这一层做三件事,一次性解决多个问题:
- 统一打点 —— 不改任何业务代码,所有接口的 RED 指标全部拿到
- 识别调用方 —— 通过
app-id之类的身份标识,直接回答”谁在用我的接口” - 生成 traceId(这条最关键)—— 网关是全链路的第一跳,在这里生成 traceId 并往下传,整条链路才有统一的标识
第 3 点是承上启下的:它既是 Metrics 的一部分,也是下一层 Traces 的起点。
想直接看效果长什么样,不必自己搭:Grafana 官方提供了公开的演示实例 play.grafana.org,里面有现成的服务监控看板可以随便点。
五、第二层 Traces:定位”慢在哪一跳”
Metrics 告警了,你知道 /api/v1/query 错误率涨到 5%。然后呢?
Metrics 只能告诉你”哪个接口有问题”,永远不能告诉你”为什么”——因为它是聚合值,聚合的过程中,单次请求的信息已经被抹掉了。
回到根因二:没有唯一标识,就无法识别一次请求的上下文,分析难度大大增加。
日志长这样的时候,你是没法分析的:
1 | 10:23:41.201 [http-nio-8080-exec-3] INFO QueryService - 开始查询 |
哪一行和哪一行属于同一次请求?靠线程名勉强能猜,但只要中间过了线程池、过了异步、过了另一个服务,线索立刻断掉。
所以第一件事是:需要一个唯一标识,来标记同一次请求产生的所有日志。
数据模型:整个链路追踪只有两个概念
链路追踪听起来复杂,但它的数据模型极简,只有两个基本概念:
- Trace:一次完整的请求。由一个全局唯一的
trace_id标识。 - Span:请求中的一个工作单元——一次 HTTP 调用、一次 SQL 查询、一次 Redis 操作、一个方法执行。由
span_id标识。
Span 之间通过 parent_span_id 建立父子关系,于是一个 Trace 内的所有 Span 就构成了一棵调用树。后端拿到这棵树,按开始时间和耗时渲染出来,就是我们熟悉的耗时瀑布图。
1 | Trace: trace_id = 4bf92f3577b34da6a3ce929d0e0e4736 |
看这张图,结论是秒级得出的:总耗时 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.statement、http.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 | [2026-09-03 10:23:41:201]|4bf92f35...4736|order-web|10.0.2.5|/api/v1/query/8823|[INFO]|QueryService|开始查询 |
拿着 trace_id 一搜,一次请求的完整故事就全出来了。 第一节那个”凭时间戳猜哪条日志”的动作,到这里彻底消失。
%X{} 从哪来:MDC 的底层是 ThreadLocal
%X{traceId} 里的 %X 是 logback 的 MDC(Mapped Diagnostic Context)取值语法。而 MDC 的实现,本质就是一个 ThreadLocal<Map>。
理解 ThreadLocal 是理解这套机制的关键,它的特点一句话说完:
哪个线程 set,哪个线程才能 get。
1 | 线程 A 线程 B |
每个 Thread 对象内部持有一个 ThreadLocalMap,key 是 ThreadLocal 实例本身,value 是你存的值。所以天然隔离,不需要加锁。
这里带出两个必须搞清楚的基础问题,它们直接关系到会不会写出内存泄漏。
问题一:Java 有哪几种引用类型?
| 引用类型 | 回收时机 | 典型用途 |
|---|---|---|
| 强引用 Strong | 永不回收(只要可达) | Object o = new Object() |
| 软引用 Soft | 内存不足时回收 | 内存敏感的缓存 |
| 弱引用 Weak | 下次 GC 必回收 | ThreadLocalMap 的 key |
| 虚引用 Phantom | 随时可能被回收,只用于回收通知 | 堆外内存清理 |
ThreadLocalMap 的 key 是弱引用(指向 ThreadLocal 实例),但 value 是强引用。这个设计不对称,正是泄漏的根源:
外部对 ThreadLocal 的强引用消失后,key 会在下次 GC 被回收变成 null,但 value 还被 Entry 强引用着。如果这个线程是线程池里的常驻线程,它永远不销毁,这个 value 就永远回收不掉。
结论:用完必须 remove(),通常放在拦截器的 finally 或 afterCompletion 里。
问题二: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 | public class TraceIdInterceptor implements HandlerInterceptor { |
第二步:在 logback-spring.xml 里用 %X{} 引用。
1 | <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 | A 系统 B 系统 C 系统 |
所以必须跨系统统一 traceId。 而”统一”意味着要有一个各方都认的标准,不能各写各的。
标准:W3C Trace Context
这件事业界已经标准化了。W3C Trace Context 定义了两个 HTTP Header,目前已是 W3C Recommendation:
1 | traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 |
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 | ┌──────────┐ ┌──────────┐ ┌──────────┐ |
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 | ┌────────┐ ┌────────┐ ┌────────┐ |
所以要把日志收集起来,集中存储、集中检索。
外部可选方案很多,能力上大同小异:
| 方案 | 组合 | 特点 |
|---|---|---|
| ELK | Filebeat + Elasticsearch + Kibana | 生态最成熟,全文检索强,资源占用偏高 |
| Grafana Loki | Promtail / Alloy + Loki + Grafana | 只索引标签不索引全文,成本低很多,和 Grafana 天然一体 |
| 云厂商日志服务 | 各家日志类产品 | 免运维,按量付费 |
不管选哪个,架构都是这四段:
1)采集:Filebeat + Sidecar 打通 Pod 日志
容器里的日志怎么拿出来?主流做法是 Sidecar 模式:
1 | ┌──────────────── Pod ─────────────────┐ |
关键点是共享卷(emptyDir):业务容器往 /data/logs 写,Filebeat 挂载同一个卷来读。业务容器完全不用感知采集这件事,也不用引任何 SDK。
另一种是 DaemonSet 模式——每个 Node 上跑一个采集器,读所有容器的 stdout。更省资源,但灵活性差一些。
2)接入管理:先把元信息规范掉
采集器配好了,还要在平台侧登记:这个应用叫什么、属于哪个业务、日志在哪个路径、怎么切分字段、保留多久、谁有权限查。
这一步看着琐碎,但直接决定后面能不能查得动。字段没切对,traceId 就成了一坨文本中间的字符串,只能全文模糊匹配,慢且不准。前面日志格式用 | 分隔,好处在这里体现——按固定分隔符切列,配置一行就够,不用写正则。
3)存储:冷热分层控成本
日志是典型的”写多读少、越新越常读”。所以务必分层:
| 层级 | 保留 | 存储介质 | 典型用途 |
|---|---|---|---|
| 热 | 3~7 天 | SSD / ES 热节点 | 日常排查,全字段可检索 |
| 温 | 7~30 天 | HDD / ES 温节点 | 追溯近期问题 |
| 冷 | 30 天以上 | 对象存储 | 审计、合规、安全事故追溯 |
冷层这一档特别容易被忽略,但它往往是最后的救命稻草——安全事故排查经常要翻一两个月前的日志。
4)查询检索:以 traceId 为入口
前面所有工作,都是为了让这一步变成一次搜索:
1 | 搜索框: traceId = "4bf92f3577b34da6a3ce929d0e0e4736" |
至此,第一节那个 15~30 分钟的流程被压缩成:
1 | 告警 → 点开 Trace 看瀑布图(定位到哪一跳)→ 用 trace_id 搜日志(看到当时的参数和堆栈) |
这才是 APM 的真正交付物:不是几张看板,而是排查路径从”翻 N 个系统”变成”两次点击”。
九、一个血的教训:磁盘打满,日志丢了
这一节单独拎出来,因为它是我们真实踩过的坑,而且代价很大。
背景:一次安全事故需要排查,运维去翻日志——发现日志没有。磁盘早就打满了,日志根本没写进去。
这个场景有多糟糕:你花了大量精力建可观测体系,结果最需要它的那一刻,数据是空的。
原因通常是三个叠加:
- 磁盘容量按”正常量”估的,没算异常情况。一旦某个接口疯狂报错,
ERROR日志加堆栈能在几小时内打满几十 G - 没配日志滚动上限。只配了按天切分,没配
totalSizeCap,文件只增不减 - 没有磁盘水位告警。满了都不知道,直到要用时才发现
解决方案是两件事同时做:扩大磁盘 + 修正 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,再谈可观测。