世界上最快的捷径,就是脚踏实地

看完这篇,分布式服务链路追踪的原理就被你摸透了

0. 概述

Dapper–Google生产环境下的分布式跟踪系统。
Dapper的英文论文:http://static.googleusercontent.com/media/research.google.com/zh-CN//pubs/archive/36356.pdf
中文论文:https://bigbully.github.io/Dapper-translation/ (建议一定要看下)

分布式调用跟踪系统实际上是随着微服务才火起来的一个概念,Google早在很多年前(2000年初便已经使用自研容器)已经微服务化了,所以他的分布式跟踪理论目前是最成熟的。
分布式跟踪系统出现的原因简单的说是因为在分布式系统中一次请求中会包含很多的RPC,迫切需要一些可以帮助理解系统行为和分析性能问题的工具,需要断定具体哪个服务拖了后腿。

根据论文来看,先看一个例子:

在这里插入图片描述
A~E分别表示五个服务,用户发起一次请求到A,然后A分别发送RPC请求到B和C,B处理请求后返回,C还要发起两个RPC请求到D和E,和D和E交互之后再返还给A,由A来响应最初的请求。
对于这样一个请求,简单实用的分布式跟踪的实现,就是为服务器上每一次你发送和接收动作来收集跟踪标识符(message identifiers)和时间戳(timestamped events)。也就是将这些记录与特定的请求进行关联到一起。

1. 如何将每个服务的日志与每一条记录与特定的请求关联到一起

目前学术界与工业界有如下两种方案,将一些记录与某个特定的请求关联到一起

黑盒方案(black box)
黑盒方案假定需要跟踪的除了上述信息之外没有额外的信息,这样就可以使用统计回归技术来推断两者之间的关系。

日志还是一样的记录,只是通过机器学习的方法来关联记录与特定的请求。
以一条特定请求RequestX为变量,通过黑盒(也就是机器学习的模型,比如回归分析)从A的日志中找出一条记录与之对应,同理可以找出B、C、D、E等等的相关记录。

黑盒方案的优势就是不需要改变现有日志记录方法,但是缺点非常明显,由于依赖统计推理,黑盒方案的精度往往不高(可以经过大量的数据学习和训练,进行提高精度),实际使用中效果不好。

(比如Project5,WAP5和Sherlock)

基于标注的方案(annotation-based)
基于标注的方案依赖于应用程序或中间件明确地标记一个所有服务全局ID,借此将一串请求关联起来。
比如对RequestX来说,赋予一个标志符1000,后续相关各个服务都会将标识符1000与记录一起打在日志里。
这种方法的优势就是比较精确,目前google、twitter、淘宝等都采用这种方式。

具体的做法就是根据请求中的TraceID来获取Trace这个实例,各种编程语言有各自的方式。获取到Trace实例后就可以调用Recorder(记录器)来记录Span了,记录值先直接以日志的形式存在本地,然后跟踪系统会启动一个Collector(收集器) Daemon(守护线程)来收集日志,然后整理日志写入数据库。 解析的日志结果建议放在BigTable(Cassandra、HDFS等)这类稀疏表的数据库里。因为每个Trace携带的Span可能不一样,最终的记录是每一行代表一个Trace,这一行的每一列代表一个Span。

但是基于标注的方案最主要的缺点也很明显,需要代码植入。(所以如何减少代码侵入是最大的问题)

对于减少代码的侵入性,建议将核心跟踪代码做的很轻巧,然后把它植入公共组件中,比如线程调用、控制流以及RPC库。

2. 跟踪树和span

分布式跟踪系统要做的就是记录每次发送和接受动作的标识符和时间戳,将一次请求涉及到的所有服务串联起来,只有这样才能搞清楚一次请求的完整调用链。

在Dapper中使用Trace表示对一次请求完整调用链的跟踪,将两个服务例如上面的服务A和服务B的请求/响应过程叫做一次span。
可以看出每一次跟踪Trace都是一个树型结构,span可以体现出服务之间的具体依赖关系。

每个跟踪树Trace都要定义一个全局唯一的TraceID,推荐用64位的整数表示,在这个跟踪中的所有Span都将获取到这个TraceID。 每个Span都有一个ParentSpanID和它自己的SpanID。上图那个例子中A服务的ParentSpanID为空,SpanID为1;然后B服务的ParentSpanID为1,SpanID为2;C服务的ParentSpanID也为1,SpanID为3,以此类推。

在Dapper跟踪树结构中,树节点是整个架构的基本单元,而每一个节点又是对span的引用。节点之间的连线表示的span和它的父span直接的关系。虽然span在日志文件中只是简单的代表span的开始和结束时间,他们在整个树形结构中却是相对独立的.

