1. 微服务排障的痛点,以及Zipkin到底解决了什么
1.1 一次典型的"请求变慢"排查经历
先讲一个我实际遇到的场景。某天线上反馈说下单接口变慢了,原来200毫秒能返回,现在要3秒。作为负责这个服务的人,我第一反应是看自己的服务日志,结果一切正常,CPU、内存、GC都平稳。接着我去问下游服务的人,对方也说自己的接口耗时没有明显变化。
问题就出在这里:一个请求从网关进来,经过鉴权服务、订单服务、库存服务、支付服务,最后还要调一次第三方接口。每个服务都只看到自己的那一段,没人能告诉我们——这3秒到底耗在了哪一跳。
你可能会说,加日志啊,在入口和出口各打一条时间戳,不就算出来了吗?但真实场景远比这个复杂:调用链可能是多级的,A调B,B又调C和D,C还调E。就算你肯在每个服务里手动传递一个请求ID,再把日志捞出来按时间拼回去,面对几十个节点、上千行日志,拼一次就得大半天。更别说日志格式不统一、时间戳不同步这些额外问题。
这正是分布式链路追踪要解决的。而Zipkin,就是这个领域里最老牌、最经典的开源方案之一。它最早出自一家大型互联网公司的内部实践,后来开源出来,核心思路来自Google发表的一篇论文,讲的是如何在大规模分布式系统里做请求追踪。简单说,Zipkin能帮你回答三个问题:一次请求经过了哪些服务、每一跳花了多少时间、哪一段是瓶颈。
1.2 Zipkin的核心价值与适用范围
Zipkin的定位很清晰:采集分布式调用链的时序数据,存储起来,然后通过Web界面展示调用链和依赖关系。它本身不负责监控告警,也不做Metrics聚合,就是把"追踪"这件事做扎实。
适用场景包括:
- 微服务架构下的延迟问题定位,比如上面说的"接口变慢但不知道慢在哪"
- 调用关系梳理,比如新同学接手一个老系统,想知道某个核心接口背后到底串联了哪些服务
- 容量评估,比如搞清楚某个下游服务的调用量、平均耗时、错误率
不适用或者说不太擅长的场景也有:它不替代日志系统,你要是想看异常堆栈原文,还是得去日志平台;它也不做Metrics监控告警,CPU飙高、内存泄漏这类问题不归它管。理解边界很重要,很多团队把Zipkin当成万能排查工具,用了一段时间发现"还是得看日志",其实是用错了方向。
Zipkin生态里有个关键组件叫Brave,是Java语言的埋点库。我在项目里用的就是Brave加Zipkin这套组合。下面我把整个链路拆开讲:从核心概念、部署方式、Java接入、数据上报,到生产环境的高可用方案,最后汇总一些我踩过的坑。
2. 先说透三个词:Span、Trace、Annotation
2.1 用一次外卖配送来理解Trace和Span
Zipkin的数据模型有三个核心概念:Span、Trace、Annotation。很多人一上来就被这几个术语搞晕,我用外卖配送来打个比方。
假设你在外卖平台下单,平台把订单拆成几个环节:商家接单、骑手取餐、骑手配送、用户签收。整个配送过程就是一个Trace,它是从下单到签收的完整链路;每一个环节就是一个Span,骑手取餐是一个Span,商家接单是一个Span。
每个Span身上带着几样东西:
- Span ID:环节自己的唯一编号
- Trace ID:整条链路的全局编号,所有Span共享
- Parent Span ID:上一环是谁。比如"骑手配送"这个Span的Parent是"骑手取餐","骑手取餐"的Parent是"商家接单"
把Span按Parent关系串起来,就形成了一棵调用树,树的根是入口Span。Zipkin UI里展示的就是这样一棵树,请求先到哪个服务,然后调了谁、再调了谁,一目了然。
2.2 Annotation与二进制注解的作用
Span里除了ID和名称,还有一类关键数据叫Annotation。它表示的是某一时刻发生的事件,最典型的四个:
- cs(Client Send):客户端发起请求的时刻
- sr(Server Receive):服务端接收请求的时刻
- ss(Server Send):服务端处理完并返回的时刻
- cr(Client Receive):客户端收到响应的时刻
这四个时间戳组合起来就能算出关键耗时:
- 服务端处理耗时= ss - sr
- 网络传输耗时= cr - cs 减去服务端处理耗时
你可能注意到,同样的一个Span,在客户端和服务端分别记录。这是因为Zipkin的数据模型里,客户端视角和服务端视角会各打一组Annotation,Zipkin在展示时会把它们合并成一个Span来呈现。
除了这些标准时间戳,Brave还允许你往Span里塞自定义的Tag,比如记录SQL语句、订单号、用户ID。这些Tag在查询时可以直接过滤,排查问题非常有用。我习惯在入口网关的Span上打一个env=prod的Tag,后面按环境过滤Trace就很方便。
2.3 数据模型与ID传播
理解Zipkin的核心原理,绕不开ID怎么在服务间传播。一次调用从服务A到服务B,A在发出HTTP请求时,会把当前的Trace ID和Span ID塞到请求头里,B收到请求后从中取出来,以A的Span作为Parent,生成自己的子Span。
这里有个细节:B生成的Span ID是新的,但Trace ID必须沿用A传过来的。如果B自己重新生成一个Trace ID,整条链路就断了,UI里会出现两条独立的Trace,谁也关联不上谁。
Brave这套埋点库把ID的生成、传递、续接全都封装好了。你用RestTemplate也好,Feign也好,只要在初始化时接入Brave的拦截器,它就自动处理请求头的注入和提取。这也是为什么我强烈建议Java服务直接用Brave,而不是自己手写埋点——手写埋点的坑实在太多了,后面踩坑实录里我会细说。
3. 本地快速起一个Zipkin:部署选型与存储配置
3.1 最省事的Docker启动方式
想先跑起来看看效果,Docker是最快的路径。Zipkin官方提供了打包好的镜像,一条命令就能启动:
docker run -d -p 9411:9411 --name zipkin openzipkin/zipkin启动后访问http://localhost:9411就能看到Zipkin的UI界面。默认端口9411,既是UI的访问端口,也是接收Span数据的HTTP端口。
这个命令用的是内存存储,数据重启就没了。本地Demo没问题,想持久化就得换存储。Zipkin目前支持的存储后端有Cassandra、Elasticsearch、MySQL三种。
3.2 存储后端怎么选
这是我的核心建议,直接说结论:
| 存储后端 | 适合场景 | 注意点 |
|---|---|---|
| Cassandra | 大规模生产环境,数据量极大 | 运维成本高,团队不熟悉的话慎选 |
| Elasticsearch | 多数团队的务实选择 | 需要管理索引生命周期 |
| MySQL | 数据量小、只想快速验证 | 查询性能有限,Span一多就卡 |
我用过MySQL和Elasticsearch两种。MySQL胜在简单,建好库表就能跑,但数据量上来之后,查询Trace列表会明显变慢。ES是生产环境的主流选择,Span写入走Bulk接口,查询走ES的倒排索引,性能好很多。
用ES启动Zipkin只需要指定环境变量:
docker run -d -p 9411:9411 \ -e STORAGE_TYPE=elasticsearch \ -e ES_HOSTS=http://localhost:9200 \ openzipkin/zipkin这里建议额外加一个环境变量ES_INDEX_REPLICAS=0,单机ES不需要副本,能省掉不少写入开销。
3.3 生产环境的组件拆分思路
本地一条命令跑起来的Zipkin是单体形态:UI、查询服务、收集服务都在一个进程里。生产环境不建议这么玩,我见过把它直接部署到生产然后被流量打挂的案例。
生产级部署至少要把Zipkin拆成两个角色:
- Zipkin Collector:负责接收服务上报的Span数据,写入存储
- Zipkin Query + UI:负责读取存储,对外提供查询接口和Web界面
Collector可以多实例横向扩展,扛写入压力;Query服务可以独立扩容,应对线上查询和排障高峰。两者通过同一个存储后端共享数据,各自无状态,扩容非常简单。
如果你在用Kubernetes,可以分别部署Deployment,用Service做内部负载均衡。Collector的HPA建议按CPU和内存双指标配置,因为Span数据往往是突发性的,某个上游服务抖动就会产生大量Span。
4. Java服务接入Zipkin:Brave埋点的完整步骤
4.1 引入依赖与基础配置
Java服务接入Zipkin,标准做法是通过Brave埋点库。以Spring Boot项目为例,引入依赖:
<dependency> <groupId>io.zipkin.brave</groupId> <artifactId>brave-instrumentation-spring-web</artifactId> <version>5.16.0</version> </dependency> <dependency> <groupId>io.zipkin.reporter2</groupId> <artifactId>zipkin-sender-okhttp3</artifactId> <version>2.16.3</version> </dependency>然后是配置类,创建一个Tracing实例:
@Configuration public class TracingConfig { @Bean public Tracing tracing() { return Tracing.newBuilder() .localServiceName("order-service") .spanReporter(asyncReporter()) .build(); } private SpanReporter asyncReporter() { Sender sender = OkHttpSender.create("http://zipkin-collector:9411/api/v2/spans"); return AsyncReporter.builder(sender) .queuedMaxSpans(10000) .build(); } }这里的localServiceName特别重要,它决定了在Zipkin UI里Span显示的服务名。如果所有服务都忘了配置这个字段,UI里会显示一堆空服务名,排查问题全靠猜,所以我在团队里强制要求每个服务必须配置。
4.2 采样率怎么定
采样率是接入Zipkin时最需要想清楚的一个参数,没有之一。
Span数据量跟调用量成正比。假设你的服务QPS是2000,每个请求平均产生5个Span,那就是每秒10000个Span。全量上报,存储压力非常大,成本也不低。而且从排查问题的角度看,偶发性的慢调用和错误,低采样率下不一定能抓到,这又让采样变成了一个权衡。
Brave默认的采样规则是每个Trace有10%的概率被采集。可通过配置调整:
Tracing.newBuilder() .sampler(CountingSampler.create(0.1f)) .build();实际操作中,我建议采样率按这个思路定:
- 核心交易链路:100%采样,至少对错误和慢请求全采
- 普通业务接口:10%~20%采样
- 日志型旁路接口:1%~5%采样
更好的方案是动态采样:正常流量采10%,一旦接口出现错误率上升或P99延迟恶化,自动把采样率调高到100%。Brave支持自定义Sampler实现,我写过一版基于配置中心和熔断状态的动态采样器,思路就是Sampler接口里根据当前服务健康状态决定是否采集。
4.3 自定义埋点与跨线程传递
框架自动埋点之外,业务代码里有时候需要手动创建Span。比如一个定时任务,它不经过HTTP入口,没有拦截器帮我们开Span,就得手动来做:
@Autowired private Tracing tracing; public void process() { Span span = tracing.tracer().nextSpan().name("sync-order-task").start(); try (Scope ws = tracing.tracer().withSpanInScope(span)) { // 业务逻辑 span.tag("batch.size", String.valueOf(size)); } finally { span.finish(); } }withSpanInScope这行很容易被忽略。它的作用是让当前线程的上下文持有这个Span,后续在这个线程里发起的HTTP调用,Brave才能自动把它挂到当前Span下面。如果漏了这一步,你手动创建的Span就是孤立节点,和后面的子调用串不起来。
跨线程传递是另一个高频问题。Java里用线程池异步处理是家常便饭,但Span上下文存在线程局部变量里,ThreadPoolExecutor的新线程不会自动继承。Brave提供了CurrentTraceContext的装饰器来解决:
Tracing.newBuilder() .currentTraceContext(ThreadLocalCurrentTraceContext.newBuilder() .addDecorator(ThreadContextDecorator.create()) .build()) .build();装饰器生效后,当你把任务提交给线程池时,当前Span上下文会被自动复制到新线程。这个知识点我在团队里讲过很多次,只要用了异步,就必须确认这个装饰器配上了,否则一半以上的Span会变成孤儿节点。
5. 从埋点到查询:一次完整Trace的生命周期
5.1 上报链路:HTTP还是Kafka
Span在客户端生成之后,需要上报到Zipkin Collector。Brave支持两种主流上报通道:HTTP和Kafka。
HTTP方式最简单,服务直连Zipkin Collector的/api/v2/spans接口,通过OkHttpSender异步批量发送。缺点也明显:上报路径和业务路径耦合在同一张网络里,如果Collector出问题或网络抖动,服务端的HTTP连接池可能被占满,反过来拖垮业务。
Kafka方式更稳。Span数据先写到Kafka的Topic,Zipkin Collector从Kafka消费数据再写存储。这样业务服务不再直连Collector,中间隔了一层消息队列,削峰填谷,解耦效果好。
我用Kafka通道的配置是这样的:
Sender kafkaSender = KafkaSender.create("bootstrap.servers=172.16.0.11:9092,172.16.0.12:9092");Collector侧对应的启动参数加上Kafka地址即可。Kafka Topic建议按环境命名,比如zipkin-prod、zipkin-staging,后面做多环境隔离时会更顺手。
5.2 在Zipkin UI里还原一次慢调用
Span上报成功后,打开Zipkin UI,可以按服务名、时间段、耗时等条件查询Trace。搜索页第一个输入框通常是服务名,输入后点Find Traces,会出现符合条件的Trace列表,每条显示总耗时和Span数量。
点进某一条Trace,会看到依赖树视图,最上方是入口Span,往下是逐级调用的子Span。每个Span的右侧有耗时条,颜色深浅表示耗时长短,瓶颈一目了然。比如一条Trace总耗时2.8秒,展开后发现inventory-service这个Span就占了2.1秒,那问题大概率在库存服务,可以接着点进这个Span看它的cs/sr/ss/cr四段时间戳,判断是服务端处理慢还是网络传输慢。
这里分享一个实操技巧:查慢Trace的时候,加一个耗时筛选条件,直接按>2000ms过滤。Zipkin UI支持按最小耗时搜索,这样不用在成千上万条Trace里翻,直接看最慢的那批。
5.3 依赖分析图怎么看
Zipkin的依赖分析功能会在服务之间生成一张调用关系图,标明每个服务对另一个服务的调用次数和平均耗时。这个功能由zipkin-dependencies定时任务生成,默认每天跑一次。
我第一次用这张图的时候惊了一下——原来某个核心服务背后挂了二十多个下游,其中好几个我们自己都不清楚是谁在调用。这张图对于梳理系统架构、评估下游故障影响范围特别有价值。
不过依赖分析有延迟,实时性不强。如果你需要按小时甚至分钟级别的依赖关系,建议另接一套日志分析来实现。Zipkin的依赖图更适合做日级别的架构梳理和容量规划。
6. 生产环境进阶:Kafka缓冲、ES索引与多环境隔离
6.1 为什么要用Kafka做缓冲
很多团队在Zipkin部署初期,都是服务直连Collector,跑了一段时间遇到一个共性问题:大促或上游故障时Span数量暴增,Collector处理不过来,开始丢数据。
丢数据的表现很隐蔽,你查一个Trace,发现只有前两跳,后面全没了。这其实是Collector的HTTP接收线程忙不过来,连接被拒绝或者超时丢弃了。
引入Kafka做缓冲,相当于给Collector加了一个无限长的等待队列。业务服务只管把Span往Kafka里写,写入速度是毫秒级的,不会阻塞业务线程;Collector根据自己的消费能力慢慢从Kafka里拉数据。即使Collector短暂宕机,数据也都在Kafka里存着,恢复后继续消费,不丢失。
Kafka的Topic分区数建议按Span写入量来估算。我这边QPS峰值大约2万Span每秒,设置了12个分区,消费端Collector起了4个实例,每个实例消费3个分区,写入35节点的ES集群,完全没有压力。
6.2 ES索引策略与生命周期管理
Zipkin使用ES存储时,默认按天创建索引,索引名类似zipkin-span-2025-01-15。生产环境跑久了会发现索引数量非常多,如果不做生命周期管理,ES集群会被这些索引拖垮。
我推荐用索引生命周期策略(ILM)来做三阶段管理:
- 热阶段:最近3天的索引,保持副本数为1,写入和查询都在这个阶段
- 温阶段:3天到15天的索引,可以关闭副本,降低存储开销
- 冷阶段:15天以上的索引,强制合并Segment并转入冷存储,配到30天左右删除
ES的ILM策略在Kibana里可以可视化配置,也可以在Zipkin启动时设置索引生命周期策略名称。这里有个细节:Zipkin的索引模板要提前确认ILM策略的名称匹配,否则策略不生效。我遇到过部署之后发现索引不断增长、ILM完全没起作用的情况,最后排查发现是索引模板里的策略名写错了。
6.3 多环境共用一套Zipkin的隔离方案
中小团队通常会共用一个Zipkin集群,但开发、测试、预发、生产多个环境的Span都会写进来。如果不做隔离,UI里查到的Trace就是大杂烩,开发环境的小流量和生产的核心链路混在一起,干扰判断。
我的做法是两件事并行:
第一,存储层面隔离。不同环境使用不同的ES索引前缀。Zipkin启动参数里可以配置ES_INDEX_PREFIX,比如生产配zipkin-prod,预发配zipkin-staging。这样数据物理隔离,查询性能和容量管理都干净。
第二,服务名规范。要求每个服务在Brave配置localServiceName时带环境前缀,比如prod-order-service和dev-order-service。这样即使误查了全量索引,也能从服务名分辨环境。
还有一个容易被忽略的点:时间同步。Zipkin的Span排序依赖各个服务器的时间戳。服务器之间时钟偏差超过几百毫秒,Trace里Span的顺序就会错乱,耗时计算也会失真。生产环境务必配置NTP服务统一校时,这一点我在踩坑部分还会展开。
7. 踩坑实录:那些让Span神秘消失的原因
7.1 最常见:采样率被覆盖
有一次同事反馈说某个服务在Zipkin里完全看不到数据,查了配置、确认了依赖包、看了日志都没发现问题。最后定位到原因:这个服务内部引入了另一个公共组件,组件里自带了一个Brave配置类,把采样率设成了0,直接覆盖了业务代码里的配置。
Spring Boot的Bean注入是有优先级的,如果你的业务配置类和公共组件的配置类都创建了TracingBean,谁后加载谁生效,行为很不可控。从那次之后,我在团队立了一个规矩:Tracing的采样率配置统一走配置中心,不走代码硬编码,公共组件里禁止出现Sampler相关的Bean定义。
另外,Brave日志里每次启动会打印一行采样器配置信息,接入时一定要看一眼这行日志,确认采样率真的符合预期,别等数据丢了才发现。
7.2 异步线程丢上下文
这是第二个高频坑。服务用了@Async注解或者手工线程池,子线程里发起的调用没有Span信息,表现为大树下面的分支缺失。
之前说过解决方案是加ThreadContextDecorator装饰器。但还有一个细节值得补充:自定义线程池和@Async默认线程池的处理方式不太一样。@Async走的是Spring的TaskExecutor,如果这个Executor包装了线程局部变量的传递机制,装饰器才能真正生效。我建议统一使用org.springframework.context.support.NamedThreadLocal配合Brave的方式,或者干脆用LazyTraceExecutor来包装你的线程池:
Executor wrappedExecutor = new LazyTraceExecutor(tracing, originalExecutor);LazyTraceExecutor是Brave提供的现成包装类,它能在提交任务时自动捕获当前Span上下文,并在执行时恢复。用这个包装之后,异步线程丢上下文的问题基本绝迹。
7.3 时间不同步导致排序错乱
前端展示的调用链是按时间排列的,如果各服务所在机器的时钟不一致,会出现一种诡异的现象:子Span的时间戳比父Span还早,UI里整条链路看起来是倒着跑的。
我处理过一起跨机房的问题。两个机房的服务器时间差了将近3秒,导致从机房A发起调用、机房B处理的Span,在Zipkin里显示的处理耗时是负数,排查了很久才发现是时钟问题。
解决方案就是前面提到的NTP统一校时。这里再补充一条经验:校验时间同步不要只看某台机器,要抽样多台对比,特别是新扩容的机器,经常因为初始化流程没走完导致NTP配置没生效。
7.4 网关没有透传Header
用Spring Cloud Gateway或者自研网关接入Zipkin时,经常出现一个问题:网关自身有Span,但下游服务的Trace串不起来。
原因在于链路追踪的ID是靠HTTP Header传递的,标准Header是X-B3-TraceId、X-B3-SpanId、X-B3-Sampled这几个。如果网关在转发请求时,把这些Header过滤掉了,下游服务就不知道自己是调用链的一部分,会新开一条Trace。
排查方法很简单:在网关所在机器的抓包工具里过滤一下X-B3,看请求头里有没有这些字段。如果网关在转发前会把请求Header清空重建,那就要显式把B3 Header加到转发Header列表里。我见过一个项目就是因为网关层统一清理了所有请求头,导致整个链路追踪形同虚设,排查了整整两天。
8. 关于Zipkin我的几点体会
做链路追踪这几年,Zipkin给我最大的价值不是那个UI界面,而是逼着团队把服务间的关系彻底理清了。很多微服务系统跑着跑着就变成一团乱麻,没人说得清谁在调谁。Zipkin的依赖图就像一面照妖镜,把这些隐藏的调用关系全部照出来。
如果你所在团队用的是Java技术栈,Zipkin加Brave依然是很务实的选择。相比其他一些方案,它部署简单、文档丰富、社区活跃,遇到问题能搜到大量现成答案。对于中小团队,不要一上来就追求全量采样和复杂的存储架构,先跑通端到端链路,把基础数据积累起来,再根据实际需求逐步演进,这才是最稳妥的路径。
最后再分享一个小技巧:把Zipkin的查询页面固定到你自己团队IM的快捷菜单里。线上出现慢接口时,大家第一个动作不是翻日志,而是打开Zipkin按Trace ID查一下,很多问题的定位时间能从小时级缩短到分钟级。这个习惯养成了,链路追踪的真正价值才算发挥出来。