时间序列数据的存储和计算 - 开源时序数据库解析

开源时序数据库


       如图是17年6月在db-engines上时序数据库的排名,我会挑选开源的、分布式的时序数据库做详细的解析。前十的排名中,RRD是一个老牌的单机存储引擎,Graphite底层是Whisper,可以认为是一个优化的更强大的RRD数据库。kdb+、eXtremeDB和Axibase都未开源,不做解析。InfluxDB开源版和Prometheus的底层都是基于levelDB自研的单机的存储引擎,InfluxDB的商业版支持分布式,Prometheus的roadmap上也规划了分布式存储引擎的支持计划。

       综合看下来,我会选择OpenTSDB、KairosDB和InfluxDB做一个详细的解析。我对OpenTSDB比较熟悉,研究过它的源码,所以对OpenTSDB会描述的格外详细,而对其他时序数据库了解的没那么深入,如果有描述错的地方,欢迎指正。

一、OpenTSDB

       OpenTSDB是一个分布式、可伸缩的时序数据库,支持高达每秒百万级的写入能力,支持毫秒级精度的数据存储,不需要降精度也可以永久保存数据。其优越的写性能和存储能力,得益于其底层依赖的HBase,HBase采用LSM树结构存储引擎加上分布式的架构,提供了优越的写入能力,底层依赖的完全水平扩展的HDFS提供了优越的存储能力。OpenTSDB对HBase深度依赖,并且根据HBase底层存储结构的特性,做了很多巧妙的优化。在最新的版本中,还扩展了对BigTable和Cassandra的支持。

架构

       如图是OpenTSDB的架构,核心组成部分就是TSD和HBase。TSD是一组无状态的节点,可以任意的扩展,除了依赖HBase外没有其他的依赖。TSD对外暴露HTTP和Telnet的接口,支持数据的写入和查询。TSD本身的部署和运维是很简单的,得益于它无状态的设计,不过HBase的运维就没那么简单了,这也是扩展支持BigTable和Cassandra的原因之一吧。

数据模型

OpenTSDB采用按指标建模的方式,一个数据点会包含以下组成部分:

1. metric:时序数据指标的名称,例如sys.cpu.user,stock.quote等。

2. timestamp:秒级或毫秒级的Unix时间戳,代表该时间点的具体时间。

3. tags:一个或多个标签,也就是描述主体的不同的维度。Tag由TagKey和TagValue组成,TagKey就是维度,TagValue就是该维度的值。

4. value:该指标的值,目前只支持数值类型的值。

存储模型

OpenTSDB底层存储的优化思想,简单总结就是以下这几个关键的优化思路:

1. 对数据的优化:为Metric、TagKey和TagValue分配UniqueID,建立原始值与UniqueID的索引,数据表存储Metric、TagKey和TagValue对应的UniqueID而不是原始值。

2. 对KeyValue数的优化:如果对HBase底层存储模型十分了解的话,就知道行中的每一列在存储时对应一个KeyValue,减少行数和列数,能极大的节省存储空间以及提升查询效率。

3. 对查询的优化:利用HBase的Server Side Filter来优化多维查询,利用Pre-aggregation和Rollup来优化GroupBy和降精度查询。

UIDTable

       接下来看一下OpenTSDB在HBase上的几个关键的表结构的设计,首先是tsdb-uid表,结构如下:

       Metric、TagKey和TagValue都会被分配一个相同的固定长度的UniqueID,默认是三个字节。tsdb-uid表使用两个ColumnFamily,存储了Metric、TagKey和TagValue与UniqueID的映射和反向映射,总共是6个Map的数据。

从图中的例子可以解读出:

1. TagKey为'host',对应的UniqueID为'001'

2. TagValue为'static',对应的UniqueId为'001'

3. Metric为'proc.loadavg.1m',对应的UniqueID为'052'

       为每一个Metric、TagKey和TagValue都分配UniqueID的好处,一是大大降低了存储空间和传输数据量,每个值都只需要3个字节就可以表示,这个压缩率是很客观的;二是采用固定长度的字节,可以很方便的从row key中解析出所需要的值,并且能够大大减少Java堆内的内存占用(bytes相比String能节省很多的内存占用),降低GC的压力。

       不过采用固定字节的UID编码后,对于UID的个数是有上限要求的,3个字节最多只允许有16777216个不同的值,不过在大部分场景下都是够用的。当然这个长度是可以调整的,不过不支持动态更改。

DataTable

