去年年底我们把一个单体订单系统拆成了 9 个微服务,拆完之后服务之间靠 HTTP 调用串起来。上线第二天就出事:一个用户下单要等 40 秒,报警响起来的时候,我打开监控面板,看见的全是绿色的 CPU 和内存曲线,每一个服务单独看都"没问题"。最后是挨个服务翻日志、拼时间戳,花了整整一个下午才拼出一条完整调用链而那还是运气好,服务数量少。
那之后我花了两个月把 OpenTelemetry 的链路追踪接进了全部 9 个服务。回头看,接入本身并不复杂,代码量加起来不到 200 行;真正踩坑的是那些"看起来配了、其实没生效"的地方。这篇就把这套接入里必须配对的 5 个东西按踩坑顺序写下来,尤其第 4 项,我漏了它整整两周,链路断在第三跳,后端同事查了几天日志都找不到原因。
如果你也在做微服务拆分,或者正在给已有的服务补可观测性,准备先拿一台机器把 Collector 和存储后端跑起来,可以先看看这几家的配置和技术说明:reliablesite(服务器与主机,可定制工作负载);如果要按区域多节点部署采集器,zlidc(全球云服务器,多城市节点) 的多地线路也值得一并比较。

先说清楚:链路追踪到底在记录什么
先对齐一下概念,不然后面容易看糊涂。链路追踪做的事很简单:给一次请求生成一个全局唯一的 ID(traceId),然后把这次请求经过的每一段都记成一个 span,span 之间有父子关系,最后所有 span 汇总成一条完整的"瀑布图"。你看到的界面上那条从左到右的横条,就是这条链路。
关键点在于 span 之间靠两个东西关联:一是进程内通过 Context 对象(Java 里是 ThreadLocal,Go 里是 context.Context)往下传;二是跨进程必须靠 HTTP 请求头传出去,标准头名是 traceparent 和 tracestate。这两套机制同时存在,缺一不可而我只配了第一套,这就是后面要说的那个大坑。
OpenTelemetry 本身不存数据。它只做三件事:产生 span、把 span 通过 OTLP 协议发给 Collector、Collector 再转发给后端(Jaeger、Tempo、或者云厂商的 APM)。所以接 OpenTelemetry 等于同时接了三层东西,很多"接了但没数据"的问题,其实出在后两层,跟你的业务代码没关系。

必配项 1:后端服务要真的创建 span
最基础也最容易忽略的一层。如果你只是引入了 OpenTelemetry 的 Java agent,把依赖加进 pom.xml,然后发现界面上什么都没有,八成是业务代码里压根没有埋点框架会自动为 Spring MVC、Nginx、gRPC 这些常见框架生成 span,但你自己写的业务方法调用,得手动开一个 span。
我用的写法比较朴素,就是把耗时统计包了一层:
Span span = tracer.spanBuilder("order.submit").startSpan();
try (Scope scope = span.makeCurrent()) {
// 原来的业务逻辑
} catch (Exception e) {
span.recordException(e);
throw e;
} finally {
span.end();
}
这里有个小坑:span 一定要在 finally 里 end()。我早期版本里忘了这行,异常路径下 span 永远不结束,Collector 那边就看到一堆"孤儿 span",后端存储压力直接翻倍。后来我在代码评审里加了一条检查:所有 spanBuilder 调用必须紧跟 try-with-resources 的 Scope。

必配项 2:OTLP 导出地址要指向 Collector 而不是后端
很多人第一次接会填错这个地址。正确的链路是:应用 → Collector → 后端。应用通过 OTLP gRPC 或者 OTLP HTTP 把 span 发给 Collector,Collector 再按自己的 pipeline 配置转发给 Tempo 或者 Jaeger。
如果你把应用的 OTLP endpoint 直接写成后端地址,短期看是"能跑通"的,但问题会在你给 Collector 加采样、限流、脱敏的时候一起冒出来。我们当时的 Collector pipeline 里配了批处理和重试,直接打到后端就意味着这些能力全都用不上,几万个 span 打过去直接把后端打挂过一次。
我们的 Collector 配置大致是这样一个结构:
receivers:
otlp:
protocols:
grpc:
http:
exporters:
otlp/tempo:
endpoint: tempo:4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, memory_limiter]
exporters: [otlp/tempo]
memory_limiter 我强烈建议开着。默认的 memory_limiter 阈值是 JVM 堆内存的某个比例,如果 Collector 所在机器内存吃紧,它会主动拒绝接收而不是撑爆自己;没开这个处理器的时候,我们那台 4G 内存的小机器被 Collector 直接 OOM 过一次,追踪数据跟着一起断。
必配项 3:采样率要在应用和 Collector 两边想清楚
全量采集听起来最美好,但生产环境基本不可行。一个日活百万的接口,全量采集意味着每天几亿个 span,存储成本和 Collector 压力都会失控。
我们用的策略是两层采样:应用侧按 traceId 哈希做头采样,比例 1%;Collector 侧再做一层尾部采样,把慢请求(超过 800 毫秒)无条件保留。这样日常流量只留 1%,但真正有问题的慢请求一条不丢。
踩坑点在于:应用侧采样必须基于 traceId 的哈希,而不是随机数。随机采样的话,同一个请求经过三个服务时,三个服务各自独立决定采不采,结果就是链路被切成三段碎片,在界面上看是三条互不相干的短链路。用哈希能保证同一条 trace 的所有服务做出相同决定。

