lucene倒排表在内存中的缓存结构1--ByteBlockPool,IntBlockPool,ParallelPostingsArray,BytesRefHash
Lucene设计了一系列内存高效的数据结构,通过 对象复用 和 内存分页 的思想,来优化Java GC问题。这部分内容将围绕ByteBlockPool, IntBlockPool,ParallelPostingsArray, BytesRefHash展开
ByteBlockPool, IntBlockPool
ByteBlockPool是Lucene实现 高效的可变长的基本类型数组 ,但实际上数组一旦初始化之后长度是固定的,因为数组申请的内存必须是连续分配的,以致能够提供快速随机访问的能力。那么ByteBlockPool是如何实现的?
Buffer结构
在JDK中,以数组为底层存储的数据结构,如ArrayList/HashMap,实现可增长时都需要花费一定的代价。数组增长的过程分两步,首先申请更大数组,然后将原数组复制到新数组中。即使JVM已经对数组拷贝做了很多优化,但随着数据量的不断增大,拷贝的性能开销问题也会越来越凸显,同时,数组的频繁创建也都会加大JVM的GC负担。
ByteBlockPool是一个可动态增长的结构,如下图所示:
ByteBlockPool是一个由多个Buffer构成的数组,如上图右侧所示,左侧的数字则代表了每一个Buffer在这个二维数组中的Index位置。Buffer的长度是固定的,当一个Buffer被写满以后,需要申请一个新的Buffer,如果这个Buffer数组要扩展,仅仅是将已有Buffer的引用拷贝了一次,而不需要拷贝数据本身。Buffer本质上是一个byte[],因此,ByteBlockPool其实是一个byte的二维数组,用Buffer数组来表达则更易理解。
Slice链表
索引构建过程需要为每个Term分配一块相对独立的空间来存储Posting信息。一个Term可能会出现在几个文档中,而且在每个文档出现的次数和位置都无法确定,所以Lucene无法预知Term需要多大的数组来存储Posting信息。
为此Lucene在ByteBlockPool之上设计了可变长的逻辑结构,这结构就是 Slice链表 。它的节点称之为Slice,Lucene将Slice分成十个级别,逐层递增,十层之后长度恒定。Slice的最后一个位置用于存储下个节点Offset,对于最后一个Slice,则存储了下个Slice的层级数。
Slice节点可以跨越多个Buffer, Slice链表为我们提供了一个逻辑上连续的内存块 。如果将Slice链表理解成类分布式文件系统上的文件,每个Slice则是文件的数据块,不过文件系统的数据块的大小是固定的,而Slice的长度则是分层级的。
这种设计方案的一个好处:Buffer是相对比较紧凑的结构,能够更高效的利用Buffer内存。按Zipf定律可知一个单词出现的次数与它在频率表里的排名成反比,也就是说可能会有很多Term的频率是很低的,同样也有小部分Term的频率则非常高,Slice的设计正是考虑到了这一分布特点。
ByteBlockPool与IntBlockPool设计思想完全一样,IntBlockPool只能存储int,ByteBlockPool存储byte,这里我们不再赘述。Lucene仅实现这两种基础的数据类型,其它的类型可以通过编码之后用ByteBlockPool来存储
BytesRefHash
Lucene在构建Postings的时候, 采用一种类似HashMap结构,这个类HashMap的结构便是BytesRefHash,它是一个非通用的Map实现。 它的非通用性表现在BytesRefHash存储的键值对分别是Term和TermID,其次它并没有实现Map接口,也没有实现Map的相关操作。
Term在Lucene中通常会被表示为BytesRef,而BytesRef的内部是一个byte[],这是一个可以复用的对象。当通过TermID在BytesRefHash获取词元的时候,便将ByteBlockPool的byte[]拷贝到BytesRef的byte[],同时指定有效长度。整个BytesRefHash生存周期中仅持有一个BytsRef,所以该BytesRef的byte[]长度是词元的长度。
BytesRefHash用来存储Term和TermID之间的映射关系,如果Term已经存在,返回对应TermID;否则将Term存储并且生成TermID后返回。Term在存储过程BytesRefHash将BytesRef的有效数据拷贝在ByteBlockPool上,从而实现紧凑的key值存储。TermID是从0开始自增长的连续数值,存储在int[]上。BytesRefHash非散列哈希表,从而TermID的存储也是紧凑的。
因为BytesRefHash为了尽可能避免用到对象类型,所以直接采用int[]存储TermID,实际上也就很难直接采用散列表的数据结构来解决HashCode冲突的问题。
Lucene构建倒排索引的过程分了两步操作,构建Postings和TermVectors。它们俩过程共享一个ByteBlockPool,也就是在每个 DocumentsWriter
共用同一个ByteRefHash(因为BytesRefHash以ByteBlockPool都不是线程安全的)。 它为Postings收集过程提供去重和Term与TermID对应关系的存储及检索等功能。
PostingsArrays(ParallelPostingsArray,FreqProxPostingsArray)
ParallelPostingsArray类只有四个成员变量, 其中三个数组, 注意和一下分析的差别
final int size;
final int[] textStarts;
final int[] intStarts;
final int[] byteStarts;
FreqProxPostingsArray继承ParallelPostingsArray
int termFreqs[]; // # times this term occurs in the current doc
int lastDocIDs[]; // Last docID where this term occurred
int lastDocCodes[]; // Code for prior doc
int lastPositions[]; // Last position where this term occurred
int lastOffsets[]; // Last endOffset where this term occurred
textStarts,用来记录term本身在ByteBlockPool中的起始位置
intStarts,用来记录对应termId对应的其他信息在IntPool中的记录位置,intpool中记录的具体是什么信息后面会说明。
byteStarts。用来记录termId的[docId,freq]组合在ByteBlockPool中的起始位置,注意是[docID,freq]组合,在bytePool中的存储形式类似于[docId,freq][docId,freq][docId,freq]…这种,这个起始位置的值 + slice初始化长度就是posi信息的起始位置
从PostingsArrays名字上容易被误以为是存储Postings数据的结构,实则不然。在Postings构建过程中,Lucene将各项信息写到ByteBlockPool的Slice链表上。链表是单向链表,它的表头和表尾存储到PostingsArrays,从而能够快速写入,并且可以从头开始遍历。这个结构见 Lucene索引流程与倒排索引实现
PostingsArrays除了记录了Slice链表的索引之外,它还存储上个文档的DocID和TermFreq,还有Term上次出现的位置和偏移信息。PostingsArrays由几个int[]组成,其下标都是TermID(TermID是连续分配的整数),对应的值便是记录TermID上一次出现的各种信息。
Lucene为了能够直接使用基本类型数据,所以才有了PostingsArrays结构。方便理解你可以理解成是Postings[],每个Postings对象含有DocId,TermFreq,intStarts,lastPosition等属性。
Lucene为了能够直接使用基本数据类型(基本类型有两大好处:减少内存开销和提升性能),所以才有了PostingsArrays结构。为了便于理解,PostingsArrays可以表示为Postings[],每个Postings对象含有docFreq,intStart,lastPos等属性。
PostingsArrays这个结构只保留每个TermID最后出现的情况,对于TermID每次出现的具体信息则需要存在其它的结构之中。它们就是IntBlockPool
&ByteBlockPool
,它能有效的避免Java堆中由于分配小对象而引发内存碎片化从而导致Full GC的问题,同时还解决数组长度增长所需要的数据拷贝问题,最后是不再需要申请超大且连续的内存。
为什么需要postingsArrays? 因为写到byte[]的只是增量,那么就需要找到上次的Term出现情况才能计算。如果总是在byte[]上查找则显得过重,因为Postings存储在byte[]时,它的结构是一个单向链表。有了PostingsArrays中记录的上条信息,则便于计算增量
构建索引的过程是ByteBlockPool(IntBlockPool)、BytesRefHash和PostingsArrays三者之间的协作,如下图所示:
这里为了简化流程,图中将IntBlockPool简化成为int[],也就是说它也是Slice的方式实现连续的链表。
PostingsArrays的 byteStarts[TermID]
记录Term的两个链表的表头在ByteBlockPool的绝对位置, intStarts[TermID]
记录下次要写的位置,则 textStarts[TermID]
则记录BytesRefHash把Term存储在ByteBlockPool的哪个位置上。
为什么byteStart和intStart需要先指向IntBlockPool呢? 主要是因为TermID可能对应了两条Slice链表,以TermID为索引的数组不方便存储。通过IntBlockPool可以方便处理,仅需要IntBlockPool连续两个位置,下一个位置用于存储第两个Slice链表。
使用slice的原因:
1.通过slice做内存分配.
2.docId+fred和pos+payload是交错存储在buffer上,使用slice构成链表区分docId+fred和pos+payload区间
重要: PostingsArrays只归属于某个索引域, 而intpool和bytepool是各个索引域所共享的
IntBlockPool的引入虽然让这个过程变得更复杂了,但也更体现了Lucene的设计之精湛和巧妙
综上所述,lucene对缓存的实现可谓煞费苦心,之所以做的这么复杂我觉得有以下几点考虑:
- 节约内存。比如用那个三个指针记录两个缓存块的偏移、Slice长度的分配策略、差值编码等。
- 方便对内存资源进行控制。几乎所有数据都是通过BlockPool来管理的,这样的好处是内存使用情况非常容易统计,FlushPolicy很容易获取当前的内存使用情况,从而触发刷新逻辑。
- 缓存不必针对每个Field,也就是说同一个Segment所有Field的数据可以放在一块缓存中,每个Field有自己的PostingList,所有Field的Term字面量共享一个缓存以及上层的Hash,这样便能很大程度上节约存储空间。对应一个具体的Field,判断Term是否存在首先判断在Term缓存块中是否存在,接着判断PostingList中是否有入口。
可以参考<lucene 原理与代码分析>, 版本老了一点, 原理一致