Lucene的索引过程分两个阶段,第一阶段把文档索引到内存中;第二阶段,即内存满了,就把内存中的数据刷新到硬盘上。

         倒排索引信息在内存存储方式


         Lucene有各种Field,比如StringField,TextField,IntField,FloatField,DoubleFieldLucene在处理的过程中把各种Field都处理成相应的byte[],以最本质的方式来看待各种Field的内容,统一了数据的存储形式。


         在写入内存阶段,第一步就是需要理清各个类之间的关系。


在索引的过程中,需要有ByteBlockPool,IntBlockPool, ParallelPostingsArray三个类来协调配合存储数据. ByteBlockPool存储Term信息/Freq信息/Prox信息,IntBlockPool起着协调控制的作用; ParallelPostingsArray同时起着协调控制和统计docFreq的作用.三者紧密结合,构成了Lucene索引内存阶段的铁三角.


Lucene的设计里,IntBlockPoolByteBlockPool的作用域是IndexChain,即每个IndexChain都会生成独立的ByteBlockPoolIntBlockPool ,这样就不会出现多线程间可变数据共享的问题,这种做法实际上是一种约定方式的线程封闭,即ByteBlockPool本身并不是线程安全的,不像ThreadLocal或者栈封闭。由于每个IndexChain都需要处理多个Field,所以IntBlockPoolByteBlockPoolField所共享的。需要注意的是ParallelPostingsArray的作用域是Field,即每个Field都有一个postingsArray


IndexChainTermHash开始,各个类的协调关系如下图所示:


wKioL1PLpI2ARmVoAAKVWQvlkE0207.jpg


第一次看这幅图会有错综复杂的感觉,的确如此。有以下几点需要注意:

1. TermsHash创建了IntBlockPool和ByteBlockPool。其中bytePool和termBytePool指向同一个对象。而且整个图中所用到的intPool和bytePool都是共享TermsHash创建的对象。


2. BytesRefHash中的bytesStart和ParallelPostingsArray中的textStarts共享同一个对象。


3. IntBlockPool管理着ByteBlockPool的Slice块信息的写入起始位置

把目光专注到ParallelPostingsArray的三个成员变量上面:


textStarts存储的是每一个termByteBlockPool里面的起始位置,通过textStarts[termID]可以快速找到termID对应的term


byteStarts存储的是termByteBlockPool的结束位置的下一个位置。


IntStarts存储的是termIntBlockPool的地址信息,而IntBlockPool则存储着termByteBlockPool中的Slice位置信息。


         比如两个词”new term”,PostingArrayIntBlockPoolByteBlockPool的数据指示关系如下:(注下图只表示各个部分的联系)


wKiom1PLo3zibBbiAAJhgwl01Z0958.jpg


Lucene在存储倒排索引的时候默认的存储选项是:wKioL1PLpKDQKkn1AABcF0ppXqg982.jpg


即需要存储DOCID;Freq;Positions三种信息。这三种信息都是随着Term存储在ByteBlockPool中。其存储的过程如下:


第一步:把term.length存储到ByteBlockPool.buffer中。这会占用1或者2byte,由term的大小决定。由于term的最大长度为32766,所以term.length最多会占用两个byte


第二步:把termbyte数组形式存储到ByteBlockPool.buffer中。


第三步:紧接着term开辟5byte大小的slice,用来存储term在每个doc中的freq信息。


第四步,再开辟一块Slice用来存储positions信息.


         第三步和第四步开辟的Slice除了存储的内容不同外,结构是没有差别的。 如果一个Slice用完了,那么按照ByteBlockPool设置的规则再开辟14byteslice.


如果slice又用完了,则再开辟20byteslice…..


         下图的两个数组代表了slice的开辟规则:一共有9种不同层次的slice,编号从1-9,每一种层次的slice大小都不相同,最小是5byte,最大是200byte


wKiom1PLo5XTmLbiAAEF8BdhPsM718.jpg


每个代码块实际可用的Bytes= Slice.length-1 。这是因为slice的最后一个byte里面存储着该slice的结束标志。5_Bytes_Slice的结束符是16,14Bytes_Slice的结束符是17,依次加1就可以了。

新开辟的slice会与前面用完的slice连接起来,像链表一样。


wKiom1PLo6CBEruOAAB2vwwp9fk825.jpg