第二张关键的表是数据表,结构如下:

       该表中,同一个小时内的数据会存储在同一行,行中的每一列代表一个数据点。如果是秒级精度,那一行最多会有3600个点,如果是毫秒级精度,那一行最多会有3600000个点。

       这张表设计的精妙之处在于row key和qualifier(列名)的设计,以及对整行数据的compaction策略。row key格式为:

       其中metric、tagk和tagv都是用uid来表示,由于uid固定字节长度的特性,所以在解析row key的时候,可以很方便的通过字节偏移来提取对应的值。Qualifier的取值为数据点的时间戳在这个小时的时间偏差,例如如果你是秒级精度数据,第30秒的数据对应的时间偏差就是30,所以列名取值就是30。列名采用时间偏差值的好处,主要在于能大大节省存储空间,秒级精度的数据只要占用2个字节,毫秒精度的数据只要占用4个字节,而若存储完整时间戳则要6个字节。整行数据写入后,OpenTSDB还会采取compaction的策略,将一行内的所有列合并成一列,这样做的主要目的是减少KeyValue数目。

查询优化

       HBase仅提供简单的查询操作,包括单行查询和范围查询。单行查询必须提供完整的RowKey,范围查询必须提供RowKey的范围,扫描获得该范围下的所有数据。通常来说,单行查询的速度是很快的,而范围查询则是取决于扫描范围的大小,扫描个几千几万行问题不大,但是若扫描个十万上百万行,那读取的延迟就会高很多。

       OpenTSDB提供丰富的查询功能,支持任意TagKey上的过滤,支持GroupBy以及降精度。TagKey的过滤属于查询的一部分,GroupBy和降精度属于对查询后的结果的计算部分。在查询条件中,主要的参数会包括:metric名称、tag key过滤条件以及时间范围。上面一章中指出,数据表的rowkey的格式为:

       从查询的参数上可以看到,metric名称和时间范围确定的话,我们至少能确定row key的一个扫描范围。但是这个扫描范围,会把包含相同metric名称和时间范围内的所有的tag key的组合全部查询出来,如果你的tag key的组合有很多,那你的扫描范围是不可控的,可能会很大,这样查询的效率基本是不能接受的。

我们具体看一下OpenTSDB对查询的优化措施:

1. Server side filter

       HBase提供了丰富和可扩展的filter,filter的工作原理是在server端扫描得到数据后,先经过filter的过滤后再将结果返回给客户端。Server side filter的优化策略无法减少扫描的数据量,但是可以大大减少传输的数据量。OpenTSDB会将某些条件的tag key filter转换为底层HBase的server side filter,不过该优化带来的效果有限,因为影响查询最关键的因素还是底层范围扫描的效率而不是传输的效率。

2. 减少范围查询内扫描的数据量

       要想真正提高查询效率,还是得从根本上减少范围扫描的数据量。注意这里不是减小查询的范围,而是减少该范围内扫描的数据量。这里用到了HBase一个很关键的filter,即FuzzyRowFilter,FuzzyRowFilter能够根据指定的条件,在执行范围扫描时,动态的跳过一定数据量。但不是所有OpenTSDB提供的查询条件都能够应用该优化,需要符合一定的条件,具体要符合哪些条件就不在这里说明了,有兴趣的可以去了解下FuzzyRowFilter的原理。

3. 范围查询优化成单行查询

       这个优化相比上一条,更加的极端。优化思路非常好理解,如果我能够知道要查询的所有数据对应的row key,那就不需要范围扫描了,而是单行查询就行了。这里也不是所有OpenTSDB提供的查询条件都能够应用该优化,同样需要符合一定的条件。单行查询要求给定确定的row key,而数据表中row key的组成部分包括metric名称、timestamp以及tags,metric名称和timestamp是能够确定的,如果tags也能够确定,那我们就能拼出完整的row key。所以很简单,如果要能够应用此优化,你必须提供所有tag key对应的tag value才行。

       以上就是OpenTSDB对HBase查询的一些优化措施,但是除了查询,对查询后的数据还需要进行GroupBy和降精度。GroupBy和降精度的计算开销也是非常可观的,取决于查询后的结果的数量级。对GroupBy和降精度的计算的优化,几乎所有的时序数据库都采用了同样的优化措施,那就是pre-aggregation和auto-rollup。思路就是预先进行计算,而不是查询后计算。不过OpenTSDB在已发布的最新版本中,还未支持pre-aggregation和rollup。而在开发中的2.4版本中,也只提供了半吊子的方案,它只提供了一个新的接口支持将pre-aggregation和rollup的结果进行写入,但是对数据的pre-aggregation和rollup的计算还需要用户自己在外层实现。

总结

       OpenTSDB的优势在于数据的写入和存储能力,得益于底层依赖的HBase所提供的能力。劣势在于数据查询和分析的能力上的不足,虽然在查询上已经做了很多的优化,但是不是所有的查询场景都能适用。可以说,OpenTSDB在TagValue过滤查询优化,是这次要对比的几个时序数据库中,优化的最差的。在GroupBy和Downsampling的查询上,也未提供Pre-aggregation和Auto-rollup的支持。不过在功能丰富程度上,OpenTSDB的API是支持最丰富的,这也让OpenTSDB的API成为了一个标杆。

