扒一下Elasticsearch的发动引擎Lucene的设计理念

446 阅读24分钟

背景

本文基于最新的Lucene9.7.0。
只讲设计理念,不讲源码实现。

1、倒排索引简介

doc0:  {"name": "Mike""remark": "Welcome Apache Lucene"}  
doc1:  {"name": "John""remark": "Welcome Elasticsearch"}  
doc2:  {"name": "Mike",  "remark": "Apache Lucene Apache Solr"}  

假设现有上面3个docs,分别对name和remark字段建立倒排索引,其中name字段是不分词的,remark字段是不分词。
关于倒排索引,主要涉及以下几个基本概念:

  • term:词项,如Mike、Welcome、Lucene。
  • field:词项组成字段,如name和remark两个字段。
    一个field的terms按出现的顺序从0开始递增分配termId。如图pic-0-termIds
  • document:字段组成文档。
  • segment:多个docs的集合,一个segment内的docs按出现的顺序从0开始递增分配docId。
  • index:segment集合。 image.png

2、倒排索引逻辑结构

image.png

其中左边是词典(terms的集合), 右边是倒排列表(posting list)。
以Elasticsearch: doc1-freq1-pos1-offset9-length13为例说明:

  • doc1表示在segment的文档号docId是0,通过docId可以从原始数据中取出目标doc。
  • freq1表示Elasticsearch在文档doc0中出现的频率是1次。主要用于计算权重打分。
  • pos1表示Elasticsearch是文档doc0的第1个位置的term。主要用于短语查询。
  • offset9表示Elasticsearch在文档doc0的offset是9。主要用于高亮显示。
  • length13表示Elasticsearch的长度是13。主要用于高亮显示.

也就是说Elasticsearch这个term出现在doc1,出现了1次,是doc1的第一个term,其offset是9,term的length是13。

明显地,有了倒排索引,就可以快速找到包含某个term的docs。 比如查找remark字段包含Lucene的docs过程如下:

  • 从remark词典中找到Lucene这个term
  • 找到lucene对应posting-list,得到目标docIds
  • 根据docIds从原始数据找到目标docs。

以上就是倒排索引的核心思想,不考虑freq、pos、offset等因素,可以简单认为就是一个map<termId,docId>的映射关系。 通过对倒排索引的基本了解,我们可能会有以下等等疑问:

  • 当词典数量很大的时候, 如何快速从词典中找到目标term并且占用内存较少?
  • 当一个term的posting-list很长时,该如何有效压缩数据以减少IO?
  • 能否反过来根据docId快速找到对应的term?

下面我们将会探讨Lucene是如何解决这个问题的。

3、构建索引的简易过程

为了方便理解,先简单看下构建索引过程的伪代码(不可运行的, 只表意),每个环节后续都会细说:

private void indexing(List<Document> docs, byte[][] pool) {  
    for (Document doc : docs) {  
        for(Field field : doc.getFields) {          
            for (Term term: field.getTerms) {// 对每个field都构建一个倒排索引  
                pool.addTerm(term);// 添加新term到pool,所有fields共享同一个pool  
                slice0 = pool.newSlice();// 对于新term,会在pool上紧接着term划分出两个slice  
                slice1 = pool.newSlice();  
                slice0.writeVInt(docId, freq);// 保存term的docId(差值)、freq到slice0  
                slice1.writeVInt(pos, offset, length);// 保存term的pos(差值)、offset(差值)、length到slice1
            }  
            saveStoredField(field.getValue());// field的原始值逐个写到buffer,满则刷盘到 .fdm .fdt 文件  
            cacheDocValue(docId++, field.getValue());// 对于不分词的field,缓存其dv, dv是指<docId, term>的关系  
        }  
    }  
    // 当达到阈值ramBufferSizeMB(默认16MB)或maxBufferedDocs(默认不限制)则会刷盘  
    flushDocValue();// 保存dv到 .dvm .dvd 文件  
    flushStoredField();// 处理还没有刷盘的原始数据到 .fdm .fdt 文件  
    flushInvertedIndex();// 保存倒排索引到 .doc .pos .pay .tim .tip .tmd  
    flushFieldInfos();// 保存field的信息到 .fnm  
    flushSegmentsInfo();// 保存segment的信息到 .si  
}