图2:5个span在Dapper跟踪树种短暂的关联关系
在这里插入图片描述
在上图中说明了span在一个大的跟踪过程中是什么样的。Dapper记录了span名称,以及每个span的ID和父ID,以重建在一次追踪过程中不同span之间的关系。如果一个span没有父ID被称为root span。所有span都挂在一个特定的跟踪上,也共用一个跟踪id(traceID在图中未示出)。所有这些ID用全局唯一的64位整数标示。在一个典型的Dapper跟踪中,我们希望为每一个RPC对应到一个单一的span上,而且每一个额外的组件层都对应一个跟踪树型结构的层级。

图3:在图2中所示的一个单独的span的细节图
在这里插入图片描述

span除了记录ParentSpanID和自己的SpanID外,还会记录自己请求其他服务的时间和响应时间。由于客户端和服务器上的时间戳来自不同的主机,必须考虑到时间偏差,为了解决这个问题需要约定一个前提,即RPC客户端必须发出请求后,服务端才能收到,即如果服务端的时间戳比客户端发出请求的时间戳还靠前,那么就按请求时间来算,响应时间也是如此。(RPC客户端发送一个请求之后,服务器端才能接收到,对于响应也是一样的(服务器先响应,然后客户端才能接收到这个响应)),这样一来,服务器端的RPC就有一个时间戳的一个上限和下限。

从上图可以首先能看出来这个span是一次”Hello.Call”的RPC,SpanID是5,ParentSpanID是3,TraceID是100。 我们重点看一下Client Send, Server Recv, Server Send, Client Recv即CS, SR, SS, CR。

  • CS:客户端发送时间
  • SR:服务端接收时间
  • SS: 服务端发送时间
  • CR: 客户端接收时间

通过收集这四个时间戳,就可以在一次请求完成后计算出整个Trace的执行耗时和网络耗时,以及Trace中每个Span过程的执行耗时和网络耗时。

  • 服务调用耗时 = CR – CS
  • 服务处理耗时 = SS – SR
  • 网络耗时 = 服务调用耗时 – 服务处理耗时

span的开始时间和结束时间,以及任何RPC的时间信息都通过Dapper在RPC组件库的植入记录下来。如果应用程序开发者选择在跟踪中增加他们自己的注释(如图中“foo”的注释)(业务数据),这些信息也会和其他span信息一样记录下来。

3. 如何实现应用级透明?

在google的环境中,所有的应用程序使用相同的线程模型、控制流和RPC系统,既然不能让工程师写代码记录日志,那么就只能让这些线程模型、控制流和RPC系统来自动帮助工程师记录日志了。

举个例子,几乎所有的google进程间通信是建立在一个用C++和JAVA开发的RPC框架上,dapper把跟踪植入这个框架,span的ID和跟踪的ID会从客户端发送到服务端,这样工程师也就不需要关心应用实现层次。

Dapper跟踪收集的流程
在这里插入图片描述
分为3个阶段:

  1. 各个服务将span数据写到本机日志上;
  2. dapper守护进程进行拉取,将数据读到dapper收集器里;
  3. dapper收集器将结果写到bigtable中,一次跟踪被记录为一行。

4. 跟踪损耗

跟踪系统的成本由两部分组成:

  1. 正在被监控的系统在生成追踪和收集追踪数据的消耗导致系统性能下降
  2. 需要使用一部分资源来存储和分析跟踪数据。虽然你可以说一个有价值的组件植入跟踪带来一部分性能损耗是值得的,我们相信如果基本损耗能达到可以忽略的程度,那么对跟踪系统最初的推广会有极大的帮助。

接下来展现一下三个方面:Dapper组件操作的消耗,跟踪收集的消耗,以及Dapper对生产环境负载的影响。我们还介绍了Dapper可调节的采样率机制如何帮我们处理低损耗和跟踪代表性之间的平衡和取舍。

4.1 生成跟踪的损耗

生成跟踪的开销是Dapper性能影响中最关键的部分,因为收集和分析可以更容易在紧急情况下被关闭。Dapper运行库中最重要的跟踪生成消耗在于创建和销毁span和annotation,并记录到本地磁盘供后续的收集。根span的创建和销毁需要损耗平均204纳秒的时间,而同样的操作在其他span上需要消耗176纳秒。时间上的差别主要在于需要在跟span上给这次跟踪分配一个全局唯一的ID。

如果一个span没有被采样的话,那么这个额外的span下创建annotation的成本几乎可以忽略不计,他由在Dapper运行期对ThreadLocal查找操作构成,这平均只消耗9纳秒。如果这个span被计入采样的话,会用一个用字符串进行标注–在图4中有展现–平均需要消耗40纳秒。这些数据都是在2.2GHz的x86服务器上采集的。

