您的位置:  首页 > 技术杂谈 > 正文

初探分布式链路追踪

2024-02-05 10:00 https://my.oschina.net/u/4662964/blog/10984756 大淘宝技术 次阅读 条评论




本篇文章,主要介绍应用如何正确使用日志系统,帮助用户从依赖、输出、清理、问题排查、报警等各方面全面掌握。


可观测性


可观察性不单是一套理论框架,而且并不强制具体的技术规格。其核心在于鼓励团队内化可观察性的理念,并确保由研发人员构建的应用程序具备可观察性。在学术领域中,尽管“可观测性”这一术语是近年来从控制理论中引进的新词,但实际上,它在计算机科学领域已有深厚的实践基础。学者们通常会把可观测性细化为三个更具体的研究方向:事件日志、链路追踪和聚合度量。这三个领域虽然各有侧重点,但并非完全孤立,它们之间存在着天然的交集与互补性。


  1. 日志(Logging),展现的是应用运行而产生的事件或者程序在执行的过程中间产生的一些日志,可以详细解释系统的运行状态,但是存储和查询需要消耗大量的资源。所以往往使用过滤器减少数据量。

  2. 度量(Metrics),是一种聚合数值,存储空间很小,可以观察系统的状态和趋势,但对于问题定位缺乏细节展示。这个时候使用等高线指标等多维数据结构来增强对于细节的表现力。例如统计一个服务的 TBS 的正确率、成功率、流量等,这是常见的针对单个指标或者某一个数据库的。

  3. 追踪(Tracing),面向的是请求,可以轻松分析出请求中异常点,但与日志有相同的问题就是资源消耗较大。通常也需要通过采样的方式减少数据量。比如一次请求的范围,也就是从浏览器或者手机端发起的任何一次调用,一个流程化的东西,需要轨迹去追踪。


在工业界,历经长期的竞争,日志和度量领域的主导技术已逐渐明朗。日志收集与分析方面,Elastic Stack(亦称为ELK Stack)已成为广泛采纳的技术选择。然而,追踪技术的发展路径与日志和度量领域显然不同。追踪技术高度依赖于特定的网络协议和编程语言。服务间是通过 HTTP 还是 gRPC 通信,会直接决定追踪实施的具体方法。同样,服务是否采用 Java、Golang、还是 Node.js 编写,也会影响到如何追踪进程内调用堆栈。这种依赖性使得追踪工具通常需要以插件或探针的形式进行深度集成,因而具有一定的侵入性。此外,由于追踪技术需要适应不同的语言和网络环境,追踪领域很难形成单一的主导者。相反,市场上存在多样化的产品,旨在满足各种不同技术栈的追踪需求。


本文主要探讨其中的追踪下日志内容。


链路追踪

分布式链路追踪公认的起源是 Google 在 2010 年发表的论文《Dapper : a Large-Scale Distributed Systems Tracing Infrastructure》,这篇论文介绍了 Google 从 2004 年开始使用的分布式追踪系统 Dapper 的实现原理。


论文 Dapper : a Large-Scale Distributed Systems Tracing Infrastructure 》地址:https://static.googleusercontent.com/media/research.google.com/zh-CN//archive/papers/dapper-2010-1.pdf

  基本原理



分布式链路追踪的基本原理就是在分布式应用的接口方法上设置一些观察点,然后在入口节点给每个请求分配一个全局唯一的标识 TraceId。当请求流经这些观察点时就会记录一行对应的链路日志(包含链路唯一标识,接口名称,时间戳,主机信息等)。最后通过 TraceId 将一次请求的所有链路日志进行组装,就可以还原出该次请求的链路轨迹。


分布式链路追踪实现请求回溯的关键点有两个:一是低成本、高质量的观察点设置,也就是链路插桩,确保追踪的信息足够丰富,能够快速定位异常根因;二是保证链路上下文在不同环境下都能够完整透传,避免出现上下文丢失导致的断链现象。


  基础术语


虽然分布式链路追踪的实现方式多种多样,但是仍然有一些基础术语在业界具备广泛的共识。
  • Trace


一条 Trace 代表一次入口请求在 IT 系统内的完整调用轨迹及其关联数据集合。其中,全局唯一的链路标识 TraceId,是最具代表的一个属性。通过 TraceId 才能将同一个请求分散在不同节点的链路数据准确的关联起来,实现请求粒度的“确定性关联”价值。


以集团的Eagleeye设计的traceId设计为例,根据这个id,可以知道这个请求在2022-10-18 10:10:40发出,被11.15.148.83机器上进程号为14031的Nginx(对应标识位e)接收到。其中的四位原子递增数从0-9999,目的是为了防止单机并发造成traceId碰撞。


  • Span


Span是一个操作,它代表系统中一个逻辑运行单元。Span之间通过嵌套或者顺序排列建立因果关系。Span包含以下对象

  1. Operation Name:描述了当前接口的行为语义,比如 /api/createOrder 代表执行了一次创建订单的动作。

  2. SpanId/ParentSpanId:接口调用的层级标识,用于还原 Trace 内部的层次调用关系。

  3. Start/FinishTime:接口调用的开始和结束时间,二者相减就是该次调用的耗时。

  4. StatusCode:响应状态,标识当次调用是成功或失败。

  5. Tags & Events:调用附加信息,详见下面的描述。



