本文为项目内部文档,用于说明SDK的设计思路。既有jaeger的原有设计思想,也有根据项目需求改造后的设计。
SDK 2.0详细设计
1 Span采集
1.1 OpenTracing接口
介绍对OpenTracing接口的具体实现,比如Tracer类,Span类等.
1.2 Tracer
-
tracer概述
Tracer类是SDK核心类,用于产生span,管理span(使用基于threadlocal的ScopeManager),上报span(异步发送),管理采样器(可使用反向控制采样策略),管理Injector与Extractor(用于服务间传播spanContext等信息),管理baggage校验器(可以反向控制baggage校验)等功能。 -
tracer的使用
Tracer的构造由用户负责,并且由用户负责将构造好的tracer注入到将要被使用的类中.
(1)java则可以使用spring的IOC容器方便的管理bean;
(2)C++需要使用智能指针管理生成的Tracer对象.
//构造 tracer
Tracer tracer = new Tracer.Builder("serviceName").withDLN("DLN").build(); //java
std::shared_ptr<opentracing::Tracer> tracer = jaegertracing::Tracer::make(serviceName, tracerConfig); //c++
-
tracer基本方法,详细参考文档.
sdk使用默认方法构建tracer.
(1)build方法中将scopeManager,report,injector,extractor以及baggage都进行了初始化;
(2)tracer提供withSampler(),withReport()等方法用于设置自己的sampler,report等;
(3)tracer提供inject与extract方法用于将span context注入到如header中,在服务间传递span信息;以及从header中获取span context核心内容,并通过该内容重构span。 -
tracer新增功能
(1)withDLN("DLN")方法用于让用户设置DLN值并存放到tracer中,只在创建rootSpan时,从tracer中获取DLN值,其他情况下从parent或者span context中获取DLN数值。
- tracer修改的功能
发送往agent的数据结构已经确定为proto格式的SpanReport将,因此Span在发送前需要提取信息转换为SpanReport,并将tracer中的reportSpan(Span)改造为reportSpanReport(SpanReport).
1.3 Span、SpanBuilder与SpanContext
-
span的创建
span的创建增加了kind接口,用于设置当前span的类型(Span发送逻辑跟类型相关,详见下节).
Span span = tracer.spanBuild("spanName").kind(SpanKind.Local).start() -
span增加的属性
Span中添加了startEndpoint与finishEndpoint属性,详细的赋值逻辑见文档 2018-6-5 SDK中的Endpoint讨论.md。
增加对startEndpoint与finishEndpoint属性的set/get方法,用于在构建完span后也能对span的endpoint进行赋值操作。
- span中修改的功能
finish方法中,需要将span转为spanReport发送。其中对于类型为LF的span有些特殊,如果用户设置了endpoint则使用用户的finishEndpoint,该方法中不发送用户设置的startEndpoint。若用户未设置finishEndpoint,则使用tracer中的endpoint。
span中增加Spankind属性。目前版本有local,client,server,producer,consumer,db,sdk根据kind属性确定不同的处理策略.
除producer和consumer,其他类型的span均分两次发送:(1)调用start()方法时发送,用于记录span的发送时间;(2)调用finish()方法时发送,用于记录span的结束时间。
以kind=lcoal(LocalSpan)为例:当LocalSpan在start()方法中,设置span的annotationType为LS,将span转为spanReport然后发送;LocalSpan在finish()方法中,设置span的annotationType为LF,将Span转换为SpanReport然后发送。
-
spanBuilder增加的功能
为了实现上文中根据span核心内容(spanContext)重构span,需要在spanBuilder中增加一个withContext()方法,并在start方法中修改逻辑为:若传入context参数,则根据参数重构span。
增加withStartEndpoint()与withFinishEndpoint()两个方法,用于在构造span时添加endpoint。
-
spanBuilder修改的功能
(1) 修改createChildContext()方法,使得新构造的child span从parent span中获取DLN属性;
(2) 修改createNewContext()方法,增加从tracer获取DLN的行为。createNewContext()只用于创建根span(rootspan),规定所有的span必须有DLN,其他span可以从parentSpan或者extract()方法返回的context中获取DLN,但是root span只能从tracer中获取用户设置的DLN;
(3) 修改start()方法,其逻辑修改为:先判断是否有parent。再判断context如何生成,在原有逻辑基础上添加一个判断若有传入的context则使用传入的context。再根据kind判断span的类型。
非MS MR类型的都要在start方法中转化为spanReport发送一次,其中LS类型的span较为特殊。若LS类型的span用户设置了endpoint,我们只使用用户设置的startEndpoint转为spanReport后发送,然后再将用户设置的endpoint赋值到span中返回给用户;若LS类型的span用户没有设置endpoint,则我们使用tracer中的endpoint。
结合上面的finish方法,我们知道非MS MR的span会发送两次,分别含有starttime与finishtime,而MS MRspan只有一个时间点,后续处理见文档。
具体每种类型的span在什么时刻发送哪种类型的endpoint见文档。 -
spanContext增加
增加的属性有:增加DLN,增加traceIdHigh用于构造128位的traceId -
spanContext修改
(1)根据增加的属性,同时修改构造函数。
(2)修改contextAsString方法,traceId直接转为string在服务间传播,不再转为16进制string。
(3)同时修改contextFromString方法,将字符串转为context
1.4 ScopeManager
对ScopeManager的详细描述见文档 jaeger-core设计思路解析 - 构建span并启动管理部分。
-
scopeManager概述
scopeManager通过threadlocal存储scope对象,scope存储当前激活的span以及上一个激活的span。
中间件接口
gRPC
介绍gRPC拦截器的实现
java中对grpc拦截器的实现较为方便。这部分内容可以结合着下面的 Span传播 部分的内容理解。
- gRPC中间件概述
gRPC中间件的目的是为了追踪gRPC过程的耗时,以及记录grpc的相关内容,并将span进行跨进程的传播,将服务间的trace追踪信息连接起来。
项目中对gRPC的设计为client端一个span,server端一个span,但是发送4次,分别为cs时刻发送,sr时刻发送,ss时刻发送以及cr时刻发送。我们已经在上面span的start()方法中讨论了。若遇到超时等错误,则可以在catch语句块中,使用span记录超时等错误信息,并且在catch中调用span.finish()方法发送。
- 实现思路
处理思路为:在client端的拦截器中先构造client_span,使用tracer.inject将traceId,spanId,parentId,flags,DLN,baggage放入到header中;在server端拦截器从header中获取上述内容,并通过withContext()根据传过来的内容重建span名为server_span;在ss时刻执行server_span.finish()结束并发送span;在client端拦截器获取到响应后将client_span结束并发送。
若上述流程中某一步catch到异常,就获取client_span或者server_span,在catch语句中记录超时等错误信息,并且在catch中调用span.finish()方法发送。
上面的内容只是描述了gRPC发送span的主要思想以及何时发送,以什么形式发送等,下面讨论较为详细的一些细节。
- 如何将spanContext写入到header中?
tracer.inject()方法并不能直接使用,需要我们实现一个接口TextMap,将该接口的实现类作为参数传入inject()方法才可以。这是因为sdk并不能知道我们想要将spanContext写入哪个容器中去,比如可以是grpc的header也可以是kafka的header,同是header但是往header写内容的实现是不同的,因此这里tracer.inject()需要根据我们具体的使用场景,实现接口中的inject与extract方法。
- 如何获取客户构建的tracer,以便使用同一个tracer构建span?
为什么要使用用户构建的tracer?因为用户构建的tracer中有seviceName,可能会自定义的report以及自定义的采样等。但是关键的是,我们封装的grpc中间件是为用户服务的,方便用户追踪服务间调用,而用户无需自己实现服务间调用的追踪,用户只需要在服务内感兴趣的方法中添加追踪即可(即local span),因此我们对中间件的封装只不过是帮助用户多做了一些事情,以方便用户少做,所以必须是同一个tracer以使得中间件中构造的span与用户构造的span能够连接上。为达到这一目的,我们封装好的拦截器对象中要提供带参构造函数,参数为tracer,要求用户构造拦截器的时候传入tracer。
- 如何保证span间的parent-child关系?
通过上面的文档 jaeger-core设计思路解析 - 构建span并启动管理 ,你应该已经了解了java-sdk中是如何在启动start()一个span时自动获取其parentSpan,从而创建span间的关系。而在grpc中,我们需要分两个方面client与server来考虑。
对于client端的拦截器中构造的client_span,由于用户构造的local span会存放在tracer的scopeManager中,而拦截器中的tracer与用户构造的是同一个tracer,且此时拦截器与用户方法在同一线程,此刻还未真正从grpc线程池获取线程发送数据,因此使用该tracer构造拦截器中的client_span时,会自动的从scopeManager中获取其parentSpan,自动构建span间关系。这时中间件构造的client_span能够很好的与上一个span构建关系,同时我们也要将client_span存入scopeManage中,方便客户端后续可能构造的span与client_span产生关系。这里要注意两点,一是所有要操作span的方法中,都需要使用try-with-resource的形式将span注册到scopeManager中,二是要将span传入new出来的回调函数,避免异步情况下回调函数与发送方法不在同一个线程造成span的信息错乱。这样在回调函数所在的线程也会有client_span注册到scopeManager中,回调函数中若用户有回调的逻辑,则其在回调方法中构造的localSpan就会以client_span为parnent,使得trace信息能够很好的延续下去。
对于server端,类似的原理,同样的构造server_span。但是与client_span的区别在于。server_span是通过从header中extract出spanContext来构造的,其span间的关系由client传递过来的信息有关,与server端无关,之后的处理类似,任务方法中需要对span进行处理,都需要使用try-with-resource的形式将span注册到scopeManager中,当用户在server端的方法中创建local span时就会以server_span为parent。
- gRPC异步情况下怎么保证span追踪不会混乱
上面稍微讨论了下。gRPC异步情况下回调函数不一定与用户当初调用的请求方法在同一个线程,因此threadlocal有可能失效,在回调函数中获取不到当初的client_span。在java中拦截器对象是与channel绑定的,该channel的所有请求都共用同一个拦截器,回调函数获取span时有可能获取的是其他请求在拦截器中创造的span。而java中拦截器的回调函数是需要我们实现其提供的接口,然后new一个回调函数的对象出来,这时,相当于每个请求都有自己对应的回调函数,我们可以将创建好的client_span作为构造函数的入参传入回调函数对象中,就能保存请求发出时创造的client_span,这样就能保证server端返回时,回调函数中使用的span是当初发送请求时创造的span,而不会取成别的请求创造的span。
2. Span发送
异步发送Span:采集到的Span存到一个队列中,根据时间限制或者数量限制发出去.
-
span发送概述
Span的发送采用grpc双向流模式异步发送。大致流程为:report()方法将span存入一个Append对象,再将Append对象存入一个线程安全的阻塞队列中;另外启动线程不断的从阻塞队列中取内容,并执行execute方法,如Append的execute方法为sender.append(span),按队列中的顺序执行相应的操作如append,close,flush等(其中sender的flush方法会将spanReport通过grpc双向流发送到agent端).
需要注意的是由于我们采用grpc双向流,我们要保证通道不关闭,因此需要另起一个线程通过while循环spanReport所在的容器来达到不关闭连接有span就送的目的。span的发送根据时间限制来发送。 -
大致的实现思路
用一个线程安全的阻塞队列作为缓冲,将span塞入队列中,另一个线程不断的轮询队列,并真正执行发送方法。这样report()方法与真正的发送之间会有一点时间上的差距,但是是异步执行发送,所以不会阻塞用户的业务线程。
2.1 具体实现
2.1.1 RemoteReport
RemoteReport类用于将span放入线程安全的阻塞队列queue中,且在RemoteReport类中新开一个线程,用于轮询queue并对取出来的span进行操作,GrpcSender类中开启第三个线程是用于维护grpc双通道流通信通道不关闭,使用while循环维持通道,发送获取的span。
首先,RemoteReport类中创建三个内部类,分别为AppendCommand,FlushCommand以及CloseCommand,三个类中均有execute方法其实现分别为sender.append(span),sender.flush(span)以及sender.close(),当调用RemoteReport的report(span)方法时,实际上是将span装入AppendCommand类中存入BlockingQueue队列中。
在RemoteReport中新开一个线程用于轮询BlockingQueue队列。当队列中有内容,即有Command类时,调用去execute方法,否则队列中没有内容,则阻塞取数据的方法,直到队列中有数据存在。
需要注意的是,我们只说了report(span)方法会将appendCommand类存入queue队列,执行的也只是sender.append(span),那什么时候将span发送呢?这就需要在queue队列中定时存入一个FlushCommand对象,当while轮询queue时,取到flushCommand对象,就会执行sender.flush(),将之前存起来的span一起发送出去。因此RemoteReport中需要开启一个定时器,定时存入FlushCommand,达到定时发送span的目的。
2.1.2 Sender接口与CommonSender
上文多次提到sender这个成员。为了功能的解耦,java版本中设置了Sender接口,其内有append(span),flush(),close()方法,java版本实现一个CommonSender抽象类处理append(),close(),flush()等方法。在CommonSender中有一个抽象方法send(ListSpan),在CommonSender中的flush()方法中调用send(ListSpan)方法。而我们使用grpc就构造一个GrpcSender实现CommonSender抽象类的send(ListSpan)即可。若以后有其他的发送方式,则可以构造一个类实现CommonSender的send(ListSpan)方法即可。
CommonSender中的append方法用于将span存入一个list中。close()方法关闭资源,需要实现类重写该方法关闭具体的如grpc连接资源等。
到目前为止,除去RemoteReport中的定时添加flushCommand对象的定时任务线程,我们已经有了两个线程,一个线程向queue中添加command,另一个线程轮询queue并执行sender方法,可以看到sender处在第二个线程中,没有并发情况,因此对CommonSender无需考虑并发情况下append(span)的问题,sender总是从队列中取到一个command后执行方法,while循环第二次取到另一个command后执行sender的第二个方法,没有并发情况,因此append(span)可以简单的将span写入list中。
2.1.3 GrpcSender
下面说下GrpcSender,如何真正执行发送span的功能。首先GrpcSender需要继承CommonSender,并实现其send(ListSpan)功能。而我们要求使用Grpc的双向流模式传输信息,不能send一次就开启一次通道,发送完关闭通道,下次send再次开启。我们需要保持通道一直是开启状态。因此我们需要在GrpcSender初始化时就开启一个线程维持通道的开启。这样grpc发送span就不与commonSender在同一个线程。又需要使用到一个线程安全的容器作为两个线程间的缓冲桥梁。考虑到先调用report方法的span需要先发送,我们采用ConcurrentLinkedQueue线程安全的先入先出队列。
这样,第三个线程同样轮询ConcurrentLinkedQueue是否有span,有的话真正发送给agent,而GrpcSender中的send(ListSpan)方法就不再是开启通道并发送了,而是将ListSpan存入到ConcurrentLinkedQueue中。
小结
上面整个发送过程用到的核心思想是使用线程安全的容器作为两个线程间的缓冲,sender线程是唯一的,但是report线程是不唯一的,是多线程的环境,多线程写入,单一线程读取,我们使用的容器为BlockingQueue,为了维持grpc通道不关闭,需要开启第三个线程,真正执行发送span到agent的功能,这时sender线程与第三个线程也需要一个容器,考虑到先入先出的要求,我们选择ConcurrentLinkedQueue。
C++ SDK的实现思路基本与java一致.
问题
对span采取按时间限制发送还是按数量限制发送的选择上,现在对span的发送策略为定时发送,整个发送过程中的瓶颈在于report线程与sender线程之间的BlocingQueue的容量,若瞬时需要report的span过多,超过sender的处理速度,就会造成无法将span存入到queue中 (jaeger的策略是与其线程等待造成业务线程阻塞,不如扔掉该span)。
深究一下,对于按时间限制发送还是按数量限制发送,这个策略的选择我们是在第二个线程sender与第三个线程grpc之间完成的,我们的方案是第二个线程sender一直将span存入list中,直到定时的flush命令传来,再到第三个线程grpc中执行发送。而系统的瓶颈存在于第一个线程(用户业务线程)与第二个线程sender线程之间。并不能通过选择 按时间限制发送还是按数量限制发送 不同策略来避免。若是选择 按数量限制发送 也只是在第二个线程sender中的append方法中判断list数量大于阈值,就调用flush方法,这样还是解决不了第一个线程(用户业务线程)与第二个线程sender之间的BlockingQueue容量有限的瓶颈。
3. Span传播
向下一级发送哪些数据,如何发,下一级收到后怎么处理
Span的传播主要指在进程间的消息传递,现阶段主要指在grpc客户端以及服务端之间的消息传递.
按照opentracing的定义,在进程间传递的span信息会抽象为SpanContext
数据结构,其包含以下内容:
message propagation {
uint64 trace_id_high = 1;
uint64 trace_id_low = 2;
uint64 span_id = 3;
uint64 parent_id = 4;
string DLN = 5;
map<string, bool> flags = 6;
map<string, string> baggage = 7;
}
其中DLN是根据项目需要添加的字段.
3.1 客户端发送请求 (CS)
grpc客户端在发送rpc请求时:
(1) 拦截器会获取channel对应的tracer;
(2) 通过tracer的ScopeManager获取parent span,如果获取到则生成相应的child span,否则生成root span;
(3) 拦截器生成的span,存放于ScopeManager中供后续的业务span使用;
(4) 提取span的内容,并存放至span context对象中;
(5) c++ sdk将序列化span context存放至拦截器中,sdk java将span context插入Meta data中(Inject);
(6) 将span发送至agent.
3.2 服务端接受请求(SR)
grpc服务端接收客户端请求:
(1) 拦截器解析获得客户端发送的span context;
(2) 对Span context进行Extract操作,得到新Span(基本内容与客户端span一致);
(3) 生成的Span存放于channel对应的tracer.scopeManager中,供业务端的localSpan使用;
(4) 将span发送至agent.
3.3 服务端发送响应(SS)
grpc服务端发送响应至客户端,此时不需要将span context序列化后发回客户端.
filter在结束rpc调用之前,会调用span的finish函数,将span内容发送至agent,并将span从ScopeManager中移除(span context的生命周期与rpc调用同生命周期).
3.4 客户端接收响应(CR)
filter在结束rpc调用之前,会调用span的finish函数,将span内容发送至agent,并将span从ScopeManager中移除(span context的生命周期与rpc调用同生命周期).
5. 反向控制
现阶段SDK的采样控制采用多重控制方式,主要控制措施为采样以及限流,采用通过采样率实施,限流通过令牌桶方案实施.
5.1 采样率
采样率以及采样上限作为第一层流量控制措施.
用户在创建span时,sdk会根据采样率随机抽取span.若span被采样,则设置其采样标志flag为true,否则为false.
通过采样率,可以有效减少采样数量,减轻系统压力.
5.2 令牌桶方案
对于较多场景,除了要求限制数据的平均传输速率,还要求允许某种程度的突发传输控制.因此,选用令牌桶算法作为第二层流量控制措施.
系统以恒定速度往tracer中放入令牌,对于经过第一层采样且采样标志为true的span,若得到令牌则不作操作,否则将flag标志置为false.系统后续根据flag标志对span进行进一步操作.
令牌桶需要设置令牌桶数量以及令牌桶发令速度,已达到动态限流作用.
5.3 控制参数
现阶段控制参数分别为:采样率,令牌桶容量,令牌桶发令速度.
agent上线,会通过grpc流模式推送以上三种采样控制参数,sdk接收进行设置;在程序运行期间,agent可以根据系统性能等指标,修改采样控制参数并推送至指定sdk进行设置.









网友评论