Druid--索引解析

1,597 阅读18分钟

druid原理第五篇 索引解析

这里主要说一下druid关于bitmap和倒排索引的使用。

之前有一篇文章介绍了关于druid的存储原理。这里说一下druid在构建文件过程中对于索引的构建和存储。

先说一下Bitmap到底是啥?

在此我们举个简单的例子来演示如何使用Bitmap Index来加速数据库的多维查询性能。下图是一张典型的时序数据表:

图中Timestamp列是时序列,Page、Username、Gender和City这几个列是维度列,Added以及Removed两列是数值列。基于这样的原始表,可以构造一个典型的多维查询如下:

select Added from datasource where Gender = ‘Female’ and City = ‘Taiyuan’

查询中使用两个维度条件进行过滤,分别是Gender以及City列。很显然,如果不使用任何技术手段的话,在原始表上根据如上两个维度的过滤条件进行查询需要遍历整个原始表,并对相应维度列进行过滤,这个代价很显然是非常可观的。那能不能有一种方法可以直接根据维度的过滤条件得到待查找目标行,比如上述示例中能不能根据Gender = ‘Female’ and City = ‘Taiyuan’这两个过滤条件直接定位到待查找目标行就是第二行,其他行都不满足条件,这样的话只需要查找第二行的Added列返回给用户即可,不再需要野蛮的全表扫描并一条一条数据进行对比。这就是Bitmap索引(倒排索引)的使命!

使用Bitmap索引的基本原理是将这两列上的数值映射到bitmap上,再采用intersection表示来实现and、or等这种查询谓词。在上述示例中,将Gender以及City两列映射成bitmap如下图所示:

原始表中,Gender列中有两个值:Male和Female,因此需要设置两个对应的bitmap,Male分配一个,Female分配一个,两个bitmap的大小对应原始表的数据行数,原始数据有4行,bitmap的大小就是4。也就是一个列值对应一个bitmap,这个bitmap存储的就是所有的行。一个bit就代表该值在这行是否存在且相等。每个维度列有多少种取值(Cardinality),这个维度列就会有多少个Bitmap。每个Bitmap表示对应取值在原始表中哪些行出现过。这样表示完成之后,再来看看查询语句:where Gender = ‘Female’ and City = ‘Taiyuan’,就可以使用对应bitmap表示为如下形式:

分别拿出Gender = ‘Female’ and City = ‘Taiyuan’对应的bitmap,执行and操作实际上对应位图的与运算,最终得到一个结果位图,结果位图中只有下标2的值置为1,说明原始表中满足查询条件的行只有第二行。这个思路决定着位图实际对于and 或者or等这种按位操作比较擅长,效率很快,能够快速找到命中多个条件的行。 Druid的bitmap的整个过程实际包括以下几个过程,这也是构建一个完成的bitmap应该考虑的实际问题:

这里的五个小功能,每个小功能都是一个单独的模块,从构建到存储到查询使用,基本都概括进去了,下边来依次看一下这五个模块:

Bitmap索引如何在内存中构建?

Druid数据实时写入节点采用LSM结构保证数据的写入性能。数据先写入内存,每隔10min(可配)会将内存中的数据persist到本地硬盘形成文件,然后会有一个线程再每隔1h(可配)将本地硬盘的多个文件合并成一个segment。

Bitmap索引构建时机

这里实际上会碰到第一个需要权衡的问题:Bitmap索引是应该在数据写入的同时实时构建呢,还是应该在数据从内存persist到硬盘的时候批量构建。很显然,实时构建会对数据写入吞吐量造成一定影响,实际测试下来发现写入性能会下降5%到15%,而且表维度越多,性能下降越明显。而另一方面,如果是批量构建,那么内存中的数据实际上是没有索引的,这部分数据的检索方式必然与已经持久化到硬盘文件数据的检索方式完全不同:内存中的数据检索不走索引直接查数据,文件中的数据检索需要先走索引再查数据,在实际查询实现中需要分别处理。 Druid中Bitmap的构建时机采用的后者,即在数据从内存persist到硬盘的时候批量构建。本人实现倒排索引时采用的是前者,主要考虑的问题是希望无论数据是在内存还是在硬盘,都能够采用统一的检索方式,即都先根据索引查询行号,再根据行号查具体数据。这样将内存检索和硬盘检索统一处理的好处是在代码实现上更加方便,更加简洁。当然,会牺牲一部分写入性能。