可以发现,倒排索引是逐个doc逐个field逐个term处理的,先缓存在内存池,达到一定阈值之后再刷盘。至此,我们可能会有以下几个疑问:

  • 内存池是如何维护的,如何从内存池中快速找到目标term?
  • VInt是什么数据类型,为什么保存的都是差值?
  • 倒排列表一开始是不确定的,那么slice是如何动态扩容的?

在讲解倒排索引内存结构之前,我们先了解下VInt。

4、关于VInt数据类型

我们都知道,在Java中int类型是定长4个bytes,无论数值大小都需要4bytes。保存一个很小的数字比如1需要4个bytes,保存一个很大的数据比如2147483647也是需要4个bytes。较严格来说,对于数值1来说只需一个byte,那么使用int保存数值1则浪费了3个bytes,如果有一堆很小的数值那么将是不小的浪费。

VInt是一种可变长的整型编码方式,类似于UTF-8。这种编码方式可以有效地压缩数值较小的单个整数。VInt占用1到5个bytes,越小的值需要更少bytes。其原理比较简单,如下:

image.png

  • 在写数据时,从低位开始处理,对其有效位的每7bits进行划分,每7bits保存到一个byte,如果还有剩余有效位没处理则当前byte的最高位置为1,直到所有有效位处理完毕。
  • 在读数据时,一个一个字节读,当遇到最高位是1(负数)时说明后面还有数据需要读取,直到遇到最高位是0的字节为止才可以读出一个完整的数值。

可见,在理想情况下,VInt可节省3/4的内存开销。数值越小,开销越小。
这也是为什么Lucene在处理docId、pos、offset等信息时保存的是其差值的原因。
另外,VInt不适合处理负数。 如果要处理负数,需要做额外的处理,把最高位的符号位移到低位即可。比如
-2147470613
=  10000000000000000011001011101011
-> 00000000000000000110010111010111
-> 11010111 11001011 00000001

5、倒排索引的内存结构

所有字段的倒排索引共享一个字节pool,每个字段分别维护两个数组termIds和bytesStart。

  • termIds记录的是<hash(term), termId>映射关系,可以快速找到term对应的termId。
  • bytesStart记录的是<termId, poolOffset>映射关系,可以通过termId快速定位到term在pool的位置。

通过termIds和bytesStart两者配合,可以快速找到term在pool的倒排信息。 字节pool实际是一个二维的字节数组byte[][],每个byte[]是一个block(默认32k), 按需分配动态扩容,每次扩容一个block。以开始的3个docs为例,其内存结构如下: pic4

记录一个term倒排信息的过程:

  • 计算hash(term)&mask确定term在termIds的槽位,记录对应的termId(termId按出现的顺序递增)到termIds。
  • 如果是新term则在pool中记录term的length+value,term不能跨block,当前block剩余空间不够则需要开辟新的block,把term写到新block。 然后记录offset到byteStarts。
  • 紧接着term后面划出两个slice,在slice0记录doc+freq,在slice1记录pos+offset+length,slice1不一定需要。

slice扩容
因为term是一个个处理的,一开始是不知道倒排列表有多长,当有很多倒排信息要记录时slice需要动态扩容。slice默认长度是5bytes,每次扩容长度规则level{5, 14, 20, 30, 40, 40, 80, 80, 120, 200}。每个slice最后的一个byte记录了当前slice的结束符和level,这样可以方便知道下一级level需要开辟的长度。在扩容时,当前slice的最后一个byte的前3bytes的内容会复制到下一级slice的头部,然后把下一级slice的address记录在当前slice的最后4bytes记录,这样slice在逻辑上就是连续的了。 另外slice也不能跨block,因此会有小部分空间浪费。
ps:只有在处理完整个doc之后才可以确定freq。当freq=1时,会借用docId的最低位,这样可以进一步减少freq的开销。

以上面3个docs展示下详细的索引过程:

doc0:  {"name": "Mike",  "remark": "Welcome Apache Lucene"}
doc1:  {"name": "John",  "remark": "Welcome Elasticsearch"}
doc2:  {"name": "Mike", "remark": "Apache Lucene Apache Solr"}

这里为了方便理解说明,不考虑freq=1借位的情况,并且假设freq都是预知的,假设

hash(Mike)=1, termId=0
hash(John)=4, termId=1

hash(Welcome)=2, termId=0
hash(Apache)=4, termId=1
hash(Lucene)=0, termId=2
hash(Elasticsearch)=5, termId=3
hash(Solr)=1, termId=4

