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 原理与代码分析>, 版本老了一点, 原理一致

 

 

posted @ 2019-03-21 19:35  車輪の唄  阅读(80)  评论(0编辑  收藏  举报  来源