二、KairosDB

       KairosDB最初是从OpenTSDB 1.x版本fork出来的一个分支,目的是在OpenTSDB的代码基础上进行二次开发来满足新的功能需求。其改造之一就是支持可插拔式的存储引擎,例如支持H2可以方便本地开发和测试,而不是像OpenTSDB一样与HBase强耦合。在其最初的几个版本中,HBase也是作为其主要的存储引擎。但是在之后的存储优化中,慢慢使用Cassandra替换了HBase,它也是第一个基于Cassandra开发的时序数据库。在最新的几个版本中,已不再支持HBase,因为其存储优化使用了Cassandra所特有而HBase没有的一些特性。

       在整体架构上,和OpenTSDB比较类似,都是采用了一个比较成熟的数据库来作为底层存储引擎。自己的主要逻辑仅仅是在存储引擎层之上很薄的一个逻辑层,这层逻辑层的部署架构是一个无状态的组件,可以很容易的水平扩展。

       在功能差异性上,它在OpenTSDB 1.x上做二次开发,也是为了对OpenTSDB的一些功能做优化,或做出一些OpenTSDB所没有的功能。我大概罗列下我看到的主要的功能差异:

       可插拔式的存储引擎:OpenTSDB在早期与HBase强耦合,为了追求极致的性能,甚至自研了一个异步的HBase Client(现在作为独立的一个开源项目输出:AsyncHBase)。这样也导致其整个代码都是采用异步驱动的模式编写,不光增加了代码的复杂度和降低可阅读性,也加大了支持多种存储引擎的难度。KairosDB严格定义了存储层的API Interface,整体逻辑与存储层耦合度较低,能比较容易的扩展多种存储引擎。当然现在最新版的OpenTSDB也能够额外支持Cassandra和BigTable,但是从整体的架构上,还不能说是一个支持可插拔式存储引擎的架构。

       支持多种数据类型及自定义类型的值:OpenTSDB只支持numeric的值,而KairosDB支持numeric、string类型的值,也支持自定义数值类型。在某些场景下,metric value不是一个简单的数值,例如你要统计这个时间点的TopN,对应的metric value可能是一组string值。可扩展的类型,让未来的需求扩展会变得容易。从第一第二点差异可以看出,KairosDB基于OpenTSDB的第一大改造就是将OpenTSDB的功能模型和代码架构变得更加灵活。

       支持Auto-rollup:目前大部分TSDB都在朝着支持pre-aggregation和auto-rollup的方向发展,OpenTSDB是少数的不支持该feature的TSDB,在最新发布的OpenTSDB版本中,甚至都不支持多精度数据的存储。不过现在KairosDB支持的auto-rollup功能,采取的还是一个比较原始的实现方式,在下面的章节会详细讲解。

       不同的存储模型:存储是TSDB核心中的核心,OpenTSDB在存储模型上使用了UID的压缩优化,来优化查询和存储。KairosDB采取了一个不同的思路,利用了Cassandra宽表的特性,这也是它从HBase转向Cassandra的一个最重要的原因,在下面的章节会详细讲解。

