漫谈分布式链路追踪

链路跟踪

链路跟踪归根到底只是一种理念和策略,简单的说就是在2次关联调用之间传递特定透传信息的能力。从组件设计的角度说其实关心的是是下面的几个特性:java

  • 泛用性:在多大范围的做用域上可用,有没有不可用的状况
  • 完备性:数据模型的设计上是否考虑的足够全面,该有的都有,不应有的能够扔
  • 成本:实现的成本和风险、接入的复杂度。
    落地到实现方案上仍是有不少不一样的策略,但总的来讲其实有三种策略。

基于特定语言实现的方案

典型的例子就是Java系的方案,总的来讲java是一种编译语言,可是得意于虚拟机和字节码的实现方式,Java其实是具备动态语言的特性的。git

这类实现的基本思路就是在利用java-agent拦截具体类加载过程,在特定的类加载过程加入自定义的代码来实现trace的能力。程序员

这类方案的主要缺点是的只能用于java,可是只要是java技术栈的实现就几乎能够无任何限制的接入,对java技术栈的公司来讲是很是有效。接入成本也很是低,只要在启动命令中指定参数就能够了,不管是部署脚本仍是构建镜像都很方便。剩下另外一个一个缺点就是改字节码自己仍是有必定风险的。不过整体来讲稳定性仍是有保障的。
skywalkinggithub

基于组织内编码规范的实现

并非全部公司内部都是java的,其余语言并无改字节码这种骚操做,或者认为这种方式太过粗暴该怎么办呢?web

这种状况下基本的思路就是抽象出协议层面的概念,让各个组件的实现内部支持链路跟踪的实现,trace日志组件的信息聚集也由组件完成。若是有业务方有特殊须要接入链路跟踪系统也须要能够依照相同的约定与trace进行交互。此外还考虑须要和各种开源的组件相适配。redis

这种状况下,协议层面的设计就显得很重要,是须要各方都认同和理解的方案,协议自己的完备性就是很是重要的。就目前来讲最为著名的就是opentracing的规范,基本上能够视为链路跟踪领域的事实上的标准。数据库

从我的来看我这种方式是更优的策略,并且在大公司的内部,推行标准化的编程规范也是必要的,可是这也是双刃剑,trace的实现依赖于标准化的程度,由于链路这种东西只要中间断过一次就没法达到链路跟踪的效果了。apache

另外一个问题是即便标准化也是有限度的,好比跨线程的信息传递绝大多少公司内部的标准化就很难作。这样作出来的功能其实仍是不如字节码加强来的简单有效。
jaegerCATSOFATracerzipkindapper编程

基于mesh的方案

固然随着近几年容器化和service mesh的推动,基于servicemesh的方案也是能够作链路跟踪的。经过sidecare劫持流量,能够构建出不依赖具体语言或者rpc的链路跟踪系统,从模型上看确实是更为理想的模型,不过如何让运行时的程序内部也感知到链路跟踪也是一个问题,同时mesh的各类方案截止现阶段其实仍是处在探索和实践阶段并无完美的解决方案。
jaegerapi

模型和协议设计

数据模型

现有的链路跟踪的模型大多参考了dapper的实现,opentracing的规范也对模型设计有很大的影响。opentracing的语义。下面大部份内容都摘取自这两部份内容

简单的说一次外部调用能够被多个内部请求组合完成,这一过程能够被描述成一个树的形式,而每次调用被定义为一个span。整个调用能够被称为一个trace,能够用一个惟一id标识。

span是构成调用树的最小单元。一般来讲包括下面几个部分,一般也会被一个惟一id标识:

  • 操做名,一般是一个pattern,好比调用方法名,好比URL等等。
  • 起止时间。
  • 0个或多个tags,key为string,value,好比ip,app名,数据名、url之类
  • 0个或多个log,好比错误码,调用栈、时间消息。
  • 0个或多个span的引用,(ChildOf、FollowsFrom)
  • SpanContext,(traceid、spanid、sampleFlag、Baggage)是一种概念或者说接口层面的东西。能够用于序列化和反序列化的对象,或是为中间件和业务程序提供瘦api,自己是不可变的。Baggage能够透传用户的kv。