在Dapper运行期写入到本地磁盘是最昂贵的操作,但是他们的可见损耗大大减少,因为写入日志文件和操作相对于被跟踪的应用系统来说都是异步的。不过,日志写入的操作如果在大流量的情况,尤其是每一个请求都被跟踪的情况下就会变得可以察觉到。

4.2 跟踪收集的消耗

谷歌的统计数据:
最坏情况下,Dapper收集日志的守护进程在高于实际情况的负载基准下进行测试时的cpu使用率:没有超过0.3%的单核cpu使用率。
限制了Dapper守护进程为内核scheduler最低的优先级,以防在一台高负载的服务器上发生cpu竞争。
Dapper也是一个带宽资源的轻量级的消费者,每一个span在我们的仓库中传输只占用了平均426的byte。作为网络行为中的极小部分,Dapper的数据收集在Google的生产环境中的只占用了0.01%的网络资源。

图3:Dapper守护进程在负载测试时的CPU资源使用率
在这里插入图片描述

4.3 在生产环境下对负载的影响

每个请求都会利用到大量的服务器的高吞吐量的线上服务,这是对有效跟踪最主要的需求之一;这种情况需要生成大量的跟踪数据,并且他们对性能的影响是最敏感的。在表2中我们用集群下的网络搜索服务作为例子,我们通过调整采样率,来衡量Dapper在延迟和吞吐量方面对性能的影响。

图4:网络搜索集群中,对不同采样率对网络延迟和吞吐的影响。延迟和吞吐的实验误差分别是2.5%和0.15%。
在这里插入图片描述
我们看到,虽然对吞吐量的影响不是很明显,但为了避免明显的延迟,跟踪的采样还是必要的。然而,延迟和吞吐量的带来的损失在把采样率调整到小于1/16之后就全部在实验误差范围内。在实践中,我们发现即便采样率调整到1/1024仍然是有足够量的跟踪数据的用来跟踪大量的服务。保持Dapper的性能损耗基线在一个非常低的水平是很重要的,因为它为那些应用提供了一个宽松的环境使用完整的Annotation API而无惧性能损失。使用较低的采样率还有额外的好处,可以让持久化到硬盘中的跟踪数据在垃圾回收机制处理之前保留更长的时间,这样为Dapper的收集组件给了更多的灵活性。

5. 采样

分布式跟踪系统的实现要求是性能低损耗的,尤其在生产环境中分布式跟踪系统不能影响到核心业务的性能。 Google也不可能每次请求都跟踪的,所以要进行采样,每个应用和服务可以自己设置采样率。采样率应该是每个应用自己的配置里配置的,这样每个应用可以动态调整,特别是刚应用刚上线使可以适当调高采样率。

一般在系统峰值流量很大的情况下,只需要采样其中很小一部分请求,例如1/1000的采样率,即分布式跟踪系统只会在1000次请求中采样其中的某一次。

5.1 可变采样

任何给定进程的Dapper的消耗和每个进程单位时间的跟踪的采样率成正比。Dapper的第一个生产版本在Google内部的所有进程上使用统一的采样率,为1/1024。这个简单的方案是对我们的高吞吐量的线上服务来说是非常有用,因为那些感兴趣的事件(在大吞吐量的情况下)仍然很有可能经常出现,并且通常足以被捕捉到。

然而,在较低的采样率和较低的传输负载下可能会导致错过重要事件,而想用较高的采样率就需要能接受的性能损耗。对于这样的系统的解决方案就是覆盖默认的采样率,这需要手动干预的,这种情况是我们试图避免在dapper中出现的。

我们在部署可变采样的过程中,参数化配置采样率时,不是使用一个统一的采样方案,而是使用一个采样期望率来标识单位时间内采样的追踪。这样一来,低流量低负载自动提高采样率,而在高流量高负载的情况下会降低采样率,使损耗一直保持在控制之下。实际使用的采样率会随着跟踪本身记录下来,这有利于从Dapper的跟踪数据中准确的分析。

5.2 应对积极采样(Coping with aggressive sampling)

新的Dapper用户往往觉得低采样率–在高吞吐量的服务下经常低至0.01%–将会不利于他们的分析。我们在Google的经验使我们相信,对于高吞吐量服务,积极采样(aggressive sampling)并不妨碍最重要的分析。如果一个显着的操作在系统中出现一次,他就会出现上千次。低吞吐量的服务–也许是每秒请求几十次,而不是几十万–可以负担得起跟踪每一个请求,这是促使我们下决心使用自适应采样率的原因。

5.3 在收集过程中额外的采样

上述采样机制被设计为尽量减少与Dapper运行库协作的应用程序中明显的性能损耗。Dapper的团队还需要控制写入中央资料库的数据的总规模,因此为达到这个目的,我们结合了二级采样。