存储模型

       OpenTSDB的存储模型,其主要设计特点是采用了UID编码,大大节省了存储空间,并且利用UID编码的固定字节数的特性,利用HBase的Filter做了很多查询的优化。但是采用UID编码后也带来了很多的缺陷,一是需要维护metric/tagKey/tagValue到UID的映射表,所有data point的写入和读取都需要经过映射表的转换,映射表通常会缓存在TSD或者client,增加了额外的内存消耗;二是由于采用了UID编码,导致metric/tagKey/tagValue的基数是有上限的,取决于UID使用的字节数,并且在UID的分配上会有冲突,会影响写入。

       本质上,OpenTSDB存储模型采用的UID编码优化,主要解决的就两个问题:

       存储空间优化:UID编码解决重复的row key存储造成的冗余的存储空间问题。

       查询优化:利用UID编码后TagKey和TagValue固定字节长度的特性,利用HBase的FuzzyRowFilter做特定场景的查询优化。

       KairosDB在解决这两个问题上,采取了另外一种不同的方式,使其不需要使用UID编码,也不存在使用UID编码后遗留的问题。先看下KairosDB的存储模型是怎样的,它主要由以下三张表构成:

       DataPoints: 存储所有原始数据点,每个数据点也是由metric、tags、timestamp和value构成。该表中一行数据的时间跨度是三周,也就是说三周内的所有数据点都存储在同一行,而OpenTSDB内的行的时间跨度只有一个小时。RowKey的组成与OpenTSDB类似,结构为tagv2>...,不同的是metric, tag key和tag value都存储原始值,而不是UID。

       RowKeyIndex: 该表存储所有metric对应DataPoints表内所有row key的映射,也就是说同一个metric上写入的所有的row key,都会存储在同一行内,并且按时间排序。该表主要被用于查询,在根据tag key或者tag value做过滤时,会先从这张表过滤出要查询的时间段内所有符合条件的row key,后在DataPoints表内查询数据。

       StringIndex: 该表就三行数据,每一行分别存储所有的metric、tag key和tag value。

      KairosDB采取的存储模型,是利用了Cassandra宽表的特性。HBase的底层文件存储格式中,每一列会对应一个KeyValue,Key为该行的RowKey,所以HBase中一行中的每一列,都会重复的存储相同的RowKey,这也是为何采用了UID编码后能大大节省存储空间的主要原因,也是为何有了UID编码后还能采用compaction策略(将一行中所有列合并为一列)来进一步压缩存储空间的原因。而Cassandra的底层文件存储格式与HBase不同,它一行数据不会为每一列都重复的存储RowKey,所以它不需要使用UID编码。Cassandra内降低存储空间的一个优化方案就是缩减行数,这也是为何它一行存储三周数据而不是一个小时数据的原因。要进一步了解两种设计方案的原因,可以看下HBase文件格式以及Cassandra文件格式。

       利用Cassandra的宽表特性,即使不采用UID编码,存储空间上相比采用UID编码的OpenTSDB,也不会差太多。可以看下官方的解释:

      在查询优化上,采取的也是和OpenTSDB不一样的优化方式。先看下KairosDB内查询的整个流程:

1. 根据查询条件,找出所有DataPoints表里的row key

      如果有自定义的plugin,则从plugin中获取要查询的所有row key。(通过Plugin可以扩展使用外部索引系统来对row key进行索引,例如使用ElasticSearch)

      如果没有自定义的plugin,则在RowKeyIndex表里根据metric和时间范围,找出所有的row key。(根据列名的范围来缩小查询范围,列名的范围是(metric+startTime, metric+endTime))

2. 根据row key,从DataPoints表里找出所有的数据

       相比OpenTSDB直接在数据表上进行扫描来过滤row key的方式,KairosDB利用索引表无疑会大大减少扫描的数据量。在metric下tagKey和tagValue组合有限的情况下,会大大的提高查询效率。并且KairosDB还提供了QueryPlugin的方式,能够扩展利用外部组件来对row key进行索引,例如可以利用ElasticSearch,或者其他的索引系统,毕竟通过索引的方式,才是最优的查询方案,这也是Heroic相比KairosDB最大的一个改进的地方。

Auto-rollup

       KairosDB的官方文档中有关于auto-rollup如何配置的章节,但是在讨论组内,其关于auto-rollup的说明如下:

       总结来说,目前KairosDB提供的auto-rollup方案,还是比较简单的实现。就是一个可配置的单机组件,能够定时启动,把已经写入的数据读出后进行aggregation后再次写入,确实非常的原始,可用性和性能都比较低。

       但是有总比没有好,支持auto-rollup一定是所有TSDB的趋势,也是能拉开功能差异和提高核心竞争力的关键功能。

BlueFlood

       上面主要分析了KairosDB,第一个基于Cassandra构建的TSDB,那干脆继续分析下其他基于Cassandra构建的TSDB。

       BlueFlood也是一个基于Cassandra构建的TSDB,可以看到整体架构上核心组成部分主要有三个:

1. Ingest module: 处理数据写入。

2. Rollup module: 做自动的预聚合和降精度。

3. Query module: 处理数据查询。

相比KairosDB,其在数据模型上与其他的TSDB有略微差异,主要在:

1. 引入了租户的维度:这是一个创新,如果你是做一个服务化的TSDB,那租户这个维度是必需的。

2. 不支持Tag:这一点上,是比较让我差异的地方。在大多数TSDB都基本上把Tag作为模型的不可缺少部分的情况下,BlueFlood在模型上居然不支持Tag。不过这有可能是其没有想好如何优化Tag维度查询的一种取舍,既然没想好怎么优化,那干脆就先不支持,反正未来再去扩展Tag是可以完全兼容的。BlueFlood当前已经利用ElasticSearch去构建metric的索引,我相信它未来的方案,应该也是基于ElasticSearch去构建Tag的索引,在这个方案完全支持好后,应该才会去引入Tag。

模型上的不足,BlueFlood不需要去考虑Tag查询如何优化,把精力都投入到了其他功能的优化上,例如auto-rollup。它在auto-rollup的功能支持上,甩了KairosDB和OpenTSDB几条街。来看看它的Auto-rollup功能的特点:

1. 仅支持固定的Interval:5min,20min,60min,4hour,1day。

2. 提供分布式的Rollup Service:rollup任务可以分布式的调度,rollup的数据是通过离线的批量扫描获取。

从它14年的介绍PPT上,还可以看到它在未来规划的几个功能点:

1. ElasticSearch Indexer and discovery: 目前这个已经实现,但是仅支持metric的索引,未来引入Tag后,可能也会用于Tag的索引。

2. Cloud files exporter for rollups: 这种方式对离线计算更加优化,rollup的大批量历史数据读取就不会影响在线的业务。

3. Apache Kafka exporter for rollups: 这种方式相比离线计算更进一步,rollup可以用流计算来做,实时性更加高。

       总结来说,如果你不需要Tag的支持,并且对Rollup有强需求,那BlueFlood相比KairosDB会是一个更好的选择,反之还是选择KairosDB。

Heroic

      第三个要分析的基于Cassandra的TSDB是Heroic,它在DB-Engines上的排名是第19,虽然比BlueFlood和KairosDB都落后,但是我认为它的设计实现却是最好的一个。       Spotify在决定研发Heroic之前,在OpenTSDB、InfluxDB、KairosDB等TSDB中选用KairosDB来替换他们老的监控系统的底层。但是很快就遇到了KairosDB在查询方面的问题,最主要还是KairosDB对metric和tag没有索引,在metric和tag基数达到一定数量级后,查询会变的很慢。所以Spotify研发Heroic的最大动机就是解决KairosDB的查询问题,采用的解决方案是使用ElasticSearch来作为索引优化查询引擎,而数据的写入和数据表的Schema则完全与KairosDB一致。

简单总结下它的特点:

1. 完整的数据模型,完全遵循metric2.0的规范。

2. 数据存储模型与KairosDB一致,使用ElasticSearch优化查询引擎。(这是除了InfluxDB外,其他TSDB如KairosDB、OpenTSDB、BlueFlood等现存最大的问题,是其核心竞争力之一)

3. 不支持auto-rollup,这是它的缺陷之一。

如果你需要TSDB支持完整的数据模型,且希望得到高效的索引查询,那Heroic会是你的选择。

三、InfluxDB

       InfluxDB在DB-Engines的时序数据库类别里排名第一,实至名归,从它的功能丰富性、易用性以及底层实现来看,都有很多的亮点,值得大篇幅来分析。

首先简单归纳下它的几个比较重要的特性:

1. 极简架构:单机版的InfluxDB只需要安装一个binary,即可运行使用,完全没有任何的外部依赖。相比来看几个反面例子,OpenTSDB底层是HBase,拖家带口就得带上ZooKeeper、HDFS等,如果你不熟悉Hadoop技术栈,一般运维起来是有一定的难度,这也是其被人抱怨最多的一个点。KairosDB稍微好点,它依赖Cassandra和ZooKeeper,单机测试可以使用H2。总的来说,依赖一个外部的分布式数据库的TSDB,在架构上会比完全自包含的TSDB复杂一点,毕竟一个成熟的分布式数据库本身就很复杂,当然这一点在云计算这个时代已经完全消除。

2. TSM Engine:底层采用自研的TSM存储引擎,TSM也是基于LSM的思想,提供极强的写能力以及高压缩率,在后面的章节会对其做一个比较详细的分析。

3. InfluxQL:提供SQL-Like的查询语言,极大的方便了使用,数据库在易用性上演进的终极目标都是提供Query Language。

4. Continuous Queries: 通过CQ能够支持auto-rollup和pre-aggregation,对常见的查询操作可以通过CQ来预计算加速查询。

5. TimeSeries Index: 对Tags会进行索引,提供高效的检索。这一项功能,对比OpenTSDB和KairosDB等,在Tags检索的效率上提升了不少。OpenTSDB在Tags检索上做了不少的查询优化,但是受限于HBase的功能和数据模型,所以然并卵。不过目前稳定版中的实现采用的是memory-based index的实现方式,这种方案在实现上比较简单,查询上效率最高,但是带来了不少的问题,在下面的章节会详细描述。

6. Plugin Support: 支持自定义插件,能够扩展到兼容多种协议,如Graphite、collectd和OpenTSDB。

       在下面的章节,会主要对其基本概念、TSM存储引擎、Continuous Queries以及TimeSeries Index做详细的解析。

基本概念

先来了解下InfluxDB中的几个基本概念,看下具体的例子:

上面是一条向InfluxDB中写入一条数据的命令行,来看下这条数据由哪几个部分组成:

1. Measurement:Measurement的概念与OpenTSDB的Metric类似,代表数据所属监控指标的名称。例如上述例子是对机器指标的监控,所以其measurement命名为machine_metric。

2. Tags:与OpenTSDB的Tags概念类似,用于描述主体的不同的维度,允许存在一个或多个Tag,每个Tag也是由TagKey和TagValue构成。