最终termIds、bytesStart、pool三者关系如下: image.png

图中假设每行是一个block,每个block为30bytes,颜色相同的是同一个term的slice。

结合上面索引过程的伪代码,建立倒排的具体过程如下(这里只画了最终的效果图,过程中的一些细微变化需要自行脑补):

  • 处理doc0的name字段的Mike,新term,在block0上记录对应的termLength+termValue,即是4Mike,因为name是不分词的,只需要开辟slice0,并记录0(doc0)。
  • 处理doc0的remark字段的Welcome,新term,在block0上记录7Welcome,然后开辟slice0和slice1,slice0此时记录01(doc0-freq1),slice1记录007(pos0-offset0-length7)。
  • 处理doc0的remark字段的Apache,新term,当前block0空间不够了,开辟新block1, 在block1上记录6Apache|01|186。
  • 处理doc0的remark字段的Lucene,新term,当前block1能够放下term,但放不下其slice了,因此需要开辟新block2,在block2上再开辟slice,在block1记录6Lucene---在block2记录|01|2156。
  • 处理doc1的name字段的John,新term,在block2上记录4John|1
  • 处理doc1的name字段的Welcome,term已存在,添加对应的slice信息即可。由于其slice1剩余空间不够,需要开辟下一级长度为14的slice,并且由于当前block2的剩余空间也不够了,因此需要开辟新block3,然后再开辟(扩容)slice。此时welcome的倒排信息为 7Welcome|0111|0next-addr-90---07007。
  • 处理doc1的remark字段的Elasticsearch,新term,当前block能放下term,但放不下slice了需要开辟新block4,在block3上记录13Elasticsearch---|11|1913。
  • 处理doc2的name字段的Mike,term已存在,更新对应的slice信息为4mike|02。
  • 处理doc2的remark字段的第一个Apache,term已存在,slice1需要扩容。
  • 处理doc2的remark字段的Lucene,term已存在,slice1需要扩容,开辟新block5。
  • 处理doc2的remark字段的第二个Apache,term已存在,slice1不需要再扩容。
  • 处理doc2的remark字段的Solr,新term,在pool上记录4Solr|21|3214。

6、关于PackedInt

前面我们了解到VInt可变长的编码方式,针对的是单个数值,可以有效压缩较小的整数且不会有空间浪费,但对较大的数压缩效果一般,甚至开销更大。那么如果有一堆较大数值该何如有效编码?

PackedInts针对的是多值编码,采用的是定长方式,会有部分空间浪费。PackedInts会把数据划分成多个block(默认1024个元素一个block),每个block进行独立编码。在一个block中,取其最大值的有效位bpv(bitsPerValue),然后每个元素按bpv定长保存即可,这就是PackedInt的基本思想。比如有一组数据 2 13 6 1 12 5 8,其最大值是13=1101,有效位4bits,那么所有元素按4bits定长保存即可。

和VInt类似,PackedInt在处理一组较小的数值时效果也是比较可观的,主要区别就是一个单值变长,一个多值定长。

但利用PackedInts+放缩操作(min、avgIcr、gcd)可以有效处理一组大数据。比如 {123456706, 123456701, 123456703, 123456709, 123456707},通过减去最小值123456701可缩小成{5, 0, 2, 8, 6},在记录的时候只需要记录min和{5, 0, 2, 8, 6}即可,效果显而易见。如果数据是递增,比如 {123456701, 123456703, 123456706, 123456707, 123456709},可通过平均斜率+最小值进行进一步放缩。

  • 求出平均斜率 avgInc = (123456709 - 123456701)/(5-1) = 2
  • 转换每个元素 e_new = new_ori - avgInc*i,变成 123456701, 123456701, 123456702, 123456701, 123456701,此时数据会变得更均匀
  • 减去最小值123456701,变成 {0, 0, 1, 0, 0},最终每个元素只需要1bit。

对于连续的或者满足等差数列的数据属于特例,也是最理想的情况,通过平均斜率+最小值放缩后,所有元素会变成0,比如{6666666660, 6666666661, 6666666662, 6666666663, 6666666664}会变成{0, 0, 0, 0, 0},由于所有元素都是0,因此只需要保存avgInc和min即可。

