时序数据以时间作为主要的查询纬度,通常会将连续的多个时间序列数据绘制成线,可制作基于时间的多纬度报表,用揭示其趋势、规律、异常,除了实时在线预测和预警,还可以做离线的数据分析甚至是机器学习。
简介
不同的时序数据库定义可能会有所不同,如下是其使用时常见的基本概念。
metric
指标,相当于关系型数据库中的 table;- data point 数据点,相当于关系型数据库中的 row;
- timestamp 时间戳,代表数据点产生的时间,一般是必须的列,常作为主健;
- field 度量下的不同字段,一般存放随着时间戳的变化而变化的数据;
- tag 标签,一般存放的是标示数据点来源的属性信息。
通常 timestamp 加上所有的 tags 可以认为是 table 的 key;如下图是采集风向的数据:
存储方案
一般分为了单机存储以及分布式存储。
单机存储
如果只存储数据,直接写日志即可,但因为需要快速聚合查询,所以需要考虑存储的结构。
传统数据库存储采用的都是 B-Tree,主要用于在查询和顺序插入时有利于减少寻道次数,对于普通机械磁盘,一般寻道时间大概需要 10ms 左右,对于随机读写大部分时间会消耗在寻道上,从而导致整个查询非常慢。
虽然 SSD 具有更快的寻道时间,但并没有从根本上解决这个问题,而且会引入新的问题。
对于 90% 以上场景都是写入的时序数据库,B Tree 很明显是不合适,大部分采用 LSM Tree 替换,如 HBase、Cassandra 等;LSM Tree 包括内存里的数据结构和磁盘上的文件两部分,分别对应 HBase 里的 MemStore/HLog 以及 Cassandra 里的 MemTable/SSTable 。
LSM Tree 的操作流程如下:
- 数据写入和更新先写入内存里的数据结构,为了避免数据丢失同时也会先写到 WAL 文件中。
- 内存里的数据会定时或者当达到固定大小时刷到磁盘,这些磁盘上的文件不会被修改。
- 随着磁盘上积累的文件越来越多,会定时的进行合并操作,消除冗余数据,减少文件数量。
可以看到 LSM Tree 的核心思想就是通过内存写和后续磁盘的顺序写入获得更高的写入性能,避免了随机写入,但同时也牺牲了读取性能,因为同一个 key 的值可能存在于多个 HFile 中。
为了获取更好的读取性能,可以通过 BloomFilter 和 Compaction 机制。
分布式存储
时序数据库面向的是海量数据的写入存储读取,单机是无法解决问题的,所以需要采用多机存储,也就是分布式存储;除了数据量的问题之外,通常也可以通过分布式解决单点问题。
对于分布式存储首先需要考虑如何将数据分布到多台机器上面,目前采用最多的是分片,也可以通过单独结点管理数据分片;关于分片,其核心问题是分片方法的选择和分片的设计。
分片方法
时序数据库的分片方法和其他分布式系统是相通的,基本分为如下几种:
- 哈希分片,实现简单,均衡性较好,但是集群不易扩展,动态增删结点容易导致大部分数据重新分布。
- 一致性哈希,这种方案均衡性好,集群扩展容易,只是实现相比略微复杂,例如 DynamoDB(Amazon)、Cassandra 。
- 范围划分,通常配合全局有序,复杂度在于合并和分裂,例如 Hbase 。
分片设计
所谓的分片设计,简单来说就是通过什么计算分片,这是非常有技巧的,将会直接影响读写性能。
结合时序数据的特点,通常会根据 metric+tags 进行分片,因为往往会按照一个时间范围查询,这样相同 metric 和 tags 的数据会在一台机器上连续存放,顺序的磁盘读取是很快的。
如下图,第一行和第三行都是同样的 tag(sensor=95D8-7913;city=上海),所以分配到同样的分片,而第五行虽然也是同样的 tag,但是根据时间范围再分段,被分到了不同的分片;而第二、四、六行与上述相同。
数据查询
对于时序数据的查询分为两种:原始数据的查询和时序数据聚合运算的查询,前者主要对历史高精度时序数据的查询,查询结果粒度太细,并不利于分析其规律或者趋势,也不适合展现给用户;后者主要用来对数据做分析。
索引设计
如上所示,时序数据库很关键的是 Label
的管理 (也被称为 Tag
两个语义上相同),包括了用户自定义的实现,也包括类似 Prometheus
通过 __name__
指定表名,也就是将 __xxx__
作为内部使用。
在使用时有两个场景:A) 写入需要判断是否存在;B) 读取则需要通过 Label
组合快速过滤所需时间线。
相比其它系统的标签来说,时序场景的 Label
有如下几个特点:
SchemaLess
对应的Label
、Field
是动态变化的,无需建表时指定。- 可能出现高基数场景,例如主机 ID (主机监控)、用户 ID (URL 监控) 等,虽然不建议但仍会出现。
- 随着时间变化,昨天存在的内容今天可能已经消失,尤其是当前容器实现方式,其变化的速率会更快。
时序数据 Series
通过 Measurement
Labels
Field
唯一确定,为减少内存占用,会将 Key 字符串映射为自增整型 ID 使用。
----- 主要是写入过程中使用
cpu,host=xxx,region=west#idle 123
cpu,host=yyy,region=west#idle 456
查询时,将维护相关的倒排索引。
{host=node1} sid1,sid2,sid3
{host=node2} sid2,sid3,sid5
{region=west} sid3,sid5,sid9
{region=east} sid2,sid3,sid7
查询的几种场景。
----- 严格匹配,可以直接通过上述Key定位
host=node1 AND region=west
----- 多个匹配
host=(node1|node2) AND region=west
----- 正则表示
host=~node* AND region=west
----- 取非
host!~node* AND region=west
后续的正则模糊匹配有几种场景:A) 倒排通过 Hash 实现,只能支持 Get 请求,此时的 Scan 需要全表扫描,那么就需要再增加倒排索引;B) 倒排通过 BTree 实现则可以支持前缀索引。
host node1,node2
region west,east
根据实现的策略,可以对 Tag 进行分类,例如 region
node
类似会全局使用,但是像 CPU 的 core
、Disk 的 mount
等只与具体的指标关联,无需全局存储。
最佳实践
方案设计过程中希望能提供机制而非策略,同时希望有零成本的抽象,但是在设计过程中必定会有些取舍,这里简单整理推荐使用的最佳场景。
- 不同的指标作为单独表使用,其采集间隔可能不同,例如
cpu
disk
net
等等。 - 采用多级分区,其中时间必须,同时允许使用类似
region
、service
这种通用方式。 - 分桶尽量保证数据的均衡。
数据量分析
在设计时分成了 Partition
Shard
两个层级,也就是分区、分桶的设计,前者通常包含时间,后者需要确保数据均衡不要发生太大的倾斜,如下针对监控场景进行简单分析。
host -> 500w
service -> 5k
region -> 20
metric -> 20
上述是 Label
中 Key
及其对应的可能数据量,注意,这里不会出现笛卡尔积的场景,上述最多会有 host
指定的数据量。
乱序数据
例如 Prometheus
只允许时序数据递增,当出现乱序会报 Out of order
的错误,而超过某个 Block
可表示的时间范围则会报 Out of Bound
的错误,而真实的现实可能会出现乱序的场景,尤其是增加了重试逻辑之后。
处理乱序数据需要考虑几个场景:
- 内存中如何保存。
- 如何执行持久化。
- 已经持久化的数据如何修改。