1、数据
Druid在数据摄入之前,首先需要定义一个datasource,类似于数据库中表的概念。每个DataSource包括三个部分:
- 时间序列(Timestamp),Druid既是内存数据库,又是时间序列数据库,每个数据集合都必须有时间列。Druid中所有查询以及索引过程都和时间维度息息相关。Druid会将时间很近的一些数据行聚合在一起,所有的查询都需要指定查询时间范围。
- Druid底层使用绝对毫秒数保存时间戳,默认使用ISO-8601格式展示时间(形如:yyyy-MM-ddThh:mm:sss.SSSZ,其中“Z”代表零时区,中国所在的东八区可表示为+08:00)。
- 维度列(Dimensions),Druid的维度概念和OLAP中一致,一条记录中的字符类型(String)数据可看作是维度列,维度列被用于过滤筛选(filter)、分组(group)数据。如图3.1中所示page、Username、Gender、City这四列。
- 指标列(Metrics),Druid的指标概念也与OLAP中一致,一条记录中的数值(Numeric)类型数据可看作是指标列,指标列被用于聚合(aggregation)和计算(computation)操作。如图3.1中的Characters Added、Characters Removed这两列。
数据格式如下图所示:
2、上卷/聚合(Rollup)
生产环境中,每天会有成百上千亿的原始数据(raw data)进入到Druid中,Druid最小粒度支持毫秒级别的事件,但是在一般使用场景中,我们很少会关注如此细粒度的数据集,同时,对数据按一定规律进行聚合不仅可以节约存储空间,亦可获得更有价值的视图。所以与其他OLAP类产品一样,Druid也支持上卷(roll-up)操作。最常用的上卷操作是对时间维度进行聚合,比如对图3.2中的数据按照小时粒度进行聚合可以得到图3.3,图3.3相对于图3.2来说,显得更加直观,也更有助于分析人员掌握全局态势。不过,上卷操作也会带来信息量的丢失,因为上卷的粒度会变成最小数据可视化粒度,即毫秒级别的原始数据,如果按照分钟粒度进行roll-up,那么入库之后我们能够查看数据的最小粒度即为分钟级别。
3、数据分区
任何分布式存储/计算系统,都需要对数据进行合理的分区,从而实现存储和计算的均衡,以及数据并行化。Druid本身处理的是事件数据,每条数据都会带有一个时间戳,所以很自然的就可以使用时间进行分区。比如下图所示,我们指定了分区粒度为为天,那么每天的数据都会被单独存储和查询(一个分区下有多个Segment的原因往下看)。
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)来存储每个维度列。每个维度列需要三个数据结构:
- 需要一个字典数据结构,将维值(维度列值都会被认为是字符串类型)映射成一个整数ID。
- 使用上面的字典编码,将该列所有维值放在一个列表中。
- 对于列中不同的值,使用bitmap数据结构标识哪些行包含这些值。
Druid针对维度列之所以使用这三个数据结构,是因为:
- 使用字典将字符串映射成整数ID,可以紧凑的表示结构2和结构3中的值。
- 使用Bitmap位图索引可以执行快速过滤操作(找到符合条件的行号,以减少读取的数据量),因为Bitmap可以快速执行AND和OR操作。
- 对于group by和TopN操作需要使用结构2中的列值列表。
我们以上面"Page"维度列为例,可以具体看下Druid是如何使用这三种数据结构存储维度列:
下图是以advertiser列为例,描述了advertiser列的实际存储结构:
前两种存储结构在最坏情况下会根据数据量增长而成线性增长(列数据中的每行都不相同),而第三种由于使用Bitmap存储(本身是一个稀疏矩阵),所以对它进行压缩,可以得到非常客观的压缩比。Druid而且运用了Roaring Bitmap(http://roaringbitmap.org/)能够对压缩后的位图直接进行布尔运算,可以大大提高查询效率和存储效率(不需要解压缩)。
Segment命名
高效的数据查询,不仅仅体现在文件内容的存储结构上,还有一点很重要,就是文件的命名上。试想一下,如果一个Datasource下有几百万个Segment文件,我们又如何快速找出我们所需要的文件呢?答案就是通过文件名称快速索引查找。
Segment的命名包含四部分:数据源(Datasource)、时间间隔(包含开始时间和结束时间两部分)、版本号和分区(Segment有分片的情况下才会有)。
分片号是从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),允许在未加载完成时查询)。
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 目录下。
segment通过datasource_beginTime_endTime_version_shard用于唯一标识,在实际存储中是以目录的形式表现的。
可以看到Segment中包含了Segment描述文件(descriptor.json)和压缩后的索引数据文件(index.zip),我们主要看的也是index.zip这个文件,对其进行解压缩。
首先看下factory.json这个文件,这个文件并不是segment具体存储段数据的文件。因为Druid通过使用MMap(一种内存映射文件的方式)的方式访问Segment文件。
Druid实际存储Segment数据文件是:version.bin、meta.smoosh和xxxxx.smoosh这三个文件,下面分别看下这三个文件的内容。
version.bin是一个存储了4个字节的二进制文件,它是Segment内部版本号(随着Druid发展,Segment的格式也在发展),目前是V9,以Sublime打开该文件可以看到:
meta.smoosh里面存储了关于其它smoosh文件(xxxxx.smoosh)的元数据,里面记录了每一列对应文件和在文件的偏移量。除了列信息外,smoosh文件还包含了index.drd和metadata.drd,这部分是关于Segment的一些额外元数据信息。
再看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中存储了指标聚合函数、查询粒度、时间戳配置等(上面内容的最后部分)。
下图是物理存储结构图,存储未压缩和编码的数据就是最右边的内容。
Segment创建
Segment都是在MiddleManager节点中创建的,并且处在MiddleManager中的Segment在状态上都是可变的并且未提交的(提交到DeepStorage之后,数据就不可改变)。
Segment从在MiddleManager中创建到传播到Historical中,会经历以下几个步骤:
- MiddleManager中创建Segment文件,并将其发布到Deep Storage。
- Segment相关的元数据信息被存储到MetaStore中。
- Coordinator进程根据MetaStore中得知Segment相关的元数据信息后,根据规则的设置分配给符合条件的Historical节点。
- Historical节点得到Coordinator指令后,自动从DeepStorage中拉取Segment数据文件,并通过Zookeeper向集群声明负责提供该Segment数据相关的查询服务。
- 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)。
参考资料: