1. 为什么单靠日志排查微服务问题会陷入死胡同
我先说个真实场景。某个订单系统拆成了六个服务:网关、用户、商品、库存、订单、支付。上线初期一切正常,某天开始出现"下单偶发超时",监控面板上每个服务的 P99 都在正常范围,日志里也查不到 ERROR。团队花了整整两天,最后发现是商品服务在某个特定 SKU 上调用外部接口时偶尔慢 800ms,而订单服务对商品服务设置了 3 次重试——一次慢变成三次慢,雪崩式叠加,但每个服务自己看自己都没问题。
这就是分布式系统最恶心的地方:故障不再发生在单个节点,而是发生在节点之间的调用关系上。你在订单服务日志里看到"调用商品服务超时",然后去商品服务日志里翻,看到的却是一片正常,因为商品服务的慢请求占比不到 1%,日志级别不够根本不会打印。你手里有的是一堆各自完整的拼图,但缺了那张把所有拼图串起来的线。
传统做法有这么几个,但都有明显天花板:
| 排查手段 | 能回答的问题 | 卡在哪里 |
|---|---|---|
| 单服务日志 | 这个服务内部发生了什么 | 无法知道上游是谁、下游等了多久 |
| 指标监控(QPS、延迟) | 整体水位是否异常 | 无法定位到单次请求,P99 正常但个别请求已经炸了 |
| 业务埋点打点 | 某个关键环节耗时 | 埋点零散、跨服务无法自动串接,改一次代码加一次 |
| 人工在请求头塞 traceId | 能串日志 | 全靠人肉维护,跨线程、异步、MQ 全断链 |
第三条是很多人踩过的路。手动在网关生成一个 traceId 塞进 Header,每个服务打印日志时带上它,然后拿 traceId 去日志平台 grep。这个方案在同步 HTTP 调用里勉强能用,一旦遇到线程池、@Async、消息队列、定时任务,traceId 就丢了。你会看到一长串日志里突然出现一条没有 traceId 的记录,然后链路断在那里。
Micrometer Tracing(也就是标题里常被写成 Sleuth 的那一层)解决的正是"上下文自动传递"这个问题,而Zipkin 解决的是"把散落在各服务的 span 收集起来做可视化"。这两者组合,才构成一条真正可用的链路。标题里写 Sleuth 其实是历史包袱——Spring Cloud Sleuth 在 2022 年之后已经进入维护状态,官方推荐迁移到 Micrometer Tracing,但两者在职责上是同一个位置:埋点 + 上下文传播。搞清楚这个关系,后面很多配置就不会配错。
我先把结论摆出来:这套东西的价值不在于"能看到调用链"这种演示级效果,而在于当线上报警响起时,你能在 3 分钟内回答"是哪个服务、哪个方法、慢在数据库还是慢在下游"这三个问题。如果你现在只能靠grep加猜,那这套东西值得花两天接进去。
2. Sleuth 与 Micrometer Tracing 的底层机制拆解
2.1 Trace 与 Span:一次请求的两种粒度
很多人配完了能跑,但看不懂面板上的树形结构,根源是没搞清这两个概念的层级关系。用快递打个比方:
- Trace是一整个订单的物流单号,从下单到签收,全流程唯一。
- Span是这段物流里的每一个节点:揽件、分拣中心 A、干线运输、分拣中心 B、派送。每个节点有自己的开始时间、结束时间,还有父子关系——派送这个 span 的父节点是分拣中心 B。
映射到代码里,一次 HTTP 请求进来,网关创建 Trace(生成 traceId),同时创建第一个 Span(root span)。网关调用订单服务,订单服务再创建一个子 Span,其 parentId 指向网关那个 span 的 spanId。订单服务调用数据库,再创建孙 Span。所有 Span 共享同一个 traceId。
这里有个容易配错的点:采样率默认不是 100%。Spring 生态里management.tracing.sampling.probability默认值是 0.1,也就是 10 个请求才记录 1 个。我见过不止一个人配完之后疯狂刷新接口,发现 Zipkin 上只有零星几条记录,然后怀疑是不是传输坏了。不是坏了,是采样把你过滤了。开发环境建议直接设成1.0,生产环境再按流量和存储成本回调。
management: tracing: sampling: probability: 1.02.2 为什么上下文能跨线程自动传递
这是整套机制里最精妙的部分,也是最多人似懂非懂的地方。
Micrometer Tracing 的上下文(traceId、spanId、采样标记等)在底层存放在一个叫ThreadLocal的结构里(具体是CurrentTraceContext的 Scope)。ThreadLocal 的特性是:变量跟线程绑定,A 线程放进去的值,B 线程看不到。
那问题就来了。业务代码里随处可见@Async、CompletableFuture、线程池提交任务、parallelStream。一旦跨线程,ThreadLocal 里的上下文就断了,新线程里的子 Span 会变成一个孤立的 root span,traceId 都不一样,链路直接裂成两截。
框架的解法是包装 Runnable 和 Callable。它在提交任务时,把当前线程的上下文抓出来,塞进一个装饰器;等目标线程执行时,先把上下文还原进去,执行完再清理。这就是所谓的"上下文传播"(Context Propagation)。
具体落地时有个硬性前提:你必须用框架托管或装饰过的线程池。裸写new Thread(...)、Executors.newFixedThreadPool(...)直接提交任务,上下文一定会丢。正确姿势是用ThreadPoolTaskExecutor,Spring 会自动帮你装饰;如果确实要自己 new,得配合ContextExecutorService.wrap(...)之类的包装方法。
同理,消息队列是另一个高频断链点。生产者发消息时把上下文序列化进消息头,消费者从消息头里反序列化恢复。Kafka、RabbitMQ 在 Spring 生态里都有对应的拦截器做这件事,但要求生产者和消费者都使用框架提供的模板类,比如KafkaTemplate、RabbitTemplate。如果你图省事用原生客户端直接发,那这一段必然断。
判断标准很简单:在 Zipkin 上看到一条链路的末尾突然出现一个孤立的 root span,而且这个 span 的时间点跟主链路对得上,基本就是上下文在某个环节丢了。
2.3 Sleuth 到 Micrometer 的迁移差异
老项目里满屏的spring-cloud-starter-sleuth依赖,现在要迁到:
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-brave</artifactId> </dependency> <dependency> <groupId>io.zipkin.reporter2</groupId> <artifactId>zipkin-reporter-brave</artifactId> </dependency>桥接层有两个选择,这是新手最容易懵的点:
| 桥接实现 | 底层追踪库 | 特点 |
|---|---|---|
micrometer-tracing-bridge-brave | Brave | 轻量、依赖少、兼容 Sleuth 时代的生态,官方默认推荐 |
micrometer-tracing-bridge-otel | OpenTelemetry | 标准更统一,跨语言场景强,但依赖体积和行为差异需要适应 |
选哪个?如果你的系统清一色是 Java + Spring,团队没有跨语言观测需求,选 Brave,坑最少、资料最多,而且从老 Sleuth 项目迁移过来几乎是零成本,因为 Sleuth 本身就是基于 Brave 的。如果团队里有 Go、Python 服务,并且已经在用 OpenTelemetry Collector,那就选 OTel 桥接,避免两套上报协议。
包名也从org.springframework.cloud.sleuth变成了io.micrometer.tracing。这带来一个实际问题:老的埋点代码要改。比如以前用@NewSpan注解,现在方法是同名但包换了;以前用Tracer接口,现在叫io.micrometer.tracing.Tracer。改的时候建议全局搜索org.springframework.cloud.sleuth,逐个替换,别指望 IDE 一键搞定,容易漏掉自定义的SpanCustomizer实现。
3. 把链路接入项目:按顺序做这几步
3.1 依赖与版本对齐,别在这里翻车
依赖装错导致的启动失败,占了新手问题的大半。核心原则:版本由 Spring Boot BOM 统一管理,不要手动指定版本号。Micrometer Tracing、Brave、Zipkin Reporter 三者之间有严格的兼容关系,你手动写一个1.2.3很可能跟 Boot 版本打架。
以 Spring Boot 3.x 为例,最小依赖集:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-brave</artifactId> </dependency> <dependency> <groupId>io.zipkin.reporter2</groupId> <artifactId>zipkin-reporter-brave</artifactId> </dependency>注意第一个是actuator,很多人忘了加,然后发现配置项不生效。Micrometer Tracing 的配置属性挂在 actuator 的管理端点体系下,虽然不加也能跑起来,但采样率、导出开关这类配置会失效,很难排查。
Spring Boot 2.x 的老项目则用 Sleuth 那一套:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-sleuth</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-sleuth-zipkin</artifactId> </dependency>这里有个典型的踩坑:Spring Cloud 版本必须和 Boot 版本对齐。Boot 2.7 配 Spring Cloud 2021.x,Boot 3.0 配 2022.x。如果版本跨度错了,你会看到启动时报NoSuchMethodError,而且错误栈指向的类名看起来毫无关联,非常迷惑。建议直接去查官方的版本对照表,别凭记忆写。
3.2 配置项逐条落地,参数背后都有账
下面这份配置是我在多个项目里沉淀下来的,逐行都有理由:
spring: application: name: order-service management: tracing: sampling: probability: 1.0 zipkin: tracing: endpoint: http://127.0.0.1:9411/api/v2/spansspring.application.name是必须的,Zipkin 用它作为服务名展示。如果多个服务名字一样,链路图会混成一团,这是很隐蔽的错误——本地起两个实例调试时经常忘记改端口,但服务名一样,最后在面板上根本分不清谁是谁。
endpoint指向 Zipkin 的接收地址。注意路径里的/api/v2/spans不能少,少了会返回 404,而客户端默认是异步批量上报,404 不会抛异常到业务代码,只在后台日志里偶尔冒一行 WARN,很容易被忽略。我第一次配的时候就是漏了这段路径,对着空面板找了一下午。
导出开关在 Boot 3 里是默认打开的,理论上不用写。但如果你发现数据传不上去,可以显式确认下这个属性。另外采样率在生产环境要算账:假设峰值 2000 QPS,平均一条 trace 有 15 个 span,每个 span 上报体积约 1KB,采样率 0.1 的情况下,一天的数据量是 2000 × 86400 × 0.1 × 15 × 1KB ≈ 25GB。这个量级下 Zipkin 默认的 Elasticsearch 存储压力不小,所以生产上常见做法是按业务重要性分级采样,或者用限流式采样器。
3.3 让日志和链路对上话
链路能看到调用关系,但看不到具体参数和业务信息。真正排查问题时,你需要的是"在 Zipkin 上看到某个慢 span → 拿到 traceId → 去日志里搜这个 traceId 的所有记录"。
日志格式里加上 traceId:
logging: pattern: level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"%X{traceId:-}是从 MDC 里取值,:-表示取不到时留空。输出大概长这样:
2024-05-11 10:23:45.123 INFO [order-service,a3f2c1d8e9b74a01,5c9d2e1f8a3b6c02] ...这个方括号里的三段分别对应:服务名、traceId、spanId。有了它,日志平台里直接按 traceId 检索,一次请求跨六个服务的所有日志就能按时间排好,比在 Zipkin 上一个个点 span 快得多。这是我个人认为这套方案里投入产出比最高的一个改动,虽然它严格来说属于日志范畴。
老项目如果用的是 Logback 1.3 之前,加上
%X{traceId}之后日志级别可能会异常变红,那是因为 MDC 里的值被误解析成颜色标记,需要在 pattern 里把 color converter 的位置调整一下。
3.4 用 Docker 起一个 Zipkin,两分钟的事
docker run -d --name zipkin \ -p 9411:9411 \ openzipkin/zipkin:latest起完访问http://127.0.0.1:9411就能看到界面。默认是内存存储,重启就清空,本地开发完全够用。生产环境要换成 Elasticsearch 或 Cassandra,加环境变量即可:
docker run -d --name zipkin \ -p 9411:9411 \ -e STORAGE_TYPE=elasticsearch \ -e ES_HOSTS=http://es-host:9200 \ openzipkin/zipkin:latest有一个细节:Zipkin 服务端和客户端的时区、时钟必须一致。容器默认用 UTC,如果宿主机是东八区而业务代码按本地时间打日志,你在面板上比对时间时会差 8 小时,白白怀疑人生。建议容器统一挂载时区或者统一用 UTC。
4. 断链、丢 Span、数据不上的排查链路
这一节我按"症状 → 可能原因 → 验证方式"的顺序展开,都是实打实踩过的。
4.1 Zipkin 面板一条数据都没有
先别急着改代码,按这个顺序过一遍:
第一步,确认采样率。前面说过默认值 0.1,改成 1.0 再看。这一步能解决一半的"空白面板"问题。
第二步,看本地上报日志。打开logging.level.zipkin2=DEBUG,或者把io.micrometer.tracing的日志级别调到 DEBUG。如果看到Reporting span之类的记录,说明客户端在发;如果全是Sending spans failed,问题出在网络或地址。
第三步,直连验证。用 curl 手动往 Zipkin 打一条,确认服务端可达:
curl -X POST http://127.0.0.1:9411/api/v2/spans \ -H "Content-Type: application/json" \ -d '[{"traceId":"1111111111111111","id":"2222222222222222","name":"manual-test","timestamp":1715400000000000,"duration":1000,"localEndpoint":{"serviceName":"manual"}}]'如果这条能出现在面板上,说明服务端没问题,锅百分百在客户端配置或网络。这招很好用,能把问题范围一刀切成两半。
第四步,检查服务名。如果spring.application.name没配,服务名会变成unknown_service,面板上确实有数据,但你可能因为搜索特定服务名而找不到。
4.2 链路只有半截,中间某个服务缺失
这是最常见的形态。表现是 A → B 有记录,B → C 完全没有。原因通常落在三处:
第一处是这个服务根本没引入依赖。团队协作时经常出现,某个边缘服务是别人搭的骨架,漏了追踪依赖。验证方式很简单,看该服务启动日志里有没有Brave或micrometer相关的初始化信息。
第二处是调用方式绕过了框架的客户端。比如用RestTemplate是能自动传播的,但如果你用HttpURLConnection或者自己封装的 HTTP 工具类直接发,Header 里的traceparent/X-B3-TraceId就不会被带上。下游拿不到上下文,自然生成新的 root span。这种问题在"老代码里有一段手写的 HttpClient"场景里特别多。
第三处是异步环节。跨线程池、跨 MQ 丢上下文,前面讲过原理。验证方式:在怀疑的代码前后打印MDC.get("traceId"),如果前后不一致或后面为空,就是断了。
log.info("before async: {}", MDC.get("traceId")); executor.submit(() -> log.info("in async: {}", MDC.get("traceId")));4.3 Span 有了但耗时对不上,或者连接关系是反的
这类问题更隐蔽,属于"数据看着有、但不可信"。
耗时对不上,多半是客户端和服务端的时钟漂移。Zipkin 的 span 时间戳来自各自机器,跨机器比较时时钟差个几百毫秒,父子 span 就会重叠甚至倒挂。容器环境尤其常见。解法是统一 NTP,或者接受这个误差、只看相对关系。
连接关系反了,通常是因为服务端也创建了一个 span,但没有正确关联。正常情况下,服务端收到带X-B3-TraceId的请求后,应该创建一个 child span 而不是新的 root。如果服务端的 span 在面板上显示成独立的 trace,说明它没读到 Header。检查一下是不是有网关或者 Nginx 把自定义 Header 过滤掉了——这个坑非常经典,X-B3-*开头的 Header 在某些反向代理的默认配置里会被丢弃,需要在代理层显式放行。
我遇到过一次,排查了半天代码,最后发现是前置的一层代理只透传白名单 Header。所以验证链路时,直接从内网 IP 直连服务,绕开所有代理,能快速排除这类干扰。
4.4 自定义埋点:什么时候需要,怎么写
框架自动埋的点覆盖 HTTP 调用、数据库访问、Redis、MQ,但业务语义它不知道。比如"风控校验"这一步,在自动埋点里只是几个方法调用拼起来的,看不出这是一次独立的业务动作。
这时候需要手动埋点:
@Autowired private Tracer tracer; public void checkRisk(Order order) { Span span = tracer.nextSpan().name("risk-check").start(); try (Tracer.SpanInScope ws = tracer.withSpan(span)) { // 业务逻辑 span.tag("risk.level", order.getRiskLevel()); span.tag("order.amount", String.valueOf(order.getAmount())); } catch (Exception e) { span.tag("error", e.getMessage()); throw e; } finally { span.end(); } }几个要点值得强调:
nextSpan()创建的是当前上下文的子 span,会自动继承 traceId 和 parentId;如果用newTrace()就变成新的 root 了,链路会断开,这是新手最容易写错的地方。
try-with-resources里的SpanInScope负责把 span 放进当前线程上下文,必须关,不关会导致后续所有操作都挂在这个 span 下,日志里的 spanId 也会错。
span.end()必须放在 finally 里。漏掉end()的 span 永远不会被上报,而且不会报错,你就看着面板上少了一段,怎么都找不到原因。这个坑我踩过不止一次。
标签(tag)要克制。每个 tag 都会占用存储和索引空间,不要往里面塞整个 JSON 或者大字符串。原则是:能用来搜索和过滤的字段才打 tag,详细内容走日志。
5. 生产环境落地时的取舍与代价
5.1 采样策略不是"调个数字"这么简单
开发环境 100% 采样,生产环境直接照抄会出事。除了前面算过的存储成本,还有一个更实际的问题:100% 采样本身会给业务带来可观测的延迟开销。每个 span 的创建、上下文切换、序列化、异步上报,虽然单个只有几十微秒,但在高 QPS 的链路上累积起来,以及上报线程占用的 CPU,都是真实成本。
实际可选的方案有这么几档:
| 策略 | 实现方式 | 适用场景 | 代价 |
|---|---|---|---|
| 固定概率 | probability: 0.1 | 大部分常规业务 | 低频故障可能刚好没被采到 |
| 限流采样 | 每秒最多 N 条 | 流量波动大 | 高峰期会丢真实故障 |
| 按请求头强制采样 | 特定 Header 触发 100% | 排查特定用户/订单 | 需要业务配合传 Header |
| 尾部采样 | Collector 侧判断 | 只想留慢请求和错误 | 架构复杂,需独立组件 |
我个人最推荐的组合是"低概率基线 + 强制采样开关"。基线设 0.05~0.1 保证大盘有数据;同时在网关层支持一个 Header(比如自定义的调试标记),一旦带上就 100% 采样。排查线上问题时,让客服或前端在复现时带上这个 Header,就能拿到完整链路,不用靠运气。
尾部采样(tail sampling)听起来很美——只保留慢请求和出错的请求,存储成本极低。但它要求所有 span 先在 Collector 里缓冲,等 trace 完整后再决定去留,这引入了额外的架构组件和延迟,小团队上这套属于过度设计。等到链路数据真的成为瓶颈了再考虑。
5.2 别让追踪系统自己成为故障源
这是最容易被忽略的一点。链路追踪本质上是旁路系统,它挂了不能影响业务。但现实中它经常反过来拖垮业务,原因通常是上报阻塞。
默认情况下上报是异步的,用的是有界队列。当 Zipkin 服务端响应慢或者不可达时,队列会堆满,然后有两种可能:要么丢弃(配置了丢弃策略,安全),要么阻塞调用线程(灾难)。所以要显式确认队列满时的行为。
management: zipkin: tracing: endpoint: http://zipkin:9411/api/v2/spans connect-timeout: 1s read-timeout: 5s超时要设短。1 秒的连接超时足够内网通信,读超时 5 秒也偏宽容了,实际可以更短。追踪数据晚到几秒没关系,业务线程被卡住就是事故。
另外,Zipkin 服务端本身要做隔离。别让它和业务服务抢同一台机器的资源,也别让业务网络的抖动影响到追踪上报。有条件的话,上报走独立的网络路径,至少要有明确的降级预案:当追踪系统不可用时,怎么快速关掉上报。
关闭的方式很简单,一个开关:
management: tracing: enabled: false但要提前验证这个开关真的有效——很多团队是出事时才想起来关,结果发现关不掉,因为配置中心的推送链路本身也有问题。
5.3 版本升级与依赖冲突的实际处理
从 Sleuth 迁到 Micrometer Tracing,不是改个依赖就完事。我梳理一下实际会遇到的问题:
包路径全变了。org.springframework.cloud.sleuth.Tracer→io.micrometer.tracing.Tracer,@NewSpan从org.springframework.cloud.sleuth.annotation移到io.micrometer.tracing.annotation。全局替换能解决 80%,剩下的 20% 是那些被封装在内部工具类里的引用,得靠编译报错一个个找。
@Async的配置方式也变了。老版本只需要@EnableAsync就自动传播,新版本要求显式配置ContextPropagatingTaskDecorator:
@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(32); executor.setTaskDecorator(new ContextPropagatingTaskDecorator()); executor.initialize(); return executor; } }ContextPropagatingTaskDecorator这个类是 Boot 3 提供的,专门用来装饰任务、搬运上下文。如果你从老项目迁过来,忘了加这一行,所有异步任务的链路会静默断掉——没有报错,只是面板上少了半截,非常难发现。
还有一个坑:如果项目里同时存在 OpenTelemetry 的 SDK 和 Brave,可能会因为自动配置冲突导致追踪不生效或者重复上报。检查mvn dependency:tree,确保只保留一套桥接实现。
6. 从链路数据里挖出真正有用的信息
6.1 慢请求的定位方法,别只看总耗时
拿到一个慢 trace,第一反应往往是"找最长的那个 span"。这个思路对,但不够。真正有价值的是看时间花在哪个环节、以及这个环节是否可以并行。
举个例子,一个下单接口总耗时 1200ms,拆开看:
| Span | 耗时 | 性质 |
|---|---|---|
| 订单服务接收 | 1200ms(root) | 总时长 |
| 校验用户 | 80ms | 串行 |
| 查询商品 | 200ms | 串行 |
| 校验库存 | 150ms | 串行 |
| 计算优惠 | 90ms | 串行 |
| 调用支付 | 600ms | 串行 |
| 写数据库 | 70ms | 串行 |
总计 1190ms,符合。这里最该盯的是支付那 600ms。但要追问:这段耗时是网络还是对方处理?在 span 上能看到下游服务的子 span 吗?如果支付服务有自己的 span 且只用了 100ms,那么剩下的 500ms 是网络/排队/连接池等待,方向立刻转到底层。如果支付服务没有子 span,说明对方没接入追踪,那就得从对方日志或者对方监控去看。
并行化的机会也要在这里看。校验用户、查询商品、校验库存这三个串行 430ms,如果业务上互不依赖,改并行能压到 200ms 左右。链路数据直接给了你做这个判断的依据,比拍脑袋强。
还有一点:关注 span 之间的空隙。两个子 span 之间有 100ms 的空白,说明时间花在业务代码本身(比如一次循环、一次序列化),而不是下游调用。这个观察在优化时很关键,很多人只盯着有名字的 span,忽略了空隙。
6.2 错误归因:从"哪个服务报错"到"哪层依赖失效"
链路里的错误标记不是简单的红叉。Micrometer 和 Brave 会把异常记录在 span 上,包括异常类型和消息。但真正定位问题时,你要看的是最靠下游的那个报错 span。
比如一条链路上 A → B → C → D 全红了,D 的异常是ConnectTimeoutException。那根因就在 D 的下一步依赖,而不是 A、B、C。反过来,如果只有 A 红,B、C、D 都正常,那说明是 A 自己的业务逻辑抛了异常,可能是参数校验失败。
这里有个技巧:在 span 上打业务标识。自动埋点只记 HTTP 状态码和方法名,对于"订单 ID 是哪个"这种信息一无所知。手动在关键入口打上 tag:
span.tag("order.id", orderId); span.tag("user.id", userId);出事时,你可以直接在 Zipkin 里按order.id搜索,而不是翻半天时间窗口。这个投入很小,收益极大。但要注意别打敏感信息,用户手机号、身份证号这类绝对不能进 tag,追踪系统的访问控制通常比业务系统松,泄露风险更高。
6.3 把链路和指标、日志三者的关系理清楚
很多团队把可观测性做成了三套互不相干的系统:Prometheus 看指标、Zipkin 看链路、日志平台看日志。出问题时在三者之间来回跳,靠时间戳手工对齐。
真正的做法是让三者能互相跳转。核心钩子就是 traceId:
- 指标 → 链路:不太可能,因为指标是聚合的,没有单个 traceId。但可以在指标告警触发时,去看那个时间窗口内的 trace 列表。
- 链路 → 日志:这是最常用的,拿到 traceId 直接搜日志。前面日志格式改造就是为了这个。
- 日志 → 链路:日志里有 traceId,反查 Zipkin。Zipkin 支持按 traceId 直接访问 URL:
http://zipkin:9411/zipkin/traces/{traceId}。 - 链路 → 指标:从 span 的 tag 里能拿到服务名和实例,跳转到该实例的监控面板。
我把每一条都实际配置过,其中收益最大的是"链路 → 日志"。原因是排查问题时,链路告诉你"哪里慢",日志告诉你"为什么慢",两者必须来回切,这个跳转路径顺了,效率提升非常明显。
至于日志量,链路本身不会显著增加日志量——它只是往每行日志里加了 30 来个字符。但如果因为有了链路就放松了日志级别的管理,DEBUG全开,那日志量会暴涨。这两件事要分开管。
6.4 一个容易被忽视的场景:排查"偶发"问题
链路追踪在稳定复现的问题上优势不明显——那种问题看日志就能定位。它真正的杀手锏是偶发问题。
比如"每天凌晨有几次接口超时"。这种问题你守着复现,一晚上都等不到;第二天看监控,只有一个延迟尖刺,没有任何上下文。有了链路且采样率足够,运气好的话能捞到几条慢 trace,一看就知道是凌晨的定时任务在并发写库导致的锁等待。
但这里有个现实问题:低频偶发问题很可能被采样率过滤掉。假设一天出现 5 次超时,采样率 0.1,期望能采到 0.5 条,大概率一条都没有。所以对于这类问题,前面提到的"强制采样开关"或者"慢请求优先采样"就变得很关键。
部分团队的做法是配一个基于耗时的采样器:正常请求按低概率采,超过阈值的请求强制采。Brave 支持自定义Sampler,实现一个判断逻辑就行。这个改动不大,但能把"偶发慢请求"的捕获率从 10% 提到接近 100%,对排查类问题价值极高。
7. 一些用久了才明白的细节
链路追踪这套东西,配通容易,用好难。我总结几个用久了才体会到的点,都是文档里不会写的。
别指望它替代 APM。Zipkin 是链路存储和展示,不是完整 APM。它没有 JVM 指标、没有方法级火焰图、没有慢 SQL 归因。如果要做到方法级,需要额外的 agent 方案,那是另一套体系。很多人接完链路发现"还是定位不到代码行",然后就觉得这套东西没用,其实是期望错了。链路负责告诉你"范围在哪",方法级细节要靠字节码增强或者代码埋点。
服务名的命名规范要提前定。order-service、order_service、OrderService三种写法混在一起,面板上就是三个服务,聚合统计全乱。这个规范要写进团队文档,比事后统一便宜得多。
span 的命名会影响可读性。自动埋点的 span 名一般是http get、SELECT这类,一堆一模一样的名字在树上看不出层次。手动埋点的时候,名字要能体现业务动作,risk-check、coupon-calc这种,一看就知道是哪一步。这个习惯在链路变长之后价值巨大。
清理策略和保留期要提前想。Zipkin 默认不清理内存数据,用 Elasticsearch 存储时也要配索引生命周期。保留 7 天和保留 30 天的成本差好几倍,而这个决策通常是在存储快满的时候才被想起。建议上线时就定好,别留隐患。
最后说个实操上的小习惯:每次排查完一个线上问题,把那条有价值的 traceId 记下来,贴到故障复盘文档里。时间长了你会积累一批"典型 trace",新人培训时直接拿这些看,比看架构图直观得多。这个习惯我坚持了很久,带过的人都说比讲十遍原理有用。