写点什么

全链路 Trace 全量存储 - 重造索引

作者:乘云 DataBuff
  • 2023-08-14
    浙江
  • 本文字数:4526 字

    阅读完需:约 15 分钟

全链路Trace全量存储-重造索引

摘要:

上一篇文章全链路Trace全量存储-性能优化介绍了常见的客户端和服务端优化手段,只是在一定程度上减少了成本,但是要想有质的飞跃,还是需要重造索引。本篇会介绍下重造索引经过的几个阶段,是如何一步步发展成终极方案的,并顺带介绍 DataBuff 所使用的 TraceX 存储子系统能够达到什么级别的性能

1 重造索引的几个阶段

还是再明确要解决的问题:


  • 海量的 kv 写入,比如上亿的 tps,查询量不高,对查询时延要求也不高,平均 200ms 都能接受

  • k 是 traceId+spanId,v 是 offset 定长的

  • kv 本身比较简短,是实时产生和处理的,在查询 k 时也可以带上一个大概的时间 t1,或者 k 本身就是带有时间的,代表 kv 大概是 t1 前后产生的

  • 数据重要性程度不高,个别情况丢数据可以容忍


重点是:能把成本大幅度降下来


重造索引也不是一蹴而就的,也是不断的拓展思路最终形成的,主要有如下几个阶段


  • 最初:用 HBase 进行存储

  • 中间 1:重写一个类似 HBase 的分布式 kv

  • 中间 2:重写一个 hash 式的分布式 kv

  • 中间 3:废除 bloom filter

  • 终极版:性能优化

1.1 用 HBase 进行存储

使用 HBase 来存储 kv,但是有如下问题


  • 由于部署在宿主机上,扩容前还是需要申请对应的机器资源来扩,达不到 k8s 那种扩容的便利性

  • 单机写入 TPS 和存储都有限,成本非常高

1.2 重写一个类似 HBase 的分布式 kv

思路:


  • 将 HBase 的存储换成 S3、OSS、COS 等更廉价的分布式对象存储上,基本可以随意扩,按量付费

  • 将 HBase 中很多对 Trace 无用的功能都去掉

  • MVCC:Trace 场景下基本无事务要求,可以去掉很多相关的锁竞争的问题,提高一定的 TPS

  • Split:大部分时候固定分片,不需要自动的分片,减少复杂性

  • Compact:Trace 场景下产生的数据都是不重复的,Compact 会耗费大量的资源进行文件合并,但是价值不大,每次内存 flush 出固定大小的文件即可


实践:


  • 仿照 HBase 内存 ConcurrentSkipListMap 缓存数据,然后 flush 出文件到 COS


碰到的问题


  • TPS 100 万基本到头了

  • 此时 GC 压力很大

  • 一个方向是:可以参照 HBase 对内存 Map 的优化 ++HBase内存管理之MemStore进化论++,由于 kv 本身都很小,将他们 append 到 chunk 换成 offset 的收益并不明显,而 ConcurrentSkipListMap 本身的 gc 开销也不小,又衍生出了 CCSMap 的优化方式,但是这些很难有质的提升

  • 寻找另外的方向

1.3 重写一个 hash 式的分布式 kv

再回顾下 Trace 的场景:


  • 写多读少

  • 给你 1 亿条 Trace 数据,HBase 是将这 1 亿条数据完整排好序,然后等着你来查,然而很多数据大部分都不会被查到,所以大量排序其实是白白浪费掉了

  • 借鉴:loki 的查询时的暴力方式,牺牲读性能来提高写入性能

1.3.1 思路 1

  • 实现

  • server 之间按 range 分片

  • 单 server 的一个文件内分段,段与段之间有序,段内无序

  • 重点

  • 内存无需再用 ConcurrentSkipListMap 来排序所有数据了

  • 直接 append 对应的内存块,极大的减少了小对象,基本上彻底解决了上面的 gc 问题

  • 查找过程

  • range 划分找到对应的 server

  • 在 server 内找到对应的段,然后暴力搜索段内的内容

1.3.2 进阶 2

  • 实现

  • server 之间按 hash 分片

  • 单 server 的一个文件内按 hash 分桶

  • 重点

  • 不再用 range 方式来计算对应的段,hash 方式计算时更加高效

  • 不过缺点就是,由于 key 是打散的,因此数据压缩方面就相对 range 就差一些

  • 查找过程

  • hash 查找对应的 server

  • 在 server 内 hash 找到对应的桶,然后暴力搜索桶内的内容

1.3.3 进阶 3

  • 实现

  • 单 server 内按时间进行划分粒度

  • 重点

  • 通过时间过滤来减少查找的文件数

  • 查找过程

  • 拿到 traceId、spanId 时都会有大致的时间信息(或者 traceId 中带有时间信息)

  • 拿着时间来查询大致确定出要查找的范围

  • 比如时间粒度是 5min,查找时间 10:00,默认查找 5 分钟之前的到 10min 之后的所有文件,如果仍然查不到,则可以再继续向后扩大查找范围

