Druid--存储原理

1,220 阅读12分钟

druid的系列文章二 存储原理

Deep storage (S3 and HDFS)是作为Segment的永久备份,查询时同样不会涉及Deep storage。

Cloumn结构:

Druid中的列主要分为3类:时间列,维度列,指标列。Druid在数据摄入和查询时都依赖时间列,这也是合理的,因为多维分析一般都带有时间维度。维度和指标是OLAP系统中常见的概念,维度主要是事件的属性,在查询时一般用来filtering 和 group by,指标是用来聚合和计算的,一般是数值类型,像count,sum,min,max等。

Druid中的维度列支持String,Long,Float,不过只有String类型支持倒排索引;指标列支持Long,Float,Complex, 其中Complex指标包含HyperUnique,Cardinality,Histogram,Sketch等复杂指标。强类型的好处是可以更好的对每1列进行编码和压缩, 也可以保证数据索引的高效性和查询性能。

Segment结构:

Segment由dataSource_beginTime_endTime_version_shardNumber唯一标识,1个segment一般包含5–10 million行记录,大小一般在300~700mb。

Druid的存储主要包含三个部分:

  • version文件
  • meta文件
  • index文件

其中meta文件主要包含每1列的文件名和文件的偏移量。(注,druid为了减少文件描述符,将1个segment的所有列都合并到1个大的smoosh中,由于druid访问segment文件的时候采用MMap的方式,所以单个smoosh文件的大小不能超过2G,如果超过2G,就会写到下一个smoosh文件)。

在smoosh文件中,数据是按列存储中,包含时间列,维度列和指标列,其中每1列会包含2部分:ColumnDescriptor和binary数据。其中ColumnDescriptor主要保存每1列的数据类型和Serde的方式。

smoosh文件中还有index.drd文件和metadata.drd文件,其中index.drd主要包含该segment有哪些列,哪些维度,该Segment的时间范围以及使用哪种bitmap;metadata.drd主要包含是否需要聚合,指标的聚合函数,查询粒度,时间戳字段的配置等。

指标列的存储格式

  • version
  • value个数
  • 每个block的value的个数(druid对Long和Float类型会按block进行压缩,block的大小是64K)
  • 压缩类型 (druid目前主要有LZ4和LZF俩种压缩算法)
  • 编码类型 (druid对Long类型支持差分编码和Table编码两种方式,Table编码就是将long值映射到int,当指标列的基数小于256时,druid会选择Table编码,否则会选择差分编码)
  • 编码的header (以差分编码为例,header中会记录版本号,base value,每个value用几个bit表示)
  • 每个block的header (主要记录版本号,是否允许反向查找,value的数量,列名长度和列名)
  • 每1列具体的值

简单列举三个指标的存储:

Long型指标: Druid中对Long型指标会先进行编码,然后按block进行压缩。编码算法包含差分编码和table编码,压缩算法包含LZ4和LZF。

Float型指标:Druid对于Float类型的指标不会进行编码,只会按block进行压缩。

Complex型指标:Druid对于HyperUnique,Cardinality,Histogram,Sketch等复杂指标不会进行编码和压缩处理,每种复杂指标的Serde方式由每种指标自己的ComplexMetricSerde实现类实现。

String 维度的存储格式:

String维度的存储格式如上图所示,前面提到过,时间列,维度列,指标列由两部分组成:ColumnDescriptor和binary数据。 String维度的binary数据主要由3部分组成:dict,字典编码后的id数组,用于倒排索引的bitmap。

以上图中的D2维度列为例,总共有4行,前3行的值是meituan,第4行的值是dianing。Druid中dict的实现十分简单,就是一个hashmap。图中dict的内容就是将meituan编码为0,dianping编码为1。 Id数组的内容就是用编码后的ID替换掉原始值,所以就是[1,1,1,0]。第3部分的倒排索引就是用bitmap表示某个值是否出现在某行中,如果出现了,bitmap对应的位置就会置为1,如图:meituan在前3行中都有出现,所以倒排索引1:[1,1,1,0]就表示meituan在前3行中出现。

显然,倒排索引的大小是 列的基数大小*总的行数,如果没有处理的话结果必然会很大。不过好在如果维度列如果基数很高的话,bitmap就会比较稀疏,而稀疏的bitmap可以进行高效的压缩。

Segment生成过程

  • Add Row to Map
  • Begin persist to disk
  • Write version file
  • Merge and write dimension dict
  • Write time column
  • Write metric column
  • Write dimension column
  • Write index.drd
  • Merge and write bitmaps
  • Write metadata.drd

Segment load过程

  • Read version
  • Load segment to MappedByteBuffer
  • Get column offset from meta
  • Deserialize each column from ByteBuffer

