可观测性 Trace 全量存储之性能优化
往期:
系列文章
全链路 Trace 全量存储-开篇
全链路 Trace 全量存储-性能优化
全链路 Trace 全量存储-重造索引
前篇文章 全链路Trace全量存储-开篇,简要概述了 Trace 存储的背景以及常见的解法,本篇文章在 Trace 全量存储的前提下,介绍客户端和服务端的常见优化手段,来进一步的减少存储成本
1 客户端优化
Trace 全量之后,客户端也需要配合做一定的优化,不然占用资源会很高,主要从如下 4 方面来详细说明:
Trace 和 Metric 在客户端的分离
Metric 的性能
Trace 的性能
处理架构
1.1 Trace 和 Metric 在客户端的分离
对 Trace 进行统计是可以产生 Metric 的,这部分到底是在客户端做还是服务端做?
在客户端做
成本低:在 Trace 对象产生时,就可以直接统计出,比如 1s 粒度的 Metric 数据
Trace 采样:Metric 不受采样影响
升级麻烦:需要提前规划好对哪些数据进行 Metric 统计,一旦想对 Trace 新加一种 Metric 统计则需要升级新版本的 sdk
在服务端做
成本高:流程是 Trace 对象->序列化后字节数组->网络传输->反序列化后的 Trace 对象->Metric 统计,明显成本开销会大很多
热点问题:根据 Trace 个数进行聚合统计时非常容易产生热点问题
Trace 采样:Metric 指标必然不准
升级容易:修改服务端的统计 Metric 的规则,比如添加或者减少一些 tag,服务端发布即实现最新的需求
又是一个各有优势的方案选择问题:
最看重哪个优势?
劣势是否可以挽回?
从以上 2 个角度来说,本篇主要是介绍性能优化,自然对成本优势会更在意一些,另一个劣势变动 Metric 统计规则一定程度也可以挽回,比如客户端可以提供一些自动机制来实现更改 Metric 的统计逻辑。
1.2 Metric 的性能
Metric 含有 metricName、tags、fields,它的主要有如下 2 个过程:
多线程聚合过程:用户在多个线程进行 Metric 打点,打点的背后是拿着 metricName、tags 作为 key 找到对应 Map 中聚合器,然后将 fields 聚合起来
方案 1:针对上述 metricName、排序后的 tags 构建出一个唯一 String,作为 Map 的 key 去查找,这里的问题在于会有大量的短生命周期的唯一 String 在不断生成,影响 GC,优化就是每个线程复用各自的 StringBuilder,将上述信息写入到 StringBuilder,将 StringBuilder 作为 key 去查找聚合器,如果 key 不存在则将 StringBuilder 复制一份存入到聚合器 Map 中。再者可重写 StringBuilder,提供一些定制化的方法,简化频繁的 append 内部处理逻辑
方案 2:将上述信息放到数组中,每次聚合时,只需要产生一个新的数组,比如 otel 的做法,不过这种在 Map 的查找过程中是比较耗时的,每次 equals 比较时要比较数组中的每个对象,中间会有多次切换对象的 equals 方法流程,而上述一个大 String 的比较就会快很多。otel 在每次聚合时都会产生新数组,以及每个 tag 对都会产生新对象,因此在 tps 比较高的时候 gc 会相对高一些
收集过程:对于上述每个聚合器定时收集,将数据序列化成字节数组,最后发送出去
性能点 1:比如每个聚合器的 metricName、tags 都要定时序列化成最终的字节数组,这块内容是一直不变的,变化的部分只是 field,因此前者可以做缓存复用,大大减少每次 Metric 序列化时的代价,比如 otel 目前可能为了更好的拓展性,这块还没有优化
性能点 2:每个聚合器基本可以一直使用,只需要每隔一段时间检查下是否有数据,没数据再安全删除即可。目前 otel 这块做的相当不好,对于 DELTA 指标收集完之后就将当前聚合器弃用,放到一个队列中,下次需要创建时再从此队列中取出,这种设计多个线程会频繁地锁竞争当前队列的取聚合器的操作
性能点 3:在上述数据收集的过程就提前构建好最终要发送的字节数组,收集数据时直接在该数组上不断写入,最终直接将字节数组发送出去,避免先将数据收集到临时地方,几经 copy 辗转再写入到最终的数组上
1.3 Trace 的性能
Trace 通常发送到一个队列中,异步化的进行处理,它的性能主要有如下 2 个过程
队列的并发处理过程:
比如 otel 用的 MpscArrayQueue、或者 disruptor
Trace 生产是多线程的,所以最好也是多线程消费,不然很可能来不及处理,造成不断地丢数据,达不到全量的效果了
批量 batch 的处理过程:
Trace 数据的实时处理:假如 1000 作为一个 batch,那并不是等够 1000 了才去执行处理,而是来一个就序列化一个,避免大量对象长时间存活,目前 otel 这块也做的不够好
提取 Trace 的公共部分:将公共部分只序列化一次即可,后续可持续复用
offset 索引的提前构建:将这 1000 个 Trace 作为一个 batch 发送给服务端,服务端需要的仅仅是每个 traceId、spanId 对应的数据块在 batch 中的 offset,而在上述序列化过程中,就可以轻易得到这个信息,而如果放到服务端来做那就要反序列化出更多的内容才可以得到这个信息,这就是避免服务端反序列化的关键操作,详细见下文的服务端架构
定制序列化和反序列化协议:Trace 在量大的时候主要的消耗就是序列化,最大化的减少对象产生、减少无谓的数据转换和复制
1.4 处理架构
上述 sdk 中有多条类似 pipeline 的任务,为了保证这些 pipeline 任务能够简易高效的多线程执行,需要抽象出一套 pipeline 处理架构
这套处理机制不仅适用于客户端,也适用于服务端,是基础库,后续的服务端也会多次使用到此模型
为什么不是一个 Queue 多 Consumer 的模式?还是为了尽可能的避免竞争问题
在此基础库之上用户只需编写每个环节 task 的具体内容即可,以及指定输出、路由方式(轮询还是 hash 等等)
可以方便的调整每个环节的线程个数
可以方便的得到每个环节的处理指标信息,处理量、丢弃量、处理时延等等
目前很多 agent 都是单 Queue 单线程处理,还不太能应对大流量的场景
2 服务端优化
服务端优化的核心要点就是:
不反序列化原始 Trace 内容
value 到 offset 的替换
整体架构见下图:
server 端收到 client 发送过来的 batch 数据,将该 batch 内容的共性内容+Trace1+Trace2+Trace3 直接压缩,然后 append 到文件中,得到其在文件 fileOffset 信息,文件后续会存储到分布式文件系统重
只需解析 batch 数据中的末尾部分,得到每个 TraceId+spanId 对应的 batchOffset,此时以 TraceId+spanId 作为 key,fileOffset+batchOffset 作为 value 存储到分布式 kv 系统中。通过 2 个 offset 就可以定位到某个 traceId+spanId 的具体内容了,fileOffset 是服务端才能得到的,而 batchOffset 是客户端就可以得到的,因此 batchOffset 最好就是在客户端提前构建,那么服务端就不需要反序列化 Trace 内容了
上述压缩一般采用 zstd 压缩,目前这个压缩率可达到 1:12,可明显减少分布式文件系统的存储压力
3 Trace 的减少
做完上述能力之后,影响整个系统的性能的主要就是 traceId+spanId 的量级了,那么有没有什么措施能减少这个量级呢?目前主要有如下 2 个措施:
Span 的定义
Merge 的能力
3.1 Span 的定义
一个请求,会经过多个服务,每个服务内部会执行多次操作,就会形成一个调用链的关系,此时有 2 种定义 Span 的方式
每个服务定义成 1 个 Span:服务内部调用形成一定的嵌套结构存放在 Span 中,即通过 Span 父子关系+Span 内部嵌套结构来表达调用链,类似 Cat 的方式
Span 的量级大大减少,上述只有 3 个 span,一个服务 Span 内部可能有几十个或者几百个操作,它们合用一个 spanId,则大大减少后端的分布式 kv 的 tps 写入压力
双向的引用关系:父子 Span 之间均有相互的引用关系,即父 Span 可以得知它所有的子 Span,子 Span 可以得知它的父 Span,这样在展示时就可以局部视角切入进行上钻或者下钻
劣势:最外层的操作完成后才会统一上报 Span 到服务端,假如某些 Span 耗时非常久,那么会长时间看不到调用链路
每次操作定义成 1 个 Span:统一使用 Span 父子关系来表达调用链,类似 otel 的方式
Span 的量级非常大,上述有 10 个 span,后端的分布式 kv 的 tps 写入压力很大
单向的引用关系:子 Span 可以得知它的父 Span,但是父 Span 无法轻易得知它的所有子 Span 列表,要么在存储上支持根据父 Span 查询子 Span 的能力,要么像上述一样在父 Span 中记录对子 Span 的引用关系,但是由于此时 Span 的量级非常大,这个查询能力或者这个记录操作也就显得非常昂贵
优势:简单,同时在耗时久的场景下,基本不存在上述看不到的问题
从总体上来说,每个服务定义成 1 个 Span 的优势还是很大的
3.2 Merge 的能力
有一些场景,比如没有上下游调用链路,只是当前服务在不断的生成简短的 Trace,并且 tps 非常高,比如
此时如果能将这些 span 合并起来共用一个 span,则也会大大降低 Trace 的量
当前线程缓存一个 Span,如果碰到要生成一个新的 Span,则只需要作为前者的 child 即可
当前线程的 Span 如果超过 10s 或者 child 过多则 flush 上报到后端
Metric 关联 Trace:Metric 中记录当前线程缓存 Span 的 traceId+spanId,同时记录 child 的 index,即通过 2 级指向来解决定位问题
Log 关联 Trace:同上
这类场景下,可节省 90%的 Span
4 重造索引(待续)
做到了上述优化之后,就会发现此时性能瓶颈就在于分布式 kv 系统了,比如这里使用 HBase,它在当前这个场景下有哪些可优化的点?
写入 HBase 时会产生大小 Put 小对象,对 GC 产生很大压力,进而影响 CPU 使用率
1 亿条数据写入 HBase,相当于会对它进行一个全量排序,对查询友好,但是 Trace 场景下是写多读少,没有必要全量排序
那么如何实现一个新的 kv 系统来满足上述要求呢?
先来预告下此新的 kv 系统:
单核处理上述 kv 的 TPS 在 130 万/s,即处理 1300 万 tps 的写入只需要 10 核资源
kv 查询平均耗时 100ms
由于大量减少了小对象的产生,对写入服务来说即上图中的 server,gc 压力明显减少,cpu 使用率也降低了一半
上述即查询耗时高一些,但是在 Trace 场景下并不明显影响用户使用体验,在写入方面却大大降低了成本,是 HBase 成本的 1/20
版权声明: 本文为 InfoQ 作者【乘云 DataBuff】的原创文章。
原文链接:【http://xie.infoq.cn/article/65f9c2379fcd2425e484da57e】。文章转载请联系作者。
评论