Druid.io——基本概念

1、数据

Druid在数据摄入之前,首先需要定义一个datasource,类似于数据库中表的概念。每个DataSource包括三个部分:

  1. 时间序列(Timestamp),Druid既是内存数据库,又是时间序列数据库,每个数据集合都必须有时间列。Druid中所有查询以及索引过程都和时间维度息息相关。Druid会将时间很近的一些数据行聚合在一起,所有的查询都需要指定查询时间范围。
    • Druid底层使用绝对毫秒数保存时间戳,默认使用ISO-8601格式展示时间(形如:yyyy-MM-ddThh:mm:sss.SSSZ,其中“Z”代表零时区,中国所在的东八区可表示为+08:00)。
  2. 维度列(Dimensions),Druid的维度概念和OLAP中一致,一条记录中的字符类型(String)数据可看作是维度列,维度列被用于过滤筛选(filter)、分组(group)数据。如图3.1中所示page、Username、Gender、City这四列。
  3. 指标列(Metrics),Druid的指标概念也与OLAP中一致,一条记录中的数值(Numeric)类型数据可看作是指标列,指标列被用于聚合(aggregation)和计算(computation)操作。如图3.1中的Characters Added、Characters Removed这两列。

数据格式如下图所示:

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8B%E5%8D%888.29.54.png?version=1&modificationDate=1562590269000&api=v2

2、上卷/聚合(Rollup)

生产环境中,每天会有成百上千亿的原始数据(raw data)进入到Druid中,Druid最小粒度支持毫秒级别的事件,但是在一般使用场景中,我们很少会关注如此细粒度的数据集,同时,对数据按一定规律进行聚合不仅可以节约存储空间,亦可获得更有价值的视图。所以与其他OLAP类产品一样,Druid也支持上卷(roll-up)操作最常用的上卷操作是对时间维度进行聚合,比如对图3.2中的数据按照小时粒度进行聚合可以得到图3.3,图3.3相对于图3.2来说,显得更加直观,也更有助于分析人员掌握全局态势。不过,上卷操作也会带来信息量的丢失,因为上卷的粒度会变成最小数据可视化粒度,即毫秒级别的原始数据,如果按照分钟粒度进行roll-up,那么入库之后我们能够查看数据的最小粒度即为分钟级别。 

20161028135405548.png?version=1&modificationDate=1562590269000&api=v2

3、数据分区

任何分布式存储/计算系统,都需要对数据进行合理的分区,从而实现存储和计算的均衡,以及数据并行化。Druid本身处理的是事件数据,每条数据都会带有一个时间戳,所以很自然的就可以使用时间进行分区。比如下图所示,我们指定了分区粒度为为天,那么每天的数据都会被单独存储和查询(一个分区下有多个Segment的原因往下看)。

druid-timeline.png?version=1&modificationDate=1562590268000&api=v2

Druid的数据被保存在datasources里面, datasource类似于关系型数据库中的table。所有的datasource是按照时间来分片的,必要时也可以额外加上其他字段来分片。每个时间区间范围被称为一个chunk(比如当你的datasource是按天来分片的,一天就是一个chunk)。在chunk内部,数据被进一步分片成一个或多个segment。所有的segment是一个单独的文件,通常一个segment会包含数百万行数据。

一个datasource可以由少数几个segment构成,也可能包含多达数十万甚至上百万个segment。所有的segment的生命周期从在MiddleManager接收摄入数据时被创建,最初的时候,segment时处于可修改和未提交的状态,segment的数据是紧凑的并且支持快速查询,它是通过如下步骤被创建:

  • 把数据转换成列式格式;
  • 通过bitmap编码来建立倒排索引;
  • 通过不同的算法进行压缩:
    • 对于string类型的列,通过字典编码的方式将string转换为id以最小化存储空间;
    • 对bitmap索引进行位图压缩;
    • 所有的列都根据类型来选择合适的压缩算法;

segment会被定期提交和发布(committed and published)。提交时它们会被写入deep storage中,提交后会变成不可写的状态,然后数据就被从MiddleManager移交给Historical进程。segment的信息也会被写入到metadata store组件,这个信息包括segment的格式,大小,以及在deep storage的存储位置。Coordinator通过这些信息来获悉哪些数据在集群中是可用的。

