Falcon 存储优化: 高性能内存 TSDB 的诞生
Posted 魏小言
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Falcon 存储优化: 高性能内存 TSDB 的诞生相关的知识,希望对你有一定的参考价值。
目录
Falcon 存储优化: 高性能内存 TSDB 的诞生
TSDB
先说 TSDB(时序数据库)是什么,下面的定义引用自 Wikipedia
A time series database (TSDB) is a software system that is optimized for handling time series data, arrays of numbers indexed by time (a datetime or a datetime range).
Influxdb
,Graphite
,RRDtool
,OpenTSDB
等都是当下流行的TSDB实现。
TSDB 多应用于监控系统等场景。 关注我 code 杂坛,了解更多......
背景
我曾提过,滴滴的监控系统,其存储部分的核心是以 open-falcon 为蓝本开发的。
虽然对以RRDtool
为核心的graph
做了一些优化,但其先天的不足,实难扭转对其高资源需求
,低性能产出
的差评。 关注我 code 杂坛,了解更多......
graph 之殇
1. graph 的io
资源需求,就滴滴的监控体量来讲,graph 实例的 iops 峰值高达50k+
,在滴滴 graph 使用的机型是使用了 nvme 磁盘的最高配机型,所以经常在申请预算时被 challenge。究其根本,无非是使用了单 series 单文件
的存储模型。
2. cache 的数据结构选型,graph 选用了container/list
作为其核心数据结构。在小型项目中,container/list
是用于队列场景的常用选型之一。但其临时内存的消耗也非常可观,参见 improving-containerlist 所述。
3. TSDB 的核心,无非就是 key
+ (t, v)...
这种结构,无论以何种方式组织这几者,例如行式、列式等。都必须有方法来生成key
,即对series
的唯一标识。在graph
中大量使用sha1
, md5
这种加密hash
来参与key
的生成,这是极大的浪费,杀鸡用了牛刀。
4. 仍然讨论key
。graph 对于 tsdb series
的标识,使用了labels
,所谓 labels,即 k1=v1, k2=v2...
这种键值对。而 graph 中对于 key 与 labels 的映射,是通过对 labels 进行字符串连接
+ sort()
+ md5()
生成的。这种转换(map <-> string
),在整个读、写路径中,在各个模块中都大量存在。而这几种操作要么是高内存耗用,要么是 CPU 杀手。
随着监控数据的不断增长,在 series 到达3亿+
的时候,graph 的内存、cpu 等指标值都很不乐观,io 更是居高不下。随之而来的,查询 latency 逐渐升高,看图、大盘等功能体验变差。
面临如上压力,我们进行了一系列分析,根据统计,我们的查询请求,有80%
都是查询最近 2 小时数据的请求。这也是graph
的最大压力来源。
因此,我们需要的,是一个简单
、高性能
、易维护
的模块,一个针对短期数据的高性能读写方案。
柳暗花明
在做调研的时候,发现了 Writing a Time Series Database from Scratch 这篇文章,这是 prometheus 作者在发布 prometheus 新版存储引擎期间释出的文章。文章本身内容非常好,强烈大家多多研读。 关注我 code 杂坛,了解更多......
而这篇文章最吸引我的,却是下面这句话:
Facebook’s paper on their Gorilla TSDB describes a similar chunk-based approach and introduces a compression format that reduces 16 byte samples to an average of 1.37 bytes.
我们知道,作为时序数据库的基本组成,(t, v)
代表最基本的一个点。在内存中一般占用16 bytes
,而如果能压缩到1.37 bytes
,这个压缩比是非常高的。
阅读 facebook 的这篇paper,Gorilla: A Fast, Scalable, In-Memory Time Series Database。总结其实现高压缩比的主要抓手如下:
1. 使用 dod(delta of delta)编码,压缩 timestamp
。原来需要存储 1551532883, 1551532890, 1551532900, 1551532910
,编码后只需要存储 1551532883, 7, 3, 0
即可,压缩效果可见一斑。而在 falcon 的体系中,因为有前置模块(transfer)做了时间戳取余对齐,所以 dod 算法的效果非常好。关注我 code 杂坛,了解更多......
2. 使用 XOR 编码,压缩 value
。其依据是一条 series,在相邻时间戳的value
在大部分时候变化不大。XOR 编码,如果相邻时间戳的两个 value
相同,那么存储 '0',只占用 1 bit,在编码前则要占用 2 个float64
的空间。
除了高压缩比,gorilla
描述的 In-Memory TSDB
架构也非常的值得借鉴,典型的数据分片
+ 多blocks轮转
的结构。建议多读几遍 paper 学习一下。
cacheserver 的诞生
压缩算法的实现
paper 也读了,原理也明白了,接下来当时就是实现。不重复造轮子,一直以来是我等的优良传统。祭出 gayhub github 来搜索现成的轮子。找到了 dgryski/go-tsz 这个库,其完整实现了 facebook 的 paper 中描述的(t, v)
编码这一部分。这位 Damian Gryski 实现过很多优秀的算法,值得 follow
。但是这个库有一个小问题,由于关键的 struct 的一些字段没有导出,不满足 golang 的 rpc 规范要求。所以进行了一些小改动,放在了 devtoolkits/go-tsz 。
数据模型的实现
为了便于理解,我们自底向上描述 cacheserver 的数据模型。
series 数据流
最底层的数据,就是1个 series 的数据流(bits),是该 series 的(t, v)...
经过编码得来的。
chunk/chunks
所谓chunk,就是一个时间窗口
内的某个 series 的 bit 流数据,而 chunks 顾名思义,就是多个 chunk 的封装。一般来说,chunk 的时间窗口越长,产出的压缩结果越小,考虑编解码效率等因素,时间窗口的设置可参考如下原则: 关注我 code 杂坛,了解更多......
1. 测试在不同时间窗口之下,平均每个 (t, v)
的占用空间。根据facebook
的测试结果,时间窗口设置为 120 分钟
,平均每个(t, v)
的占用空间为大概 1.37bytes
。而更大的时间窗口压缩效果就不太明显了。经过简单测试后,cacheserver的 chunk 时间窗口也设置为 120 分钟。
2. 理论上,chunks 就是包含了 chunk 的 slice,但频繁进行 append 的 slice,对 gc 是不友好的。所以需要有一个机制来保证,不需要的 chunk,可以被清理
掉。cacheserver 中的 chunks 使用了 Ringbuffer
结构来存储 chunk,使用固定的空间
来存储多个时间窗口的 series 数据流。
这部分的实现,大部分借鉴了 grafana/metrictank,感兴趣的同学可以去看一下。
cache 结构
上面对于 1 条 series 的结构已经描述完毕。下面来说多条 series 的存储,也就是对多个 key
+ (t, v)...
的存储设计,这种场景,使用 golang 的map
数据结构当然最合适不过。
但还有一个问题,cacheserver 的每个实例,设计目标是要能存储 1000w ~ 2000w
条 series。这个量级,使用 map 光存储没有问题,但这个 map 还要有大量的读/写操作,所以一定会用到读写锁。在我们的业务场景中,1次查询几千几万条 series 的场景有很多,再加上源源不断的写操作,这样锁的竞争会非常激烈。如何解决这个问题?
常见的做法是使用分片锁
。所谓分片锁,就是将 map 的 key 做分片
,然后包括读/写等操作,都先通过分片
算法,寻找 key 所在的分片,然后再对得到的分片加锁。
以读为例,从原始的 RLock -> Read -> RUnlock
,经过分片锁的优化之后,变成 getShard -> shard.RLock -> Read -> shard.RUnlock
。后者在高并发的场景下,效率会高很多。
分片的算法,也很重要,如果key
是整型,那么直接按照分片数求余即可。但我们的key
是字符串,字符串到整数类型的转换方法有很多,我们需要的是一个速度快
、占用内存低
的方式,核心代码如下:
func fnv32(key string) uint32
hash := uint32(2166136261)
const prime32 = uint32(16777619)
for i := 0; i < len(key); i++
hash *= prime32
hash ^= uint32(key[i])
return hash
以上就是 cacheserver 核心的数据模型,下面从读写路径2个角度来简要地描述一下具体的数据流。
写路径
rpc Call
--> cache getshard
--> chunks push
--> chunk locate
--> chunk push
读路径
rpc Call
--> cache getshard
--> chunks get
--> chunk locate
--> get Iters
解压 Iters 的时机
在读路径中,获取到的是 Iter
,即包含(t, v)...
数据的压缩包
,那么这个压缩包应该在哪里解开,有2种选择:
1. 由 rpc server
,即 cacheserver
解压。
2. 由 rpc client
解压。
在 server 中解压的好处是,client 可以拿到明文的 (t, v)...
数据,直观、方便。
在 client 中解压的好处是,节省 server 的资源。
再三考虑,为节省 cacheserver 的资源开销,我们采用后者,即在 rpc client
中做解压动作。后来 uber 开源的m3db
,也涉及到这方面的描述,和我的想法不谋而合,以下引用自 uber blog 的文章 The Billion Data Point Challenge: Building a Query Engine for High Cardinality Time Series Data
One key insight from our evaluation process was that we shouldn’t decompress data on fetch if we are dealing with storage backends that keep data compressed internally, which is exactly how M3DB stores data. If we delay the decompression as long as possible, we might be able to reduce our memory footprint.
rpc encode/decode
gob
作为 golang 默认的 rpc 编解码方案,其性能是相对低下的,在使用 pprof 生成的火焰图中,大量宽大的 gob encode/decode 调用也证明了这一点。在 cacheserver 中,使用 msgpack 编解码,看中的就是其 It's like JSON. but fast and small.
的特点。
key 的组成
上文提过,graph 的key
是由labels
组合计算而成,且带有业务意义。cacheserver
不同,key
只是标识 series,不带有任何的业务意义,只需上下游模块(transfer 和 query)达成共识即可,cacheserver 不关心。这样除了更具通用型,分离了存储与索引的耦合,也降低了在既有方案中对map <-> string
的转换带来的不必要开销。
为什么不落盘
如上文描述,我们整个架构都是基于内存
的,并没有涉及到持久化
的任何描述。原因有几点:
1. 持久化会加大 cacheserver 的复杂度,持久化就要考虑文件读写、WAL 等,相对会比较麻烦。开发成本和时间成本都比较高。
2. 持久化后,那么 cacheserver 将成为一个完备的TSDB,这不是我们的初衷。我会在下一章节详细描述。
与graph的关系及相关统计数据
cacheserver
的设计初衷,是对 graph 的一个补充。目标是产出一个高性能
,低成本
,提供热数据查询
的服务,降低 graph 的压力。而 graph 作为永久存储方案,实现诸如将采
、归档
、持久化
等逻辑。
在读链路,先读 cacheserver,如果 cacheserver 数据不足
或者出现异常
,再从 graph 读取。
写链路则沿用 open-falcon 的设计,在 transfer 中进行双写,写入 graph 的同时,也写入 cacheserver。
我们部署了20
个实例:
-
存储了
2亿活跃 series
,平均每个实例存储1000w series
-
共使用内存不到
1 TB
,平均每个实例不到50 GB
-
查询
latency(tp95)
在200ms
以下
结尾
关注我 code 杂坛,了解更多......
文章来自,Open-Falcon 开源社区,尊重创作。
以上是关于Falcon 存储优化: 高性能内存 TSDB 的诞生的主要内容,如果未能解决你的问题,请参考以下文章