天天看点

分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

背景

链路追踪是微服务中排查的利器,用一个 TraceId 就可以串起整个调用链路的所有环节,对排查一些生产问题帮助很大。但是目前公司自研的链路追踪系统因为资源限制,数据处理延迟很严重。本篇文章分享实习期间对链路追踪进行优化的过程。

实现原理

我们先简单的认识下一个典型的链路追踪系统:

如下图所示,如果想知道一个请求在哪个环节出现了问题,就要知道这个请求调用了哪些服务,调用的顺序和层级关系。这些调用信息像链条一样环环相扣,我们称之为调用链。

分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

p1.png

而在这条链中,每个调用点,比如服务A对服务B的调用,服务B访问数据库,我们称之为 Span。将一次请求看作一个 Trace,使用唯一的 TraceId 标识,通过 SpanId 和 ParentId 记录调用顺序和层级关系,使用 Timestamp 记录调用的各项时间指标。

把 TraceId 相同的 Span 都整合起来,就可以绘制出这个 Trace 的调用情况图,帮助分析问题。如下图所示:

分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

p2.png

存储系统架构

Trace 的数据有以下特点:

  1. Trace 的 Span 数量各不相同:有的 Trace 只有一个 Span,有的 Trace 有上百个 Span。
  2. 数据量大:我们每天需要收集 8千万+ Span,这些 Span 组成 1千万+ Trace。

借鉴 Google 的 Dapper,我们选择 HBase 来存储 Trace 数据,它支持超大规模数据存储和稀疏存储动态列的特性可以满足存储 Trace 的需求,存储格式如下:

分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

p3.png

分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

p4.png

为了避免海量的 Trace 数据对 HBase 的冲击,我们使用 Kafka 在中间缓冲。Consumer 节点从 Kafka 拉取到 Span 后,以 TraceId 作为 RowKey,SpanId 作为 ColumnKey 存到 HBase 中。

问题

这种方式受限于 HBase 的写入速度(公司的的 HBase Trace 集群配置不高,且集群不大。因为 Trace 的使用特征是需要每时每刻收集和存储大量的数据,但是只有碰到链路问题时才可能会使用,所以为了节约成本,一般 Trace 的 HBase 集群配置不会太高。),Span 从产生到被 Consumer 消费有数小时到天的延迟,导致链路追踪系统可用性较低。

阶段一:磁盘存储

分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

p5.png

针对 HBase 写入速度慢导致链路追踪系统不可用的问题,我们尝试对存储系统的写入性能进行优化。在考虑写入的时候,我们首先想到:磁盘顺序I/O的性能极高,甚至高于内存的随机I/O,那么我们能不能将 Trace 数据直接顺序写到磁盘上呢?而且 Trace 数据对可靠性的要求并不高,直接写单机的磁盘即可。

如果能顺序写磁盘,写入性能是能解决,但我们还要解决查询的问题:如果只是把 Span 一个一个存到文件中,查询时就需要遍历文件匹配 TraceId 聚合结果,效率太差。所以要在写入磁盘时就把同一个 Trace 的 Span 聚合起来,查询时只需要读取一次文件就能拿到结果。

实现

要将相同 Trace 的 Span 都聚合在一起该如何办到呢?因为每个 Trace 的到达是随机的,我们接收 Span 的顺序可能是Span1 of Trace1, Span1 of Trace2, Span2 of Trace1......为此我们在内存中分配一个 Buffer 来缓存 Span,当收到一个 Trace 的所有 Span 时,将这些 Span 聚合成一个 Trace 写入磁盘。Trace 落盘时,将 TraceId 作为 Key,(FileName,Offset,Length)作为 Value 记录在 RocksDB 中,查询时只需要根据 TraceId 拿到(FileName,Offset,Length)作为索引去文件中读取 Trace。

Trace 的存储格式如下图所示(不同的 Trace 使用不同的颜色标识):

分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

p6.png