TODO 这里留一个疑问,就是Druid在实时节点会存储数据,在把数据persist到离线数据之前,数据在内存中的存储是什么样儿的?以及实际的查询过程是什么样儿的。

维度列构建维度字典

为维度列构建维度字典是Druid中非常重要的一个步骤。维度列中的值通常都可枚举,比如上文示例中维度列Gender只有两个可选值:Mela和Female,City列同样取值可枚举。因此有必要为每个维度列构建字典,将维度值(大多数为String)映射为Int值,大规模减少数据量。维度字典最核心的是两个Map映射:valueToId和idToValue,以City列为例,该列有三个值,构建出的字典就是 valueToId : <SanFrancisco, 0>, <Taiyuan,1>, <Calgary, 2>,idToValue是map反过来。可以看出来,构建字典就是为维度列的取值赋一个自增的Int值。 同理,可以分别为Page列、UserName列和Gender列构建相应的维度字典,构建完成之后,原始表中第三行的所有维度列就不再是Page:Ke$ha, UserName:Helz, Gender:Female, City:Calgary,而是[1, 2, 1, 2]。

这里数据存储实际上存储的就是映射后的整数值,这样整体就比较省空间。 TODO: 如果映射的是整数值,那如果超过普通int的高基数是怎么应对的呢?

构建Bitmap索引

上文说到Druid中Bitmap索引是在内存数据异步persist到硬盘文件的时候构建的,那接下来就需要看看表中一行记录过来之后如何分别为每个维度列构建Bitmap索引。 在介绍具体的构建流程之前,需要先说明一个关键的点:每个维度列实际上都会维护一个Bitmap数组:MutableBitmap[],数组大小为每个维度列的可取值多少(Cardinality),比如Gender列只有Male和Female两个取值,Bitmap数组大小就为2。数组的第一个值为Male对应的位图数据,数组的第二个值为Female对应的位图数据。这里就有一个问题,为什么说数组的第一个值是Male对应的位图数据,而不是第二个值呢?这就是用到了上文中提到的维度字典,Male对应的维度字典值为0,就对应数组下标为0;Female对应的维度字典值为1,对应数据下标就为1。

下面以其中一行数据为例介绍构建Bitmap索引的过程:

  1. 首先会为每一行生成一个自增的rowNum

  2. 遍历所有维度列,分别为每个维度列构建相应的Bitmap数组

    • 针对某个纬度列的value值,首先在维度字典中根据value找到对应的id,这个id即是Bitmap数组的下标,根据这个下标找到该value对应的位图数据,即MutableBitmap[id]定位到位图数据之后,再将该位图下标为rowNum的bit位置为1

Bitmap索引如何进行压缩处理?

Bitmap索引为什么需要压缩?

还是以Gender列为例,上文我们知道这个维度列只有两个取值:Male和Female,因此无论对于Male对应的位图数据,还是Female对应的位图数据,都会存在大量的连续的0或者连续的1,非常适合压缩编码,减小存储空间。压缩就意味着每个维度列值的位图数据是非定长的。

Bitmap索引如何进行压缩?

针对Bitmap的压缩有非常多的算法,大家可以自行Google。根据压缩效率、解码效率以及intersection等计算效率等指标的权衡,特别推荐使用RoaringBitmap压缩算法。有兴趣的同学可以自行Google。这个压缩算法是位图的数据结构设计中使用比较广泛的,比如kylin等,效率很快,支持的操作也足够。

Bitmap索引如何持久化存储?

Druid中原始数据每隔一段时间就会落盘一次,随着原始数据的落盘,原始数据中维度列对应的Bitmap索引也需要执行持久化存储。在实际实现中,Druid首先将维度字典持久化到文件,再将原始数据(维度列都使用维度字典编码处理)持久化到文件,最后再将维度列对应的Bitmap索引持久化到文件。 对于Druid系统来说,这里需要关注两点:

  1. Druid系统是列式存储系统,同一个segment中所有列的数据都会分别独立存储在一起形成多个列文件,比如City列的数据会存储在一起形成文件,Added列的数据会存储在一起形成文件。其他列同理。
  2. Druid系统中的文件分为两种,一种是定长文件格式,一种是非定长文件格式。定长文件针对于列数值是定长的,比如某些数值列是Double的,有些数据列是Long类型,再比如维度列经过编码之后所有维度列都是Int类型,时间列是Long类型。这些定长文件格式很简单,直接存储数值即可。而非定长文件通常主要针对列数值不是定长的,比如维度字典文件中需要存储维度值,这些维度值通常是字符串,并不定长;比如Bitmap索引的存储文件中需要存储Bitmap位图数据,这些值也不是定长的。下文主要介绍Bitmap索引的存储,所以重点介绍非定长文件格式。