Segment Query过程

Druid查询的最小单位是Segment,Segment在查询之前必须先load到内存,load过程如上一步所述。如果没有索引的话,我们的查询过程就只能Scan的,遇到符合条件的行选择出来,但是所有查询都进行全表Scan肯定是不可行的,所以我们需要索引来快速过滤不需要的行。Druid的Segmenet查询过程如下:

  • 构造1个Cursor进行迭代
  • 查询之前构造出Fliter
  • 根据Index匹配Fliter,得到满足条件的Row的Offset
  • 根据每列的ColumnSelector去指定Row读取需要的列。

Druid的编码和压缩

前面已经提到了,Druid对Long型的指标进行了差分编码和Table编码,Long型和Float型的指标进行了LZ4或者LZF压缩。

其实编码和压缩本质上是一个东西,一切熵增的编码都是压缩。 在计算机领域,我们一般把针对特定类型的编码称之为编码,针对任意类型的通用编码称之为压缩。

编码和压缩的本质就是让每一个bit尽可能带有更多的信息。

白话总结:

Segment文件的内部结构可以看做是列式存储. 每一列的数据都是以不同的数据结果存储. 通过列式存储,查询时只查询需要的列可以减少延迟. 因为列式存储,要保存的是某一列的所有行. 所以数组的每一个元素表示的是每一行的这一列的值. 列式存储还有一个好处是压缩,当查询确定要查询哪些行之后, 首先解压缩(因为存储的时候会压缩),然后直接取出相关的行(定位到数组的指定位置),并运用聚合算子计算指标。这里就要注意列存储实际上存储都是按照数组存储的,过滤或者查询的时候,如果命中某一行,那就直接按照下标去下一个数组查找对应的值,这个就是一行的数据的查找方式。如果有多个列的过滤,那就先按照单个列取出来对应的符合要求的列值,然后再去下一个列里取符合要求的列,然后两个结果取并集,就得出来最终需要聚合的列。

看下边的例子:

timestamp             publisher          advertiser  gender  country  click  price
2011-01-01T01:01:35Z  bieberfever.com    google.com  Male    USA      0      0.65
2011-01-01T01:03:63Z  bieberfever.com    google.com  Male    USA      0      0.62
2011-01-01T01:04:51Z  bieberfever.com    google.com  Male    USA      1      0.45
2011-01-01T01:00:00Z  ultratrimfast.com  google.com  Female  UK       0      0.87
2011-01-01T02:00:00Z  ultratrimfast.com  google.com  Female  UK       0      0.99
2011-01-01T02:00:00Z  ultratrimfast.com  google.com  Female  UK       1      1.53

druid读取数据的入口并不会直接存储原始数据, 而是使用Roll-up这种first-level聚合操作压缩原始数据,如下:

timestamp             publisher          advertiser  gender country impressions clicks revenue
2011-01-01T01:00:00Z  ultratrimfast.com  google.com  Male   USA     1800        25     15.70
2011-01-01T01:00:00Z  bieberfever.com    google.com  Male   USA     2912        42     29.18
2011-01-01T02:00:00Z  ultratrimfast.com  google.com  Male   UK      1953        17     17.31
2011-01-01T02:00:00Z  bieberfever.com    google.com  Male   UK      3194        170    34.01

用SQL表示类似于对时间撮和所有维度列进行分组,并以原始的指标列做常用的聚合操作:

GROUP BY timestamp, publisher, advertiser, gender, country
  :: impressions = COUNT(1),  clicks = SUM(click),  revenue = SUM(price)

为什么不存原始数据? 因为原始数据量可能非常大,对于广告的场景,一秒钟的点击数是以千万计数. 如果能够在读取数据的同时就进行一点聚合运算,就可以大大减少数据量的存储.这种方式的缺点是不能查询单条事件,也就是你无法查到每条事件具体的click和price值了.由于后面的查询都将以上面的查询为基础,所以Roll-up的结果一定要能满足查询的需求.通常count和sum就足够了,因此Rollup的粒度是你能查询的数据的最小时间单位. 假设每隔1秒Rollup一次,后面的查询你最小只能以一秒为单位,不能查询一毫秒的事件.默认的粒度单位是ms.