但是,我们从 Kafka 拉取到的 Span 没有明确的标志表示一个 Trace 的结束。怎么判断已经完整的接收了一个Trace呢?

答案是时间。我们可以这样判断:如果一个 Trace 超过一定时间(10 分钟)没有产生新的 Span,就认为这个 Trace 结束了,就可以写到磁盘上去(这种判断方式可能不是完全准确,但是基本上能覆盖绝大多数场景了)。但这里用于判断的时间不能使用当前系统的本地时间,设想一个情况:某个 Consumer 消费 Span 的速度很慢,Span 在 Kafka 中积压,这个 Consumer 消费到的都是 10 分钟前的消息,它会认为收到的每个 Span 都标志着对应 Trace 结束,然后将 Trace 落盘,导致存储的 Trace 不完整。我们可以使用 EventTime 解决这个问题,也就是这个Trace发生时候的时间:维护一个时间变量,记录当前消费到的所有 Span 的最晚结束时间,用这个时间去判断 Trace 是否结束。

判断 Trace 结束之后,我们就可以将其写入到磁盘文件,然后将其从内存 Buffer 中删除,但是这样就有问题了:Buffer 中会有很多空洞,导致出现很多碎片,最后的结果就是虽然 Buffer 还有空间,但已经不能写入新的数据了,为此我们使用类似 JVM “标记-复制”的垃圾收集算法:申请两个大小相同的 Buffer,每次只使用其中一个,清理空洞时只需要将还没落盘的 Span 复制到另一个上面,然后交换两个 Buffer 的引用即可。如下图所示:

分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

p7.png

细节优化

DirectByteBuffer

使用 DirectByteBuffer 作为缓存 Span 的缓冲区,它相比 ByteBuffer 和 byte 数组有以下两点优势:

  1. 写入文件时更少的内存拷贝: 如果是非 DirectByteBuffer,JDK 会先创建一个 DirectByteBuffer,再去执行真正的写操作。这是因为,当我们把一个地址通过 JNI 传递给底层的 C 库的时候,有一个基本的要求,就是这个地址上的内容不能失效。然而,在 GC 管理下的对象是会在 JVM 堆中移动的。也就是说,有可能我们把一个地址传给底层的 write,但是这段内存却因为 GC 整理内存而失效了。所以我们必须要把待发送的数据放到一个 GC 管不着的地方。
  2. GC 压力更小: 虽然 GC 仍然管理着 DirectByteBuffer 的回收,但它是使用 PhantomReference 来达到的,在平常的 Young GC 或者mark and compact 的时候不会在内存里来回拷贝数据。

PreAllocate

创建文件时使用 FileChannel 的 map 函数固定分配 1GB 的空间,之后的写入操作都在这已分配的 1GB 中进行,这样可以使文件分配到的数据块在磁盘上尽可能连续,保证磁盘大部分时间都是顺序I/O,提高性能。

合并写入

最初,我们的 Trace 落盘逻辑是这样的:

for (Trace trace : waitToFlushTraces) {
    List<SpanOffset> spanOffsets = trace.getSpanOffsets();
    ByteBuffer[] spanBufferArray = new ByteBuffer[spanOffsets.size()];
    for (int i = 0; i < spanOffsets.size(); i++) {
        SpanOffset spanOffset = spanOffsets.get(i);
      
        ByteBuffer spanBuffer = buffer.duplicate(); // 收集 buffer 的浅拷贝到数组中
        spanBuffer.position(spanOffset.getLeft());
 spanBuffer.position(spanOffset.getRight());
      
        spanBufferArray[i] = spanBuffer;
   }
    
    fileChannel.write(spanBufferArray); // 每个 trace 调用一次 write
}
 
organizeBuffer(); // 整理 buffer,去除空洞
           
分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

p8.png

这样依次写入 Trace 有一个问题:系统调用需要从用户态切换到内核态,频繁调用 write 方法产生的的上下文切换开销很大。