Causal relationships between Spans in a single Trace

        [Span A]  ←←←(the root span)
            |
     +------+------+
     |             |
 [Span B]      [Span C] ←←←(Span C is a `ChildOf` Span A)
     |             |
 [Span D]      +---+-------+
               |           |
           [Span E]    [Span F] >>> [Span G] >>> [Span H]
                                       ↑
                                       ↑
                         (Span G `FollowsFrom` Span F)

在如上的链路跟踪调用树比较直观了解,只有2个地方须要解释下:

span之间的关系

一个是span中的引用,其实我以为这应用更理解不一样,在实现上大部分不太可能找到全部的子span的引用,或者没有必要。大部分状况下其实使用parent-span-id的概念来构造。一个引用的类型,

  • ChildOf主要是在同步的场景下,这种场景下子span必定在父span的结束以前返回。
  • FollowsFrom主要是指一些异步的场景,这种状况下子span和父span只有逻辑上的关联性,可是时间上并没包含关系,在链路的构造上并无什么问题,可是在某些数据处理的场景下就会比较麻烦。好比没法肯定链路什么时候结束?


其实这里我其实更想讨论的是如何界定span的范围,但以我我的的来看并不太同意将异步场景都串联起来。主要基于2点考虑

  • 一是trace不少时候用于性能分析或者依赖分析之类的场景,在这2种比较典型的场景下其实将异步场景串联起来并无太大的意义,反而不利于后续的数据分析工做

  • 另外一缘由是即便不使用spanid自己的结构串联也并不意味着丢失了关联信息,由于trace自己是有信息透传能力的,咱们彻底能够构造一个相似logid的概念或者业务上有意义的数据,进行透传即便链路自己再也不同一个trace下,可是信息依然是能够透传的。

透传实现原理

透传的实质上就是在两个上下文之间完成spancontext的构造。总的来讲须要作2件事情,一件事情用尽量无侵入的方式传递spanContext用于重建span。另外一件就是在当前的context构造的spanwapper中构造一个新的span并推入栈顶,固然也能够根据状况来选择是否构造新的span。下图展现了一个跨线程传递的例子。


在透传的场景下其实参数是可选的,大部分场景下,trace只针对跨runtime的请求处理,内部跨线程不会建立信息的span,这种状况下不会构造新的span,若是是rpc调用则会生成新的span。

span日志数据构成、透传与搜集

另外一个须要讨论的是SpanContext:SpanContext其实在不一样场景下都有不一样的概念。这里更多的指的是用于下游恢复链路的构造部分和须要透传的参数。
根据上述内容,很容易理解一个span实际上是须要对端构造的2条日志才能完整的构造。这2条日志会被日志被各自实例收集起来各自上传,仅仅有少许的参数会随rpc之类的介质透传给下游用于恢复链路或者参数透传,总的来讲参数透传成3个部分:

  • 须要透传的业务参数:好比说压测标识、logId之类的。这些参数不管何种场景下都是须要传递下去的。
  • 用于构造span的参数:其实只须要三个:traceId、spanId、sample,这些参数根据不一样场景会决定传递或者不传递,一般来讲是同步场景传递异步场景下不传递。若是不传递,则一般下游会构造新的spanId和traceid,这时能够构造span的日志只有单端的日志。
  • 用于信息分享的参数:除了上述的参数其余参数均可以经过带外的通道进行传递,大部分状况下都是利用相似ELK的技术栈传递。
    具体的说其实透传的方式其实也有不一样的实现策略,以下图所示

  • 一种方案是将业务信息和span构造信息都request传递,下游返回结果也将一样的信息传递回来。这种模型的优点是概念和模型比较清晰。可是问题在于上游的信息是否还须要须要在请求发出的时候打印出来?可是这时截止时间内并无打印,待请求信息回复以后须要作一次merge。或者打印两第二天志,在存储层作1次数据的merge。
  • 另外一种方案是将暂时的数据存储当前thread变量的栈中,待请求返回以后再栈中pop出当前的对象。将请求参数添加到对象中。不过这种方式没法处理异步回调的场景。
  • 最后一种就是异步的从新构造span的场景了。这里的sample参数特殊一些,大部分链路跟踪系统都是须要采样的,采样模式都是head-based,就是跟节点采样后续都采样。这致使在某些异步场景下即便traceI和spanid都不传递,可是sample也须要传递。

