做一个 Prototype 或者 Demo 很简单,但做出一个真正好的时序数据库产品却很难。
之所以说做 Prototype 简单,是因为时序数据库天生就不擅长处理一些数据,比如带事务的交易数据。基于此,我们可以大刀阔斧地砍掉一些在通用型数据库中很重要的特性,例如事务、MVCC、ACID(在 Facebook 的 Gorilla 中甚至提出不需要保证 Duration)。某些时序数据库的存储引擎,甚至不能处理乱序数据,在无乱序的前提下,存储引擎几乎可以退化为带 Index 的 Log。所以,从这个角度来看,时序数据库可以做得很简单。
但是,从另一方面来说,做一个好的时序数据库产品又很难。试想一下,在时序数据库的设计上,我们大刀阔斧地砍掉了比如事务、ACID 等特性之后,如果依然不能使其在时序场景下的表现远超通用型数据库,那做一个专门的时序数据库就毫无意义了。这样的话,还不如不做,就直接用通用型数据库好了。
所谓“在时序场景下的远超”,应该是全方位的,比如写入的延迟与吞吐量、查询性能、处理的实时性、甚至包括集群方案的运维成本等,都应该有一个跨越式的提升。另一方面,从时序数据量大、价值偏低等特点出发,压缩率就显得比较重要了,而通用型数据库却很少强调压缩率,由此可见,压缩率是在时序场景下真实生长出来的需求。
高压缩率的实现没有什么黑科技,也不需要自己重新发明压缩算法——无非就是列存并对各个类型使用其最好的压缩算法;更多是工程实现的问题——好好写代码,认真做优化,平衡好写入性能与压缩比之间的关系。
此外,在时序数据场景下的“远超”是建立在时序数据的写入与查询分布特点极其明显的基础上,当数据本身 key 的特征分布十分明显时,自然可以充分利用其特征来打造截然不同的存储引擎与索引结构。
先说写入。时序数据库的吞吐量远超一般的通用型数据库,尤其是 IoT 设备,其设备规模可能达到千万甚至上亿,数据均为自动生成,假设 1s 采样一次,那每秒就能产生千万、亿级别的数据写入,这并不是普通数据库能承受的,在这样大的吞吐量的情况下,数据如何分区分片、如何实时地构建索引,都是具有挑战性的问题。在写入链路上,时序数据库在时序场景下替代的是 OLTP 数据库的位置,而后者在事务与强一致的模型下产生的读写延迟很难支撑时序数据库的高吞吐量写入。
再说查询。在大写入吞吐量的情况下,数据对实时性的要求也很高。例如,我们将时序数据的统计量关联做监控、报警,能容忍的延迟可能在秒级。查询的模式通常是聚合查询,例如某时间段内的统计值,而不是精确的单条记录。总的来说,时序数据库的查询模式通常是交互式分析,这不同于 T+1 的离线数仓,也区别于经常运行数小时的 OLAP 查询,交互式分析查询的响应时间通常是秒级、亚秒级。
以上,在明确了写入与查询需求的同时,下面我们以存储引擎为例,来看一看一个时序数据库的某一个部分应该如何设计。
目前,数据库的存储引擎可以粗略分为两大类:一类是基于 B-Tree 的,另一类是基于 LSM-Tree 的。前者常见于传统 OLTP 数据库,比如 MySQL、PQ 这类的默认引擎,更适用于读多写少的场景;如 HBase、LevelDB、RocksDB 一类数据库使用的是 LSM-Tree,在写多读少的场景下比较适合。实际上,现代数据库的存储引擎,基本都会在某种程度下对这两者融合。LSM Tree 上怎么就不可以建 B-Tree Index 了?(HBase 在 region 上也有 B-Tree Index)B-Tree 怎么就一定要直写硬盘,不能先写 WAL 和走内存 Cache 呢?
对于存储引擎,时序数据库的先行者 InfluxDB 曾经做过很多尝试,在各个存储引擎(LevelDB、RocksDB、BoltDB 等)之间反复横跳,遇到过的问题也有很多,比如 BoltDB 中 mmap+BTree 模型中随机 IO 导致的吞吐量低、RocksDB 这类纯 LSM Tree 存储引擎没办法很优雅快速地按时间分区删除、多个 LevelDB + 划分时间分区的方法又会产生大量句柄……踩了这一系列的坑后,最终 InfluxDB 换成了自研的存储引擎 TSM。可见对时序数据库来说,一个好的存储引擎有多么重要,又是多么难得,要想做到极致,还得自己研发。
不同于 InfluxDB,TDengine 的存储引擎从一开始就是自研的——从 LSM Tree 中汲取了 WAL、先写内存的 skip list 等等技术,但把 LSM Tree 的树层级结构去掉了,而只是按时间段分区、按表分块的 log 块。
读到这里,细心的读者可能会发现,按表分块的设计和 OpenTSDB 的行聚合有些相似。OpenTSDB 的行聚合是把相同 tag 以一小时为时间范围,将这些数据都放到一行中存储,这样大大减少了聚合查询要扫描的数据量。不过不同的是,TDengine 是多列模型,而 OpenTSDB 是单列模型,单列模型下是多行的聚合,多列模型下聚合会自然形成数据块。
而熟悉 LSM Tree 的 KV 分离设计的朋友应该也能够从 TDengine 的存储引擎设计中看到一些熟悉的影子。如果把数据块作为存储引擎的 value,那么 key 就应该是块的起止时间 ,把 key 提出来自然就得到了 TDengine 的 BRIN 索引。从这种视角来看,TDengine 的 .head 文件就是 key,而 .data 和 .last 文件就是 value,而 key 自身又可以结合时序数据的特征组合成有序文件。在时序场景下,有了 BRIN 索引,也就可以不需要 bloom filter,这样一看,TDengine 的存储引擎设计就很清晰了。
此外,TDengine 会将 tag 数据和时序数据分离开来,这样就能够大大减少 tag 数据占用的存储空间,在数据量大的情况下尤其显著。
TDengine 的 tag 与时序数据的划分,和数仓的维度建模里面维度表与事实表的划分有些类似,tag 数据类似维度表,而时序数据类似事实表。但又有所不同,因为 TDengine 中表的数目是和设备数目相同的,上亿设备就是上亿张表(在正在开发的 TDengine 3.0 中,我们要支持 100 亿张表),这样频繁创建、又极其庞大的表,并不容易处理,主要的麻烦是其产生了大量的元数据,超过了单点的处理能力,这就要求 TDengine 能将这部分元数据也进行分片存储。
当数据与元数据进行分片、多副本操作时,就自然涉及到一致性与可用性的问题。在时序数据库中,时序数据通常是最终一致同步的,因为最终一致算法的吞吐量高延迟低、可用性也比强一致算法好,比如 InfluxDB 的集群版会用 Dynamo 这种无主风格的数据同步。但元数据(也就是我们上面提到的标签和表数据)需要强一致,强一致通常会用 Raft、Paxos 这类算法来保证正确性。
由于元数据量的巨大需要分片,而当时序数据与元数据都做分片(甚至时序数据和其关联的元数据应该在同一分片),但又有截然不同的一致性要求,这就导致 TDengine 的副本复制并不是简单地使用 Raft 这类算法就能够驾驭得了的,除非牺牲时序数据的写入吞吐和可用性,也做强一致复制。这就是 TDengine 使用自研复制算法的根本原因。当然,这些算法在复杂的分布式环境下的一致性保证又是另外的问题了,也是我们要着重解决的挑战。
一个好的时序数据库,起源于对时序数据领域的数据特征的洞察,成长于大量真实场景的考验与用户的反馈,又在数据库领域的最先进技术中吸取经验得以完善。只有这样,最终才能做到在时序场景下“远超”通用型数据库,成为此场景下的优选数据库。而要做到这一步,其实并不容易。
最后预告一下我们正在开发的 TDengine 3.0。在 3.0 版本中,我们对现在的 2.x 版本存在的一些待解问题做了重新设计与彻底重构,敬请期待。另外关于在 3.0 开发中踩过的坑,以后有机会再和大家慢慢道来。
点个在看少个 bug 👇