其中最重要的是SpanId的设计。同样以阿里的Eagleeye的spanId(rpcId)设计为例,Eagleeye设计了RpcId来区别同一个调用链下多个网络调用的顺序和嵌套层次,用0.X1.X2.X3.....Xi来表示,根节点的RpcId固定从0开始,id的位数("."的数量)表示了Span在这棵树中的层级,Id最后一位表示了Span在这一层级中的顺序。那么给定同一个Trace中的所有RpcId,便可以很容易还原出一个完成的调用链:
- 0  - 0.1    - 0.1.1    - 0.1.2      - 0.1.2.1  - 0.2    - 0.2.1  - 0.3    - 0.3.1      - 0.3.1.1    - 0.3.2

  • Tags


如果需要进一步记录请求的行为特征,可以使用 Tags 来扩展语义。Tags 是一组由 {Key:Value} 组成的键值对集合,描述这一次接口调用的具体属性。比如Eagleeye里就有「Tags」的实现(UserData),阿里的全链路压测标识就是通过UserData里传递实现的。


在分布式追踪的上下文中,一个Trace可被视为一个有向无环图,其中每个Span代表图中的一个节点,而节点之间的连接则表示为链接。观察这样的图表,我们可以看到一个单独的Span节点可能拥有多条链接,这表明它具有多个子Span。这种结构揭示了各个操作间的父子或者因果关系,从而使得追踪整个请求的流程成为可能。


Trace定义了Span间两种基本关系:

  1. ChildOf:表示父 Span 在一定程度上依赖子 Span。

  2. FollowsFrom:表示父 Span 完全不依赖其子Span 的结果。


  链路追踪的挑战


  • 日志存储


本地存储埋点数据涉及磁盘操作,而磁盘I/O速度通常较慢。在高并发的环境中,如果采用同步刷新磁盘的方式,可能会对原有业务的性能造成显著影响。因此,在链路追踪系统中进行数据埋点时,应当尽量减少对系统的性能损耗,确保对原业务的逻辑和性能具有无侵入性。这意味着追踪系统的设计需要尽量轻量化,并采取策略如异步处理、缓存等方法来降低对业务性能的影响。


并发环形队列


集团的Eagleeye采用并发环形队列存储Trace数据,如下图所示:


环形队列确实是日志框架中实现异步日志写入的一种常用技术。在这种队列中,存在两个关键的指针,即读指针(take)和写指针(put)。

  1. 读指针(take): 它指向队列中的最旧数据,即下一条待消费的数据。

  2. 写指针(put): 它指向队列中下一个将要存放新数据的位置。


这个队列被设计为支持原子性的读写操作,以保证在多线程环境中数据的一致性。这意味着,当一个线程在执行读或写操作时,其他线程不能同时修改被操作的数据项。


读指针和写指针按同一方向(如时钟方向)移动,随着数据的不断生产和消费,这两个指针在队列中循环前进。然而,如果生产数据的速度持续超过消费速度,写指针最终会“追上”读指针,这种情况被称为“套圈”。


当出现套圈现象时,队列将根据预设的策略来处理新产生的数据。常见的处理策略包括:

  1. 丢弃即将写入的新数据,以保护队列中已存在的数据不被覆盖。

  2. 覆盖队列中最旧的数据,这意味着最先入队的数据将被新数据取代,这种策略适用于对最新数据保持最高优先级的场景。


选择哪种策略取决于具体应用场景的需求和对数据丢失的容忍度。环形队列的这种设计使得它在处理高并发日志数据时非常高效,能够在保证性能的同时减少资源占用。


Skywalking在实现上有所区别,采用分区的QueueBuffer存储Trace数据,多个消费线程通过Driver平均分配到各个QueueBuffer上进行数据消费:


在高性能的应用场景中,队列的实现方式对于整体性能有显著的影响。QueueBuffer 通常有两种不同的实现方式:

  1. 基于 JDK 的阻塞队列:这种实现方式利用了 Java 标准库中的 java.util.concurrent 包下的阻塞队列,例如 ArrayBlockingQueue 或 LinkedBlockingQueue。这些队列内部处理了线程同步的逻辑,使得在多线程环境下生产者和消费者可以安全地进行并发操作。基于 JDK 的阻塞队列通常用于服务端,因为服务端可以承受相对较重的负载,并且更注重于稳定性和线程安全。

  2. 普通数组 + 原子下标:这种实现方式使用了一个普通的数组,并结合了原子操作的下标(如 AtomicInteger)用于记录读写的位置。这种队列不是阻塞的,因此,当队列满时,它需要显式地处理生产者的等待逻辑,当队列空时,需要处理消费者的等待逻辑。SkyWalking 的 Agent 端采用这种方式,因为它相对更轻量级,且在高吞吐量场景下提供了更高的性能。Agent 端的性能尤其重要,因为它直接运行在应用程序内部,性能的任何抖动都可能影响到应用程序本身的性能。


在普通数组 + 原子下标的实现中,有一些值得关注的点:

  1. 原子性操作:通过使用原子类(如 AtomicInteger),可以确保在并发场景下修改下标时的线程安全性,避免了锁的开销。

  2. 非阻塞算法:由于不使用阻塞队列,这种实现可以采用非阻塞算法来优化性能,尤其是在高并发的生产者-消费者模型中。

  3. 回绕逻辑:当数组达到其末端时,读写下标需要回绕到数组的开始,形成一个环状结构,这就是所谓的环形缓冲区。

  4. 饱和策略:需要定义当队列满时的行为,是等待、丢弃数据还是覆盖旧数据。

  5. 总的来说,不同的 QueueBuffer 实现方式适用于不同的使用场景


  • 日志