3. Field:在OpenTSDB的逻辑数据模型中,一行metric数据对应一个value。而在InfluxDB中,一行measurement数据可以对应多个value,每个value根据Field来区分。

4. Timestamp: 时序数据的必备属性,代表该条数据所属的时间点,可以看到InfluxDB的时间精度能够精确到纳秒。

5. TimeSeries:Measurement+Tags的组合,在InfluxDB中被称为TimeSeries。TimeSeries就是时间线,根据时间能够定位到某个时间点,所以TimeSeries+Field+Timestamp能够定位到某个Value。这个概念比较重要,在后续的章节中都会提到。

最终在逻辑上每个Measurement内的数据会组织成一张大的数据表,如下图所示:

       在查询时,InfluxDB支持在Measurement内任意维度的条件查询,你可以指定任意某个Tag或者Filed的条件做查询。接着上面的数据案例,你可以构造以下查询条件:

      从数据模型以及查询的条件上看,Tag和Field没有任何区别。从语义上来看,Tag用于描述Measurement,而Field用于描述Value。从内部实现来上看,Tag会被全索引,而Filed不会,所以根据Tag来进行条件查询会比根据Filed来查询效率高很多。

TSM

       InfluxDB底层的存储引擎经历了从LevelDB到BlotDB,再到选择自研TSM的过程,整个选择转变的思考可以在其官网文档里看到。整个思考过程很值得借鉴,对技术选型和转变的思考总是比平白的描述某个产品特性让人印象深刻的多。

       我简单总结下它的整个存储引擎选型转变的过程,第一阶段是LevelDB,选型LevelDB的主要原因是其底层数据结构采用LSM,对写入很友好,能够提供很高的写入吞吐量,比较符合时序数据的特性。在LevelDB内,数据是采用KeyValue的方式存储且按Key排序,InfluxDB使用的Key设计是SeriesKey+Timestamp的组合,所以相同SeriesKey的数据是按timestamp来排序存储的,能够提供很高效的按时间范围的扫描。

       不过使用LevelDB的一个最大的问题是,InfluxDB支持历史数据自动删除(Retention Policy),在时序数据场景下数据自动删除通常是大块的连续时间段的历史数据删除。LevelDB不支持Range delete也不支持TTL,所以要删除只能是一个一个key的删除,会造成大量的删除流量压力,且在LSM这种数据结构下,真正的物理删除不是即时的,在compaction时才会生效。各类TSDB实现数据删除的做法大致分为两类:

1. 数据分区:按不同的时间范围划分为不同的分区(Shard),因为时序数据写入都是按时间线性产生的,所以分区的产生也是按时间线性增长的,写入通常是在最新的分区,而不会散列到多个分区。分区的优点是数据回收的物理删除非常简单,直接把整个分区删除即可。缺点是数据回收的精细度比较大,为整个分区,而回收的时间精度取决于分区的时间跨度。分区的实现可以是在应用层提供,也可以是存储引擎层提供,例如可以利用RocksDB的column family来作为数据分区。InfluxDB采用这种模式,默认的Retention Policy下数据会以7天时间跨度组成为一个分区。

2. TTL:底层数据引擎直接提供数据自动过期的功能,可以为每条数据设定存储时间(time to live),当数据存活时间到达后存储引擎会自动对数据进行物理删除。这种方式的优点是数据回收的精细度很高,精细到秒级及行级的数据回收。缺点是LSM的实现上,物理删除发生在compaction的时候,比较不及时。RocksDB、HBase、Cassandra和阿里云表格存储都提供数据TTL的功能。

       InfluxDB采用的是第一种策略,会按7天一个周期,将数据分为多个不同的Shard,每个Shard都是一个独立的数据库实例。随着运行时间的增长,shard的个数会越来越多。而由于每个shard都是一个独立的数据库实例,底层都是一套独立的LevelDB存储引擎,这时带来的问题是,每个存储引擎都会打开比较多的文件,随着shard的增多,最终进程打开的文件句柄会很快触及到上限。LevelDB底层采用level compaction策略,是文件数多的原因之一。实际上level compaction策略不适合时序数据这种写入模式,这点原因InfluxDB没有提及。

       由于遇到大量的客户反馈文件句柄过多的问题,InfluxDB在新版本的存储引擎选型中选择了BoltDB替换LevelDB。BoltDB底层数据结构是mmap B+树,其给出的选型理由是:1.与LevelDB相同语义的API;2.纯Go实现,便于集成和跨平台;3.单个数据库只使用一个文件,解决了文件句柄消耗过多的问题,这条是他们选型BoltDB的最主要理由。但是BoltDB的B+树结构与LSM相比,在写入能力上是一个弱势,B+树会产生大量的随机写。所以InfluxDB在使用BoltDB之后,很快遇到了IOPS的问题,当数据库大小达到几个GB后,会经常遇到IOPS的瓶颈,极大影响写入能力。虽然InfluxDB后续也采用了一些写入优化措施,例如在BoltDB之前加了一层WAL,数据写入先写WAL,WAL能保证数据是顺序写盘,但是最终写入BoltDB还是会带来比较大的IOPS资源消耗。

       InfluxDB在经历了几个小版本的BoltDB后,最终决定自研TSM,TSM的设计目标一是解决LevelDB的文件句柄过多问题,二是解决BoltDB的写入性能问题。TSM全称是Time-Structured Merge Tree,思想类似LSM,不过是基于时序数据的特性做了一些特殊的优化。来看下TSM的一些重要组件:

1. Write Ahead Log(WAL): 数据会先写入WAL,后进入memory-index和cache,写入WAL会同步刷盘,保证数据持久化。Cache内数据会异步刷入TSM File,在Cache内数据未持久化到TSM File之前若遇到进程crash,则会通过WAL内的数据来恢复cache内的数据,这个行为与LSM是完全类似的。

2. Cache: TSM的Cache与LSM的MemoryTable类似,其内部的数据为WAL中未持久化到TSM File的数据。若进程发生failover,则cache中的数据会根据WAL中的数据进行重建。Cache内数据保存在一个SortedMap中,Map的Key为TimeSeries+Timestamp的组成。所以可以看到,在内存中数据是按TimeSeries组织的,TimeSeries中的数据按时间顺序存放。

3. TSM Files: TSM File与LSM的SSTable类似,TSM File由四个部分组成,分别为:header, blocks, index和footer。其中最重要的部分是blocks和index:

(1) Block:每个block内存储的是某个TimeSeries的一段时间范围内的值,即某个时间段下某个measurement的某组tag set对应的某个field的所有值,Block内部会根据field的不同的值的类型采取不同的压缩策略,以达到最优的压缩效率。

(2) Index:文件内的索引信息保存了每个TimeSeries下所有的数据Block的位置信息,索引数据按TimeSeries的Key的字典序排序。在内存中不会把完整的index数据加载进去,这样会很大,而是只对部分Key做索引,称之为indirectIndex。indirectIndex中会有一些辅助定位的信息,例如该文件中的最小最大时间以及最小最大Key等,最重要的是保存了部分Key以及其Index数据的文件offset信息。若想要定位某个TimeSeries的Index数据,会先根据内存中的部分Key信息找到与其最相近的Index Offset,之后从该起点开始顺序扫描文件内容再精确定位到该Key的Index数据位置。

4. Compaction: compaction是一个将write-optimized的数据存储格式优化为read-optimized的数据存储格式的一个过程,是LSM结构存储引擎做存储和查询优化很重要的一个功能,compaction的策略和算法的优劣决定了存储引擎的质量。在时序数据的场景下,基本很少发生update或者delete,数据都是按时间顺序生成的,所以基本不会有overlap,Compaction起到的作用主要在于压缩和索引优化。

(1)LevelCompaction: InfluxDB将TSM文件分为4个层级(Level 1-4),compaction只会发生在同层级文件内,同层级的文件compaction后会晋升到下一层级。从这个规则看,根据时序数据的产生特性,level越高数据生成时间越旧,访问热度越低。由Cache数据初次生成的TSM文件称为Snapshot,多个Snapshot文件compaction后产生Level1的TSM文件,Level1的文件compaction后生成level2的文件,依次类推。低Level和高Level的compaction会采用不同的算法,低level文件的compaction采用低CPU消耗的做法,例如不会做解压缩和block合并,而高level文件的compaction则会做block解压缩以及block合并,以进一步提高压缩率。我理解这种设计是一种权衡,compaction通常在后台工作,为了不影响实时的数据写入,对compaction消耗的资源是有严格的控制,资源受限的情况下必然会影响compaction的速度。而level越低的数据越新,热度也越高,需要有一种更快的加速查询的compaction,所以InfluxDB在低level采用低资源消耗的compaction策略,这完全是贴合时序数据的写入和查询特性来设计的。

(2)IndexOptimizationCompaction: 当Level4的文件积攒到一定个数后,index会变得很大,查询效率会变的比较低。影响查询效率低的因素主要在于同一个TimeSeries数据会被多个TSM文件所包含,所以查询不可避免的需要跨多个文件进行数据整合。所以IndexOptimizationCompaction的主要作用就是将同一TimeSeries下的数据合并到同一个TSM文件中,尽量减少不同TSM文件间的TimeSeries重合度。

(3)FullCompaction: InfluxDB在判断某个Shard长时间内不会再有数据写入之后,会对数据做一次FullCompaction。FullCompaction是LevelCompaction和IndexOptimization的整合,在做完一次FullCompaction之后,这个Shard不会再做任何的compaction,除非有新的数据写入或者删除发生。这个策略是对冷数据的一个规整,主要目的在于提高压缩率。