使用时间分区我们很容易会想到一个问题,就是很可能每个时间段的数据量是不均衡的(想一想我们的业务场景),而Duid为了解决这种问题,提供了“二级分区”每一个二级分区称为一个Shard(这才是物理分区)。通过设置每个Shard的所能存储的目标值和Shard策略,来完成shard的分区。Druid目前支持两种Shard策略:Hash(基于维值的Hash)和Range(基于某个维度的取值范围)。上图中,2000-01-01和2000-01-03的每个分区都是一个Shard,由于2000-01-02的数据量比较多,所以有两个Shard。

Segment

Shard经过持久化之后就称为了Segment,Segment是数据存储、复制、均衡(Historical的负载均衡)和计算的基本单元了。Segment具有不可变性,一个Segment一旦创建完成后(MiddleManager节点发布后)就无法被修改,只能通过生成一个新的Segment来代替旧版本的Segment。

Segment的内部存储结构

因为Druid采用列式存储,所以每列数据都是在独立的结构中存储(并不是独立的文件,是独立的数据结构,因为所有列都会存储在一个文件中)。Segment中的数据类型主要分为三种:时间戳、维度列和指标列如1、中所示

对于时间戳列和指标列,实际存储是一个数组,Druid采用LZ4压缩每列的整数或浮点数。当收到查询请求后,会拉出所需的行数据(对于不需要的列不会拉出来),并且对其进行解压缩。解压缩完之后,在应用具体的聚合函数。

对于维度列不会像指标列和时间戳这么简单,因为它需要支持filter和group by,所以Druid使用了字典编码(Dictionary Encoding)和位图索引(Bitmap Index)来存储每个维度列。每个维度列需要三个数据结构:

  1. 需要一个字典数据结构,将维值(维度列值都会被认为是字符串类型)映射成一个整数ID。
  2. 使用上面的字典编码,将该列所有维值放在一个列表中。
  3. 对于列中不同的值,使用bitmap数据结构标识哪些行包含这些值。

Druid针对维度列之所以使用这三个数据结构,是因为:

  1. 使用字典将字符串映射成整数ID,可以紧凑的表示结构2和结构3中的值。
  2. 使用Bitmap位图索引可以执行快速过滤操作(找到符合条件的行号,以减少读取的数据量),因为Bitmap可以快速执行AND和OR操作。
  3. 对于group by和TopN操作需要使用结构2中的列值列表。

我们以上面"Page"维度列为例,可以具体看下Druid是如何使用这三种数据结构存储维度列:

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8A%E5%8D%8811.12.14.png?version=1&modificationDate=1562590268000&api=v2

下图是以advertiser列为例,描述了advertiser列的实际存储结构:

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8A%E5%8D%8811.13.25.png?version=1&modificationDate=1562590268000&api=v2

