前言

通常在搜索打分完毕后,IndexSearcher会返回一个docID序列,但是仅仅有docID我们是无法看到存储在索引中的document,这时候就需要通过docID来得到完整Document信息,这个过程就需要对fdx/fdt文件进行读操作。为了更清楚地了解fdx/fdt文件的作用,本文把fdx/fdt文件的读和写整合到了一起,尽管这在Lucene中是两个分开的过程。


1. 索引生成阶段

索引生成阶段包含着一个复杂的过程,所以了解本文前最好对Lucene的索引架构有一定的了解,可以参考博客:Lucene的索引链结构_IndexChain 

由于在数据处理的过程中大量用到Packed,所以对数据的压缩最好也要有一点的了解,可以参考博客:Lucene源代码学习之PackedInts;由于在存储的过程也用到了LZ4算法,关于LZ4算法的原理,可以参考博客:lucene源代码学习之LZ4压缩算法在lucene中应用

1.1      fdx/fdt文件的创建。

fdx/fdt文件的创建完整线条如下:


wKioL1PZtvOy6ptIAAHXVBe7otc589.jpg


dwpt完成一个document的分析时,如果CompressionStoreFieldsWriter没有实例化,则创建:


wKioL1PZtwbipXdVAAFgSKonyYQ551.jpg


 

 

1.2     fdx/fdt文件的格式。

  具体参考Lucene41StoredFieldsFormat.html (Lucene4.2.0docs)


fdt文件结构:


wKiom1PZtfjwjAp2AAE8A626pf0459.jpg


上图理解起来也不难,<Header>和PackedIntsVersion略过,我们重点关注<Chunk>,Chunk的中文意思是”大块”,我们可以理解为数据的存储区域。在内存中表现为缓存。一个Chunk由5个部分组成:DocBase表示当前的Chunk块的起始DocId;ChunkDocs表示当前Chunk中的doc个数;DocFieldCounts是一个数组,表示每个doc中Field的个数;DocLengths也是一个数组,表示每个doc占用byte的个数,即doc的长度;<CompressedDocs>即doc的内容,用LZ4算法压缩存储。FieldNumAndType是把FieldNumber和FieldType合并到一个VLong字段里面,整个<CompressedDocs>就是FieldNumAndType和Value的交替序列。


   fdx文件结构:

wKioL1PZtx7wcSlQAAGzPbD3Rfc294.jpg


fdx文件重点关注的是<Block>,一个Block由三个部分组成:BlockChunks表示当前BlockChunk的个数;<DocBases>表示当前Block中每个Chunkdoc个数,可以看作一个数组;<StartPointers>表示当前Block中每个Chunkfdt文件中的起始位置,其结构与<DocBases>相同。


尽管fdx/fdt文件只是Lucene的正向文件,并不是Lucene的核心。但是还是有干货的。在Lucene4中引入了LZ4算法对fdtdoc进行了实时压缩/解压。而且用SPIService Provider Interface)技术对架构进行了重构。


1.3    fdx/fdt文件的写入。

fdx/fdt文件的写入操作非常清晰。逻辑上都在CompressingStoredFieldsWriter类中完成,而CompressingStoredFieldsIndexWriter则作为其成员变量。其写入的顺序与上面的格式一致,只是有些名字不一样。在写入docs的过程中,用GrowableByteArrayDataOutput作为缓存,直到缓存满了,才flush到硬盘上去。用LZ4算法压缩就是在flush时处理的。(关于LZ4算法会在另外的博文中描述)


fdt文件的写入:


       fdt文件的基本单位是Chunk,这一点需要牢记。


一个Chunk写入到文件中的代码如下:<对照着前面的图看代码>


wKioL1PZt0XB5rX4AAKFhFT4z3c838.jpg


那么什么时候会调用上面的flush函数呢?


情况:索引提交.


情况二:doc的大小或者doc的数量超过设定阈值.一般是1<<14=16384 (参见函数triggerFlush)


wKiom1PZtjuhGaf8AADTovpoGaY513.jpg


       通过观察flush函数,我们会发现fdt文件的写入非常简单,就两句代码:


wKioL1PZt2OjH9IiAADPeAo2cR8310.jpg


前面一句代码记录整个chunk中的docBase(最小docID),numBufferedDocs(doc数量),numStoredFields(每个docField个数),lengths(每个doc的长度),一共四种信息.在记录numStoredFieldslengths,PackedInts及其它的方式对内容进行了压缩。后面一句代码记录整个chunk中的doc的完整内容(LZ4算法进行压缩).


关于writeHeader(docBase,numBufferedDocs, numStoredFields, lengths); 这一句代码,存储numBufferedDocs和存储numStoredFields方式是一样的,存储方式如下:


wKiom1PZtliTd3TIAAPIf2OkHrc882.jpg


(上图截自于Lucene41StoredFieldsFormat.html


    解释一下上图:在存储DocFieldCounts,numBufferedDocs时,如果ChunkDocs=1(即当前Chunk只有一个doc),那么一个VInt存储就足够了;否则首先存储一个VInt的标志位,暂时称为bitsRequired。如果bitsRequired = 0 ,代表当前Chunk中所有docFieldCount相同;否则用Packed Array来存储DocFieldCountsPacked Array中每个值占用的bit数即bitsRequired


DocLengths的存储方式与DocFieldCounts相同,实现的代码如下:


wKiom1PZtmuDlI2IAAK-hukcivo489.jpg


fdx文件的写入


       fdxfdt文件的辅助文件.如果说fdt是一本书的正文,那么fdx就是目录.fdx的基本单位是Block,一个Block中包含多个Chunk


       一个Block写入到fdx文件中代码参看CompressingStoredFieldsIndexWriter.wirteBlock方法。由于代码太长,这里就不贴出来了。


       一个Block包含三方面的内容:


       1 ChunkCount;写入的代码如下:


       wKioL1PZt6vi3IADAAB_-RZzaYg717.jpg


       2 <DocBases>;写入的代码如下:


wKiom1PZtp6DxpMCAAORw6LbpYw227.jpg


       3 <StartPointers> ;写入的代码如下:


wKiom1PZtqyg5IHpAAPD-Bc6zQE382.jpg


       如果细细读这两段代码,会发现两段代码逻辑相似性达90%。确实,这两段代码的内容的处理方式上是一样的。在LUCENE-4512这个Improvement里面,有这样一段文字能帮助我们理解上面代码:


wKiom1PZtrnhxBidAADkdFY44eI938.jpg


       这段文字讲了一个技巧:“存储真实值和平均值的差值来代替存储真实值”;比如有下面几个数据需要存储到文件中:[10000,9888,10002,99997,10003];各个数与平均值之间的差值如下:[0,-2,2,-3,3] ,用差值存储就可以节约很多bits了。但是这样做又带来一个新的问题:负数的符号位都在最高位,而且PackedInts无法存储负数。因此需要对数据进行转码,转码方式就是zigzag转码Zigzag编码的方法非常简单:


Int32: (n << 1) ^ (n >> 31)


Int64: (n << 1) ^ (n >> 63)


Zigzag编码主要在于对负数的压缩,比如-1(1111 1111 1111 1111 1111 1111 1111 1111),经过转码后,变成了1(0000 0000 0000 0000 0000 0000 0000 0001),节约了很多符号位。


经过Zigzag编码的数怎么还原呢?


(n>>> 1) ^ -(n & 1)


       了解了原理,我们再来分析<DocBases>内容的写入过程:


第一步:计算平均值(avgChunkDocs)


wKioL1PZt-PT-A9NAADJpDqsrQU395.jpg


一般最后一个chunk都没有存满,所以docNum会低于其它的Chunk,所以在计算平均值的时候不用它。


第二步:存储docBase和平均值(avgChunkDocs)


wKioL1PZt_Hh5p4WAABs0195iwo714.jpg


第三步:计算最大的差值(delta),这个delta是用来计算bitsRequired


wKiom1PZtuXBaefHAADClhZ4I-8638.jpg


第四步:用PackedInts来压缩并存储docBaseDeltas


wKioL1PZuAzBjzd5AAH8zX-rzs8063.jpg


存储<startPointerDeltas>的逻辑与<DocBases>类似,就不再赘述了。


      


2       索引读取阶段

  当希望通过一个DocId得到Doc的全部内容,那么就需要对fdx/fdt文件进行读操作了。具体的代码在CompressingStoredFieldsReader类里面。与CompressingStoredFieldsWriter一样,这些操作都是建立在fdx/fdt文件格式理解的基础上。


image036.jpg


       既然前面有一个比喻:如果fdt是一本书的正文,那么fdx则是书的目录。那么通过docID来得到doc全部内容的这个过程则是需要两个文件联合起来发挥作用。


       具体的过程如下:


第一步:在CompressingStoredFieldsIndexReader的构造函数中加载所有的目录信息


image038.jpg


第二步:确定docID所在Segment,由于starts数组记录了每个SegmentdocID的起始值,所以通过二分查找,很快就能定位到对应的Segment.并进入到相应的SegmentReader去读取doc内容。


image040.jpg


通过docID确定所在Segment


image042.jpg


第三步:确定docID所在的Block


       image044.jpg


第四步:确定docID所在的Chunk


 image046.jpg


第五步:根据docID确定的Chunk找到chunkfdt文件中的起始位置


image048.jpg


第六步:读取fdt文件中的Chunk信息,通过<DocLengths>和给定的docID确定整个Chunk存储的所有doc的总长度totalLength和从baseDocdocIDdoc长度length。并用LZ4解压Chunk中的doc内容。当然,并不需要整个chunkdoc都解压,只需要解压到length的长度就可以了。


image050.jpg


得到lengthtotalLength后,就可以解压了。并读取解压后文本的内容,生成Document


image052.jpg


这样的话,就通过docID得到了存储到索引中document的所有内容了。


3       总结

fdx/fdt文件不涉及Lucene的核心,只是对索引内容本身的读写操作。而且fdx/fdt的文件格式相当简单明了:fdt文件存储着一个个的Chunkfdx文件存储一个个的Block,每个Block管理着一批Chunk


fdt/fdxLucene中最有价值的地方在于:


1、给定一个DocId,如何快速还原一个Document


2、索引内容本身的实时压缩/解压,也就是LZ4算法。这其实是为上一条服务。


3、通过SPI机制,允许用户自定义存储格式。这是Lucene在架构上面的进步。


通过这个过程的解析,也能了解到通过docID读取到document需要完成SegmentBlockChunkdocument四级查询。SegmentBlockChunk的查找都是二分查找,速度很快,但是Chunk中定位document则是顺序查找,所以Chunk的大小直接影响着读取的性能。