浅谈时序数据库内核:如何用单机扛住亿级数据写入
版本 | 日期 | 备注 |
---|---|---|
1.0 | 2021.10.19 | 文章首发 |
1.0 | 2021.11.21 | 针对在公司中分享时添加的内容进行补充 |
0. 背景
标题来源于InfluxDB对于它们的存储引擎诞生的背景介绍:
The workload of time series data is quite different from normal database workloads. There are a number of factors that conspire to make it very difficult to get it to scale and perform well:
- Billions of individual data points
- High write throughput
- High read throughput
- Large deletes to free up disk space
- Mostly an insert/append workload, very few updates
The first and most obvious problem is one of scale. In DevOps, for instance, you can collect hundreds of millions or billions of unique data points every day.
To prove out the numbers, let’s say we have 200 VMs or servers running, with each server collecting an average of 100 measurements every 10 seconds. Given there are 86,400 seconds in a day, a single measurement will generate 8,640 points in a day, per server. That gives us a total of 200 * 100 * 8,640 = 172,800,000 individual data points per day. We find similar or larger numbers in sensor data use cases.
最近负责了产品中一部分的监控事宜。我想到时序数据库的对于RT和IOPS的要求应该很高,因此想看看它内部是怎么实现的——会不会和我认识的Kafka、HBase很像。
先简单做一下科普。时序数据库是用于存储跟随时间而变化的数据,并且以时间(时间点或者时间区间)来建立索引的数据库。 那么它最早是应用于工业(电力行业、化工行业)应用的各类型实时监测、检查与分析设备所采集、产生的数据,这些工业数据的典型特点是产生频率快(每一个监测点一秒钟内可产生多条数据)、严重依赖于采集时间(每一条数据均要求对应唯一的时间)、测点多信息量大(常规的实时监测系统均可达到成千上万的监测点,监测点每秒钟都在产生数据)。 其数据是历史烙印,它具有不变性、唯一性、有序性。时序数据库同时具有数据结构简单、数据量大的特点。
1.问题
用过时序数据库的同学都知道。时序数据库的数据通常只是追加,很少删改或者根本不允许删改,查询的场景一般也是有连续性的。比如:
- 我们通常会在监控页面上根据观察某个时间端的数据。在需要时,会寻找其中更细的时间段来观察。
- 时序数据库会将告警系统关心的指标推送过去
1.1 Prometheus踩过的坑
在这里,我们先简单复习一下Prometheus中的数据结构。其为典型的k-v对,k(一般叫Series)由MetricName
,Lables
,TimeStamp
组成,v则是值。
在早期的设计中,相同的Series会按照一定的规则组织起来,同时也会根据时间去组织文件。于是就变成了一个矩阵:
优点是写可以并行写,读也可以并行读(无论是根据条件还是时间段)。但缺点也很明显:首先是查询会变成一个矩阵,这样的设计容易触发随机读写,这无论在HDD还是SSD上都很难受(有兴趣的同学可以看后面的3.2小节)。
于是Prometheus又改进了一版存储。每一个Series一个文件,每个Series的数据在内存里存满1KB往下刷一次。
这样缓解了随机读写的问题,但也带来新的问题:
- 在数据没达到1KB还在内存里时,如果机器carsh了,那么数据则丢失
- Series很容易变成特别多,这会导致内存占用居高不下
- 继续上面的,当这些数据一口气被刷下去时,磁盘会变得很繁忙
- 继上,很多文件会被打开,FD会被消耗完
- 当应用很久没上传数据时,内存里的数据该刷不该刷?其实是没法很好的判定的
1.2 InfluxDB踩过的坑
1.2.1 基于LSM Tree的LevelDB
LSM Tree的写性能比读性能好的多。不过InfluxDB提供了删除的API,一旦删除发生时,就很麻烦——它会插入一个墓碑记录,并等待一个查询,查询将结果集和墓碑合并,稍后合并程序则会运行,将底层数据删除。并且InfluxDB提供了TTL,这意味着数据删起来是Range删除的。
为了避免这种较慢的删除,InfluxDB采用了分片设计。将不同的时间段切成不同的LevelDB,删除时只需关闭数据库并删文件就好了。不过当数据量很大的时候,会造成文件句柄过多的问题。
1.2.2 基于mmap B+Tree的BoltDB
BoltDB基于单个文件作为数据存储,基于mmap的B+Tree在运行时的性能也并不差。但当写入数据大起来后,事情变得麻烦了起来——如何缓解一次写入数十万个Serires带来的问题?
为了缓解这个问题,InfluxDB引入了WAL,这样可以有效缓解随机写入带来的问题。将多个相邻的写入缓冲,然后一起fresh下去,就像MySQL的BufferPool。不过这并没有解决写入吞吐量下降的问题,这个方法仅仅是拖延了这个问题的出现。
2. 解决方案
细细想来,时序数据库的数据热点只集中在近期数据。而且多写少读、几乎不删改、数据只顺序追加。因此,对于时序数据库我们则可以做出很激进的存储、访问和保留策略(Retention Policies)。
2.1 关键数据结构
-
参考日志结构的合并树(Log Structured Merge Tree,LSM-Tree)的变种实现代替传统关系型数据库中的B+Tree作为存储结构,LSM 适合的应用场景就是写多读少(将随机写变成顺序写),且几乎不删改的数据。一般实现以时间作为key。在InfluxDB中,该结构被称为Time Structured Merge Tree。
-
时序数据库中甚至还有一种并不罕见却更加极端的形式,叫做轮替型数据库(Round Robin Database,RRD),它是以环形缓冲的思路实现,只能存储固定数量的最新数据,超期或超过容量的数据就会被轮替覆盖,因此它也有着固定的数据库容量,却能接受无限量的数据输入。
2.2 关键策略
- WAL(Write ahead log,预写日志):和诸多数据密集型应用一样,WAL可以保证数据的持久化,并且缓解随机写的发生。在时序数据库中,它会被当作一个查询数据的载体——当请求发生时,存储引擎会将来自WAL和落盘的数据做合并。另外,它还会做基于Snappy的压缩,这是个耗时较小的压缩算法。
- 设置激进的数据保留策略,比如根据过期时间(TTL),自动删除相关数据以节省存储空间,同时提高查询性能。对于普通的数据库来说,数据会存储一段时间后被自动删除的这个做法,可以说是不可想象的。
- 对数据进行再采样(Resampling)以节省空间,比如最近几天的数据可能需要精确到秒,而查询一个月前的冷数据只需要精确到天,查询一年前的数据只要精确到周就够了,这样将数据重新采样汇总,可以节省很多存储空间。
3.小结
总体看下来,相比Kafka、HBase来说,时序数据库的内部结构并不简单,非常有学习价值。
3.1 参考链接
- https://prometheus.io/docs/prometheus/latest/storage/
- https://docs.influxdata.com/influxdb/v1.8/concepts/
- https://blog.csdn.net/cymm_liu/article/details/88344215
- https://zhuanlan.zhihu.com/p/32710333
- https://archive.docs.influxdata.com/influxdb/v0.9/concepts/storage_engine/
- 周志明:《凤凰架构》
3.2 磁盘随机读写vs顺序读写
3.2.1 HHD
HHD的随机读写弱势根本原因在于它的物理结构。当我们对磁盘产生寻址请求时候(可能是读一个区域的数据,或者定位到某个区域来写入数据),首先可以看到的瓶颈就是主轴的转速,其次是磁头臂。
放到现在来看,HHD的随机读写大致为速度为2MB/S、2.2MB/S。而顺序读写大致为200MB/S,220MB/S。
3.2.2 SSD
SSD看起来一切都很美好,随机读写速度一般在400MB/S、360MB/S,顺序读写速度一般在560MB/S,550MB/S。
但真正的问题在于它的内部结构。它的最基本物理单位是一个闪存颗粒,多个闪存颗粒可以组成一个page,多个page可以组成一个block。
写入时,会以page为单位,我们可以看到图里是4kb。这意味着你哪怕写1b的数据,也要占据4kb。这还不是最致命的,最致命的是删除,删除是以整个block为单位发生的。图中是512kb,这意味着你哪怕删里面1kb的数据,都要导致写放大发生。