模块拆分和设计边界

系统模块拆分

这里面描述的是通用的分布式链路追踪的模块设计类型,不一样的系统可能在不一样的地方有所取舍。但整体来讲遵循

客户端功能拆分和实现

  • agent-转码打包的代码模块用于javaagent
    • transform代码加强相关
    • classloader合理agent代码,防止类污染
    • static-config,静态配置用于拦截逻辑和静态配置
  • plugin-特定业务层面的逻辑
  • client-组件pom依赖
    • opentracing接口构造span
    • processor,通用处理逻辑
    • weavepoint,加强点类型定义用于界定做用域
    • dynamic-config。用于获取动态配置
    • log,打印日志
    • util,工具包
  • core-数据模型依赖

类与接口设计

链路跟踪有不少实现形式,从我我的的理解来看须要作2层抽象,一层是加强点层面的。
具体实现上代码模块仍是分了不少有意思的部分代码模块拆分红了多个部分


代码层面其实要作2层抽象,

  • 一层抽象是针对加强点或者植入点的。由于不一样介质的入口都是不同的,wapper是统一的针对加强点的处理,用来统一的代码收口,wapper层面会判断当前代码的入口类型,并选取不一样的AbsctrctWeaveProcess来进行处理,代码能够设计抽象的代码模板(client和service端基础类)。不一样植入点在基础代码暴露出来的接口上进行拓展。
  • 通常来讲上述代码能够保证链路跟 踪的基本功能。可是在公司内部每每须要服务不少业务功能。好比压测、自定义参数透传、环境染色、logId之类的。这些功能每每有一层统一的业务抽象,须要在各类加强点复用,所以抽象出来插件层更为合理。

有趣的设计细节

Trace-id vs log-id

logid是否是traceid?两种有什么区别?
严格来讲log-id不是trace-id,可是也能够是。trace本质上是提供透传信息的能力,logid经常使用于串联日志信息,因此大部分场景下logid都是trace的透传能力在系统间透传的,在系统内部每每是threadlocal或者context的概念保存。
初次以外,以前咱们还讨论过一种有意思的问题就是异步场景下的串联。根据以前的内容咱们其实讨论过,trace自己因为起止时间的限制虽然能够用于异步场景,可是这样会给信息分析带来不少麻烦,在实际中我其实更倾向于将traceid定义在同步调用的scope内,在异步场景下,好比异步rpc,或者消息队列场景下,从新构造logId。

Java-agent与字节码加强

这里还有一个问题没有解释就是如何实现字节码加强的,基本原理是用的java-agent。java虽然是编译性的语言可是因为jvm和classloader的存在,java具备必定的动态特性。java的实际运行逻辑其实是取决于jvm中的字节码,好比大多数javaer怀念的事务管理的注解,本质上上就给某些方法或者成员变量打上标记,运行过程当中生成一个代理类同时在原有方法的基础上添加一些事务管理的模板。不管是生成新的代理类或者改变原有的类的字节码,从而实现动态代理。
咱们回到trace的使用场景下,其实咱们也是但愿对字节码实现加强,理论上说也是能够基于自定义的类加载去制做动态代理实现的,可是一个主要的问题是没有办法控制全部的类加载器,其实trace但愿的是在某个方法上实现wapper而并不关心具体的类加载是哪一个。java自己提供了一种java-agent机制能够实现拦截全部的类加载过程,或者在运行过程当中重载某个类的后门,显然更适合咱们的场景。使用中只要是实现了对应接口,并打包成jar就能够。java-agent提供了2种方式一种是做为启动参数与jvm-runtime同时启动。或者在jvm实例启动以后,做为队列进程启动并attchment到jvm进程上。
这里不详细讨论代码实现的方式,由于网上例子不少,这里想说的实际上是一套经常使用的java-agent使用的设计方式,这样对理解其余开源设计也有不少帮助。