原始数据中的.fdx中的chunkStartDocIds(每个chunk的起始docId)和chunkFpInFdts(每个chunk的偏移量)都是采用了PackedInt的编码方式,默认1024个元素为一个block。

7、原始数据的存储格式 .fdm、.fdx、.fdt

原始数据是以chunk为单位进行管理,数据先写到缓存,当到达一个chunk阈值,大于chunkSize(默认8k)或者maxDocsPerChunk(默认1024)则进行flush。

  • .fdt保存的是chunk,也就是data。
  • .fdx保存的chunk的索引,索引包括两部分chunkStartDocIds和chunkFps,通过chunkStartDocIds可以快速根据docId找到对应的chunkNo。
  • .fdm保存的是fdx的元信息。

fdm、fdx、fdt三者的简化版结构关系图如下 image.png

  • chunk: 缓存的size大于8k或者docs大于1024则会产生一个chunk,其格式如下:
    chunkStartDocId|docsNumOfChunk|fieldsNumOfDoc*n|lengthOfDoc*n|docData*n,
    其中n表示有n个docs,
    - chunkStartDocId表示该chunk的第一个docId,
    - docsNumOfChunk表示该chunk的docs数量,
    - fieldsNumOfDoc表示每个doc的storedField的数量,
    - lengthOfDoc表示每个doc的长度,用于计算其偏移量,
    - docData:一个doc的具体格式为(fieldNum|fieldType|fieldValue)*m,m表示该doc有m个storedField

  • chunkStartDocIds: 每个chunk的第一个docId组成的数组,相当于一层跳表索引,方便根据docId快速定位到对应的chunk。默认1024个元素划分block,每个block使用了PackedInt编码,因此需要额外记录每个block的元数据到fdm。

  • chunkFps:每个chunk在fdt的偏移量(file pointer)组成的数组,默认1024个元素划分block,使用了PackedInt编码。

  • chunkStartDocIdsMeta:记录了chunkStartDocIds的元信息,包括startFpInFdx|(min|avgInc|bpv)*k,k表示有k个blocks。

  • chunkFpsMeta:记录了chunkFps的元信息,格式和chunkStartDocIdsMeta类似,包括startFpInFdx|(min|avgInc|bpv)*k,k表示有k个blocks

原始数据写入流程:

  • 迭代docs,迭代storedFields,逐个field写到缓存fieldsBuffer(byte[]),
    一个doc的格式为 (fieldNum|fieldType|fieldValue)*m,m表示一个doc的storedFields的数量
  • 当缓存达到chunk阈值则进行flush,产生一个chunk
    - 把缓存的数据写到.fdt,其格式为
    chunkStartDocId|docsNumOfChunk|fieldsNumOfDoc*n|lengthOfDoc*n|((fieldNum|fieldType|fieldValue)*m)*n,n表示这个chunk有n个docs。
    - 把该chunk的chunkStartDocId写到临时文件.doc_ids, 其在fdt的偏移量filePoint写到临时文件.file_pointers
  • 当所有docs迭代完,执行收尾工作
    - 把临时文件.doc_ids的数据读取出来进行PackedInts编码处理,然后写到.fdx。
    - 把临时文件.file_pointers的数据读取出来进行PackedInts编码处理,然后写到.fdx,此时.fdx的格式为 chunkStartDocIds*k|chunkFpInFdts*k,k表示有k个chunks。
    - 产生.fdm文件,其格式为 totalDocsNum|totalChunksNum|fpInFdx-chunkStartDocIds|fpInFdx-chunkFpInFdts

根据docId查找原始数据的过程:

  • 读取fdm的chunkStartDocIdsMeta,定位到fdx的chunkStartDocIds,然后使用二分查找定位到docId对应chunkNo。
  • 读取fdm的chunkFpsMeta,定位到fdx的chunkFps,然后根据chunkNo定位到目标chunk的chunkFp。
  • 根据chunkFp定位到在fdt的chunk,然后根据lengthOfDoc计算目标doc在该chunk的offset即可定位到目标doc。

8、关于bitSet(位图)

在讲解DocValue之前,我们先聊聊BitSet。我们都知道,在处理海量数据时使用BitSet可以有效减少内存资源的开销,尤其是对于较连续的稠密数据。

FixedBitSet
在Lucene中,使用了long[]实现了定长的FixedBitSet。简单的get/set过程如下:

private final long[] bits;  

