全链路追踪治理:从埋点到排查闭环 — editorial cover photo

没有链路追踪,出问题就是抓瞎

链路追踪埋了点但用不起来,治理的核心是让追踪真正服务于排障。

2025年11月18日·返回文章列表
文章大纲

蜂控那块接了全链路追踪之后,有段时间我是很失望的。系统跑着,TraceId 也在生,可视化平台里花花绿绿一堆链路图。真正出事的时候呢,一个下单慢的客诉进来,我打开追踪系统想看是哪一环卡住的,链路走到某个内部异步调用就断了,后面一段全是黑盒。再翻日志,那批日志压根没带 TraceId,没法和链路对上。最后还是靠重新复现、人肉翻代码定位的。花大力气接的追踪,在关键时刻没帮上忙。

这件事让我意识到一个分水岭,追踪系统接了,和追踪能用,中间隔着一整轮治理。很多团队停留在接了这一步,TraceId 半透半传,业务环节没埋点,日志和追踪各玩各的,出事还是靠经验猜。真正把追踪变成排障工具,要做的是把断链补上、把业务语义埋进去、把日志和追踪绑到同一个 TraceId 上。下面把那轮治理里踩过的坑和最终收敛的做法写清楚。

断链是追踪失效的头号元凶

PlantUML
正在加载图表…
PlantUML diagram

图:TraceId 透传机制与三类高发断链点(MQ、异步线程、第三方 SDK)

TraceId 透传这件事,听着简单,做起来全是坑。它的原理不复杂,入口服务生成一个全局唯一 ID,后续每一次跨进程调用都把它带过去,HTTP 走 header,RPC 走 attachment,MQ 走消息属性,异步线程走上下文传递。只要 ID 不断,整条链路就能串起来。

问题在于,这套机制只在最主流的同步调用链路上是默认生效的。SDK 自动处理的范围通常是 HTTP 客户端、RPC 框架、数据库驱动这几样,一旦调用方式跳出这个圈子,TraceId 就会悄悄断掉。

最容易断的几个地方我一个个踩过。第一是自定义异步调用,业务里 new Thread 或者直接丢线程池执行的任务,SDK 不会自动把父线程的上下文传进去,子线程拿到的 TraceId 是空的,于是这段执行彻底脱离链路。修法是把任务提交包装一层,在提交前抓取当前 context,在子线程执行前恢复回去。第二是第三方 SDK 调用,有些老 SDK 根本不识别你塞的 header,发出去的请求 TraceId 就丢了,这类得靠 SDK 提供的拦截器接口硬塞。第三是跨进程消息,MQ 消费端如果没配置继承生产者的 TraceId,链路在队列那个位置截断,生产端和消费端成了两条互不相干的链路。

断链要专项排查,不能靠肉眼

断链最隐蔽的地方在于平时根本看不出来,只有出事排查时才发现某段是黑的。治理时要做的是写个巡检脚本,扫描最近一段时间的 Trace,找出那些调用深度突然归一、或者 span 之间出现时间空档的链路,这些大概率是断链点。逐个修复,确保 HTTP、RPC、MQ、异步线程、定时任务这几条主流路径全覆盖。

自动埋点有边界,业务语义得手动补

PlantUML
正在加载图表…
PlantUML diagram

图:自动埋点只看技术调用(孤立方框),手动埋点拆出业务子 span,瓶颈一目了然

SDK 自带的自动埋点覆盖的是技术调用,HTTP 进出、RPC 调用、DB 查询、缓存访问这些,每一步生成一个 span,记录耗时和调用关系。这套东西能让你看到服务之间的拓扑和每一跳的延迟,但它看不到业务。

举个例子,下单接口里调用风控服务花了 80 毫秒,自动埋点只能告诉你 riskService.check 这个调用耗时 80 毫秒。但风控服务内部做了什么,是规则匹配慢、是外部数据源查询慢、还是缓存没命中回源了,自动埋点一概不知。链路图上一个孤零零的 80 毫秒方框,对定位问题帮助有限。

这时候要靠手动埋点补业务语义。在风控服务内部,把规则匹配、外部数据查询、缓存读写这几个关键业务环节各包一个子 span,标注清楚它代表什么。这样链路图就能拆开显示,规则匹配 20 毫秒,外部数据源 55 毫秒,缓存读写 5 毫秒,瓶颈一目了然。