一个典型的java-agent相关的模块不少状况下包括上面几个部分,

  • 首先是agent-entrace。这里是是实现java-agent的接口也就相似于main方法,是主逻辑展开的地方。
  • 实际上是一个与外部接口对接的部分,能够提供一个可观测和可操做的入口,好比一个httpserver或者一个etcd/zk-client。
  • 不少java-agent最主要的功能是为了加强字节码,这实际上是一个很危险的操做,至关于不停机的状况下该代码逻辑,所以一般会定义一些spi接口出来,把加强点限定在一些范围内,经过独立编译一些jar来修改制定的接口的方式加载,除了安全性的考虑以外也是但愿考虑代码的解耦。这个过程有点相似于咱们写一个api服务,会先顶一个url,在去写这个url实现同样。
  • 另外一个场景的模块是独立的classloader。由于javagent不少时候是以premian的方式运行,这时main方法尚未运行,若是因为简介依赖引用了不少类可能会致使后续应用正常启动的时候加载了被污染的类。所以大部分会写一个classloader来独立加载agent用到的类,避免污染问题。这个类加载一般都不是双亲委派模型的。


java-agent应用其实很是广,这里能够举几个列子;

  • 开源的trace实现:https://skywalking.apache.org/
  • 混沌工程:https://github.com/alibaba/jvm-sandbox 、https://github.com/chaosblade-io/chaosblade
  • java分析诊断工具:https://github.com/alibaba/arthas
    Head-base sample vs tail-base sample
    每次rpc会生成2个日志,不采样的话日志的量会很是大,大多数trace日志都是采样的,并且采样率很是低,基本在1-5%之间。可是这对trace的功能并不影响,如以前所说,采样只涉及到的日志的落盘和上传,并不影响span的生成和参数的透传,只是生成的span是否上传而已。
    采样自己也是有一个有意思的话,大部分trace都是head-base的,就是只有根节点有权利决定采样不采用,后续节点都是根据上游传过来的sample标志位来肯定是否采样的。

典型的应用场景

请求染色(环境路由)

首先介绍下环境隔离的概念:大部分的开发模式都是基于giflow的。若是只有一套环境的话就会有多个代码合并的蛋疼问题,若是建多套环境的话成本有很高(好比独立的数据库,Nginx、注册中心、redis,以及大量的依赖服务),那有没有一种方式既能够建立建立多套环境又避免蛋疼的产品问题呢?

环境染色的方案就是trace在各个中间件请求下解析出入口环境信息,而且将其做为参数透传,各个中间件组件配合该信息将trace路由到对应集群上的方案:举一个简单的例子以下所示:


基本的思路就是这样,全部集群都有一套基准环境,一般部署master分支,而本次分支涉及到的变革部署一套feature集群。。全部的公共组件都用同一套,好比Nginx、注册中心、数据库、kafka集群等等。可是应用用到的资源会略有区别,好比说注册中心上带有集群的环境信息,rds能够建一个带有环境名后缀的影子表。kafka建有对应环境名后缀的topic。 用户请求的时候使用相同的域名可是附带上具备环境名的header,app在接受请求的时候会解析header并将入口环境信息放入baaage中,该信息会随链路下传。

对于rpc,客户端作路由的时候会根据环境信息优先选取特定子环境的集群,若是没有则调用基准环境,基准环境中的应用在调用的时候也能够根据相同的规则优先调用子环境。即便调用穿过了中间件好比队列,则传递的消息也附带有环境信息,trace也能够根据信息解析出路由规则并进行透传。不过为了不消息被基准环境的app消费仍是须要建特定子环境的topic。

其余应用场景

  • 压测
  • 应用分层
  • 故障演练

微信公众号:神奇的程序员


接受一线大厂内推(微软、阿里、头条、网易),详情请联系公众号或者发邮箱Andrewzhch@gmail.com