很多微服务项目前期都是“有日志就行”,单服务阶段一个接口报错,翻一下日志就能定位。但服务数量一多,尤其一个请求要经过网关、鉴权、订单、库存、支付好几个服务之后,排查问题就变成了“先找请求路径,再看具体节点”。链路追踪解决的核心问题不是“加一组监控”,而是把分布式环境里散落在多个进程中的日志、耗时和异常串起来,让一个请求从入口到出口的完整路径可以被还原。
这也是为什么“微服务一多,就必须做链路追踪”不是一句口号,而是规模变大之后被现实逼出来的选择。真正开始排查线上问题的人都会发现:大多数时候,时间不是花在“改代码修Bug”上,而是花在“这个请求到底经过了哪些服务、卡在哪一步”上。
1. 服务一多,原来的排查方式为什么失效
1.1 请求路径从“可预期”变成“不可预期”
单体架构时代,一个请求进来,逻辑清楚,出错路径基本固定。查日志只需要看一个进程,从上往下翻,出错位置往往就在最后几行。到了微服务阶段,一个接口的前后依赖关系会变得非常复杂。网关转发,鉴权服务校验身份,订单服务创建订单,库存服务扣减库存,支付服务发起支付,中间可能还夹着消息队列、缓存、分布式任务和第三方接口。
服务少的时候,团队里两三个人还能靠经验记住主要调用路径。一旦服务数量涨到十个、二十个,调用关系图会超出任何人的记忆范围。同一个接口在不同时段可能走不同分支,某个分支慢会拖累整个接口,某个新上线的服务可能临时修改了内部调用方式,但这些变化不会主动通知所有下游使用者。
我曾经处理过一个商品详情页变慢的问题。页面接口本身很简单,但下面的子接口有十多个分支调用,有的查缓存,有的回源数据库,有的调搜索服务,有的调评价服务。某个评分服务在高峰期多了一个慢查询,页面整体耗时从200毫秒涨到3秒。如果不看调用链,你只能从页面最外层接口一层一层往下猜。
1.2 日志分散,只看“最后一个服务的报错”解决不了问题
很多人一开始做分布式排查,还是沿用单体的老经验:先看报错,再回推原因。这个思路在微服务环境里经常失效。
原因在于,报错往往出现在最下游的服务,但真正的问题可能在上游。比如订单服务调用库存服务,库存服务返回了一个“库存不足”的异常。看起来问题在库存侧,但如果继续追,会发现是因为订单服务传了一个错误的商品ID,或者是上游请求里的店铺ID与商品维度不匹配。没有统一的traceId,你想把订单服务和库存服务的日志按时间对齐起来,难度极高。
日志分散也是一个大问题。微服务的日志通常会落在不同虚拟机、不同容器、不同pod里。出了问题,先要去日志平台里搜索,再把多个服务的时间和节点拼起来。这个过程非常依赖运维系统做得好不好。如果日志平台本身没有把traceId作为索引字段,排查一次像做一个手工拼图游戏。
更关键的是,日志记录的内容经常不在同一个时间粒度上。A服务记录的是入口时间,B服务记录的是处理完成时间,中间的网络耗时、排队时间和重试时间在单条日志里完全看不出来。
1.3 微服务排障的真实成本:大头不是“修”,而是“找”
做微服务时间长了,你会发现一个规律:真正修Bug的时间往往很短,改代码可能只需要几分钟,但“找到这个Bug到底发生在哪”可能需要几小时甚至几天。
举一个很常见的场景:某个下单接口突然变慢,用户已经开始投诉。网关超时时间设置的是5秒,现在大量请求超过5秒,网关直接返回504。订单服务日志显示请求进来了,但执行到一半就超时退出。库存服务日志显示某个查询数据库的SQL耗时接近4秒。你以为问题定位在数据库慢查询,但继续查下去,会发现是因为Redis里某个商品维度的缓存Key长时间未命中,缓存回填逻辑又同时被多个线程触发,数据库连接池被打满,最后导致正常的库存查询被阻塞。
这个过程中,你至少要看三个服务的日志:网关、订单服务、库存服务,可能还要查Redis监控和数据库连接池监控。真正动手修只需要把缓存回填的并发限制改一下,但定位这个位置,可能需要好几个小时。
我一般会建议团队做一个简单统计:线上问题的平均定位耗时是多少。如果已经超过30分钟,而且其中80%的时间是在“找路径”而不是“验证修复方案”,那就说明没有链路追踪已经变成一个明显的效率瓶颈。
2. 没有链路追踪时,线上故障的三种典型形态
2.1 接口超时:你以为A挂了,其实是B慢
这是最典型的微服务故障形态。现象是用户请求超时,直觉反应是某个服务宕机了。但实际查下来,A服务没有宕机,只是A服务还在等待B服务的响应。
比如A服务接到请求后,需要调用B服务获取用户信息。B服务本身没有挂,但它依赖的数据库连接池资源不足,每个请求排队等待了3秒。A服务自身的处理能力没有问题,但因为下游响应慢,在线程池资源固定且不会无限等待的情况下,A服务的线程也被占满,最终导致大量请求超时。
只看A服务的监控时,你看到的是线程池活跃数很高,容易误判为“A服务需要扩容”。只有把调用链打开,才能看到A服务的耗时大头在“下游调用等待”上,B服务才是导致延迟的根源。
2.2 数据不一致:同一个操作在两个服务里结果不同
微服务拆分之后,一个业务流程往往要跨多个服务,但跨服务之间并没有单体时代那种单库事务保障。比如订单状态更新成功,库存没有扣减;用户看到订单已支付,但支付回调没有及时通知订单服务;退款操作在订单系统成功,在财务系统里却没有生成凭证。
这类问题最麻烦的地方是:每个服务都记录了成功日志,你无法从单个服务判断到底是谁先谁后,谁调用谁失败,补偿逻辑有没有被触发。如果没有链路追踪,你只能拿业务流水号或订单号,在各个服务的数据库里手工比对,效率非常低。有了调用链,至少可以看到这个订单请求经过的完整节点,以及每一步的返回结果和耗时,判断范围能缩小很多。
2.3 单个服务都正常,但用户就是觉得慢
还有一种情况很隐蔽:不是某一个服务明显变慢,而是所有服务的平均耗时都在正常范围内,但用户从入口到出口的总耗时就很高。
原因是服务之间的网络往返、序列化、反序列化、网关转发、负载均衡、以及多次的RPC调用叠加在一起。比如一个操作需要串行调用4个服务,每个服务只花50毫秒,看起来都不算慢,但加上4次网络开销和等待时间,总耗时可能超过500毫秒。如果再叠加一层网关和一次外部HTTP调用,用户体感就会明显变差。
这在单服务视角里是看不出来的。只有把整个调用链展示出来,看到总耗时和各个Span的耗时占比,才能意识到瓶颈不在任何单个节点,而在于调用链路的“节点之间”。
3. 链路追踪到底做了什么:从traceId到完整调用链
3.1 核心概念:Trace、Span、traceId和parentId
链路追踪有三个核心概念,理解了它们,后面看文档和排查问题都会顺畅很多。
Trace,指一次请求从入口到出口的完整过程。在调用链上,它是一个树状结构,根节点是入口请求,一级一级往下展开。
Span,指Trace中的一个独立处理单元。一次HTTP请求、一次数据库访问、一次消息发送,都可以是一个Span。每个Span记录了开始时间、结束时间、状态、操作名称、服务名称等关键信息。
traceId,是连接整个Trace的全局唯一标识。同一个请求经过的所有服务,都会把traceId记录在自己的日志和Span里。这样,无论日志散落在哪里,只要按traceId搜索,就能把整条请求串起来。
parentId,用来标记一个Span由哪个父Span发起。通过parentId,系统才能还原出调用层级,知道哪个节点先调用、哪个节点是它的下游。
链路追踪系统的价值,就是围绕traceId把分散的Span聚合成一棵调用树,再统一展示起来。
3.2 上下文是怎么传递的:请求头、RPC Attachment和异步消息
链路追踪能跑通,关键前提是“traceId要跟着请求走”。在不同场景里,传递的方式不一样。
HTTP调用,通常通过请求头传递。比如网关在入口生成traceId,放到请求头里,下游服务接收到后从请求头读取,再继续传给更下游的服务。
RPC调用,比如Dubbo这类框架,一般通过RPC的Attachment或附加参数传递。Spring Cloud Alibaba、Spring Cloud Sleuth这些组件已经封装好了自动透传逻辑,但用得不对还是会有坑,比如拦截器没有生效、附加上下文被覆盖、自定义ThreadLocal没有清理干净等。
消息队列,比如RocketMQ、Kafka,一般会选择把traceId放到消息的Header里。消费端收到消息后,从Header里取出traceId,把消费逻辑也纳入同一条调用链里。
这里最重要的认知是:链路追踪不是“只有接入组件才有用”,还要关注上下文在“创建新线程、跨进程、跨队列”时是否被正确传递。很多链路断掉,不是链路追踪系统不行,而是业务代码里上下文丢了。
3.3 数据链路:探针、上报、聚合、存储、展示
链路追踪系统的完整工作流程,通常包含五个环节。
第一步,探针或SDK。它可以是一个Java Agent,也可以是一个第三方SDK。侵入程度各有不同,核心作用是拦截关键调用,生成Span。
第二步,上报。探针把生成的Span数据异步发送给Collector或收集端。这里要注意,上报一般不能阻塞业务线程,否则会反过来拖慢请求。
第三步,聚合。同一个traceId的Span会被收集端汇总,通过parentId还原调用关系。
第四步,存储。聚合后的数据会写入存储层,常见的包括Elasticsearch、MySQL、H2、ClickHouse等。生产环境一般会优先考虑长期存储能力较强的方案。
第五步,展示。通过Web界面查看调用链、服务拓扑、耗时分布、错误节点。
开源工具里,SkyWalking、Zipkin、Jaeger、Pinpoint、Cat都有各自的定位。SkyWalking在Java生态里比较流行,使用Java Agent接入,对代码侵入极低;Zipkin和Jaeger轻量,适合和Spring Cloud生态结合,但要自己搞定采集器、存储和UI;Pinpoint对调用细节展示很丰富,但资源占用通常偏高;Cat偏向日志分析型,需要前期投入工程化改造。
选型不需要迷信哪个最强,重点看团队的运维能力、存储设施和对“侵入程度”的容忍度。我个人会更关注一个问题:接入之后,能不能在不改业务代码的情况下停掉或降采样,避免链路追踪变成生产环境的“另一种故障源”。
3.4 只靠调用关系还不够,要能看耗时、状态和异常点
链路追踪如果只展示“A调用了B”,那价值有限。真正能帮助排查问题的,是每个Span的耗时、状态码、异常栈、成功失败标志,以及缺陷调用有没有触发重试。
举个例子:一个接口调用了三次下游服务,第一次失败后自动重试,第二次成功。如果只展示调用链,你看到的是一条成功的链路。但如果你把注意力放在耗时上,会看到调用链里多了一个耗时很高的Span,这个多余耗时就是一次失败重试造成的。这类信息在容量评估、超时设置和接口调优时非常关键。
到了生产环境,还要考虑采样问题。全量上报会带来很大的存储和网络开销。一般开发环境可以全量采样,生产环境按流量设置采样率,比如1%或10%,具体要看你们对“可回溯性”的要求和数据量。低采样率能接受,但不要低到“出问题根本查不到那一天的数据”的程度。
4. 从零落地的实操路径:先日志traceId,再接入开源系统
4.1 第一步:先给日志统一加上traceId
哪怕你的团队暂时没有精力接入完整链路追踪系统,我也建议先做这一步:让每个日志文件里都能看到traceId。
具体做法不复杂:在网关或所有请求入口处生成一个traceId,放到SLF4J的MDC(Mapped Diagnostic Context)里;再在日志格式里加上这个字段,比如%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] [%level] %logger - %msg%n。这样每一行日志里都会带上traceId,后续在日志平台上按traceId搜索,能把同一个请求经过的所有服务日志串起来。
这里最容易忽略的是异步线程。接口里开启线程池做计算时,子线程里的MDC上下文不会自动从父线程复制过去。如果你不处理,子线程里打印的日志traceId就是空的,链路又断了。常见处理方式是使用线程池装饰器,在提交任务时把父线程的MDC内容复制到子线程,任务执行完再恢复。
4.2 第二步:选一个开源链路追踪工具,先跑通单机Demo
日志里有了traceId,是“靠日志串链路”。如果服务数量一多,这个方式仍然很费劲,因为要从日志平台里来回搜索,耗时也高。这时候就该考虑接入链路追踪系统了。
选型之前先想清楚几个前提:团队主要技术栈是什么?有没有统一的运维平台?存储用什么?能不能接受Agent方式接入?
Java技术栈里,如果追求低侵入,可以先试SkyWalking,用Java Agent方式接入,业务代码基本不用动。如果是Spring Cloud生态,而且团队对中间件理解比较深,也可以考虑Zipkin或Jaeger,搭配Spring Cloud Sleuth或Micrometer Tracing使用。
不管选哪个,第一件事都是先在测试环境跑通一个最小Demo:部署一个提供HTTP接口的服务,手动发起几次请求,确认在UI上能看到完整调用链、节点耗时和日志关联,再去考虑跨服务传递和批量部署。
4.3 第三步:网关和RPC调用怎么透传上下文
很多团队接入完链路追踪系统后,发现了一个奇怪现象:服务内部有调用链,但跨服务就断了。最常见的原因是网关没有透传traceId,或者RPC框架的上下文传递配置不对。
网关本身就是流量入口,它负责生成traceId并向下游透传。这里要注意,网关不能每转发一次就重新生成一个traceId,否则所有下游服务记录的traceId五花八门,还是串不成链。生成规则应该是:入口请求里如果已经带了traceId,就继续沿用;如果不带,网关新生成一个,写进请求头,再一路传下去。
在RPC调用里,要关注框架是否已经把traceId放进Attachment或请求头里。如果框架自带支持,通常不需要额外处理;如果用的是自研RPC、历史遗留框架,就要手动在拦截器里取traceId并传递。排查的时候,优先看两个方向:一个是网关到第一个服务的头部信息有没有带traceId,另一个是服务间调用时框架有没有保留上游头部信息。
4.4 第四步:异步任务、消息队列和定时任务怎么衔接
异步场景是最容易让链路追踪“断链”的地方。线程池、MQ Consumer、定时任务、回调,如果不在这些入口处理好traceId的传递和恢复,链路永远只会显示一半。
消息队列是比较典型的情况。生产端在发送消息时,把traceId放到消息的Header里;消费端接收到消息后,先从Header里取出traceId,放入当前线程的MDC,再执行业务逻辑。这样,一条消息的消费处理也能挂到同一条调用链上。
定时任务特殊一点,它通常不是由用户请求触发,而是由调度中心触发。这类场景要建立自己的“根Trace”,可以为每次调度生成一个traceId,调度任务里所有子任务都继承这个traceId,方便事后回溯“某次定时任务为什么跑了很久、某个批次为什么处理失败”。很多团队在排查定时任务相关问题时,会发现链路追踪里根本没有定时任务的数据,原因就是当时没有做入口Trace初始化。
4.5 第五步:采样率、存储和保留策略要提前设计
链路追踪接入后,最怕的就是“存储压力过大”。
生产环境建议先按业务量估算数据量。一个请求平均产生多少个Span,日请求量有多少,再乘上采样率,就能粗略估算存储需求。如果日请求量达到百万级别,全量采集会产生大量数据,存储成本会迅速上升。这时候就要考虑降采样,比如按百分比采样,或者按接口重要性设置不同采样策略。
我一般会建议:开发环境全量采样,方便排查;预发环境按50%或100%采样,用来做上线前的链路验证;生产环境核心交易链路做较高采样,日志类、查询类、非核心链路做低采样。具体数字不是死的,要结合团队存储能力和诉求来调。
采样率调低带来的问题是:线上出故障时,可能恰好没有采到那条请求。这对存储压力大的团队来说属于可接受的取舍,但如果你所在的业务对可观测性要求很高,建议宁可多花存储成本,也至少保证核心交易链路的全量采样。
5. 落地后最常见的问题和排查顺序
5.1 链路只显示半截:先怀疑上下文丢失
链路追踪接入完成后,最常见的现象是:同一个请求,前面几个服务有链路,到某个服务之后就没有了。这种情况我首先会怀疑“请求在进入那个服务时,traceId没有传递过去”。
检查顺序一般是:
- 先看不完整链路的边界是在哪个服务。
- 再看这个服务是通过什么方式被调用的,HTTP还是RPC,还是MQ。
- HTTP就查请求头里有没有带traceId,RPC就查Attachment里有没有,MQ就查消息Header里有没有。
- 确认调用方有没有做透传,被调用方有没有从对应位置读取。
- 最后确认这个服务里有没有异步线程,中间有没有重启线程池、新开Thread、使用CompletableFuture并行调用。
排查过程中,很多问题不是出在框架,而是业务代码里“手动开了线程、又没把MDC内容复制过去”。这种问题用单机日志不一定能发现,因为日志能打印,但traceId是缺的,或者跟主链路对不上。
5.2 traceId在日志里没打出来:查MDC生命周期和线程隔离
有时候链路系统里能看到Trace,但日志文件里每一行traceId都是空的,或者同一个请求在不同行里traceId不一样。这种情况,基本是MDC的使用问题。
重点检查三处:
- 日志格式里是否真的引入了
%X{traceId}。 - traceId是否在拦截器或过滤器里放进了MDC,并且有没有被后续代码提前清除。
- 异步线程里有没有复制主线程的MDC上下文,执行完之后有没有清理。
MDC底层是ThreadLocal,天然具备线程隔离性,但正因如此,在线程复用场景里如果不注意清理,很容易出现“traceId串了”的现象:本来没有traceId的任务,复用了上一个任务残留的traceId。所以,入线程时复制MDC,出线程时清空MDC,这两个步骤都要有。
5.3 链路有了,但看不出谁慢:看Span父子关系和耗时占比
链路追踪系统把Trace展示出来后,很多人只会看最后的耗时,然后凭感觉判断瓶颈。更可靠的判断方式是看整条链路的耗时分布。
比如一个接口总耗时3秒,但调用链里第一个Span显示网关耗时0.5秒,订单服务耗时2秒,库存服务耗时0.3秒。那重点就应该放在订单服务里,继续看它的子Span,到底是在查询数据库、调用外部HTTP、还是写消息队列消耗了大部分时间。
还有一个容易被忽略的是“在等待”的时间。RPC调用的Span耗时通常会包含“发送请求到拿到响应”的整个过程,其中既有下游执行耗时,也有网络耗时和序列化耗时。如果发现这个Span耗时很大,但是下游服务的实际执行时间很小,那就要关注客户端连接池、网络抖动、超时重试等问题了。
5.4 接入后性能下降:检查探针、采样和高频接口CPU
链路追踪系统本身也有开销,尤其是在Java Agent场景下,字节码增强和Span采集会带来一定的CPU、内存、网络消耗。接入之后如果发现服务性能下降,优先看这些方面:
- 探针日志是否过多,默认日志级别是不是DEBUG。
- 高频接口是否没有做采样,每个请求都生成大量Span。
- 是否开启了过于细粒度的插件,比如数据库调用、Redis调用、HTTP调用全部拦截,导致Span数量爆炸。
- 采集端上报的网络路径是否稳定,如果上报链路阻塞,探针内部缓冲区会积压,带来内存压力。
排查这类问题时,先关闭非必要插件,再降低采样率,然后再看CPU和内存是否恢复正常。如果问题依旧,就要去看探针自身运行时有没有报错、有没有和业务代码冲突。
6. 什么时候不得不做,什么时候可以再等等
6.1 判断标准不是服务数量,而是排障成本
“微服务一多就必须做链路追踪”这句话里的“多”,并没有一个绝对数字,更多是看排障成本。
我习惯用一组条件来判断:
| 指标 | 大概状态 | 建议动作 |
|---|---|---|
| 服务数量 | 5个以下,团队小 | 可以先做日志traceId,链路追踪系统可选 |
| 服务数量 | 10到20个,跨两三个团队 | 建议正式接入链路追踪 |
| 平均单次线上问题定位耗时 | 超过30分钟 | 建议尽快接入 |
| 单条请求跨服务深度 | 稳定超过3层 | 建议接入 |
| 跨团队协作排障频率 | 每个月至少一次 | 建议接入 |
| 高峰流量下出现超时、数据不一致次数 | 每周都有 | 必须接入 |
这些条件不要求全部满足。只要排障成本已经明显影响到线上恢复速度,链路追踪就值得投入。
6.2 轻量替代方案:统一日志格式+按traceId检索
如果团队暂时没有人力运维完整的链路追踪系统,可以先做一个轻量方案:统一日志格式,把所有服务的日志都输出成结构化JSON或带固定字段的文本,traceId单独作为一个字段,统一接入日志平台。
这样做的好处是:至少你能在出问题时,按traceId搜索到一个请求经过的所有日志。排查路径仍然靠人工一步步串,但比没有强很多。缺点也很明显:不能自动生成调用拓扑,不能自动还原Span耗时,异常定位还是依赖经验和耐心。
所以,轻量方案适合服务数量还在可控范围、链路深度不深的阶段。一旦服务规模扩大、线上问题开始频繁出现,还是要尽快切到正式链路追踪系统。
6.3 分阶段落地,不要想一口气做到位
链路追踪项目如果一开始就追求大而全,反而容易失败。我更建议分阶段往前走。
第一阶段,只做日志traceId。所有服务统一日志格式,入口生成traceId并透传,日志平台支持按traceId检索。
第二阶段,接一个开源链路追踪系统,先在一两个核心服务上试点,跑通跨服务调用链,确认UI和存储逻辑都没问题。
第三阶段,逐步扩大到网关、RPC、MQ、定时任务等场景,打开采样和告警,把“按traceId查问题”变成团队默认的排查习惯。
第四阶段,再考虑把链路追踪和现有监控系统、告警系统、日志平台联动起来,形成完整的可观测体系。
这套路径的优点是每一步都有明确产出,不会在接入阶段就把资源烧光。真正落地的时候,最该盯住的不是“看起来全不全面”,而是任何一个关键接口是否都能在几秒钟内还原完整调用链,以及团队成员是不是已经养成了“先看链路,再写结论”的习惯。
很多项目最后不是死在技术选型上,而是死在“出了故障,却没人能快速说清请求到底卡在哪一步”。链路追踪的价值,就是让排障从“靠经验猜”变成“靠数据查”。