// 业务关键环节手动埋点
public OrderResult createOrder(OrderRequest req) {
Span span = tracer.spanBuilder("order.create").startSpan();
try (Scope scope = span.makeCurrent()) {
span.setAttribute("userId", req.getUserId());
span.setAttribute("amount", req.getAmount());
Span riskSpan = tracer.spanBuilder("risk.check").startSpan();
try {
riskResult = riskService.check(req);
} finally {
riskSpan.end();
}
Span couponSpan = tracer.spanBuilder("coupon.calculate").startSpan();
try {
couponResult = couponService.calculate(req);
} finally {
couponSpan.end();
}
return result;
} finally {
span.end();
}
}

手动埋点有个克制原则,不要逢方法就埋。每个 span 都有创建和上下文切换的开销,埋得太密链路图会变成密密麻麻一片,反而看不清主干。我自己的标准是只埋两类,一是耗时占比显著的业务环节(风控、券计算、库存校验这种动辄几十毫秒的),二是容易出问题的关键路径(外部依赖调用、状态变更)。那种执行几微秒的内部方法埋了纯粹添乱。

日志不打 TraceId,追踪系统就瘸了一条腿

光有链路定位还不够。链路告诉你哪一环慢或者错,但具体那一环内部的细节,异常堆栈、关键参数、中间状态,全在日志里。所以日志和追踪必须能通过 TraceId 关联起来,链路定位环节,日志看细节,两者联动才能完成排障闭环。

这要求每一行日志都带上当前请求的 TraceId。实现上通常靠日志框架的 MDC(Mapped Diagnostic Context),在请求入口把 TraceId 塞进 MDC,日志模板里配上 %X{traceId},所有线程内打的日志自动带上。这件事的坑和断链是同一类问题,MDC 也是基于 ThreadLocal 的,跨线程传递同样要手动处理,否则子线程打的日志 TraceId 又是空的。

一个常见疏漏是只给 ERROR 日志打了 TraceId,INFO 和 WARN 没打。排障时往往是先从链路上看到一个慢请求,然后想看这个请求从头到尾都经历了什么,这时需要的是全量日志按 TraceId 过滤,只有 ERROR 的话很多上下文就丢了。所以规则要一刀切,所有级别日志必须带 TraceId,没有例外。

采样策略,全采太贵但异常不能漏

全量采集 Trace 数据在规模稍微大一点的系统里扛不住。一个 span 几百字节,一次请求几十个 span,乘以 QPS,一天的存储就是天文数字。所以生产环境基本都走采样。但采样有个天然矛盾,正常请求可以采样,异常请求如果被采漏了,出事时根本没链路可看。

解决这个矛盾的办法是异常优先采样,也就是尾部采样。常规的做法是请求正常就按比例采(比如百分之一),但如果这个请求最终失败了,或者耗时超过某个阈值,强制全量采集。实现上要等请求完整结束、拿到结果之后再决定要不要上报这条 Trace,所以叫尾部采样。它的代价是上报逻辑要等请求结束才能跑,并且需要一个短暂的内存缓冲来暂存尚未决定的 span,工程复杂度比头部采样高不少。但这个代价值得,因为它保住了追踪系统在排障时的核心价值,保证出问题的请求一定有链路可查。

从告警到根因,三者怎么串起来

PlantUML
正在加载图表…
PlantUML diagram

图:Metrics 告警 → Tracing 定位环节 → Logging 看细节,三者靠 TraceId 与时间戳串联成排障闭环

追踪治理做完之后,整个排障流程会顺畅很多。触发点是 Metrics 告警,错误率飙升或者 RT 劣化,告诉你出问题了。但告警通常只告诉你哪个服务的指标异常,不告诉你为什么。这时切到追踪系统,用时间和服务名过滤出问题时段的异常 Trace,沿着链路看哪一环的 span 标红(报错)或者耗时异常。定位到具体环节后,用那条 Trace 的 TraceId 去日志系统搜,把该请求所有的日志拉出来,看异常堆栈和参数,最终锁定根因。

这条链路 Metrics 告警、Tracing 定位、Logging 细节,三者靠 TraceId 和时间戳串联起来,才能从知道出事快速推进到知道为什么。任何一个环节掉链子,排障就会卡住。这就是追踪治理的全部意义,真正的功夫花在透传、埋点、日志关联、采样这几件事的打磨上,让这套工具在关键时刻真的能救命。

Continue Reading

关联文档推荐

查看全部
链路追踪:分布式系统的可观测性之眼 — editorial cover photo监控告警治理:从噪音到信号的提炼 — editorial cover photo优化前:同步写入所有数据源 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。