1.3.4 进阶 4

  • 实现

  • kv 分离

  • 重点

  • 在扫描 key 时,无需读取 value 的内容,使得过滤更加高效

  • 查找过程

  • 由于 v 是定长,只需要找到当前 key 是第 N 个,那么 v 对应的位置=N*定长 size

1.3.5 进阶 5

  • 实现

  • 为每个文件加上 bloom filter

  • 重点

  • 通过 bloom filter 减少查找的文件个数

  • 查找过程

  • 先通过 bloom filter 过滤一下,大大减少后续的查找文件个数

1.4 废除 bloom filter

这点当初卡了一段时间,还是对 bloom filter 的认识不够深刻,没能很早的发现 bloom filter 不合适


bloom filter 是存 k 的信息,用来检测 k 是否存在


  • 全部加载在内存中,发现需要非常大的内存,达不到减少成本的目的

  • 最近一段时间的 bloom filter 的在内存,历史的在文件,最终发现从 COS 中加载 bloom filter 成为了耗时大头,过滤一部分文件,然后再加载剩余的文件,总体上多了 1 次串行加载 bloom filter 的环节


bloom filter 真正能充分发挥优势的场景到底是什么?先来梳理下


  • 开销:bloom filter 本身会增加额外的存储开销

  • 好处:bloom filter 在 kv 场景下,通过过滤 k 来达到减少读取 v 的目的,v 越大,过滤效果越强


所以重点就是上述的好处要远远大于开销时,bloom filter 才能充分发挥优势,即每个 key 对应的 v 的 size 越大,bloom filter 的性价比越高。而目前我们的场景 v 是定长并且 size 比较小,所以此时 bloom filter 的性价比就很低了


为什么 HBase 使用 bloom filter 就可以?


  • 如果使用本地盘来加载 bloom filter,这个加载影响可以忽略,如果是去远程访问分布式文件系统来加载,影响就相对大很多

1.5 性能优化

在做完上述操作后,一些之前不是瓶颈的也逐渐成为了瓶颈


  • Netty 的 ByteBufAllocator 分配 ByteBuf 逻辑竟成为了瓶颈:

  • 在处理写入请求时,由于要划分很多的桶,比如 200 个,那每次都要从 Netty 的 ByteBufAllocator 分配 200 次,所以应该是将这些桶整体性的缓存复用,针对请求级别的缓存复用,而不是针对桶级别的缓存复用,这样就大大降低了单次写入请求的分配内存的次数

  • 去掉对 k、v 的压缩:

  • 由于 key 本身已经是随机的了,并且 size 也小,所以很难再压缩,v 是文件名+offset,还是可以压缩的,但是性价比也不是很高,所以去掉 k 的压缩,保留对 v 的压缩,但是默认不开启对 v 进行压缩

  • 上述由于是 hash 方案,所以 key 相似度比较低,不过对于 range 方案,k 的压缩上还是有一定优势

2 重造索引的具体方案

2.1 整体分布式架构


  • 客户端按照 shard 数进行 hash 分片,积攒 batch,然后发送给具体负责对应 shard 的 server

  • server 负责将每个 shard 的数据写入到对应分布式文件系统中,如云厂商的 S3、OSS、COS 等

  • server 无状态,可随意扩容,扩容后每个 server 负责的 shard 个数变少而已,server 和分布式文件系统基本不存在扩容瓶颈,可随时应对各种大流量

  • 一旦增加 shard 个数,那么路由方式就会发生变化,查询时双查新老路由即可

  • 分布式实现:server 无状态如何实现一套分布式的架构?本着一切从简的设计策略

  • shard 信息的记录:存储到类似 zk 等分布式协调服务中

  • 机器的上下线感知:通过类似 zk 等分布式协调服务来感知

  • leader 的选举:机器感知,排序后的第一个可作为 leader

  • 总之:依赖 zk 或者类似的分布式协调服务即可轻松实现分布式架构

  • 机器上下线时:通过历史分配结果+变动的机器,实行新的的分配,目标就是最小化的 shard 变动

2.2 客户端写入


  • 客户端的多个线程按照 k 进行 hash,找到对应的 shard,直接将 kv append 到 batch 中

  • 一旦 batch 满了或者时间过长,则将 batch 发送到对应的 server 上


这个过程基本上不产生大量的小对象


batch 内的数据如下所示:


2.3 单机内写入


  • 多台机器之间按 shard 进行分片

  • 为了多线程支持单个 shard 的消费,每个 shard 又支持按照 task 进行分片,每个 task 对应 1 个线程,所以收到某个 shard 的数据后,按照 task 个数继续 hash 进行分拣

  • 一个 task 内继续分桶,比如默认 200 个桶,那么 task 分拣后的数据继续按照桶进行分桶

  • 将分拣后的数据发送给对应的 task,然后小桶直接 append 到 task 的对应大桶内

  • 一旦 task 内的 kv 个数超过一定数据量,如 100 万个后,然后直接 flush 出所有的桶为 data 文件

  • 然后在内存 index 中记录当前 data 文件中每个桶的 offset 和 size 信息

  • 由于 index 文件的内容很少,所以 index 文件可以记录多个 data 文件的桶信息,index 和 data 是 1 对多的关系