我们把整理 buffer 和收集待刷盘数据这两个操作合并起来:把不需要刷盘的 Span 放到 cloneBuffer 前面,待刷盘 Span 放到 cloneBuffer 后面,这样只需要一次 write 调用就可以把待刷盘的 Span 全部写入磁盘。

分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

p9.png

效果

链路追踪系统的查询延迟从几小时降到了毫秒级别,能够满足需求。

问题

  1. 受限于磁盘容量(部署的 VM 是 150G 大小),系统只能保存最近 5 个小时的 Trace。但我们需要至少保存两周内的数据才能满足需求。
  2. 只支持查询已经落盘的 Trace,如果 Trace 还在 Buffer 中没有落盘就无法查询到这条数据。

阶段二:重回 HBase

为了解决磁盘存储导致 Trace 有效时间不足的问题,我们需要接入一个容量更大的存储平台。最初我们考虑接入 HDFS,但因为以下几点,我们选择重回 HBase:

  1. 因为之前使用的就是 HBase,有现成的环境和可重用的代码,开发成本更低;
  2. 如果文件存储在 HDFS,我们还需要一个地方存储偏移量索引,可能还是需要存 HBase;
  3. 因为前一阶段已经将 Span 聚合成 Trace,现在我们可以把 Trace 整个写入 HBase。相比最初一个一个写入 Span 的方式,HBase 的写入压力急剧下降(假设每个 Trace 大概 10 个 Span,以前每个 Trace 写入 10 次,现在只需要写入 1 次),HBase 的写入速度能够与 Trace 产生的速度持平;
  4. 我们已经在本地磁盘构建了索引,这样最近几个小时的查询可以走磁盘,另外白天 Trace 数据产生较多,而晚上是低峰期,这样正好做到一个错峰的效果。

实现

我们使用一个独立的线程定时扫描文件夹中上次修改时间在 1 小时之前的 Trace 文件,把其中的 Trace 批量写入 HBase。

为了简化从文件中读取 Trace 的操作,在创建 Trace 文件时,会创建一个同名的以

.index

为后缀的索引文件,索引文件每行存储一个(TraceId,Offset,Length)的三元组。读取 Trace 文件时只需要打开同名的索引文件,通过索引文件读取 Trace 的索引,就可以通过这个索引读取 Trace 写入到 HBase 中。

分布式链路追踪系统的存储优化背景阶段一:磁盘存储阶段二:重回 HBase总结作者招聘官网

p10.png

以上图中的 Trace 文件为例,我们会创建一个名为 xxxx.index 的文件,内容如下:

a-1 0 20
a-2 20 12
           

同时,我们还支持了从内存中查询 Trace 信息,构成一个内存、磁盘、HBase 的三级缓存结构,既解决了查询延迟的问题,又能保证 Trace 的有效时间。

效果

链路追踪系统可以做到实时查询,并且 Trace 的有效时间达到两周,完全满足现有需求。同时得益于磁盘存储的数据缓冲,流量尖峰也不会对 HBase 的写入造成冲击,可以平稳写入。

总结

我们通过将 Span 聚合为 Trace,降低了 HBase 的写入频率,完成对分布式链路追踪系统的存储系统优化,使查询延迟从小时级降低到毫秒级。同时,在优化过程中构建出内存、磁盘、HBase 的多级存储结构,做到性能和成本的平衡。

作者

张同学,便利蜂基础架构实习生。作者在接到实习任务后提出了很多分析建议,并且在过程中利用火焰图分析出内存buffer写文件的IO瓶颈,通过合并write大大优化了写盘效率,作为大三实习生表现出很好的技术素养。 如果你对相关的技术感兴趣,致力于研发效率提升,欢迎加入我们。

  • 邮箱地址:[email protected]
  • 邮件标题:产研平台基础组件部

招聘官网

https://bianlifeng.gllue.me/portal/homepage

继续阅读