可以看出,Druid系统中使用了3个文件来存储非定长数据:meta文件,header文件以及value文件,其中meta文件主要存储一些元数据信息,比如存储数值个数、存储数值总大小等;value文件存储实际的数值,一个数值一个数值写进去,在实际数据之前有一个int值表示该数值的大小;header文件实际上是value文件中每个数值在value文件的偏移量,文件中每个值都是一个int。 记住这里head文件和value文件,druid中这种存储方式比较常用。

维度字典文件存储

纬度列数据字典在数据写入的时候就会构建,并一直保存在内存。Druid会在persist的时候将其持久化形成维度字典文件,每个维度列的字典会单独形成一个文件,比如Gender维度列的数据字典会形成一个文件,City维度列的数据字典会形成另一个文件。下图是City维度列形成的维度列字典文件格式(没有列出meta文件):

City维度列的数据字典一共有3个值:Calgary、San Francisco和Taiyuan,持久化到文件后就是上图格式,需要特别注意的是:数据字典的值是按照字典序由小到大排列之后存入文件的。比如上图中Calgary、San Francisco和Taiyuan就是按照由小到大排序后存储的。

这个点是工程实践中非常重要的一个技术点。上文中我们说数据字典在构建的时候其实并没有强调排序,而是按照维度列进来系统的顺序构建字典的,比如San Francisco先进入系统,在第一行,所以San Francisco对应的编码值就为0,Taiyuan是第二行,所以Taiyuan对应的编码值为1,同理,Calgary编码值为2。而且,Bitmap索引构建也是依赖于非排序的维度字典。如果此时在持久化的时候要将维度字典进行排序,那意味着Bitmap位图数据在Bitmap数组MutableBitmap[]中的位置也需要相应的变化,保持一致。

为什么需要排序?如果不排序直接存储行不行?

解答这个问题之前先看看维度字典文件,可以得到文件中只存储了维度列的值,并没有存储对应的编码值,那编码值哪去了?实际上编码值隐含在维度列值的下标,比如Calgary是第一个值,那对应的编码值就是0,Taiyuan是第三个值,对应的编码值就是2。基于这样的事实,如果不排序,你来告诉我如果说我想查Taiyuan对应的编码值,如何查?那就蒙圈了,需要一个一个遍历的查,如果某个维度Cardinality很大的话,不就跪了。而反过来,如果排序的话,就可以通过二分查找来查,下文会举例介绍。

实际使用中,Druid应该是用了别的算法,应该不只是二分查找,这里的意思是说排序之后就有更加丰富的算法运用空间来提高查询效率了。 TODO: 这块就需要确认一下,Druid在查询的时候,或者说在内存中的存储是什么方式,这也决定了查询或者遍历这个树的方式是什么样儿的。

Bitmap索引文件存储

Bitmap索引文件和维度字典文件是一一对应的,每个维度列的Bitmap索引会单独形成一个文件,比如Gender维度列的Bitmap索引会形成一个文件,City维度列的Bitmap索引会形成一个文件。下图是City维度列形成的Bitmap索引文件:

注意,Bitmap索引文件中Bitmap位图数据的存储顺序必须和维度字典中对应值的存储顺序一致。比如维度字典中Calgary存储在文件中第一的位置,对应的Bitmap位图就必须存储在相应第一的位置。

查询时如何根据Bitmap索引构建Cursor体系?

以查询语句select Added from datasource where Gender = ‘Female’ and City = ‘Taiyuan’为例,这样一个过程实际上可以分为两步:

  1. 如何根据Gender = ‘Female’找到对应的位图数据?同理,如何根据City = ’Taiyuan’找到对应的位图数据?
  2. 如何根据and操作符实现位图与操作? 根据and操作符实现位图与操作是比较简单的,现在很多Bitmap实现包中都有类似的功能,在此不再赘述。因此构建Cursor体系实际上就简化为根据维度过滤条件查找对应的位图数据这样一个问题。为了更加具体,我们以City = ’Taiyuan’为例定位对应的位图数据。
  • 在City列对应的维度字典文件中查找’Taiyuan’值在文件中的下标

