时序数据库(Time Series Database)的存储引擎要想做到极致,还得自研

目前,数据库的存储引擎可以粗略分为两大类:一类是基于 B-Tree 的,另一类是基于 LSM Tree 的。前者常见于传统 OLTP 数据库,比如 MySQL、PQ 这类的默认引擎,更适用于读多写少的场景;如 HBase、LevelDB、RocksDB 一类 Database 使用的是 LSM Tree,在写多读少的场景下比较适合。实际上,现代数据库的存储引擎,基本都会在某种程度下对这两者融合。LSM Tree 上怎么就不可以建 B-Tree Index 了?(HBase 在 region 上也有 B-Tree Index)B-Tree 怎么就一定要直写硬盘,不能先写 WAL 和走内存 Cache 呢?

对于存储引擎,Time Series DatabaseTSDB) 的先行者 InfluxDB 曾经做过很多尝试,在各个存储引擎(LevelDB、RocksDB、BoltDB 等)之间反复横跳,遇到过的问题也有很多,比如 BoltDB 中 mmap+BTree 模型中随机 IO 导致的吞吐量低、RocksDB 这类纯 LSM Tree 存储引擎没办法很优雅快速地按时间分区删除、多个 LevelDB + 划分时间分区的方法又会产生大量句柄……踩了这一系列的坑后,最终 InfluxDB 换成了自研的存储引擎 TSM。可见对 TSDB 来说,一个好的存储引擎有多么重要,又是多么难得,要想做到极致,还得自己研发。

同为 Time Series Database,不同于 InfluxDB 的是,TDengine 从一开始就是自研的——从 LSM Tree 中汲取了 WAL、先写内存的 skip list 等技术,但把 LSM Tree 的树层级结构去掉了,而只是按时间段分区、按表分块的 log 块。

读到这里,细心的读者可能会发现,按表分块的设计和 OpenTSDB 的行聚合有些相似。 OpenTSDB 的行聚合是把相同 tag 以一小时为时间范围,将这些数据都放到一行中存储,这样大大减少了聚合查询要扫描的数据量。不过不同的是,TDengine 是多列模型,而 OpenTSDB 是单列模型,单列模型下是多行的聚合,多列模型下聚合会自然形成数据块。

而熟悉 LSM Tree 的 KV 分离设计的朋友应该也能够从 TDengine 的存储引擎设计中看到一些熟悉的影子。如果把数据块作为 TSDB 存储引擎的 value,那么 key 就应该是块的起止时间 ,把 key 提出来自然就得到了 TDengine 的 BRIN 索引。从这种视角来看,TDengine 的 .head 文件就是 key,而 .data 和 .last 文件就是 value,而 key 自身又可以结合时间序列数据的特征组合成有序文件。 在时序场景下,有了 BRIN 索引,也就可以不需要 bloom filter,这样一看,TDengine 的存储引擎设计就很清晰了。

此外,TDengine 会将 tag 数据和时间序列数据分离开来,这样就能够大大减少 tag 数据占用的存储空间,在数据量大的情况下尤其显著。

TDengine 的 tag 与时间序列数据的划分,和数仓的维度建模里面维度表与事实表的划分有些类似,tag 数据类似维度表,而时间序列数据类似事实表。但又有所不同,因为 TDengine 中表的数目是和设备数目相同的,上亿设备就是上亿张表,这样频繁创建、又极其庞大的表,并不容易处理,主要的麻烦是其产生了大量的元数据,超过了单点的处理能力,这就要求 TDengine 能将这部分元数据也进行分片存储。

当数据与元数据进行分片、多副本操作时,就自然涉及到一致性与可用性的问题。在 TSDB 中,时间序列数据通常是最终一致同步的,因为最终一致算法的吞吐量高延迟低、可用性也比强一致算法好,比如 InfluxDB集群版会用 Dynamo 这种无主风格的数据同步。但元数据(也就是我们上面提到的标签和表数据)需要强一致,强一致通常会用 Raft、Paxos 这类算法来保证正确性。

由于元数据量的巨大需要分片,而当时序数据与元数据都做分片(甚至时间序列数据和其关联的元数据应该在同一分片),但又有截然不同的一致性要求,这就导致 TDengine 的副本复制并不是简单地使用 Raft 这类算法就能够驾驭得了的,除非牺牲时序数据的写入吞吐和可用性,也做强一致复制。这就是 TDengine 使用自研复制算法的根本原因。当然,这些算法在复杂的分布式环境下的一致性保证又是另外的问题了,也是我们要着重解决的挑战。