Lucene的索引过程分两个阶段,第一阶段把文档索引到内存中;第二阶段,即内存满了,就把内存中的数据刷新到硬盘上。
倒排索引信息在内存存储方式
Lucene有各种Field,比如StringField,TextField,IntField,FloatField,DoubleField…,Lucene在处理的过程中把各种Field都处理成相应的byte[],以最本质的方式来看待各种Field的内容,统一了数据的存储形式。
在写入内存阶段,第一步就是需要理清各个类之间的关系。
在索引的过程中,需要有ByteBlockPool,IntBlockPool, ParallelPostingsArray三个类来协调配合存储数据. ByteBlockPool存储Term信息/Freq信息/Prox信息,IntBlockPool起着协调控制的作用; ParallelPostingsArray同时起着协调控制和统计docFreq的作用.三者紧密结合,构成了Lucene索引内存阶段的铁三角.
在Lucene的设计里,IntBlockPool和ByteBlockPool的作用域是IndexChain,即每个IndexChain都会生成独立的ByteBlockPool和IntBlockPool ,这样就不会出现多线程间可变数据共享的问题,这种做法实际上是一种约定方式的线程封闭,即ByteBlockPool本身并不是线程安全的,不像ThreadLocal或者栈封闭。由于每个IndexChain都需要处理多个Field,所以IntBlockPool和ByteBlockPool是Field所共享的。需要注意的是ParallelPostingsArray的作用域是Field,即每个Field都有一个postingsArray。
从IndexChain的TermHash开始,各个类的协调关系如下图所示:
wKioL1PLpI2ARmVoAAKVWQvlkE0207.jpg
第一次看这幅图会有错综复杂的感觉,的确如此。有以下几点需要注意:
1. TermsHash创建了IntBlockPool和ByteBlockPool。其中bytePool和termBytePool指向同一个对象。而且整个图中所用到的intPool和bytePool都是共享TermsHash创建的对象。
2. BytesRefHash中的bytesStart和ParallelPostingsArray中的textStarts共享同一个对象。
3. IntBlockPool管理着ByteBlockPool的Slice块信息的写入起始位置
把目光专注到ParallelPostingsArray的三个成员变量上面:
textStarts存储的是每一个term在ByteBlockPool里面的起始位置,通过textStarts[termID]可以快速找到termID对应的term 。
byteStarts存储的是term在ByteBlockPool的结束位置的下一个位置。
IntStarts存储的是term在IntBlockPool的地址信息,而IntBlockPool则存储着term在ByteBlockPool中的Slice位置信息。
比如两个词”new term”,PostingArray和IntBlockPool及ByteBlockPool的数据指示关系如下:(注下图只表示各个部分的联系)
wKiom1PLo3zibBbiAAJhgwl01Z0958.jpg
Lucene在存储倒排索引的时候默认的存储选项是:wKioL1PLpKDQKkn1AABcF0ppXqg982.jpg
即需要存储DOCID;Freq;Positions三种信息。这三种信息都是随着Term存储在ByteBlockPool中。其存储的过程如下:
第一步:把term.length存储到ByteBlockPool.buffer中。这会占用1或者2个byte,由term的大小决定。由于term的最大长度为32766,所以term.length最多会占用两个byte。
第二步:把term的byte数组形式存储到ByteBlockPool.buffer中。
第三步:紧接着term开辟5个byte大小的slice,用来存储term在每个doc中的freq信息。
第四步,再开辟一块Slice用来存储positions信息.
第三步和第四步开辟的Slice除了存储的内容不同外,结构是没有差别的。 如果一个Slice用完了,那么按照ByteBlockPool设置的规则再开辟14个byte的slice.
如果slice又用完了,则再开辟20个byte的slice…..
下图的两个数组代表了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个位置就用来存储新的Slice在buffer中的地址信息。两个Slice连接的代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*@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分布。只有少量的词频很高,大量的词词频其实很低。所以最小的Slice是5byte,但是如果所有的Slice都是5byte的话,对于高频词汇,又太浪费空间,所以最大的Slice是200byte。而且有9种不同的Slice,满足了不同词频的存储需求。
如果要从Slice中读取数据,怎么知道里面的byte是数据信息还是下一层的地址信息呢?通过ByteSliceReader就很容易了.在写入数据到Slice时记录ByteBlockPool.buffer中代表Slice块链表的startIndex和endIndex。接下来我们肯定是从5_Bytes_Slice块开始读取.如果startIndex+5>=endIndex,那么就可以确定当前块中存储的内容只是整个Slice链表的一部分.就很自然得到5_Bytes_Slice中数据信息的终结位置limit.接下来用同样的方法确定出下一层的limit就OK啦。
具体的实现细节可以参考ByteSliceReader类,代码很容易读懂。
根据前面描述的ByteBlockPool存储term的方式,如果document如下:
wKiom1PLo-zzdW2BAAB5Fek2yyU447.jpg
则在ByteBlockPool中,存储的结构如下:
wKioL1PLpRCwZn3VAAIJSOqtK7Q208.jpg
可以看到每个term后面都跟了两个5_Bytes_Slice,米***的块用来存储docDelta和docFreq信息;蓝色的块用来存储position信息。这就需要为每一个term分配两个int来保存Slice的起始位置,IntBlockPool则正好实现了上面的要求。接下来就会出现新的问题了,IntBlockPool中的哪两个位置是分配给给定termID的呢?IntStarts[termID]就正好指明了分配给term的位置起点。(注:两个位置是连续的)。所以ParallelPostingsArray和IntBlockPool可以视为整个倒排索引的藏宝路线图,而ByteBlockPool则可视为宝藏所在地。
还有就是Lucene存储在索引中的并非真正的docId,而是docDelta,即两个docId的差值.这样存储能够起到节约空间的作用.
正向信息在内存中的存储
正向信息在Lucene中只有docId-document的映射,由CompressingStoredFieldsWriter类来完成。
wKioL1PLpR7ivoDbAAFOlLKOb7o962.jpg
Lucene的正向信息存储比较简单,按Field依次把内容写入到bufferedDocs中,然后把偏移量写入到endOffsets中就OK了。
当满足flush条件或者执行了IndexWriter.commit()方法,则会进行一次flush操作,把内存中缓存的document及倒排信息flush到硬盘中。
阅读(2829) | 评论(0) | 转发(0) |