写入时间粒度的问题:



  • 每隔 5min 写入一个新的文件目录

  • 目录构成为:日期+小时-5min 粒度+shard+task+写入服务的 ip-random 数值,random 就是为了防止这个 ip 重启后,写入到一个新的目录,避免和之前的冲突覆盖

  • 在 5min 钟内可以 flush 出多个 data 文件,index 一直在内存,记录每个 data 文件的桶的 offset 和 size,当切换到下一个 5min 时,才将 index 数据 flush 到文件

  • Trace 场景下,个别情况丢数据完全可以容忍,所以设计上一切从简

2.4 查询

当拿着 k 和大概的时间 t1 来查询 v 时


  • 先确定大概得查询范围,起始时间 t1-5min,结束时间 t1+10min,来确定查哪几个 5min 粒度的数据,并行去查询(如果时间范围内不合适,则可再放大查询范围)

  • 计算 k hash 对应的 shard 和 task 对应的目录,负责这 shard 的 server 启动时,就会默认异步加载 shard 和 task 目录下所有的 index 文件

  • 如果 index 文件未加载(比如查历史或者刚启动还未来得及加载),则加载对应的 index 文件

  • 计算出 k hash 对应的桶 m,从 index 信息中取出多个 data 文件中桶 m 的 offset 和 size,即 List

  • 并行加载每个 data 文件的 offset 位置和 size 的长度,即取出桶 m 的数据

  • 遍历桶 m 的 keys 的内容,查看是否跟 key 相等,如果相等则即可找到对应 v 的数据


从上面可以看出查询耗时主要在于:


  • 是否需要加载 index 文件:index 文件主要记录的每个桶的 offset 和 size,这块内容非常小,基本可以存放几天内的数据到内存中

  • 并行加载某个粒度内所有 data 文件的某个桶的数据:

  • 100 万个 kv 记录,单个 kv60 个字节,大概 60M 数据,分 200 个桶,单个桶大概 300K,单次加载耗时平均大概 100ms

  • 由于查询 qps 比较低,所以基本这个查询能力在可接受范围内


调节的艺术:


  • 增大机器个数,shard 数不变:每个机器负责的 shard 个数变少,写入能力增强,但是单个 shard 内的文件数仍然不变,但查询耗时未明显变化

  • 增大 shard 个数,机器数不变:每台机器负责的 shard 个数变多,内存缓存的 task 的变多,对内存要求增大,单个 shard 内的文件数变少,查询耗时相对变快

  • 增大桶的个数:桶分配的数据块增多,块大小变小了,总内存相对不变,index 文件内容相对增多,data 文件大小和个数都不变,只是查询 data 文件的单个桶的 size 变小,如果单次从分布式文件系统中加载 size 和 1/2size 区别不大时,这个调节也没太大意义

3 重造索引的效果

  • 单核处理上述 kv 的 TPS 在 130 万/s,即处理 1300 万 tps 的写入只需要 10 核资源

  • kv 查询平均耗时 100ms

  • 由于大量减少了小对象的产生,对 Trace 写入服务来说,gc 压力明显减少,cpu 使用率也降低了一半


上述即查询耗时高一些,但是在 Trace 场景下并不明显影响用户使用体验,在写入方面却大大降低了成本,总体是 HBase 成本的 1/20

4 TraceX 一览图

针对 Trace 的整个存储系统,在 DataBuff 产品体系中称之为 —— TraceX 存储系统,具体设计如下所示:



TraceX 的整体效果可以达到:每天 1PB 的原始 Trace,高峰期 1300 万 TPS 的 Trace 写入,使用的资源如下


  • 110C 的计算资源

  • 上述写入服务 100C

  • 上述重造的 kv 索引系统 10C,单核可支撑 130 万 tps

  • 80T 的存储:上述写入和 kv 索引的总量

  • 单 Span 查询平均耗时 200ms


TraceX 的系统设计,本质上适用于任何一位购买 APM 或 可观测软件的客户,尤其适用于交易规模特别庞大的行业客户(如互联网电商公司、银行高并发交易系统等)。很多可观测软件厂商,为了降低后台集群压力,不得不大量丢弃客户的链路追踪数据,最终在故障排除、性能优化、历史回溯、安全审计与合规等环节面临严峻挑战。TraceX 能够有效的降低 Trace 存储成本、提升分析效率,真正达到降本增效的目的。


用户头像

让云运维更简单 2023-06-25 加入

云观测领导者

评论

发布
暂无评论
全链路Trace全量存储-重造索引_乘云 DataBuff_InfoQ写作社区