@Override  
  public boolean get(int index) {// 判断数值index是否存在

    int i = index >> 6// div 64 // 目标word(long)  
    long bitmask = 1L << index;// 目标word中的第几个bit  
    return (bits[i] & bitmask) != 0;  
  }  
  
  @Override  
  public void set(int index) {// 把数值index对应的bit设置为1  
    int wordNum = index >> 6// div 64  
    long bitmask = 1L << index;// 从低位开始写  
    bits[wordNum] |= bitmask;// 目标bit设置为1  
  }  

对于FixedBitSet,在处理稀疏数据时,有部分word可能根本没有被使用到,当数据特别稀疏的时候将是不小的浪费。数据越稀疏,浪费的空间则越多。对此,我们得寻找一种更有效的结构,Lucene的SparseFixedBitSet很好解决了这个问题。

SparseFixedBitSet
SparseFixedBitSet的基本思想是对数据进行分治处理,划分成多个block,每个block逻辑长度4096bits,也就是64个word(long),每个block的words都是按需延迟初始化,没有使用到的word就不会开辟,这样就有效避免了空间浪费。也正因如此,需要额外维护每个block中的words的使用情况,因为一个block有64个word,因此每个block使用一个long就可以管理所有的words。具体的get/set过程如下:

final long[] indices;// 记录每个block的words的使用情况  
final long[][] bits;// blocks  

public void set(int i) {  
    final int i4096 = i >>> 12;// 第几个block  
    final long index = indices[i4096];// 该block的longs使用情况  
    final int i64 = i >>> 6;// blocks中的第几个long  
    final long i64bit = 1L << i64;// 该block中逻辑上的第几个long  
    if ((index & i64bit) != 0) {// 目标long已启用,直接在目标long记录  
      bits[i4096][Long.bitCount(index & (i64bit - 1))] |= 1L << i; // 该block中实际上的第几个long  
    } else if (index == 0) {// 目标block还没启用,延迟开辟新的block  
      insertBlock(i4096, i64bit, i);  
    } else {// 目标long还没启用,延迟开辟新的long,可能是中间的long,需要移动部分数据  
      insertLong(i4096, i64bit, i, index);  
    }  
  }  

public boolean get(int i) {  
    final int i4096 = i >>> 12;// 第几个block  
    final long index = indices[i4096];// 该block的longs使用情况  
    final int i64 = i >>> 6;// blocks第几个long  
    final long i64bit = 1L << i64;// 目标block中的第几个long  
    if ((index & i64bit) == 0) {// 目标long没有启用  
      return false;  
    }  
  
    final long bits = this.bits[i4096][Long.bitCount(index & (i64bit - 1))];// 目标long  
    return (bits & (1L << i)) != 0;// 目标bit  
  }  

通过上面的了解,FixedBitSet适合稠密场景,SparseFixedBitSet适合稀疏场景,理论上在数据没写满的情况两者都有小部分空间浪费。

稠密数据可以选择FixedBitSet,稀疏数据可以选择SparseFixedBitSet,那么如果是部分稠密部分稀疏又该如何选择?Lucene选择使用roaringbitmap。

roaringbitmap
roaringbitmap是结合了稠密和稀疏两者,也是把数据划分成多个block,按需延迟开辟block,逻辑上每个block大小65535,只是稀疏的block使用了short[]数组。在构建时必须是有序递增的,对于每个block,数据一开始放在稀疏的short[]数组,当该block的元素达到4096时转换成FixedBitSet。如果FixedBitSet特别稠密时(浪费的空间不够4096),又会转换成short[]数组进行反向记录不存在的元素即可。roaringbitmap就不画图了,我们直上IndexedDISI(持久化的roaringbitmap+ords)。

IndexedDISI
这里说的roaringbitmap基于内存结构的,如果不考虑其他因素,其内存结构和持久化结构没啥不一样,在持久化时按顺序持久化每个block,然后记录好每个block的offset即可。在内存结构中,如果想知道某一个元素的序号(ord,集合的中第几个元素),只需要简单统计目标元素之前的block的元素数量之和。比如元素524295(65536*8+7)对应的ord是 4+6+4-1 =13。如果在持久化结构中也想获取某个元素对应的ord,那么将需要加载所有的block进行统计,显然这是很耗性能的。在记录每个block的offset的同时记录每个block之前的元素数量即可缓解这问题。另外一个block包含了1024个long,当目标long比较靠后时,需要把前面的long逐个读出来统计其ord显然也不科学。Lucene对每个稠密block在逻辑上进一步划分,每8个longs为一个rank,一个block则有128个rank,然后记录每个rank的之前元素数量(ord),这样就可以快速定位到目标long所在的rank,进一步减少了io。

