首页 / 资讯中心 / 文章详情

Dubbo链路追踪实战:基于Zipkin注解方式接入全链路观测

Dubbo链路追踪实战:基于Zipkin注解方式接入全链路观测 ★ FEATURED ARTICLE
你是不是也有过这种经历线上一个 Dubbo 接口偶发超时消费端日志里已经看到调用失败提供端运行日志却一切正常两边各查各的对时间戳对到怀疑人生。今天我要聊的是用 Zipkin 给 Dubbo 做链路追踪而且是注解方式的接入不碰 Dubbo 内核也不用在业务方法里埋一坨手动埋点代码。这套方案能帮你把一次跨应用调用的完整链路串起来在 Zipkin UI 里直观看到某个接口到底慢在哪个服务、哪一段适合正在用 Dubbo 做微服务拆分、又想快速建立调用链观测能力的团队。哪怕你之前没接触过链路追踪也没关系原理、配置、坑我会一次性讲透照着也能落地。1. 先想明白Dubbo 链路追踪到底在追什么1.1 一次调用在 Zipkin 里长什么样很多人一上来就急着配依赖、写注解结果链路出了还看不懂 UI原因就是没搞明白 Zipkin 里的几个基本概念。一次 Dubbo 调用在 Zipkin 眼中是一个 Tree最外层是一个 Trace代表一次完整的业务请求Trace 里有若干个 Span每个 Span 代表一次具体的调用行为比如订单服务调用用户服务就是一个 Span用户服务查数据库又是下一个 Span。Span 和 Span 之间通过 Parent Span ID 挂成父子关系。举个例子用户点了一个“查询订单详情”按钮订单服务收到请求后去调用户服务拿用户信息用户服务内部再查缓存和数据库。Zipkin 中会产生一个 TraceId订单服务收到请求时创建 root span往下调用户服务时产生子 span用户服务内部再产生孙 span。你看到的就是一棵倒挂的 span 树。排查慢请求时不再是翻日志逐条猜而是直接看整棵树上哪个 span 耗时占比最大哪个节点报错链路会清清楚楚摆在那。这里的关键是TraceId 是整条链路的唯一标识SpanId 是每个节点的唯一标识ParentSpanId 负责把节点挂到正确的位置。如果这三个值传丢了哪怕你上报了一堆 SpanZipkin 也拼不成一条完整 trace只会看到零散的碎片数据。后面排查部分我会再细说这是最常见的“链路不出来”的原因。1.2 注解方式和传统 XML 埋点的区别在 Dubbo 老项目里很多人习惯用 XML 配置服务暴露和引用埋点过滤器也是写在dubbo:provider filterxxx/这类标签里的。注解方式不是完全扔掉配置而是把服务暴露、服务引用、过滤器启用这些事换成 Java 注解来表达。比如服务提供者用DubboService(filter zipkinProvider)而不是dubbo:service ...服务消费者用DubboReference(filter zipkinConsumer)而不是dubbo:reference ...这样做最大的好处有三个第一代码和配置在一起服务接口和埋点开关一目了然不会出现 XML 文件里配了一堆过滤器、业务代码里却完全不知道的局面第二和 Spring Boot 的风格统一新同学接手成本低第三可以按服务粒度精确控制埋点开关某个接口不想采样去掉 filter 属性就行不用动全局配置。有一点要先打好预防针注解只负责“开不开”Dubbo 过滤器本身的加载机制还是离不开 SPI 配置。这不是玄学是 Dubbo 的扩展点设计决定的。所以后面会有一步去写META-INF/dubbo/org.apache.dubbo.rpc.Filter文件这一步不能省否则注解上的 filter 属性根本找不到对应的类。2. 整体设计思路基于 Brave 的注解式埋点方案2.1 trace 上下文在 Dubbo 之间是怎么传的链路追踪要成立最核心的问题不是“埋点”而是“传递”。一次调用从消费者到提供者如果每个服务各自生成一个毫无关系的 TraceId那不管埋多少点都没用。所以跨进程传递 trace 上下文是链路追踪的命根子。Zipkin 的官方埋点库叫 BraveBrave 里的Propagation组件专门负责把 TraceId、SpanId、ParentSpanId 等信息塞进请求载体。Dubbo 的 RPC 请求有一个天然的好处RpcInvocation自带 attachments 机制相当于一个附加参数透传 Map。消费者把 trace 上下文写进 attachments提供者再从 attachments 里取出来链路上下文就这样跟着 RPC 请求跑到了下一个服务。这里我用了 Brave 的自动传播格式默认就是 B3 格式包含四个 keyX-B3-TraceId、X-B3-SpanId、X-B3-ParentSpanId、X-B3-Sampled。你可以把 B3 想象成快递单Dubbo 的 attachments 是快递盒快递盒里装着业务参数快递单上写着这票货属于哪个链路、哪个父节点。2.2 为什么选择自定义 Filter 而不是业务埋点实现 Dubbo 链路追踪有几种常见路线最省事的是引入一个现成的官方 starter但很多时候现成方案和项目里用的 Dubbo 版本对不上或者公司内部网络限制没法直接拉依赖所以我更推荐自己写 Filter 来实现理由很实际。Dubbo 本身提供了 Filter 扩展点作用在每次 RPC 调用前后这就像中间件自带的 AOP。把链路追踪逻辑放到 Filter 里业务代码完全无感知不需要在每个方法开头去tracer.startSpan()也不需要改造接口签名。而且 Filter 是按服务、按调用方可以独立配置的如果想灰度可以在注解上单独开想回退把 filter 属性去掉即可比改业务代码安全得多。有人会问那直接在业务方法里用Tracer埋点不行吗当然行但埋点和业务逻辑纠缠在一起代码可读性会变差。一个订单查询方法里散落着十几个 span 操作别人看代码时根本分不清哪些是业务逻辑哪些是观测逻辑。用 Filter 把这些脏活隔离掉业务方法干干净净链路追踪的改动也集中在固定目录下维护成本低很多。2.3 方案里的各个组件怎么分工把这套方案里的角色理清楚后面看代码就不会晕。组件职责Zipkin Server接收并存储 span 数据提供查询和 UI 展示Brave Tracing生成 TraceId、SpanId维护当前 span 上下文AsyncReporter OkHttpSender把 span 异步批量上报到 Zipkin ServerProvider 端 Filter从 Dubbo attachments 中提取 trace 上下文创建 server spanConsumer 端 Filter创建 client span并把 trace 上下文注入 attachmentsDubboService/DubboReference注解方式暴露/引用服务并指定过滤器名称其中最容易忽略的是 AsyncReporter 的作用。如果每次请求都同步把 span POST 到 Zipkin那链路追踪本身就会拖慢接口响应。用 AsyncReporter 做异步批量上报Filter 里的操作基本都是纯内存操作对业务接口的耗时影响可以忽略。3. 实操前置版本选型与 Zipkin 环境准备3.1 版本选型与兼容性坑在做这套方案之前最值得先花时间的是版本确认。我最早接的时候直接引入了当时最新的 Brave结果 Dubbo 版本还是老的Filter 接口方法签名对不上编译都过不了。后来固定下来一套组合在项目里跑了很久都很稳Spring Boot 2.7.xApache Dubbo 2.7.xBrave 5.13.xZipkin Server 2.23.x强调一下Dubbo 2.7.x 和 Dubbo 3.x 的包名、Filter 接口、attachment 方法都有差异下面代码我是以 Dubbo 2.7.x 为例的你如果用的是 3.x要重点核对org.apache.dubbo.rpc.Filter和invocation.getAttachments()这两个地方。很多网上资料混用新旧版本抄过去很容易踩坑。3.2 启动 Zipkin Server本地调试最简单的办法是用 Docker 起一个单机版 Zipkin。执行docker run -d --name zipkin -p 9411:9411 openzipkin/zipkin:2.23启动完成后浏览器访问http://localhost:9411/zipkin/能看到查询界面就说明 Zipkin 起来了。默认存储是内存重启数据就没了本地够用。生产环境不要用内存模式下面常见问题部分我会专门说存储选型。如果公司环境不允许用 Docker也可以直接下载 Zipkin 的可执行 jar用 Java 8 以上环境跑java -jar zipkin-server-2.23.2-exec.jar反正只要能访问到 9411 端口就行。3.3 Maven 依赖引入在需要接入的 provider 和 consumer 工程里引入以下核心依赖dependency groupIdio.zipkin.brave/groupId artifactIdbrave/artifactId version5.13.10/version /dependency dependency groupIdio.zipkin.reporter2/groupId artifactIdzipkin-sender-okhttp3/artifactId version2.16.3/version /dependencybrave负责生成和管理 trace 上下文zipkin-sender-okhttp3负责把 span 数据批量发送到 Zipkin Server。这两个依赖是整套方案的底座别漏。如果你用的是 Spring Boot 的 BOM 管理依赖也可以把版本号交给 BOM这里列出具体版本只是为了让你知道兼容范围。4. 注解方式埋点完整实现4.1 Provider 端 Filter把 trace 上下文接住Provider 端的任务有两个从 attachments 里把消费者传过来的 trace 上下文提取出来然后创建一个 server span代表“我收到了这次调用并处理了多久”。如果提取不到上下文说明这是链路起点就直接生成一个新的 root span。import brave.Span; import brave.Tracer; import brave.TraceContext; import brave.propagation.TraceContextOrSamplingFlags; import org.apache.dubbo.common.extension.Activate; import org.apache.dubbo.rpc.*; import java.util.Locale; import java.util.Map; public class ZipkinProviderFilter implements Filter { private final Propagation.GetterMapString, String, String getter (carrier, key) - { if (carrier.containsKey(key)) { return carrier.get(key); } return carrier.get(key.toLowerCase(Locale.ROOT)); }; Override public Result invoke(Invoker? invoker, Invocation invocation) throws RpcException { Tracer tracer SpringContextHolder.getBean(Tracer.class); TraceContextOrSamplingFlags extracted tracer.propagation().extractor(getter).extract(invocation.getAttachments()); Span span tracer.nextSpan(extracted) .kind(Span.Kind.SERVER) .name(invoker.getInterface().getSimpleName() # invocation.getMethodName()) .start(); try (Tracer.SpanInScope ws tracer.withSpanInScope(span)) { return invoker.invoke(invoker, invocation); } catch (RuntimeException e) { span.error(e); throw e; } finally { span.finish(); } } }这里用tracer.propagation().extractor(getter)把 attachments 里的 B3 信息解出来然后tracer.nextSpan(extracted)会基于父 span 创建子 span。kind(SERVER)很关键它让 Zipkin 知道这是服务端处理节点。withSpanInScope的作用是让当前线程内部的后续调用都能拿到这个 span 作为 parent比如 Dubbo 服务里再调数据库新建的子 span 会自动挂到当前 server span 下面。SpringContextHolder是我项目里的一个静态工具类后面统一说明。你也可以在 Filter 里通过别的方式拿 Tracer但静态持有者最不容易出幺蛾子。4.2 Consumer 端 Filter把链路信息送出去Consumer 端的任务和 Provider 刚好相反创建 client span并且把当前 trace 上下文塞进 Dubbo attachments让 Provider 能接住。import brave.Span; import brave.Tracer; import brave.propagation.Propagation; import org.apache.dubbo.rpc.*; import java.util.Map; public class ZipkinConsumerFilter implements Filter { private final Propagation.SetterMapString, String, String setter (carrier, key, value) - carrier.put(key, value); Override public Result invoke(Invoker? invoker, Invocation invocation) throws RpcException { Tracer tracer SpringContextHolder.getBean(Tracer.class); Span span tracer.nextSpan() .kind(Span.Kind.CLIENT) .name(invoker.getInterface().getSimpleName() # invocation.getMethodName()) .start(); try (Tracer.SpanInScope ws tracer.withSpanInScope(span)) { tracer.propagation().injector(setter) .inject(span.context(), invocation.getAttachments()); return invoker.invoke(invoker, invocation); } finally { span.finish(); } } }注意 inject 的位置必须在invoker.invoke之前否则 attachments 都发出去了Provider 什么都拿不到。这看起来像是废话但很多人就是喜欢把注入逻辑放在调用之后或者放在返回结果的处理里链路当然连不上。这里生成的是 client span语义上是“我作为客户端发起了一次 RPC”。Provider 端那一个是 server span两个 span 通过同一个 TraceId 和 ParentSpanId 串起来合在一起才是一个完整的跨服务节点。4.3 SPI 注册与 Tracing Bean 配置Dubbo 的 Filter 是通过扩展机制加载的所以我们需要在src/main/resources/META-INF/dubbo/目录下创建一个名为org.apache.dubbo.rpc.Filter的文件文件名不能改这是 Dubbo SPI 约定的路径。文件内容zipkinProvidercom.example.common.trace.ZipkinProviderFilter zipkinConsumercom.example.common.trace.ZipkinConsumerFilter等号左边是过滤器名称右边是全限定类名。我在两个服务工程里都放了这份文件因为 provider 和 consumer 的角色在链路里是相对的同一个服务可能既是消费方又是提供方两边都注册比较省心。接下来在配置类里创建 Tracer 相关 Beanimport brave.Tracing; import brave.sampler.Sampler; import zipkin2.reporter.AsyncReporter; import zipkin2.reporter.Reporter; import zipkin2.reporter.okhttp3.OkHttpSender; import brave.Span; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class ZipkinTracingConfig { Bean public ReporterSpan zipkinReporter() { return AsyncReporter.builder( OkHttpSender.create(http://127.0.0.1:9411/api/v2/spans) ).build(); } Bean public Tracing zipkinTracing(ReporterSpan reporter) { return Tracing.newBuilder() .localServiceName(user-service) .sampler(Sampler.create(1.0f)) .spanReporter(reporter) .build(); } }localServiceName一定要改成你当前应用的名称比如订单服务就叫order-service用户服务就叫user-service。Zipkin UI 上的搜索就是靠这个名称过滤的一旦名称配错后面的查询就会对不上。不同服务的localServiceName必须不同否则上报之后分不清节点。Sampler.create(1.0f)表示 100% 采样本地调试阶段建议全采。生产环境一般用0.1f或者 10% 左右后面会细说。Tracer 的 Bean 已经注册到 Spring 容器了我在 Filter 里是通过一个 SpringContextHolder 静态工具类拿的import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.stereotype.Component; Component public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext context; Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { context applicationContext; } public static T T getBean(ClassT clazz) { return context.getBean(clazz); } }这个做法不太优雅但极端可靠。因为 Dubbo 的 SPI 过滤器实例不是由 Spring 管理的Spring 的自动注入对它不起作用而静态持有者绕过了这层限制在 Filter 真正执行时总能从容器拿到单例 Tracer不会出现注入为 null 的问题。4.4 在注解上启用链路追踪下面就是重头戏注解方式怎么用。Provider 端在DubboService上加上filter属性DubboService(filter zipkinProvider, timeout 3000) public class UserServiceImpl implements UserService { Override public User getById(Long id) { // 业务逻辑照常写 return userMapper.selectById(id); } }Consumer 端在DubboReference上加上filter属性Service public class OrderServiceImpl { DubboReference(filter zipkinConsumer, timeout 3000) private UserService userService; public OrderDetail getOrderDetail(Long orderId) { User user userService.getById(orderId); // ... } }这样就完成了注解方式接入。Dubbo 在处理这个DubboService或DubboReference时会拿着 filter 属性里的zipkinProvider、zipkinConsumer去 SPI 文件里找对应的过滤器类然后在 RPC 调用链路里生效。如果你不想在每个注解上重复写 filter也可以在application.yml里做全局配置dubbo: provider: filter: zipkinProvider consumer: filter: zipkinConsumer这两种方式不冲突。全局配置更适合“所有服务都要埋点”的场景注解属性适合“某些服务特殊开关”的场景。我在项目里是先用全局配置把全链路打开再对个别不想被追踪的接口在注解上排除。Dubbo 的 filter 参数支持用减号排除比如filter-zipkinProvider这个技巧很实用。4.5 验证从一次调用到看到完整链路按照上面的配置启动 provider 和 consumer 后调用一次订单查询接口然后打开 Zipkin UI在搜索框里输入order-service点查询应该能看到一条 trace。点进去之后会看到类似这样的信息一个 trace整体代表这次订单详情查询第一个 span 是 consumer 端发起的调用名称大概是OrderController#getOrderDetail或者你业务里的方法名下面挂着几个子 span包括UserService#getById每个 span 旁边都有耗时点进去能看到实际节点名称和上报时间如果你的屏幕上只看到孤零零的一个 span或者什么都搜不到别急着怀疑配置先去看下一章的问题排查。我敢说十次有八次不是代码写错而是 trace 上下文没传过去或者上报链路断了。5. 常见问题与排查技巧实录5.1 数据上报了但 Zipkin UI 里没有 trace这个问题我踩的次数最多。明明调用也清空了日志Zipkin Server 也启动了界面就显示空通常从三个方向排查。第一步查采样率。看你的Sampler.create(1.0f)是不是被框架自带配置覆盖了。有时候 starter 里默认带了一个 0.1 的采样器你自定义的 Tracing Bean 没生效最终只有 10% 请求采进来查的时候当然时有时无。最粗暴的验证方法把采样率改成 1.0重启后多调几次再查。第二步查 reporter。AsyncReporter是异步的数据不是立刻发如果 Zipkin 地址写错了错误只会在后台日志打出来UI 上没有任何提示。把OkHttpSender.create的地址改成 Zipkin 的/api/v2/spans确认端口和路径都对。第三步查 filter 是否真正生效。在 Filter 里临时打一条日志比如在invoke方法入口打印当前线程名称和 filter 名称然后触发一次调用看日志有没有输出。没输出就说明 filter 名称和 SPI key 对不上排查注解和 SPI 文件里的名字是不是完全一致包括大小写。5.2 TraceId 一致但 Span 错乱有一种很迷惑的情况Zipkin 里能看到一堆 span但是树形关系乱七八糟要么父子接不起来要么同一个 span 被反复挂到不同父节点。这种基本是 B3 注入出了问题。最常见的原因是 Consumer 端 Filter 把上下文注入了 attachments但 Dubbo 在传输过程中对 attachments 的 key 做了一些转换或者你取的时候大小写没对上。我在 getter 里特意做了小写兜底就是因为在某个项目里踩过这个坑provider 端拿到的是小写 key直接按原样取就取不到。解决思路很简单在 Provider Filter 的 extract 之前把invocation.getAttachments()的 key 打出来看一眼确认实际传输的 key 是什么样的然后调整 getter。不要凭文档里的大写 key 想当然。另一种情况是父子关系错乱往往是同一个 Provider 应用被多个 Consumer 同时调用但 TraceId 没变SpanId 生成又冲突。Brave 底层生成 span id 时用的是随机数理论上冲突概率可以忽略真出现这种问题大概率不是随机冲突而是你把同一个 Tracer 单例的 span 上下文跨线程乱用了参见 5.4。5.3 Provider 端正常但 Consumer 端报超时这个场景太经典了。接口在 Provider 端执行得很好日志也显示很快就返回了但 Consumer 端就是超时。很多人第一反应是网络问题其实可能出在链路追踪 Filter 自身的处理上。如果 Filter 里用了同步 reporter 或者误把 span 上报放在invoke之前那上报耗时会被算进 RPC 总耗时里。Zipkin Server 万一响应慢Consumer 端等 Filter 处理完了才真正发起 RPC超时随之而来。所以一定要用异步 reporter并且确认代码里没有阻塞操作。还有一个隐蔽的点如果 Consumer Filter 和超时过滤器之间的执行顺序有问题可能会在超时判断之后才执行span.finish()导致 span 的耗时数据异常。这个可以通过调整 filter 在dubbo.consumer.filter中的顺序来解决比如把zipkinConsumer放在 timeout 前面让它作为外层过滤器。5.4 异步调用时链路丢失链路追踪最怕异步。Dubbo 2.7 支持异步调用返回结果可能是CompletableFuture...。如果你在异步回调线程里继续做后续操作而当前线程已经退出Brave 的 trace context 默认是存在线程局部变量里的回调线程里根本拿不到原来的上下文。表现出来就是Provider 端和 Consumer 端都有单独 span但 TraceId 对不上或者中间有一段缺失。解决思路是在进入异步回调之前手动把当前TraceContext抓到临时变量里然后在回调线程里用tracer.withSpanInScope(span)重新包装。具体代码里可以这样处理TraceContext context tracer.currentSpan().context(); CompletableFutureUser future userService.getByIdAsync(orderId); future.whenComplete((user, throwable) - { try (Tracer.SpanInScope ws tracer.withSpanInScope(span)) { // 异步回调里的后续逻辑 } });不过这里要小心不能让同一个 span 在多个线程里并发 finish。建议异步场景另外开启子 span而不是复用原始 span否则耗时统计会交叉错乱。5.5 生产环境存储与采样Zipkin Server 默认内存存储只适合本地测试生产环境重启一次数据就没了而且 span 多了查询性能会急剧下降。常见的方案是把存储切到 MySQL 或 Elasticsearch其中 Elasticsearch 是 Zipkin 在大数据量下的主流选择。启动 Zipkin 时指定存储docker run -d --name zipkin \ -p 9411:9411 \ -e STORAGE_TYPEelasticsearch \ -e ES_HOSTShttp://localhost:9200 \ openzipkin/zipkin:2.23采样率也要根据流量调整。总量小可以 100% 全采量大就老老实实降下来我用Sampler.create(0.1f)能保留十分之一链路对定位问题基本够用。真正需要排查的时候可以临时把某个服务的采样率调到 1.0排查完再改回去比一直全采划算得多。6. 这套方案落地后的几点经验把注解方式跑通不难难的是让它真正变成团队可依赖的排查工具。我个人实际用下来有几点体会很深。第一日志里一定要带上 traceId。光在 Zipkin UI 里能看到链路还不够线上排查经常是拿着日志里的 traceId 去 Zipkin 搜。我在 Filter 里创建 span 之后会顺手把 TraceId 塞进 MDCMDC.put(traceId, tracer.currentSpan().context().traceIdString());这样日志里每一行都能看见 traceId从日志到链路、从链路到日志都能互相跳转排查效率高很多。第二不要一上来就追求把埋点做得多完美。先把跨服务 RPC 的链路串起来看到完整链路再逐步加 tag。我后来在 Filter 里加了span.tag(userId, ...)、span.tag(orderId, ...)这类业务标记Zipkin 里可以直接按业务字段过滤对一线排查非常有帮助。第三版本升级要谨慎。Dubbo 的 Filter 接口、附件操作方式在各个版本里变化不小升级 Dubbo 时链路追踪代码往往是第一批出问题的模块。建议把版本组合写进项目的依赖管理说明里升级前先在小流量环境把链路功能回归一遍。这套方案用到现在给我最大的感受是线上定位慢接口终于不用再靠“怀疑”和“对时间戳”了而是直接打开 Zipkin点开那条 trace看两个 span 之间到底有几毫秒延迟、哪个节点掉了链子。链路追踪的本质不是让人觉得系统很先进而是把“看不见的调用关系”变成“看得见的树状图”。你把这套注解方式接完以后不妨再想想怎么把 traceId 和告警平台联动起来那才是让它发挥出更大价值的地方。
阅读完成 · 觉得有帮助?
咨询建站