Luence-StoredField

429 阅读12分钟

主要包括了fdt、fdx、fdm三个文件的codec-内容,这部分数据主要用于存储document的正排数据,在根据docid可以快速的将完整的日志数据全部拉取出来。参考代码是luence-9.0.0不过各个版本的codec实现差异不大。主要实现类在,下面三个类中

Lucene90CompressingStoredFieldsFormat
Lucene90CompressingStoredFieldsReader
Lucene90CompressingStoredFieldsWriter

有2个很好的参考资料www.amazingkoala.com.cn/Lucene/suoy…zhuanlan.zhihu.com/p/384486147

fdt文件

Header

自顶向下的开始拆分,最上层是header+Check...+Footer,

Header+Footer这部分存在于所有的Luence文件的头尾,格式和内容基本固定,我们只在这篇文章中描述一遍,后续以Header和Footer替代。

  • MagicCode:为固定值
  • CodecName:每个文件类型名字是固定的,
  • Version:版本
  • SegmentId:16Byte固定值
  • Suffix:空字符串,不知道干嘛用的
  • Chunk:存储的过程中会将一批document打包在一起形成一个chunk存储,这么做主要是出于2个方面的考虑一方面是希望在内存中buffer这一批document,其次是将数据拆成小批量的数据整体的压缩率会提高。

Chunk

Chunk是在处理一系列文件的过程中会在内存buffer满足一定条件的情况下将其部分数据进行flush,每次flush之后会在fdt文件中形成一个chunk。luence的各种压缩策略基本和chunk绑定,主要目的在于去除一些由于文件数量太多,导致数据之间差异太大,无法形成有效编码。

  • DocBase:当前chunk的docid的基准值
  • NumBufferedDocs:当前chunk的文件中的docment的大小
  • DirtyBit:标识当前是否是强制刷新的,一般在fdt文件刷新最后一个chunk或者merge后生成的fdtchunk中是true
  • SlicedBit:当前chunck是不是由于单个docment的文件太大而拆分形成的
  • StoredFieldsIntArray:这里存储的是每个document的field个数的数组
  • LengthIntArray:这里存储的是每个document的对应的content的offset的差值产生的length
  • Content:每个document具体的值

StoredFieldsIntArray和LengthIntArray

整体结构比较清晰,但是针对StoredFieldsIntArray和LengthIntArray的编码需要进一步描述一下

  • 以StoreFieldsIntArray为例,在这里存储的是每个document的filed的个数的列表,这里要分几种情况来处理。
    • 只有一个文档直接写入唯一个document的filed数量length。
    • 有多个文档
      • 假如当前chunk所有的filed的数量都是相同的:写入byte-0,写入这个相同的field的数量length
      • 如果所有的chunk的filed的数量是不同的,先找到当前filed的最大值,
        • 如果最大值小于0xff位,写入byte-8,则将将所有length的低8位写入
        • 如果最大值小于0xffff位,写入byte-16,则将将所有length的低16位写入
        • 如果最大值小于0xffffffff位,写入byte-32,则将将所有length的低32位写入

另外早期版本的luence的实现中采用了packedInt编码也是一种值得借鉴的方案

Content

Content是每个Doc的filed的值的集合。

  • FieldId:当前Field的编号
  • FiledType:当前数据类型的编码
  • Value:真实的数据类型

首先codec只支持6种类型,STRING,BYTE_ARR,NUMERIC_INT,NUMERIC_FLOAT,NUMERIC_LONG,NUMERIC_DOUBLE。比较有意思的是luence针对不同数据类型的压缩方式

具体的编码是通过一个ByteBuffersDataOutput的类完成的有兴趣的看下源码。

NUMERIC_INT

首先对数据进行的ZigZag编码,然后再按照Vint进行进行编码写入,使用Vint编码主要是基于这样的假设(即大部分场景下会使用小数字),Zigzag编码是为了避免负值的场景下小的负值导致的Vint编码膨胀,这种方式在编码中是比较常见的方式,pb也采用了的类似方式,比较通用我们简单说明下2种编码即可

  • ZigZag

很简单将负值和正值交替编码就行。

原始值0-11-222147483647-2147483647
编码值0123442949672944294967295

  • Varint

用每个byte的最高位来标识当前编码字节数是否截止,为0则说明编码结束,为1则说明还有后续的byte,最后一个字节的如果不够则可以使用0来补充。比如下面下面对-13先进行zigZag编码然后采用varint,在数值接近于0的情况下产生的byte空间非常小。