内存逻辑结构
image.png

如图中,有10个block,开辟了block1、block4、block8,其中block1和block4是稀疏的,使用的是short[],block8是稠密的,使用的是FixedBitSet。
block1保存的具体元素是{65536+6, 65536+68, 65536+999, 65536+65530}
block4保存的具体元素是{655364+0, 655364+56, 655364+256, 655364+512, 655364+789, 655364+8888}
block8保存的具体元素是{655368+2, 655368+3, 655368+4, 655368+7, 655368+9, ......., 655368+65526, ......, 65536*8+65535 }

持久化结构 image.png 读过程,以589814为例,这里的ranks是假设的:

  • 计算589814所在的block,589814/65536=8,从blockOffsets获取block8的offset,21
  • 计算589814是block8的第几个元素,589814&0xFFFF=65526
  • 计算589814是block8的第几个rank,65526/512=127, 此时可知目标rank的offset是 21 + 127*8*8 = 8149
  • 定位到目标rank,逐个读取rank中的longs直到找到目标long,可以发现589814元素是存在的, 其对应的ord=jumpTable[8] + rank[8] + 6 = 10 + 63024 + 6 = 63040,这里的6是假设目标是该rank中的第6个元素

9、DocValue存储格式

DocValue,dv保存的就是docId和term value的关系,只对不分词的字段生效。
dv有以下几种类型:
NUMERIC:数值
BINARY:二进制
SORTED:有序的、去重的二进制
SORTED_NUMERIC:有序数组
SORTED_SET
下面主要讲解下BINARY和SORTED。

BINARY类型
image.png

  • terms:直接记录term本身即可,如 hello|lucene|looks|good
  • docIds: 类似IndexedDISI格式,(blockNo|docsNum|ranks|docIdsBlock)*m|jumpsTable, m表示有m个block,docIdsBlock可能是稠密的FixedBitSet,也可能是稀疏的short[]。隐含了<docId, ord>的映射关系。
  • termOffsets:记录每个term的offset,划分成多个block,每个block使用PackedInt编码。<termId, offset>,这里的termId和docId对应的ord是一一对应的。
  • termsMeta:记录了termsStartOffset和termsLength。
  • docIdsMeta: 记录了docIdsOffsetInDvd|docIdsLength|jumpsEntryCnt|rank_power。
  • termOffsetsMeta:记录了termStartOffsetsInDvd|blockShift|(min|avgInc|bpv)*k|termOffsetsLength,k表示有k个blocks。

根据docId查询term的过程:

  • 读取dvm,获取dataOffset、docIdsOffset、termOffsetInDvd等信息
  • docIdBloc是roaringmap结构,通过其可以获取到<docId, ord>映射关系
  • 根据ord从termOffsets定位到目标term的Offset,然后即可读取到term

SORTED类型
SORTED类型相对会复杂很多 image.png

.dvd

  • docIds: 类似IndexedDISI格式,隐含了<docId, ord>映射关系,这里的ord是term出现的序号,也就是termId。
  • ords:隐含了<ord, soredOrd>映射关系,这里的sortedOrd是term排序后的序号。划分成多个block,使用了PackedInt编码。
  • terms:有序去重的terms数组,隐含了<sortedOrd, term>关系。每64个terms划分成一个block进行管理,因此需要记录每个block的offset。因为term是有序的,在每个block中只需要记录第一个term,其他的term只需要记录后缀即可。格式如下
    (firstTermLength|firstTerm|prefixLen|suffixLen|termSuffix)*n)*m|termBlocksAddrs,
    - n表示一个block有n个term,如前面所说除了最后一个block,n=64,
    - m表示有m个block,termsBlocksAddrs(数组)记录每个block的addr。
  • termsIndex:对terms做一层稀疏跳表索引,每1024个记录一次,也就是一个稀疏的<term,termAddr>映射关系,存储格式比较简单termsk|termAddrk,这里的k表示有k个term。