Druid的分片是Segment文件. Druid首先总是以时间撮进行分片, 因为事件数据总是有时间撮. 假设以小时为粒度创建下面的两个Segment文件

  • 在几乎所有的NoSQL中都有数据分片的概念,比如ES的分片,HBase的Region,都表示的是数据的存储介质.为什么要进行分片,因为数据大了,不能都存成一个大文件吧,所以要拆分成小文件以便于快速查询. 伴随拆分通常都有合并小文件.
  • 从Segment文件的名称可以看出它包含的数据一定是在文件名称对应的起始和结束时间间隔之内的.
  • Segment文件名称的格式:dataSource_interval_version_partitionNumber.最后一个分区号是当同一个时间撮下数据量超过阈值要分成多个分区了:
    • 分片和分区都表示将数据进行切分. 分片是将不同时间撮分布在不同的文件中, 而分区是相同时间撮放不下了,分成多个分区
    • 巧合的是Kafka中也有Segment和Partition的概念.Kafka的Partition是topic物理上的分组,一个topic可以分为多个partition,它的partition物理上由多个segment组成.即Partition包含Segment,而Druid是Segment包含Partition. 注意上边的这种规律的总结。

再说一遍这个数据结构:

维度列因为要支持过滤和分组,每一个维度列的数据结构包含了三部分:

  • 值到ID的Map映射
  • 列的值列表, 存储的是上一步对应的ID
  • 倒排索引

代表 page 这一维度列的数据结构如下:

1: Dictionary that encodes column values
  {
    "Justin Bieber": 0,
    "Ke$ha":         1
  }

2: Column data
  [0,
   0,
   1,
   1]

3: Bitmaps - one for each unique value of the column
  value="Justin Bieber": [1,1,0,0]
  value="Ke$ha":         [0,0,1,1]

注意: 在最坏情况下前面两种会随着数据量的大小而线性增长. 而BitMap的大小则等于数据量大小 * 列的个数.

以一个查询为例,select sum(Characters Added) from table where timestamp between 2011-01-01T00 and 2011-01-02T00 and Page=Justin Bieber and Gender=Male,首先根据时间段定位到这个Segment,然后查出Justin Bieber的字典编码0,Reach的字典编码1,得到Justin Bieber的bitmap为1100,Male的bitmap为1111,做AND操作得到1100,得到index为0,1,将这两个位置的Characters Added进行相加得到4712。

这里就需要说明一下,有了字典,一个列值就有一个ID,这个ID会对应一个bitmap,这个bitmap一个位代表的是一行,如果有100W行,这个bitmap就是100W。所以这个列的bitmap的大小就等于bitmap的大小*行数。那如果有n列,就要再乘以一个N。

注意上边求两个filter的并值是通过两个bitmap求并集的方式得到的。这个效率很高。并集之后的bitmap集合的1的位置索引就是对应的行的索引。这个行是roolup之后存储的度量值的行。看上图就明白了。

结构说明

字典表的key都是唯一的, 所以Map的key是unique的column value, Map的value从0开始不断增加. 示例数据的page列只有两个不同的值. 所以为Bieber编号0, Ke$ha编号为1.

Key            |Value
---------------|-----
Justin Bieber  |0
Ke$ha          |1

列的数据: 要保存的是每一行中这一列的值, 值是ID而不是原始的值. 因为有了上面的Map字典, 所以有下面的对应关系,这样列的值列表直接取最后一列: [0,0,1,1],

rowNum  page                ID
1       Justin Bieber       0
2       Justin Bieber       0
3       Ke$ha               1 
4       Ke$ha               1

BitMap的key是第一步Map的key(列的原始值). value数组的每个元素表示指定列的某一行是否包含/存在/等于当前key. 注意: BitMap保存的value数组只有两个值: 1和0, 1表示这一行包含或等于BitMap的key, 0表示不存在/不包含/不等于,如下:

第一行的page列值为Justin Bieber/列值为Justin Bieber的在第一行里
                        ^
                        |
value="Justin Bieber": [1,1,0,0]
value="Ke$ha":         [0,0,1,1]
                        ^
                        |
                        第一行的page列值不是Ke$ha

这种存储方式, 如果unique重复的列很少,比如page列的每一个值都是不同的. BitMap就会是一个稀疏矩阵.

A: [1,0,0,0,0,0,0,0,0,0,0]
B: [0,1,0,0,0,0,0,0,0,0,0]
C: [0,0,1,0,0,0,0,0,0,0,0]
D: [0,0,0,1,0,0,0,0,0,0,0]
E: [0,0,0,0,1,0,0,0,0,0,0]

unique的重复数量很少也叫做high cardinality,表示基数很高,不同列的数量很多,列值相同的记录数很少. 稀疏矩阵对于BitMap而言却是有优点的,因为越是稀疏,它可以被压缩的比例越大,最后存储的空间越少(相对原始数据). 上面只是针对page列的BitMap, 对于其他的维度列, 都有自己的BitMap! 即每一个维度列都有一个BitMap.

以上就是关于druid的数据存储的总结,要跟load数据和query结合起来思考。