全链路 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 存储成本、提升分析效率,真正达到降本增效的目的。
评论