.dvm

  • docIdsMeta: 包括docIdsOffsetInDvd、docIdsLength、jumpsEntryCnt、rank_power
  • ordsMeta: 包括totalDocsNum、ordsOffsetInDvd、ordslength
  • termsMeta:包括totalTermsCnt、termMaxLength、maxBlockUncompressedLength、termsOffsetInDvd、termsLength、termsBlocksAddrsOffset、termsBlocksAddrsLength
  • termsIndexMeta: 包括termsIndexOffset、termsIndexlength、termsIndexBlocksOffset、termsIndexBlocksLenth

根据docId查询term的过程:
1 读取dvm的docIdsMeta,定位到docIds,根据docId找到对应的ord
2 读取dvm的ordsMeta,定位到ords,根据ord找到对应的sortedOrd
3 根据sortedOrd计算出目标term所在的block
4 读取dvm的termsMeta,根据termsBlocksAddrs定位到目标block
5 遍历目标block,直到找到目标term

10、PForUtil/ForUtil编码

ForUtil和PackedInt的基本思想是类似的,都是把数据划分成block,然后每个block独立编码,block的使用空间取决其最大值。

不同的是PackedInt是逐个编码,而ForUtil利用了类似SIMD的操作一次编码多个值(java不支持SIMD指令,但C2编译器可以优化识别)以提升效率。

ForUtil处理的int类型元素,每128个元素是一个block。
PForUtil是进一步封装了ForUtil,作用是对个别极端的大数做平滑处理,进一步减少资源开销。

ForUtil
128个int元素中,

  • 如果bpv小于等于8的,每个元素则先按8bits进行第一次收缩,也就是1个long可以存放64/8=8个元素,因此这128个元素可以收缩到128/8=16个longs。
  • 如果bpv小于等于16的,每个元素则先按16bits进行第一次收缩,也就是1个long可以存放64/16=4个元素,因此这128个元素可以收缩到128/4=32个longs。
  • 如果bpv小于等于32的,每个元素则先按32bits进行第一次收缩,,也就是1个long可以存放64/2=2个元素,因此这128个元素可以收缩到128/2=64个longs。

假设bpv=5,则先按8bits收缩,需要16个longs。第i个long是下标为16n+i的元素集合,n范围[0,7]。

收缩之后一个long内部使用的状态如下, 这里的01表示的是bit的使用状态,不是真实的值,1表示已使用,0表示还没被使用。
00011111, 00011111, 00011111, 00011111, 00011111, 00011111, 00011111, 00011111

128个元素,每个元素需要5bits,那么totalBits=128*5=640,也就是说最终只需要10个longs即可。那么我们可以进一步把后面6个long往前面的10个long填充压缩。

把long10填充到long0和long1的过程:
1 先把long0和long1左移3bits
00011111, 00011111, 00011111, 00011111, 00011111, 00011111, 00011111, 00011111
左移3bits=》
11111000, 11111000, 11111000, 11111000, 11111000, 11111000, 11111000, 11111000

2 long10右移2bits, 然后取出有效bits,填充到long0
00011111, 00011111, 00011111, 00011111, 00011111, 00011111, 00011111, 00011111
右移2bits=》
00000111, 11000111, 11000111, 11000111, 11000111, 11000111, 11000111, 11000111
mask出有效位=》
00000111, 00000111, 00000111, 00000111, 00000111, 00000111, 00000111, 00000111
填充到long0=》
11111000, 11111000, 11111000, 11111000, 11111000, 11111000, 11111000, 11111000+
00000111, 00000111, 00000111, 00000111, 00000111, 00000111, 00000111, 00000111=
11111111, 11111111, 11111111, 11111111, 11111111, 11111111, 11111111, 11111111

3 此时可见long0所以bits都被利用了,然后long10还剩下的bits按同样的方式填充到long1即可,long10剩余没处理的bits
00000011,00000011,00000011,00000011,00000011,00000011,00000011

11、倒排索引持久化

- 逐个field处理,  
  - terms排序,然后逐个term开始处理  
    - 处理term的倒排列表,逐个doc处理  
      - 记录docIds(差值)到buffer,达到阈值128则ForUtil编码写到.doc  
      - 记录freqs到buffer,达到阈值128则ForUtil编码写到.doc  
      - 记录pos(差值)到buffer,达到阈值128则ForUtil编码写到.pos  
      - 记录offset(差值)到buffer,达到阈值128则ForUtil编码写到.pay  
      - 记录termLenth到buffer,达到阈值128则ForUtil编码写到.pay
      - 当倒排列表处理完,最后一个不满一个block的写VInt,最后返回docFp、posFp、payFp  

  - 处理词典
     词典的存储构建过程较为复杂,涉及term前缀处理和fst持久化等。