NUMERIC_LONG

本来可以直接使用类似NUMERIC_INT和Zigzag-Vint的编码实现,但是luence的场景下,long在很多时候用于表示时间。争用在整秒、整小时、整天的场景下就有了一些可以操作的空间,基本的思路就是先尝试对数据对整天、整小时、整秒,进行整除,如果可以正常整除,则使用一个header然后将倍数进行编码。这里文章写的很详细,我觉得直接看这部分比较好,只把原文里面的一个样例贴出来。

简单的说如果在整除时间单位的场景下可以考虑将其先进行整除然后利用时间单位的前置标识来表示整个数值

NUMERIC_FLOAT

luence基于这样的假设,大部分浮点数都可以转换成小数,并且数字的值比较小。所以会尝试进行如下的编码

  1. 尝试将浮点数转换成int
    1. 如果满足下面3个条件,
      1. 转换后的数字和浮点数是相等的
      2. 数字在-1和125范围内
      3. 数字非负。
    2. 会将数据+1后写入,且只用1个字节表示
  2. 如果是正数,写入IEEE的IEEE编码值
  3. 如果是负数,先写入一个0xFF,然后再写入IEEE编码值

NUMERIC_DOUBLE

类似于Float

  1. 尝试将浮点数转换成int,看是否相等
    1. 如果满足下面3个条件,
      1. 转换后的数字和浮点数是相等的
      2. 数字在-1和124范围内
      3. 数字非负。
    2. 会将数据+1后写入,且只用1个字节表示
  2. 尝试转换成float,看是否相等
    1. 先写入0xFE,然后写入float的编码值
  3. 如果是正数,写入IEEE编码值
  4. 如果是负数,先写入一个0xFF,然后再写入IEEE编码值

STRING

主要有2点需要注意。

一是编码格式,首先Codec最终落盘的是UTF8,但是Java用的编码实际上是UTF16,所以实际上luence在先是按照最大的长度,即每个字符都用3个字节去申请长度。然后转换的过程中会获取到真实的length,写入的时候写入这个length即可。

二是写入的过程中,如果需要写入的string过大(char>1024),会以1024为单位进行写入。

BYTE_ARR

这个类型基本类似于String,先写入长度然后分批写入。

fdx+fdm文件

这部分文件存储的是主要是fdt文件中每个chunck的docs的size和文件的offset,用于主要是用于根据docid获取数据的时候使用二分法快速的定位到docId所在的chunk。

进一步细化来说,fdx内存放内容相当于是2个数组,一个是fdt中每个chunk的document的数量的数组,另一个是fdt中每个chunk的length的数组。如果单纯的将数字写入fdx任务就完成了,但是为了极致的压缩这部分,数据luence采用了一种等差数列模型的压缩方式对数据结构进行了压缩,delta相关的数组依然放在fdx,但是将一些元数据放到了fdm中。所以理论上这2个部分需要一起看。

在看具体的codec之前,我们需要看下我们需要做一件什么样的事情?

其实我们可以这样来看待fdx和fdm文件的编码方式。我们已知这2个文件描述是每个chunck文件的doc的数量和offset的位置,我们可以将他们视为一种尝试编码一种单调递增的数列,其实这种形式在其他的luence文件里面也有用到。luence采用下面2个类完成的类似的工作。

DirectMonotonicWriter

我们先忽略复杂的编码逻辑,举一个比较简单易懂的例子

  • 以DocumentNumForChunk为例,比如我现在有7个chunck,其数组内容为doc的数量。例如为
    • 【2,3,4,2,4,5,5】
  • 将其构造一个单调递增的数组传递给DirectMonotonicWriter:
    • 【2,5,9,11,15,21,26】
  • DirectMonotonicWriter获取到这个值的时候会计算其平均的增量并且使用计算出其单调递增delta值,
    • (Max-Min)/Size=Inc:(26-2)/6=4
    • 【2-0,5-4,9-8,11-12,15-16,21-20,26-24】
    • 【2,1,1,-1,-1,1,2】
  • 其实看到这里可以比较清晰的看到作者的想法是希望用较小的数来标识这个数组,但是这里还有个问题是有一些负数存在,当然这里我们可以使用此前说明的zigzag编码来解决,但是luence这里采用了另一种方式,找到最小的delta,并且让所有的数字减去这个delta。
    • 【2-(-1),1-(-1),1-(-1),-1-(-1),-1-(-1),1-(-1),2-(-1)】
    • 【3,2,2,0,0,2,3】
  • 计算器最大delta值,也就是3,而3最多需要2个bit去表示,这时候我们的需求转换成了将数字编码成一种最小化压缩。这时候就需要DirectWriter来实现了

DirectWriter

DirectWriter需要解决的问题在luence中广泛而普遍,他们有这样几个特征

  • 有一堆数字需要进行刷新
  • 知道这堆数字的值域及个数,比如最大值不超128,个数不超过1000
  • 不希望频繁的刷新数据,以减少磁盘的io。希望有内置的buffer

DirectWriter的解决这些问题的思路大概是这样的,尝试将不同编码长度的数字都打包成一个block,然后再将block写入以long的形式写入到具体的output里面

  • 首先什么是block

我们已经知道最大的数字,这样我们就知道一共需要多少位就可以标识每个数,比如我最大数字不超过3,则需要最多2个bit比较;最大数字不超过8191,则需要13位进行标识。

但是如果单纯使用bitsPerValue来进行组织数据,会导致大量的数据不是字节对齐的,这样每次写入或者读取的时候需要不停的进行位移计算和操作,成本非常高,所以有些时候虽然我们可以使用更小的bitsPerValue,但是为了减少计算代价和io操作,我们会将其归整到更大的范围内进行写入。

比如下面的例子,底层均是byte数组的情况下,我们如果我们用bitsPerValue=12来标识,则每2个value则字节是对齐的,如果是bitsPerValue=9则需要8个字节才能对齐。

因此综上所述,所以实际上block是一种数字压缩效率和平衡读写效率之后的bitsPerValue。对于luence来说实际上支持下面规格的block

static final int[] SUPPORTED_BITS_PER_VALUE =
    new int[] {1, 2, 4, 8, 12, 16, 20, 24, 28, 32, 40, 48, 56, 64};
  • 编码过程在编码的过程中需要分三种情况来讨论;
    • block是byte的整数倍:8、16、24、32、40、48、56、64
      • 我们只需要有一个工具类将long从指定的byte数组的指定位置开始写入即可
    • block是小于byte的:1、2、4
      • 我们在写入的时候需要将多个value拼接成long,然后写入
    • block的写入是跨byte的:12、20、24
      • 我们需要进行位移操作然后进行写入。

Codec

理解完上述的概念之后再看codec其实就简单很多,只是一个数据存放位置的问题而已。

从代码实现上说,在StoredFieldsWriter不断写入数据的过程中,FieldsIndexWriter会创建2个临时文件{segment}-Lucene90TermVectorsIndex-doc_ids和{segment}-Lucene90TermVectorsIndex-file_pointers,分别保存每次flush(chunk)中doc的数量和fdt文件的offset。然后在StoredFieldsWriter的finish的阶段将2个文件数据合并到fdx和fdm文件中。每次finish会生成一个fdx的chunk。但是因为我现在不太确定会finsh几次所有不太确定这里有几个chunk,目前我倾向于有多个。

  • fdx文件
    • DocumentNumForChunk:docmentNum的数组
    • FilePositionForChunk:FilePosition数组
  • fdm文件
    • chunkSize:默认8KB。关系到每个chunk的size大小,作为控制flush的阈值。
    • numDocs:当前block的文档数量
    • blockShift:用于控制存储数组的大小
    • totalChunks:chunk的数量。
    • fdxOffset-A:对应fdx的a的offset,后面4个字节是对应于docmentNum数组
      • minOfBlock:这个block中的最小值,
      • avgFloatinIntFormat:增量,float形式,但是存储的时候以int形式
      • fdtLength:数组长度
      • bitsRequired:每个delta所需要的bit的大小
    • fdxOffset-b:对应fdx的b的offset,后面4个字节是对应于FilePosition数组
      • 同上
    • fdxOffset-C:对应fdx的a的offset,后面4个字节是对应于docmentNum数组
    • fdtOffset:对应fdt的a的offset

思考和理解

其实有一个有意思的问题是为什么需要在fdx中采用这种比较复杂的方式进行编码?为什么不直接用类似packedInt的之类方式或者fdt中StoreFieldsIntArray的方式进行压缩呢?个人的理解应该是期望通过构建这种等差数列的方式在查询的过程中迅速的定位到某个位置。