前两种存储结构在最坏情况下会根据数据量增长而成线性增长(列数据中的每行都不相同),而第三种由于使用Bitmap存储(本身是一个稀疏矩阵),所以对它进行压缩,可以得到非常客观的压缩比。Druid而且运用了Roaring Bitmap(http://roaringbitmap.org/)能够对压缩后的位图直接进行布尔运算,可以大大提高查询效率和存储效率(不需要解压缩)。

Segment命名

高效的数据查询,不仅仅体现在文件内容的存储结构上,还有一点很重要,就是文件的命名上。试想一下,如果一个Datasource下有几百万个Segment文件,我们又如何快速找出我们所需要的文件呢?答案就是通过文件名称快速索引查找。

Segment的命名包含四部分:数据源(Datasource)、时间间隔(包含开始时间和结束时间两部分)、版本号和分区(Segment有分片的情况下才会有)。

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8A%E5%8D%8811.16.33.png?version=1&modificationDate=1562590268000&api=v2

分片号是从0开始,如果分区号为0,则可以省略:test-datasource_2018-05-21T16:00:00.000Z_2018-05-21T17:00:00.000Z_2018-05-21T16:00:00.000Z

还需要注意如果一个时间间隔segment由多个分片组成,则在查询该segment的时候,需要等到所有分片都被加载完成后,才能够查询(除非使用线性分片规范(linear shard spec),允许在未加载完成时查询)。

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8A%E5%8D%8811.17.47.png?version=1&modificationDate=1562590268000&api=v2

Segment的物理存储实例

下面我们以一个实例来看下Segment到底以什么形式存储的,我们以本地导入方式将下面数据导入到Druid中。

{"time": "2018-11-01T00:47:29.913Z","city": "beijing","sex": "man","gmv": 20000}

{"time": "2018-11-01T00:47:33.004Z","city": "beijing","sex": "woman","gmv": 50000}

{"time": "2018-11-01T00:50:33.004Z","city": "shanghai","sex": "man","gmv": 10000}

我们以单机形式运行Druid,这样Druid生成的Segment文件都在${DRUID_HOME}/var/druid/segments 目录下。

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8B%E5%8D%888.43.39.png?version=1&modificationDate=1562590268000&api=v2

segment通过datasource_beginTime_endTime_version_shard用于唯一标识,在实际存储中是以目录的形式表现的。

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8B%E5%8D%888.44.13.png?version=1&modificationDate=1562590268000&api=v2

可以看到Segment中包含了Segment描述文件(descriptor.json)和压缩后的索引数据文件(index.zip),我们主要看的也是index.zip这个文件,对其进行解压缩。

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8A%E5%8D%8811.23.32.png?version=1&modificationDate=1562590268000&api=v2

首先看下factory.json这个文件,这个文件并不是segment具体存储段数据的文件。因为Druid通过使用MMap(一种内存映射文件的方式)的方式访问Segment文件。

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8A%E5%8D%8811.24.56.png?version=1&modificationDate=1562590267000&api=v2

Druid实际存储Segment数据文件是:version.bin、meta.smoosh和xxxxx.smoosh这三个文件,下面分别看下这三个文件的内容。

version.bin是一个存储了4个字节的二进制文件,它是Segment内部版本号(随着Druid发展,Segment的格式也在发展),目前是V9,以Sublime打开该文件可以看到:

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8A%E5%8D%8811.25.51.png?version=1&modificationDate=1562590267000&api=v2

meta.smoosh里面存储了关于其它smoosh文件(xxxxx.smoosh)的元数据,里面记录了每一列对应文件和在文件的偏移量。除了列信息外,smoosh文件还包含了index.drd和metadata.drd,这部分是关于Segment的一些额外元数据信息。

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8A%E5%8D%8811.26.27.png?version=1&modificationDate=1562590267000&api=v2

再看00000.smoosh文件前,我们先想一下为什么这个文件被命名为这种样式?因为Druid为了最小化减少打开文件的句柄数,它会将一个Segment的所有列数据都存储在一个smoosh文件中,也就是xxxxx.smoosh这个文件。但是由于Druid使用MMap来读取Segment文件,而MMap需要保证每个文件大小不能超过2G(Java中的MMapByteBuffer限制),所以当一个smoosh文件大于2G时,Druid会将新数据写入到下一个smoosh文件中。这也就是为什么这些文件命名是这样的,这里也对应上了meta文件中为什么还要标识列所在的文件名。

通过meta.smoosh的偏移量也能看出,00000.smoosh文件中数据是按列进行存储的,从上到下分别存储的是时间列、指标列、维度列。对于每列主要包会含两部分信息:ColumnDescriptor和binary数据。columnDescriptor是一个使用Jackson序列化的对象,它包含了该列的一些元数据信息,比如数据类型、是否是多值等。而binary则是根据不同数据类型进行压缩存储的二进制数据。

^@^@^@d{"valueType":"LONG","hasMultipleValues":false,"parts":[{"type":"long","byteOrder":"LITTLE_ENDIAN"}]}^B^@^@^@^C^@^@ ^@^A^A^@^@^@^@"^@^@^@^A^@^@^@^Z^@^@^@^@¢yL½Ìf^A^@^@<8c>X^H^@<80>¬^WÀÌf^A^@^@^@^@^@d{"valueType":"LONG","hasMultipleValues":false,"parts":[{"type":"long","byteOrder":"LITTLE_ENDIAN"}]}^B^@^@^@^C^@^@ ^@^A^A^@^@^@^@ ^@^@^@^A^@^@^@^X^@^@^@^@1 N^@^A^@"PÃ^H^@<80>^P'^@^@^@^@^@^@^@^@^@<9a>{"valueType":"STRING","hasMultipleValues":false,"parts":[{"type":"stringDictionary","bitmapSerdeFactory":{"type":"concise"},"byteOrder":"LITTLE_ENDIAN"}]}^B^@^@^@^@^A^A^@^@^@#^@^@^@^B^@^@^@^K^@^@^@^W^@^@^@^@beijing^@^@^@^@shanghai^B^A^@^@^@^C^@^A^@^@^A^A^@^@^@^@^P^@^@^@^A^@^@^@^H^@^@^@^@0^@^@^A^A^@^@^@^@^\^@^@^@^B^@^@^@^H^@^@^@^P^@^@^@^@<80>^@^@^C^@^@^@^@<80>^@^@^D^@^@^@<9a>{"valueType":"STRING","hasMultipleValues":false,"parts":[{"type":"stringDictionary","bitmapSerdeFactory":{"type":"concise"},"byteOrder":"LITTLE_ENDIAN"}]}^B^@^@^@^@^A^A^@^@^@^\^@^@^@^B^@^@^@^G^@^@^@^P^@^@^@^@man^@^@^@^@woman^B^A^@^@^@^C^@^A^@^@^A^A^@^@^@^@^P^@^@^@^A^@^@^@^H^@^@^@^@0^@^A^@^A^@^@^@^@^\^@^@^@^B^@^@^@^H^@^@^@^P^@^@^@^@<80>^@^@^E^@^@^@^@<80>^@^@^B^A^@^@^@^@&^@^@^@^C^@^@^@^G^@^@^@^O^@^@^@^V^@^@^@^@gmv^@^@^@^@city^@^@^@^@sex^A^A^@^@^@^[^@^@^@^B^@^@^@^H^@^@^@^O^@^@^@^@city^@^@^@^@sex^@^@^AfÌ<91>Ð^@^@^@^AfѸ,^@^@^@^@^R{"type":"concise"}{"container":{},"aggregators":[{"type":"longSum","name":"gmv","fieldName":"gmv","expression":null}],"timestampSpec":{"column":"time","format":"auto","missingValue":null},"queryGranularity":{"type":"none"},"rollup":true}

【注】smooth文件中的binary数据经过LZ4或Bitmap压缩,所以无法看到数据原始内容。

在smooth文件最后还包含了两部分数据,分别是index.drd和metadata.drd。其中index.drd中包含了Segment中包含哪些度量、维度、时间范围、以及使用哪种bitmap。metadata.drd中存储了指标聚合函数、查询粒度、时间戳配置等(上面内容的最后部分)。

下图是物理存储结构图,存储未压缩和编码的数据就是最右边的内容。

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202019-07-08%20%E4%B8%8A%E5%8D%8811.30.41.png?version=1&modificationDate=1562590267000&api=v2

Segment创建

Segment都是在MiddleManager节点中创建的,并且处在MiddleManager中的Segment在状态上都是可变的并且未提交的(提交到DeepStorage之后,数据就不可改变)。
Segment从在MiddleManager中创建到传播到Historical中,会经历以下几个步骤:

  1. MiddleManager中创建Segment文件,并将其发布到Deep Storage。
  2. Segment相关的元数据信息被存储到MetaStore中。
  3. Coordinator进程根据MetaStore中得知Segment相关的元数据信息后,根据规则的设置分配给符合条件的Historical节点。
  4. Historical节点得到Coordinator指令后,自动从DeepStorage中拉取Segment数据文件,并通过Zookeeper向集群声明负责提供该Segment数据相关的查询服务。
  5. MiddleManager在得知Historical负责该Segment后,会丢弃该Segment文件,并向集群声明不在负责该Segment相关的查询。

如何配置分区

Druid通过Segment实现了对数据的横纵向切割(Slice and Dice)操作。从数据按时间分布的角度来看,通过参数segmentGranularity的设置,Druid将不同时间范围内的数据存储在不同的Segment数据块中,这便是所谓的数据横向切割。这种方式有一个明显的有点:按照时间范围查询数据室,仅需要访问对应时间段内的这些Segment数据块,而不需要进行全表数据范围查询,这使效率得到了极大的提高。

同时,在Segment中也面向列进行数据压缩存储,这便是所谓的数据纵向切割。并且在Segment中使用了Bitmap等技术对数据的访问进行了优化。

可以通过granularitySpec中的segmentGranularity设置segment的时间间隔(http://druid.io/docs/latest/ingestion/ingestion-spec.html#granularityspec)。为了保证Druid的查询效率,每个Segment文件的大小建议在300MB~700MB之间。如果超过这个范围,可以修改时间间隔或者使用分区来进行优化(配置partitioningSpec中的targetPartitionSize,官方建议设置500万行以上;http://druid.io/docs/latest/ingestion/hadoop.html#partitioning-specification)。

参考资料:

转载于:https://my.oschina.net/liyurong/blog/3071580

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值