此时我们拿到了<term, doc-pos-payFp>关系映射等关键信息,最简单的是使用HashMap进行保存,但当term的数量很大时显然是不合适的。

Lucene采用的FST,FST的速度比HashMap稍慢,但内存开销较小,主要是通过共享前缀和后缀。这里不细说FST的构建过程,大家先简单了解FST是一个占用内较小的map即可。比如下图 image.png

fst本身通过共享前缀和后缀减少了内存开销,如果term很多,则每个term都要放到fst上,内存占用还是会较大。Lucene在构建fst之前,会对term先加工处理。当公共前缀相同的term的数量大于阈值25则进行归并处理。这里我们假设阈值是4,如下图有sing、help...apple等term待处理,这些terms是有序的,从上到下逐个压栈的过程进行归并: image.png

  • 归并helloabc、helloabd、helloabe、helloabf,最终效果如橙色部分,把每个term都后缀记录到block0,然后把公共前缀helloab和block0-offset的映射关系记录到fst。
  • 归并helloaa、helloab、helloac1、helloac2,’ 最终效果如绿色部分,把每个term都后缀记录到block1,然后把公共前缀helloa和block1-offset的映射关系记录到fst。
  • 归并head、hello、helloa、help,’ 最终效果如绿色部分,把每个term都后缀记录到block2,然后把公共前缀hello和block2-offset的映射关系记录到fst。
  • 最后剩下apple、beyond、he、sing,没有公共前缀了,直接保存到block3, 这是最后一个block,其offset保存在tmd。

此时fst只包含三对kv,相对保存原始term已是大幅度裁剪
helloab/block0-offset
helloa/block1-offset
he/block2-offset

block是保存在tim文件,block里面的每个后缀都保存了doc、pos、pay的offset等相关信息。 fst是保存的tip文件。

查找一个term的过程,比如查找helloabc:

  • 先从fst找到前缀helloab,然后定位到block0
  • 遍历block,定位到后缀e,term存在

查找h开头的term:

  • 先从fst找到已h开头的前缀有he,然后定位到block1
  • 遍历block1,可以找到的term有head、hello、helloa、help,然后发现helloa是sub-block,指向block1,然后遍历block1找到的term有helloaa、helloab、helloac1、helloac2,然后发现helloab也是一个sub-block,指向block0,遍历block1找到的term有helloabc、helloabd、helloabe、helloabf,至此,所有h开头的term都已找到。

大block的处理
当相同前缀的term很多的时候,将会产生一个很大的block,对于大block查询性能将会比较差。Lucene对于这些大block,当阈值大于48时,将会进一步划分成多个sub-block。不同的是fst的处理,此时fst的output是每个block的offset和block中的第一个后缀的第一个字符。如下图的 <helloab,block0-c|block1-g|block2-v>,通过记录第一个后缀的第一个字符可以方便过滤掉不匹配的 block,相当一层跳表索引,因为term是有序的。

image.png

tmd tip tim三者关系
image.png

  • fieldMeta: 每个field一个meta,具体格式如下 fieldNum|numTerms|rootCode|totalDocsNum|firstTerm|lastTerm|fstFpInTip|rootNodeFpInTip ,其中rootCode表示最后一个block的offset,rootNodeFpInTip表示fst的rootNode,是因为fst在构建从是尾部开始的,那么读取的时候也要逆向读取。
  • fst:持久化的fst,这里不细说。每个field一个fst。
  • suffxBlock:分为普通的leafBlock和包含子block的非leafBlock,
    -leafBlock的具体格式:
    numTerms|isLeafBlock|compressAlg|suffixes|suffixLens|docFreq-totalFreqs|docFp-posFp-payFps,
    其中suffixes可能会进一步压缩,比如使用 LZ4,因此需要记录压缩方式compressAlg
    -非leafBlock的具体格式:
    numTerms|isLeafBlock|comressAlg|suffixes|(suffxLen|isSubBlock|subBlockFpInTim)*n|docFreq-totalFreqs|docFp-posFp-payFps
    两者差别在于suffixLens,非leafBlock还需要记录subBlock的offset。

ENDING.