kvrocks在货拉拉全链路Trace下的应用
作者简介:
宋高飞,主要从事客户端、服务cpp、golang等领域, 熟悉流媒体推流、即时通讯等系统开发。
席在盛,资深后端研发工程师,开发过KMS、API网关等基础组件,也主导研发过大型业务系统(贷后资产管理)等。目前主要负责监控产品Trace、Java SDK研发工作。
一、引言
传统Trace场景下,没有完全符合Trace场景的存储数据库,在业务量增长的情况下,一款高性能、大容量、持久化的No-sql存储数据库就越来越重要。kvrocks是以RocksDB为基础,支持了redis协议的kv数据库,相比于其它的同类开源产品,它更年轻,因此框架功能更简洁,历史包袱更小,并且社区活跃度很高,我们最终选择了kvrocks为基础进行二次开发。
二、Trace介绍
分布式链路追踪作为分布式应用可观测(Metric&Trace&Log)技术的重要组成部分,在故障诊断、性能调优、架构演进等方面发挥着重要作用。货拉拉基于Apache开源Skywalking V8搭建的Trace平台,目前架构已演进到V3.0版本,接入应用累计1000+;
Trace整体架构:使用 ES+ HBase的存储方案;
Trace的数据结构:有向无环图。常用存储方案为ES、HBase(HDFS)等分布式NoSQL数据存储,使用类KV存储结构(Key:TraceID,Value:完整Span数据);
Trace的数据规模:
业务高速增长背景下,Trace数据存储面临的问题?
在成本约束前提下, Trace采样率不高(热数据1H内稳定在50%,冷数据3D在10%左右),导致在排障过程中,部分链路数据缺失,不能借助Trace数据定位问题,不利于故障的快速定位和恢复。
如何破解当前面前的问题?
在数据结构优化带来的收益有限前提下,让我们重新开始考虑是否有更合适Trace数据的存储方案。
Trace数据特征:Trace场景是一个写流量极大、读流量极少、无需事务,并且存在数据周期性过期(数据有效周期短)的特征。即便丢失部分数据(无需多副本,只需保证可快速恢复)也不会造成重大影响,这让我们在调研新的数据存储方案时,将目光投向了新兴起的KV存储:kvrocks。
三、kvrocks简介
3.1 kvrocks简介
kvrocks使用reuse_port多线程监听同一个端口来并发处理请求,在单个worker线程中处理redis协议,并转换为RocksDB支持的kv类型,一个kvrocks节点使用一个RocksDB实例。
3.2 RocksDB简介
RocksDB底层是LSM Tree,其是通过将写请求的数据缓存在内存,等待被批量写入磁盘,与InnoDB相比,大大降低了io次数,同时删除和修改也都是写入新的记录,极大的提高了写性能。数据文件在磁盘上通过某种Compaction策略进行合并组成特定的层级结构。
3.2.1 Memtable
Memtable默认实现为跳表,写请求都被缓存在Memtable中,当被写满数据会更改为immutable Memtable等待落盘,同时我们可以对Memtable的大小、可留在内存中的个数、刷盘时合并个数等参数进行控制来达到优化性能的目的。
3.2.2 Compaction
为什么需要Compaction?
为了优化读取性能。如果不进行Compaction,所有SST文件如果范围重合,那么就需要遍历读取所有的SST文件。
为了被修改或删除的数据。如果不进行Compaction,SST文件中可能存在大量相同的key,占用额外的磁盘空间,而只有一个最新版本的key才是我们需要的。
Compaction带来了写放大、读放大、空间放大,不同的Compaction就是在这三者之间进行平衡以适配流量负载,达到最好的性能。
写放大:Write amplification is the ratio of bytes written to storage versus bytes written to the database.
读放大:Read amplification is the number of disk reads per query.
空间放大:Space amplification is the ratio of the size of database files on disk to data size.
因此写放大影响写入性能,读放大影响读取性能,空间放大影响磁盘占用空间。
Leveled Compaction Strategy
此策略将在磁盘上的文件分成多个层级,L0、L1等。每一层都是上一层的数倍大小,被称为扇出。
L0包含从Memtable落盘的文件,因为同时只存在一个可被写入的Memtable,因此每个Memtable之间的数据范围可能是重叠的,但除了L0每一层都是一个有序数据集。
因此从L0到L1层级的合并是需要所有文件参与的合并。而其他层的合并只需要some-to-some。
L0层一般数据少,为方便分析,以下说明都是忽略L0层。
读放大:因为除了L0外的每一层都是一个有序结果集,因此查询只需要二分查找(因为每个文件都有范围,所以只需要在包含查询key的文件中进行查找)即可,读放大最坏的情况为每一层都进行一次二分查找。
写放大:假设Ln层是Ln-1层的K倍大小,Ln-1层的数据合并到Ln层最坏情况是Ln层的所有文件都需要参与进行合并,因此一次合并的写放大为K,最坏情况下就是每一层都出现写放大为K的情况。
空间放大:假设扇出为10,最坏情况下Ln-1层的数据在Ln层都有,空间放大最坏情况为(1+10+100)/100=1.11...
总结:此策略读放大和空间放大较优,写放大较大。
Universal Compaction Strategy
Universal策略属于LSM Tree中tiered策略家族,也是将数据文件在磁盘上分层,但是这个分层不同于leveled策略,它是概念上的一种分层,为了描述树的形状和估计写放大。在tiered策略中,每一层有多个排序结果集。
每次合并都是将所有Ln-1层的排序结果集合并为一个新的Ln层的排序结果集。
理论上的tiered策略在最大层,会存在多个最大的排序结果集,也就是可能存在多个数据的版本,最坏情况下,有几个排序结果集,空间放大至少就是几倍,此时需要将最大层的所有排序结果集进行合并,所以存在major Compaction和minor Compaction两种。在各种工程中实现是不同的,在RocksDB中是惰性Compact,只有排序结果集的个数达到指定个数才会进行Compact,并且遵循以下几个规则:
周期性的尝试将旧的排序结果集合并到最底层。
如果空间放大大于指定值,尝试一次major compaction。空间放大的计算方式为 其它排序结果集/最大的排序结果集。
根据size_ratio进行一次minor Compaction。此种方式尽可能将大小相近的排序结果集进行合并。
如上三种都没有触发,那么尝试一次minor Compaction,规则类似于第三条。
此类策略的各种放大难以用数学模型描述,但是从合并的操作来看,universal Compaction每次合并是将上层的排序结果集往更底层合并,所以写放大比leveled Compaction小很多。同时因为每层都存在多个排序结果集,所以读放大比较大。major Compaction的存在导致最大层的最大结果集需要进行合并,此时需要双倍的空间。
总结:此策略是优化了写放大,但是牺牲了读放大和空间放大,并且会在major Compaction的时候有非常大的尖刺。
scylladb官网中两种策略的空间放大比较:
以恒定的速度不停的写入新数据
反复写入1.2GB的数据
FIFO Compaction
顾名思义,此策略只有一层结构,并且先入先出,当达到指定的磁盘占用量时会自动删除最旧的文件。
此策略写放大为1(因为即便是Compaction,也是指定最新的几个文件进行一次归并排序,不需要更多次了),同时不存在空间放大(只是在Compaction时临时占用几个文件的大小,可忽略不计)
四、货拉拉Trace场景下的尝试
Trace场景本来使用的是HBase,Trace业务属于典型的写应用场景,超过60w的写QPS,一天偶尔二十的读QPS,并且数据存在过期的特征。
我们选用32C 128G 本地NVMe SSD进行了初步测试。
Leveled策略下的测试
参数条件:
Memtable大小256M,个数6个。
可动态调整SST文件大小。
后台Flush和Compaction线程12个。
最小Memtable合并数量2个。
测试结果:出现Compaction pending,服务撑不住写入压力。
出现了Compaction pending,这表示底层线程已经堆积了太多的Compaction任务,对数据的写入造成了影响。
那么我们增多Memtable的个数,增多Compaction线程的数量,这样我们给更多的cpu和更多的内存来缓冲,虽然最终的结果依然还是pending,不过好消息是坚持的时间久了一点,表示我们的方向没错。
参数改进:
Memtable大小512M,个数20个。
不可动态调整SST文件大小,文件大小1个G。
后台Flush和Compaction线程20个。
Block cache增加到20G。
测试结果:依然出现Compaction pending。
同时我们观察到出现Compaction pending的时候,L1层的所有文件都在合并:
那么,根据对Leveled的理解,在L0层是多个排序结果集,当合并时,大概率需要与L1层的文件全部进行合并,因此这里就出现了瓶颈,我们就测试了单机双节点,事实证明,它是一个解决方案,但考虑到可运维性和扩缩容,不是最优。
Universal策略下的测试:
考虑到Compaction pending是因为写入数据量太快导致底层来不及进行合并,那么我们就尝试了对写放大较友好的universal Compaction策略:
参数调整:修改合并策略为universal策略
测试结果:运行了三天,没有出现Compaction pending,同时在QPS比较高的时候,出现了较多尖刺。
通过监控分析,出现尖刺时请求的RT都非常高,达到了1.5s甚至更高,这时候通过kvrocks支持的perflog将RocksDB底层执行请求时的时间分析抓了出来,发现主要有以下原因:
hset 时会对同一个key进行加锁操作,影响写入效率。
改进方法:kvrocks的hset数据结构是通过存储一个meta data,其中有数据类型、版本、大小、过期时间等元数据,真实的field和value数据是通过拼接namespace+slot+key+filed作为一个key(其中的size忽略了),每次对hset添加数据都会对meta data进行修改,所以原操作是有一个锁的。Trace业务使用方式极其简单的情况下,完全可以去掉meta data和锁。
请求到达后,虽然是多线程处理,但是最终写入请求还是单线程进行写入内存、日志刷入磁盘。其中一半慢请求都是因为这个原因。
改进方法:在不需要快照视图一致性的情况下,可以打开unordered_write参数来大大提升写入吞吐量。
读取请求的时候对太多的文件进行搜索查询,导致io操作太多。
改进方法:在转换为存入RocksDB的key时,将Trace自带的时间戳提前,这样多个排序结果集之间的key range重合大大减少,可以极大的提高读取性能。
这次能扛住极大的写入压力了:
测试结果:写入压力可以承载,但是空间放大太高。
空间放大,高达1.7倍的空间放大,导致即便是方案可用,成本也会不可控。
FIFO策略下的测试:
最终目光转向了FIFO:
参数调整:
Memtable大小更改为1个G,同时10个Memtable触发合并刷盘。
关闭FIFO策略的Compaction。
发现FIFO方案完美符合Trace场景,业务不存在删除命令,数据到期自动删除,基本不存在的写放大能承载极高流量等特性。当然,我们也被迫舍弃了读性能,不过恰恰契合Trace的业务。
扩容方案
kvrocks的数据迁移
数据迁移分为全量和增量两个过程:
通过使用snapshot快照复制传输文件来进行现有数据的全量迁移
通过WAL log来进行增量数据迁移,将数据组织成redis协议格式的请求发送到新节点
短暂禁止迁移槽的写入来将增量数据迁移过程中新增的增量数据迁移完成
最终修改集群拓扑信息,结束整个迁移过程
kvrocks的迁移数据依赖于WAL log来进行增量数据的迁移,当数据量TB级别的时候全量迁移时间可能比较长,导致WAL log已经被刷新,然后继续全量迁移,造成迁移不仅完不成,还一直对机器造成巨大的压力,唯一解决方案就是扩大WAL log。
最终确定扩容方案为请求转发,此方案依赖于Trace数据具有过期特性且key中存在时间戳。我们将每次集群拓扑信息变更全部记录下来,当读请求发送到某个节点时,检查key中的时间戳和集群变更的时间戳,判断key落在了哪个时间段的集群中,再将请求转发到对应的节点进行查询即可。
五、总结
目前Trace切换kvrocks存储后,已0故障稳定运行3个月。最终实现了近百T数据、集群最高70w QPS、并且成本降低55%的目标。kvrocks能够满足Trace场景下的数据存储需求,且在性能和成本方面比Hbase更优。
Trace使用kvrocks后数据流:
运行监控:
成本对比:
未来规划:
支持Trace数据的冷热分离。
对老旧数据再次进行Compaction,提高压缩率。
进一步提升读取性能。
以上就是kvrocks在货拉拉的实践过程,谢谢。