目前我们的生产集群每天产生超过1TB的采样跟踪数据。Dapper的用户希望生产环境下的进程的跟踪数据从被记录之后能保存至少两周的时间。逐渐增长的追踪数据的密度必须和Dapper中央仓库所消耗的服务器及硬盘存储进行权衡。对请求的高采样率还使得Dapper收集器接近写入吞吐量的上限。

为了维持物质资源的需求和渐增的Bigtable的吞吐之间的灵活性,我们在收集系统自身上增加了额外的采样率的支持。我们充分利用所有span都来自一个特定的跟踪并分享同一个跟踪ID这个事实,虽然这些span有可能横跨了数千个主机。对于在收集系统中的每一个span,我们用hash算法把跟踪ID转成一个标量Z,这里0<=Z<=1。如果Z比我们收集系统中的系数低的话,我们就保留这个span信息,并写入到Bigtable中。反之,我们就抛弃他。通过在采样决策中的跟踪ID,我们要么保存、要么抛弃整个跟踪,而不是单独处理跟踪内的span。我们发现,有了这个额外的配置参数使管理我们的收集管道变得简单多了,因为我们可以很容易地在配置文件中调整我们的全局写入率这个参数。

如果整个跟踪过程和收集系统只使用一个采样率参数确实会简单一些,但是这就不能应对快速调整在所有部署的节点上的运行期采样率配置的这个要求。我们选择了运行期采样率,这样就可以优雅的去掉我们无法写入到仓库中的多余数据,我们还可以通过调节收集系统中的二级采样率系数来调整这个运行期采样率。Dapper的管道维护变得更容易,因为我们就可以通过修改我们的二级采样率的配置,直接增加或减少我们的全局覆盖率和写入速度。

6 最重要的Dapper的不足

  1. 合并的影响:我们的模型隐含的前提是不同的子系统在处理的都是来自同一个被跟踪的请求。在某些情况下,缓冲一部分请求,然后一次性操作一个请求集会更加有效。(比如,磁盘上的一次合并写入操作)。在这种情况下,一个被跟踪的请求可以看似是一个大型工作单元。此外,当有多个追踪请求被收集在一起,他们当中只有一个会用来生成那个唯一的跟踪ID,用来给其他span使用,所以就无法跟踪下去了。我们正在考虑的解决方案,希望在可以识别这种情况的前提下,用尽可能少的记录来解决这个问题。
  2. 跟踪批处理负载:Dapper的设计,主要是针对在线服务系统,最初的目标是了解一个用户请求产生的系统行为。然而,离线的密集型负载,例如符合MapReduce模型的情况,也可以受益于性能挖潜。在这种情况下,我们需要把跟踪ID与一些其他的有意义的工作单元做关联,诸如输入数据中的键值(或键值的范围),或是一个MapReduce shard。
  3. 寻找根源:Dapper可以有效地确定系统中的哪一部分致使系统整个速度变慢,但并不总是能够找出问题的根源。例如,一个请求很慢有可能不是因为它自己的行为,而是由于队列中其他排在它前面的(queued ahead of)请求还没处理完。程序可以使用应用级的annotation把队列的大小或过载情况写入跟踪系统。此外,如果这种情况屡见不鲜,那么在ProfileMe中提到的成对的采样技术可以解决这个问题。它由两个时间重叠的采样率组成,并观察它们在整个系统中的相对延迟。
  4. 记录内核级的信息:一些内核可见的事件的详细信息有时对确定问题根源是很有用的。我们有一些工具,能够跟踪或以其他方式描述内核的执行,但是,想用通用的或是不那么突兀的方式,是很难把这些信息到捆绑到用户级别的跟踪上下文中。我们正在研究一种妥协的解决方案,我们在用户层面上把一些内核级的活动参数做快照,然后绑定他们到一个活动的span上。

7 MapReduce模型

MapReduce是一种编程模型,用于大规模数据集(大于1TB)的并行运算。概念”Map(映射)“和”Reduce(归约)”,是它们的主要思想,都是从函数式编程语言里借来的,还有从矢量编程语言里借来的特性。它极大地方便了编程人员在不会分布式并行编程的情况下,将自己的程序运行在分布式系统上。 当前的软件实现是指定一个Map(映射)函数,用来把一组键值对映射成一组新的键值对,指定并发的Reduce(归约)函数,用来保证所有映射的键值对中的每一个共享相同的键组。

8 ProfileMe提到的成对采样技术

英文论文地址:https://www.cs.tufts.edu/comp/150PAT/tools/dcpi/micro30.pdf

赞(0)
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

世界上最快的捷径,就是脚踏实地

微信公众号:架构技术专栏