Continuous Queries

       对InfluxDB内的数据做预聚合和降精度有两种推荐的策略,一种是使用InfluxData内的数据计算引擎Kapacitor,另一种是使用InfluxDB自带的Continuous Queries。

       如上是一个简单的配置Continuous Queries的CQL,所起的作用是能够让InfluxDB启动一个定时任务,每隔5分钟将『machine_metric』这个measurement下的所有数据按cluster+hostname这个维度进行聚合,计算cpu这个Field的平均值,最终结果写入average_machine_cpu_5m这个新的measurement内。

       InfluxDB的Continuous Queries与KairosDB的auto-rollup功能类似,都是单节点调度,数据的聚合是滞后而非实时的流计算,在计算时对存储会产生较大的读压力。

TimeSeries Index

      时序数据库除了支撑时序数据的存储和计算外,还需要能够提供多维度查询。InfluxDB为了提供更快速的多维查询,对TimeSeries进行了索引。关于数据和索引,InfluxDB是这么描述自己的:

      在InfluxDB 1.3之前,TimeSeries Index(下面简称为TSI)只支持Memory-based的方式,即所有的TimeSeries的索引都是放在内存内,这种方式有好处但是也会带来很多的问题。而在最新发布的InfluxDB 1.3版本上,提供了另外一种方式的索引可供选择,新的索引方式会把索引存储在磁盘上,效率上相比内存索引差一点,但是解决了内存索引存在的不少问题。

Memory-based Index

如上是InfluxDB 1.3的源码中对内存索引数据结构的定义,主要有两个重要的数据结构体:

Series: 对应某个TimeSeries,其内存储TimeSeries相关的一些基本属性以及它所属的Shard

1. Key:对应measurement + tags序列化后的字符串。

2.tags: 该TimeSeries下所有的TagKey和TagValue

3. ID: 用于唯一区分的整数ID。

4. measurement: 所属的measurement。

5. shardIDs: 所有包含该Series的ShardID列表。

Measurement: 每个measurement在内存中都会对应一个Measurement结构,其内部主要是一些索引来加速查询。

1. seriesByID:通过SeriesID查询Series的一个Map。

2. seriesByTagKeyValue:双层Map,第一层是TagKey对应其所有的TagValue,第二层是TagValue对应的所有Series的ID。可以看到,当TimeSeries的基数变得很大,这个map所占的内存会相当多。

3. sortedSeriesIDs:一个排序的SeriesID列表。

全内存索引结构带来的好处是能够提供非常高效的多维查询,但是相应的也会存在一些问题:

1. 能够支持的TimeSeries基数有限,主要受限于内存的大小。若TimeSeries个数超过上限,则整个数据库会处于不可服务的状态。这类问题一般由用户错误的设计TagKey引发,例如某个TagKey是一个随机的ID。一旦遇到这个问题的话,也很难恢复,往往只能通过手动删数据。

2. 若进程重启,恢复数据的时间会比较长,因为需要从所有的TSM文件中加载全量的TimeSeries信息来在内存中构建索引。

Disk-based Index

       针对全内存索引存在的这些问题,InfluxDB在最新的1.3版本中提供了另外一种索引的实现。得益于代码设计上良好的扩展性,索引模块和存储引擎模块都是插件化的,用户可以在配置中自由选择使用哪种索引。

       InfluxDB实现了一个特殊的存储引擎来做索引数据的存储,其结构也与LSM类似,如上图就是一个Disk-based Index的结构图。

       索引数据会先写入Write-Ahead-Log,WAL中的数据按LogEntry组织,每个LogEntry对应一个TimeSeries,包含Measurement、Tags以及checksum信息。写入WAL成功后,数据会进入一个内存索引结构内。当WAL积攒到一定大小后,LogFile会Flush成IndexFile。IndexFile的逻辑结构与内存索引的结构一致,表示的也是Measurement到TagKey,TagKey到TagValue,TagValue到TimeSeries的Map结构。InfluxDB会使用mmap来访问文件,同时文件中对每个Map都会保存HashIndex来加速查询。

       当IndexFile积攒到一定数量后,InfluxDB也提供compaction的机制,将多个IndexFile合并为一个,节省存储空间以及加速查询。

总结

       InfluxDB内所有的组件全部采取自研,自研的好处是每个组件都可以贴合时序数据的特性来做设计,将性能发挥到极致。整个社区也是非常活跃,但是动不动就会有一次大的功能升级,例如改个存储格式换个索引实现啥的,对于用户来说就比较折腾了。总的来说,我还是比较看好InfluxDB的发展,不过可惜的是集群版没有开源。

推荐阅读更多精彩内容