必配项 4:跨服务传递 traceparent,这一条漏了链路必断
这是我说的那个漏了两周的坑。上面三项都配好之后,界面上能看到链路,但每条链路只有一跳:网关 → 订单服务,下游的库存服务、支付服务完全不在图里。当时我们查日志、查代码、查配置,整整两天没进展。
根因是上下文传播没做。Java agent 默认会自动给出站的 HTTP 请求注入 traceparent 头,但如果你是自己封装的 HTTP 客户端、或者走了某个内部 RPC 框架的自定义适配层,agent 就插不上手了。表现出来的现象特别有迷惑性:同一个服务的入站 span 有 traceId,出站的 span 却是一个全新的 traceId,两边对不上。
排查这个问题有个很好用的土办法:在每个服务的拦截器里把出站请求头全部打出来,肉眼比一下 traceId 前 32 位是不是一致。我们的最后定位过程就是靠这个,发现库存服务调库存仓库时,走的是自己封装的 HttpClient,头根本没带过去。
补法也简单:要么让 agent 能识别这个客户端(换成标准的 OkHttp 或者 RestTemplate 就自动好了),要么手动注入在拦截器里取当前 Context 的 span,把它的 traceparent 手工写进请求头。手动注入的代码大概是这样:
Span current = Span.current();
String traceparent = "00-" + current.getSpanContext().getTraceId() + "-" + current.getSpanContext().getSpanId() + "-01";
request.addHeader("traceparent", traceparent);
顺带提醒一句:traceparent 是敏感信息,跨公网传输时要注意别把内部 traceId 直接暴露给不受信任的调用方,很多安全扫描项就是专门查这个头的。
必配项 5:把 traceId 写进日志,两件事才能串起来
最后这一条不配的话,链路追踪和日志系统就是两套割裂的东西。当你在瀑布图里看到某个 span 慢了 3 秒,想去看那时候到底发生了什么日志,还得靠时间戳去猜。
做法是在日志输出里带上 traceId 和 spanId,这样在日志系统里按 traceId 一搜,整条链路相关的日志就都串出来了,排查效率完全不一样。我们的 logback 配置里加了 MDC 输出:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %X{trace_id:-} %X{span_id:-} %-5level %logger{36} - %msg%n</pattern>
还有个实践上的建议:给 traceId 加一个便捷入口。日志系统里一般都能把 traceId 做成可点击的链接,直接跳到链路追踪的后端界面。我们内部就在日志平台加了个小插件,鼠标悬停就能看这条链路的完整瀑布图,用起来之后基本没人再回手写 traceId 去搜了。

费用和资源账,顺手算给你看
有人会问,接了追踪是不是要多花很多钱。实际算下来大头不是存储,是采集器本身。9 个服务,每个服务一台 2 核 2G 的小机器跑 agent 绰绰有余,Collector 单独一台 4 核 8G 就够撑住我们日均千万级 span 的量(1% 采样之后)。存储这块,用对象存储存热数据、冷数据定期归档,比长期挂在对象存储上更省。
我们踩过的一个坑是一开始把采样率设成 100%,纯粹为了"看全一点",结果一周之后存储账单涨了三倍多,只能回头把历史数据删掉重新降采样。采样率这个东西一旦上线就很难改,因为改了之后新旧数据混在一起看图表会非常迷惑,建议一开始就定好,并且写进部署文档里。
写在最后
链路追踪这套东西,原理一点都不复杂,就是给请求发一个 ID,把每一段的耗时记下来串成一条线。但真正花时间的部分全在"怎么让它真的连起来"上上下文传播、采样一致性、时区时钟对齐、agent 覆盖不到的客户端,这些坑每一个都能让你在监控面板上盯着满屏绿灯发愣。
如果你正打算给服务加追踪能力,我建议就按上面 5 条顺序来,先把 1、2、3 跑通,能看到单跳的链路,再去解决 4 的跨服务传递。第 4 条我花了最久,但也正因为花得久,值得单独写一篇链路断在中间的时候,怎么用最土的办法把它定位到具体是哪个客户端没带上下文。
工具链的名字不重要,重要的是先把链路连起来。连起来之后,你会发现很多以前只能靠猜的问题,开始有答案了。


喜欢
顶
难过
囧
围观
无聊