因为文件中维度值是由小到大排序的,所以查找的战术思想是二分查找。首先将查找指针移动到header文件的中心,中心下标curIndex = (minIndex,maxIndex)>>>1,header文件的中心值为offset_SanFrancisco,这个offset实际上指向了value文件中的San Francisco(这里忽略了一些细节),这个值与我们要找的值Taiyuan相比较,很显然前者小于后者,因此继续往后找。经过多次的查找,最终定位到Taiyuan对应的下标是2(从0开始哦)。

这里是在head文件中进行二分查找,然后再去value中进行比对,不对的话,再去head中查找。注意这个过程。有优化空间。

  • 在City列对应的Bitmap索引文件中查找下标为2的Bitmap位图数据,如下图所示,首先在header文件中找到下标为2的offset为offset_ty_bm,再根据偏移值在value文件中定位出Taiyuan对应的bitmap位图数据。(忽略具体的查找细节)

经过这两步的执行就可以根据City = ’Taiyuan’得到对应的bitmap位图数据,同理,根据Gender = ‘Female’可以得到对应的bitmap位图数据,两者使用与运算就可以得到一个最终的Bitmap位图索引,这个位图索引我们称为Cursor。

如何根据Cursor体系快速查找对应行数据?

Cursor结构体构建出来之后,如果根据这个结构快速的查找对应的行数据呢?这个过程也可以分为两步:

  1. 根据上文介绍知道Cursor结构体实际上就是一个bitmap,bitmap中置为1的下标表示该行数据符合过滤条件。因此需要顺序遍历这个bitmap的所有位,如果目标位为1,表示该目标位下标对应的行满足过滤条件,需要将该行的对应数据找出来返回给用户。否则不满足过滤条件,直接跳过。
  2. 假如bitmap中下标为的位置值为1,表示第二行满足过滤条件,因此需要查找第二行Added列的值。实现起来很简单,因为该列的所有值都存储在一个文件中,而且每个值都定长(都是Int),因此可以很快的在文件中加载出startOffset为Ints.Bytes,endOffset为2*Ints.Bytes的值,即为Added的值。

其实这个过程总结起来就是根据bitmap索引找到了对应的行,也就指导了第几行是需要的数据。剩下的一个操作就是根据这个行号怎么去定位我所需要的数据。因为实际上都是按列存储的数据,而列存储的时候,都是存储的映射的字典值。这基本上直接凭借整数值的占位单位*索引值就能定位对应的id。如果是度量值,就要根据度量值的数据类型来定位。定长值都比较好算,如果是非定长值,基本上就是把非定长的查询转化为定长的查询,也就是head查询,然后找到了offset_start就能找到对应的值了。

TODO:列值在存储的时候除了valueToId的映射,是否有IdToValue的映射来拿回来对应的原始值,这个需要确认。

其他需要考虑的问题:

Bitmap的更新和合并操作 更新就是类似于kylin中关于全局字典,然后基于全局字典构建这个bitmap,后续这个cube再有数据进来,怎么解决这个bitmap的问题。 而合并就是怎么合并两个bitmap的过程。

Inverted Index(倒排索引)

倒排索引实际上跟bitmap还是有区别的。 核心不同点在于:倒排索引中每个维度列取值不再对应bitmap,而是对应一个列表。举个栗子,Bitmap索引体系中,Gender维度列中Male对应一个bitmap是[1,0,0,1]。换成倒排索引,Gender维度列中Male对应的不再是bitmap,而是一个List : [0,2],分别表示第1行和第三行。 除此之外,还有一些实现细节有些许不同:

  1. Bitmap压缩性能通常没有倒排索引中List压缩效果好,前者会存在较大的存储空间开销。
  2. Bitmap使用intersection实现and、or等操作的性能要好于倒排索引的List结构,后者需要从小到大遍历查找。这个区别导致两者的适用场景有了一些区别。
  3. 使用Bitmap构建的Cursor加速原始数据查找,需要遍历bitmap来找哪一行满足条件,只有bit位是1的才满足条件;而倒排索引构建的Cursor不需要查找,List中的数值就直接对应行号。 在常见的时序数据库中,InfluxDB和HiTSDB都使用了倒排索引来加速多维度查询,倒排索引会首先在内存中构建并持久化到文件(或HBase),在使用时再将索引加载到内存。

以上内容参考了大神范欣欣的博客内容:hbasefly.com/

后续会解决里边的TODO再补充。