连接的方式比较特殊:把前一个Slice除结束符外最后的三个位置里面存储的数据转移到新的Slice中,这样前一个Slice的最后4个位置就用来存储新的Slicebuffer中的地址信息。两个Slice连接的代码如下:

 /*@xh 传入的参数slice 与函数体中的 buffer指向同一块内存地址。这样做的目的在于编码上清晰。* */
 public int allocSlice(final byte[] slice, final int upto) {
        /*@xh
         * slice[upto]里面存储的是当前slice的结束标志,slice[upto] & 15即得到当前层ID。
         * 通过数组NEXT_LEVEL_ARRAY 得到下一层的ID,
         * 通过数组LEVEL_SIZE_ARRAY 得到下一层的Slice大小
         * */
        final int level = slice[upto] & 15;
        final int newLevel = NEXT_LEVEL_ARRAY[level];
        final int newSize = LEVEL_SIZE_ARRAY[newLevel];
        // Maybe allocate another block
        if (byteUpto > BYTE_BLOCK_SIZE-newSize) {
          nextBuffer();
        }
        final int newUpto = byteUpto;
        final int offset = newUpto +byteOffset;
        byteUpto += newSize;
        // Copy forward the past 3 bytes (which we are about
        // to overwrite with the forwarding address):
        /*@xh 简单翻译就是:把当前Slice结束标志位前面的存储的内容移到下一层Slice的前三个位置
         * */
        buffer[newUpto] = slice[upto-3];
        buffer[newUpto+1] = slice[upto-2];
        buffer[newUpto+2] = slice[upto-1];
        /*@xh 然后用当前Slice空出来的三个位置连同结束标志位,一共4个Byte,来存储下一层Slice在buffer中起始位置。
         * 这样的话就可以通过当前Slice定位到下一层的Slice
         * */
        // Write forwarding address at end of last slice:
        slice[upto-3] = (byte) (offset >>> 24);
        slice[upto-2] = (byte) (offset >>> 16);
        slice[upto-1] = (byte) (offset >>> 8);
        slice[upto] = (byte) offset;
        // Write new level:
        //@xh 把下一层的结束标志写入。
        buffer[byteUpto-1] = (byte) (16|newLevel);
        //@xh 返回下一层可用的起始位置(由于下一层的前三个位置已经被占用<参看上面的代码>,所以需要+3)
        return newUpto+3;
      }

    这种以链表的方式管理内存空间,是充分考虑了数据的特点。在文档集中的词分布是zipf分布。只有少量的词频很高,大量的词词频其实很低。所以最小的Slice5byte,但是如果所有的Slice都是5byte的话,对于高频词汇,又太浪费空间,所以最大的Slice200byte。而且有9种不同的Slice,满足了不同词频的存储需求。


如果要从Slice中读取数据,怎么知道里面的byte是数据信息还是下一层的地址信息呢?通过ByteSliceReader就很容易了.在写入数据到Slice时记录ByteBlockPool.buffer中代表Slice块链表的startIndexendIndex。接下来我们肯定是从5_Bytes_Slice块开始读取.如果startIndex+5>=endIndex,那么就可以确定当前块中存储的内容只是整个Slice链表的一部分.就很自然得到5_Bytes_Slice中数据信息的终结位置limit.接下来用同样的方法确定出下一层的limitOK啦。


    具体的实现细节可以参考ByteSliceReader类,代码很容易读懂。


         根据前面描述的ByteBlockPool存储term的方式,如果document如下:


wKiom1PLo-zzdW2BAAB5Fek2yyU447.jpg


则在ByteBlockPool中,存储的结构如下:


wKioL1PLpRCwZn3VAAIJSOqtK7Q208.jpg


可以看到每个term后面都跟了两个5_Bytes_Slice,米***的块用来存储docDeltadocFreq信息;蓝色的块用来存储position信息。这就需要为每一个term分配两个int来保存Slice的起始位置,IntBlockPool则正好实现了上面的要求。接下来就会出现新的问题了,IntBlockPool中的哪两个位置是分配给给定termID的呢?IntStarts[termID]就正好指明了分配给term的位置起点。(注:两个位置是连续的)。所以ParallelPostingsArrayIntBlockPool可以视为整个倒排索引的藏宝路线图,而ByteBlockPool则可视为宝藏所在地。


 还有就是Lucene存储在索引中的并非真正的docId,而是docDelta,即两个docId的差值.这样存储能够起到节约空间的作用.


正向信息在内存中的存储


正向信息在Lucene中只有docId-document的映射,由CompressingStoredFieldsWriter类来完成。


wKioL1PLpR7ivoDbAAFOlLKOb7o962.jpg


Lucene的正向信息存储比较简单,按Field依次把内容写入到bufferedDocs中,然后把偏移量写入到endOffsets中就OK了。


   当满足flush条件或者执行了IndexWriter.commit()方法,则会进行一次flush操作,把内存中缓存的document及倒排信息flush到硬盘中。