Luecene源码解析——倒排表生成

848 阅读24分钟

隔了有半年没写源码解析系列了, 有很大一方面原因是自己懈怠了畏难了,其实倒排表部分五月份的时候就看过一次了,但其实讲真是真的很难看懂哈, 所以一直拖着没更新,不过十一月份又有功夫慢慢琢磨了, 那就开始边读边写呗。

序言

倒排表其实算是检索引擎层面最核心的部分了,也是区别于传统数据库的一个重要feature, 那倒排表是什么?存什么数据、 怎么存储落盘,就是本章最核心要研究的问题。

首先说倒排表是什么,看到这篇文章的读者大概率都对倒排表已经有一个大致的认知了,简单来说倒排表就是term与包含这个term的docID的映射关系,就比如说doc0 是: I have a banana , doc1 是 Do you have any banana or apples? 就可以有如下倒排表: I : 0, have:[0, 1] a : 0, banana:[0,1], do: [1], you:[1], any:[1], or: [1], apples:[1] , 还是比较容易理解的。 但实际上, 我们并不仅仅会保存field到docid的映射关系, 在更多场景下,我们会需要保存它的一些其它属性、 比如位置、词频等,从而满足一些更高频的需要, 比如PhraseQuery 这类对两个term距离有要求的查询。

基本流程

前面我们讲过DefaultIndexingChain类是最核心的索引类,对于倒排表而言,它会先调用invert方法来生成一个内存版本的倒排表, 然后再调用flush方法来将这些倒排表落盘, 因为这两个过程都非常复杂,所以不打算放在一章里面说完, 先将内存版的生成。其核心就是调用invert(field, first) , 读出一个个的token,改变 用termsHashPerField.add() 将每个token生成倒排数据结构, 所以TermsHashPerField和它底下的几个数据结构就是本章最关键的部分。

几个核心类

从UML图可以看的出来,想要读懂倒排表的存储,它的前置知识非常的多, (合情合理, 大招的前摇都是比较长的), 所以为了接下来大家能够理解的更加透彻,能听明白后续的流程, 我会详细介绍一些工具类。

ByteBlockPool

这个类的核心作用就是分配一个内存上连续的空间, 将所有byte内容全部写入到这片空间中,从而最大化利用内存, 方便寻址与CPU缓存等。我们在很多地方都能看到它的身影,比如后面要说的ByteRefHash类中, 如果我们要建立一个hashMap,用O1的复杂度能够拿到一个任意的二进制串, 那这个二进制串的存储就是用的ByteBlockPool来存储的。

首先我们来看下它的接口成员等有哪些:

image.png

接下来对着这幅图来理解一下这个ByteBlockPool:

buffers

这个就是用来装数据的二维数组, 为什么是二维数组做一个容器呢?直接用一个铺平的vector作为容器不好吗?这个也很好解释, 倘若我们用一个一维数组来作为容器, 那如果分配的空间不够了怎么办?扩容对吧,扩容怎么扩?是不是要分配一个更大的空间,然后把原来数组的内容copy过去。但如果是个二维数组, 二维数组实际上也是一个一个数组, 但它存放的元素实际上是一维数组的指针, 那这时候如果要扩容的话就很方便了, 它在copy的时候并不需要copy实际的内容,只需要去copy数组的指针就可以了对吧,这样开销会低非常多。这个buffer既然是个二维数组,那初始的长度的宽度是多少呢?也给出了, 长度是1<<15=32768 ,宽度是10 , 也就是说开辟这么一个大小的pool需要耗费320kb, 开销还是不小的,这就可以解释为什么ES是内存杀手了吧, 所以如果CPU跑的比较快且想节约内存的话, 可以把列数分配低一些。

bufferUpto

当前的游标在第几个buffer,也就是当前走到多少行了, 也就是y轴的位置, 上图中当前是第1行

byteUpto

在当前buffer内,游标在第几行, 也就是x轴的位置, 在图中当前是第2列。有bufferUpto 和byteUpto就能确定游标的绝对位置

buffer

是指当前正在写入的行, 在图中就是[32,31,75,-1,-1]那一行。

byteOffset

指的是当前所在行开始位置的的offset,在图中就是1<<15。

allocSlice方法

最难理解的就是这部分, 在写入的时候, BlockPool并不会直接暴露给使用方写入pool或者写入bytes到buffer的接口方法,而是提供了一个slice给调用方,调用方对slice进行写入bytes的操作,每次都多给一点, 不够就跟pool要,这样做的好处是:分配者不需要再去记录给使用者多大的空间了,使用者写入的时候自动就能检查出来是否不够了,不够了就继续分配,这块代码需要结合TermsHashPerField.java来一起看,需要好好咀嚼一下。

 // TermsHashPerField 写入一个byte的行为
void writeByte(int stream, byte b) {
    int upto = intUptos[intUptoStart+stream];
    byte[] bytes = bytePool.buffers[upto >> ByteBlockPool.BYTE_BLOCK_SHIFT];
    assert bytes != null;
    int offset = upto & ByteBlockPool.BYTE_BLOCK_MASK; // 定位到即将要写入的位置
    if (bytes[offset] != 0) { // 查看这个位置是否是0,如果这个位置不是0,就说明要分配一个slice了
      // End of slice; allocate a new one
      offset = bytePool.allocSlice(bytes, offset);
      bytes = bytePool.buffer;
      intUptos[intUptoStart+stream] = offset + bytePool.byteOffset;
    }
    bytes[offset] = b;
    (intUptos[intUptoStart+stream])++;
  }

TermsHashPerField是干啥的我们现在不用很具体的知道,只要知道这个类负责具体写入倒排数据结构的类就可以了(而且真正写入的类并不是TermsHashPerField类,而是它的子类FreqProxTermsWriterPerField), 这个方法的意思大概就是,给定一个stream, 一个要写入的byte, 然后往blockPool里面去写入这个byte, 这里的stream我稍微解释一下, 我们写入倒排表的时候,其实不仅要写入这个term的位置,还要记录它的词频、 offset这些,打个比方, 写入词频就是 1 stream, 写入位置信息和offset信息就是第二个stream,不过这些都不重要,后面会细讲的,这个时候我们只要知道,这个方法其实就是定位到当前bytePool里面即将要写入的位置,然后检查这个位置上是否是0, 如果是0, 就在这个offset上面去写入这个byte;如果不是0,就卡住了, 就说明要分配一个slice了,告诉bytePool我当前正在写入的数组和offset, 让它返回一个新的offset可以供TermsHashPerField 来写入接下来的数据。

bytePool 拿到这个"不够用"(只是逻辑上的不够用) 的slice以及当前写入状态卡住的offset来做进一步操作, 我们来看allocSlice的实现如下:

public int allocSlice(final byte[] slice, final int upto) {

    final int level = slice[upto] & 15;  // 当前slice 的upto是非0的,把这个数字拿出来跟0b1111做个交操作,得到一个level 
    final int newLevel = NEXT_LEVEL_ARRAY[level];// 去NEXT_LEVEL_ARRAY 找下一个level
    final int newSize = LEVEL_SIZE_ARRAY[newLevel]; // 根据这个下一个level去得到需要分配多大空间给这个slice

    // Maybe allocate another block
    // 当然有可能出现物理上不够用的情况, 那就需要扩到下一行到下一个buffer, 因为我们一行有32768, 所以这是个很低频的操作。
    if (byteUpto > BYTE_BLOCK_SIZE-newSize) { 
      nextBuffer(); // 注意如果出现这个操作byteUpto, byteOffset, buffer都会因为挪到下一行而变掉
    }
    
    final int newUpto = byteUpto;  //  当前blockPool写入buffer的相对偏移,通常 upto 的下一个就是
    final int offset = newUpto + byteOffset;  // 算出在整个pool里面的绝对偏移
    byteUpto += newSize;  // 相对偏移向右移动newSize个单位

    // Copy forward the past 3 bytes (which we are about
    // to overwrite with the forwarding address):
    // 把upto的前三个迁移到upto的后三个去, 因为upto的前三个要用来写offset信息了, 比如:
    // [32, 12, 51, upto, x, x, x, x, ] => [32, 12, 51, upto, 32, 12, 51, x]
    buffer[newUpto] = slice[upto-3];
    buffer[newUpto+1] = slice[upto-2];
    buffer[newUpto+2] = slice[upto-1];
    // [32, 12, 51, 32, 12, 51,x] => [offset 的24-32位, offset 16-24位, offset 8-16位, offset 最后8位, 32 ,12, 51, x]
    // Write forwarding address at end of last slice:
    // 把offset信息写到upto的前三位里面
    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:
    // 往新的upto的前一位里面塞入level信息
    buffer[byteUpto-1] = (byte) (16|newLevel);
    // 返回可以写入元素的位置, 也就是那个x所在的位置
    return newUpto+3;
  }

一定要结合我写的中文注释来看, 看完以后我举个例子:

一开始分配的slice大小从level1 开始分配,也就是5, 具体每个level的长度是由 LEVEL_SIZE_ ARRAY 来决定的,具体来说就是是 {5, 14, 20, 30, 40, 40, 80, 80, 120, 200}。 然后在第5-1=4位给上一个标记位16|level , 也就是17。

接下来我们依次要写入12, 15, 2, 5, 11这五个字节, 我们首先写入12, 15, 2, 5 都是没问题的, 只要这个位置是0就往里面写入数据;

要写入11的时候撞到17了, 开始分配slice, 这时候的byteUpto是5, 就要把这个offset信息记录下来,记录在哪里呢?用byteUpto的前四位记录, 但前四位里面的三位都有数据了啊, 怎么办?那就把这三位数据写在byteUpto后面三位。 然后新分配的slice长度是14,也就是从5-19 这14个格子就是下一个slice,标志位放在第18位, 16|level 也就是18 ,这时候再把要写的内容11 放在新分配的这个slice里面。

我知道所有人都会很疑惑这么做的目的是什么, 为什么要折腾一个这么复杂的机制?其实本质原因就是这个pool不是给你一个term用的,而是所有term都会写到这个pool里面,比如有个term列表: [ I, love, you , love, I] , 写完I 写love再写you, 再写到下一个love记录它的pos 的时候, 应该是要定位到上一个love所在的slice中的,同理写I 的时候也应该是写在上一个I所在的slice中。本质上也就是说,由于你只能流式地去扫整个term, 导致你每次看到一个新term,就一定要追加在block的后面, 而看到一个旧term, 也只能通过追加的方式去写在后面, 那最后怎么把一个term的所有pos信息串起来呢?那就通过记录offset信息告诉以后要来读取的对象: 我这个没写完, 下一块的地址在某个地址上面, 你得跳到那块地址上面去继续读。

IntBlockPool

这个类和上面那个ByteBlockPool其实是差不多的, 但是目的不太一样,这个类的作用主要是用来记录偏移值的,后面介绍TermsHashPerField的时候会说, 而偏移值是用Int来表示的, ByteBlockPool是用来记录内容byte的,从名字就能看出来它们的区别。 另外由于它们记录的内容属性不太一样,所以这个pool的参数也有些区别。

  1. 首先区别在于每一行元素数的数量不一样, 也就是pool的列数不一样, 对于ByteBlockPool来说,这个数字是1<<15,也就是32768 ;但对于IntBlockPool 来说, 这个数字是1<<13 ,也就是8192,为什么小这么多呢?很简单, 因为IntBlockPool记录的是int,1Int=4byte, 8192 * 4 = 32768 ,两个pool实际占用的内存都是一样一样的。
  2. 分配newSlice的时候标记位是不一样的, ByteBlockPool 一开始用16当作标记位,每次碰到标记位的时候和15进行一个&运算, 得到下一个slice的level, 而IntBlockPool一开始直接用1当作标记位, 直接就是下一个slice的level。还没搞明白为什么要这么做;
  3. 每个level 的size都不一样, ByteBlockPool 每一层的size是{5, 14, 20, 30, 40, 40, 80, 80, 120, 200}; IntBlockPool每一层的size是 {2, 4, 8, 16, 32, 64, 128, 256, 512, 1024} 很大程度上也是因为它俩装的东西不一样, ByteBlockPool装的是内容, 无法预知长度, 所以出来的size都是十进制整数,IntBlockPool 装的是int, 可以知道它的长度,所以size就是二进制整数;
  4. 扩容的时候记录下一个slice所在位置偏移的方式不一样, ByteBlockPool 由于它的元素都是byte,所以一个byte(256) 装不下这个offset, 所以用了四个byte值去凑成一个int去表示这个offset, 但是IntBlockPool本身不它的元素就是int,所以用一个int就能表示了。

ByteRefHash

这个类简而言之是实现了一个hashMap,维护了一个从id到bytes的一个映射关系, 并且能够将字节有效地存储在一个连续空间内, 这种映射关系是被封装在这个类里面的, 并且能够保证对于每个bytes对象都会分配一个递增的id, 看下它的注释怎么说的:

/**
 * {@link BytesRefHash} is a special purpose hash-map like data-structure
 * optimized for {@link BytesRef} instances. BytesRefHash maintains mappings of
 * byte arrays to ids (Map&lt;BytesRef,int&gt;) storing the hashed bytes
 * efficiently in continuous storage. The mapping to the id is
 * encapsulated inside {@link BytesRefHash} and is guaranteed to be increased
 * for each added {@link BytesRef}.
 * 
 * <p>
 * Note: The maximum capacity {@link BytesRef} instance passed to
 * {@link #add(BytesRef)} must not be longer than {@link ByteBlockPool#BYTE_BLOCK_SIZE}-2. 
 * The internal storage is limited to 2GB total byte storage.
 * </p>
 * 
 * @lucene.internal
 */

BytesRefHash类图

上面给出了它的类图,重点看下它的findHash 、add 和get方法

findHash 方法

输入一个BytesRef(所谓BytesRef就是一个任意bytesd对象) 返回它的id 值。

推荐对着代码直接读, 比较容易理解,给定一个BytesHash流程是这样的:

  1. 用murmurHash3 算法计算hash值, 关于murmusrHash3 的介绍在这里 : MurmurHash3_最详细的介绍_温柔善良懒屁股-CSDN博客_murmurhash, 我们只需要知道它是一个不容易碰撞的、性能也不错的Hash算法就行,这个算出来的是一个int值,我们叫它code
  2. 把这个code和hashMask做个&操作得出hashPos值 这个hashMask其实就是容量-1, 这样就能保证出来的值一定在ids数组范围以内。
  3. 从ids里面取出hashPos位置对应的值, 这个值我们叫它e, 如果这个位置有值的话,应该是一个序列值, 如果没有值,应该是-1.
  4. 用线性探测法来确定是否冲突, 所谓线性探测法就是说,如果这个e值等于-1或者不等于-1但对应的bytesRef刚好就是输入的bytesRef, 就说明找到了。但如果不是-1, 并且这个位置上对应的bytesRef不等于输入的bytesRef, 那就说明没找到, 需要把code + 1 ,然后进行步骤2、3、4.. 这就是线性探测了;
  5. 把最后的hashPos值返回。

为什么这里的hashMap没有采用拉链法而是采用了线性探测?推荐阅读一下这篇文章 issues.apache.org/jira/browse…

也就是说通过benchmark发现在大多数情况下, murmur3 + 线性探测法是要比拉链碰撞概率要低的。最后看下代码:

private int findHash(BytesRef bytes) {
    int code = doHash(bytes.bytes, bytes.offset, bytes.length); // 步骤1 

    // final position
    int hashPos = code & hashMask; // 步骤2 
    int e = ids[hashPos]; // 步骤3
    if (e != -1 && !equals(e, bytes)) { // 步骤4
      // Conflict; use linear probe to find an open slot
      // (see LUCENE-5604):
      do {
        code++;
        hashPos = code & hashMask;
        e = ids[hashPos];
      } while (e != -1 && !equals(e, bytes));
    }
    
    return hashPos;
  }

add 方法

输入一个BytesRef,如果它不存在,就进行添加操作, 并返回它的id值, 如果它存在,就返回- (id + 1)。为什么这么操作呢?直接返回-id不行吗? 不行,如果这个id是0怎么办?它有了二义性。

接下来说它不存在,要进行添加操作时的步骤:

  1. BytesRef 对象的length + 2就是即将要占用BytesBlockPool的大小, 这个pool我们也不陌生了,之前重点讲过, 每次拿到的是其中的一个buffer对其进行写入,所以我们首先要检查一下bytesRef.length + 2是否>这个buffer的边界32768, 如果大于就挪到下一个buffer。
  2. 为什么+2? 因为我们没地方专门记录这个bytesRef对象的长度,所以在写入pool的时候前两位或者前一位要留着存它的length,如果这个长度<128,就一个byte就能满足。如果长度>128需要两个byte。 第一个byte存低7位, 第二个byte存高7位。
  3. 把BytesRef的内容直接copy到pool的buffer后面;
  4. count ++ 序号递增1,以后这个序号e就代指这个bytesRef对象了;
  5. bytesStart记录一下这个序号e到blockPool写入的起始位置的映射。
  6. 如果此时元素的数量超过了整个容器的一半,就要进行rehash, rehash是个复杂度比较高的操作, 它会将每个元素重新算一遍hash值,然后和mask进行运算以后重新进行分配赋值,代价较高。

代码如下:

  public int add(BytesRef bytes) {
    assert bytesStart != null : "Bytesstart is null - not initialized";
    final int length = bytes.length;
    // final position
    final int hashPos = findHash(bytes);
    int e = ids[hashPos];
    
    if (e == -1) {
      // new entry
      final int len2 = 2 + bytes.length;
      if (len2 + pool.byteUpto > BYTE_BLOCK_SIZE) {
        if (len2 > BYTE_BLOCK_SIZE) {
          throw new MaxBytesLengthExceededException("bytes can be at most "
              + (BYTE_BLOCK_SIZE - 2) + " in length; got " + bytes.length);
        }
        pool.nextBuffer();
      }
      final byte[] buffer = pool.buffer;
      final int bufferUpto = pool.byteUpto;
      if (count >= bytesStart.length) {
        bytesStart = bytesStartArray.grow();
        assert count < bytesStart.length + 1 : "count: " + count + " len: "
            + bytesStart.length;
      }
      e = count++;

      bytesStart[e] = bufferUpto + pool.byteOffset;

      // We first encode the length, followed by the
      // bytes. Length is encoded as vInt, but will consume
      // 1 or 2 bytes at most (we reject too-long terms,
      // above).
      if (length < 128) {
        // 1 byte to store length
        buffer[bufferUpto] = (byte) length;
        pool.byteUpto += length + 1;
        assert length >= 0: "Length must be positive: " + length;
        System.arraycopy(bytes.bytes, bytes.offset, buffer, bufferUpto + 1,
            length);
      } else {
        // 2 byte to store length
        buffer[bufferUpto] = (byte) (0x80 | (length & 0x7f));
        buffer[bufferUpto + 1] = (byte) ((length >> 7) & 0xff);
        pool.byteUpto += length + 2;
        System.arraycopy(bytes.bytes, bytes.offset, buffer, bufferUpto + 2,
            length);
      }
      assert ids[hashPos] == -1;
      ids[hashPos] = e;

      if (count == hashHalfSize) {
        rehash(2 * hashSize, true);
      }
      return e;
    }
    return -(e + 1);
  }

get方法

理解了add方法以后就不难理解get方法了, get方法输入一个bytesID, 返回它的bytesRef。

方法很朴素, 就是直接去bytesStarts数组里面去找到在pool里面存在的位置,然后去读第一位确定它的长度length以后, 再去读length个单位的bytes作为它的内容。非常好理解。

Demo

给个小demo玩一玩。

TermsHashPerField

终于要写到最后的这个核心类了,写这个类会把前面介绍的这些核心类的知识点都串起来。

大概UML图长上面那样,会比较复杂,慢慢看;具体负责写入的类并不是TermsHashPerField类, 而是FreqProxTermsWriterPerField, 这个类继承了TermsHashPerField而已,大多数的方法和属性还是在TermsHashPerField上。

IntPool(IntBlockPool)

用于记录某个term下次应该从byteBlockPool的哪个位置开始写;

bytePool(ByteBlockPool)

所有写入的具体内容都在这里;具体写入的内容包括freq, pos ,offset, payload;

postingsArray(PrallelPostingsArray)

可能很多人读到现在没有看到形似于经典倒排表termID-> docID映射的结构, 这个类就是倒排表了,里面所有的元素的下标都是termID, 真正使用的是它的子类比如FreqProxPostingsArray类,termID 对应的项会根据当前的docID来不断动态更改, 下面以这个类来做具体讲解

textStarts int[]

每个下标是termID,每个值是指它在bytePool的起始位置;

byteStarts int[]

下标是termID, 值是它在bytePool下次要写入的位置,注意之前我们说过IntPool也是用来做这个事情的,但是它俩的区别在于, intPool还包括了term下面的每个stream在bytePool的起始位置, 而byteStarts记录的是这个term下第1个stream在bytePool的其实位置;

intStarts int[]

下标是termID, 值是在intPool的起始位置。

termFreqs int[]

下标是termID, 值是在当前doc下出现的次数。

lastDocIDs int[]

下标是termID, 值是这个term上次出现在哪个doc中

lastDocCodes int[]

下标是termID, 值是这个term出现的docID | freq的组合值

lastPositions int[]

下标termID, 值是它上次出现在的position的位置

lastOffsets int[]

下标termID,值是这个term上次出现的offset位置;

docState

TermsHashPerField是field独占级别的,也就是说, 有几个field就有几个termHashPerField,它们在处理不同文档的时候,docState会随之发生变化..

fieldState

fieldState不仅仅是field独占级别的存在,更是doc独占级别的;可以说每处理一个doc的时候, fieldState都会重新初始化一份;

writeByte(int stream, byte b)逻辑梳理

在讲add之前先将一下TermsHashPerField如何写入一个byte的, 这个类首先会维护一个数组intUptos, 它其实就是intPool当前正在写入的那一行的一个引用, 另外还会维护一个int值intUptoStart,用来记录intUptos数组写入的位置。

stream之前提到过,每个stream都代表一类值, 像经典的FreqProxTermsWriterPerThread通常有两个stream, 像position,payload之类的值都会写到第二个stream上面去(序号1),所以此时stream为1; 而termFreq和lastDocCodes这种会写到第一个stream上面(序号0)。

void writeByte(int stream, byte b) {
    int upto = intUptos[intUptoStart+stream]; // 锁定到bytePool的绝对位置
    byte[] bytes = bytePool.buffers[upto >> ByteBlockPool.BYTE_BLOCK_SHIFT]; // 获得当前写入bytePool的行
    assert bytes != null;
    int offset = upto & ByteBlockPool.BYTE_BLOCK_MASK; // 获得bytePool当前写入的起始列
    if (bytes[offset] != 0) { // 如果非0,碰到flag,需要扩容了
      // End of slice; allocate a new one
      offset = bytePool.allocSlice(bytes, offset); //算出新offset
      bytes = bytePool.buffer;
      intUptos[intUptoStart+stream] = offset + bytePool.byteOffset; // 更新intUpto
    }
    bytes[offset] = b;  //将byte 写入的当前的bytes数组中 
    (intUptos[intUptoStart+stream])++;  // intUpto对应位前进一位
  }

add()核心逻辑

1.先调用bytesHash 把这个term add进去, 由于bytesHash内部的bytesPool用的和 TermsHashPerField指向的是同一个,所以后面在往bytesPool写入的时候用的也是bytesHash内部的这个pool(这一点很重要),add方法会返回当前term的termID, 如果是正数就是新的term, 需要新建一个倒排, 如果是负数就是老term,那就需要老倒排表上写;

如果是情况1,表示首次看到这个term:进行如下操作

①:intPool 的intUpto+=streamCount,往前推streamCount个单位(这个数字通常是2), 这个intPool的intUpto的作用是记录下次要从intPool的哪个地方开始写,往前推两个单位的意思就是说:这两个位置我先占了, 后面要写的话从第3个位置开始写..

②:postingArray 的intStarts数组记录一下这个termID所在的intPool的绝对位置;

③:给每个stream分配一个bytePool的slice, 这个slice是用于写具体内容的, 把slice的地址填到intPool的里面;比如两个stream,第一个stream分配了bytePool的slice是从7开始的, 那就把7放到intPool的第0个位置里面, 然后给第二个stream分配的slice是从12开始的(slice的第一层是5),就把12放到intPool的第1个位置中。

④:postingsArray的byteStarts数组记录这个termID在bytePool的起始位置,也就是7

⑤: postingsArray 的lastDocIDs 数组记录这个termID对应的docID;

⑥: postingsArray 的lastDocCodes 记录termID对应的docID << 1;

⑦: postingsArray 的termFreqs 记录termID对应的词频;

⑧: 写入位置信息, writeProx, 当没有payload的时候, 调用writeVint(1, position<<1) , 关于vint前几张已经说过了, 当position在7位以内可以表示时,可以理解为就是调用了writeByte(1, position<<1) ;当含有payload的时候, 首先调用writeVint(1, (position<<1|1)),也就是position的最后一位用来标识是否有payload信息,1 为有,0为没有;接着调用writeVInt(1, payload.length),,最后调用writeBytes(1, payload.bytes, payload.offset,payload.length) , 本质上就从offset开始调用length次writeByte,简单粗暴到源代码里都知道把它作为一个todo项需要优化的程度(优化的方向大概是提前判定slice扩容,避免writeByte里频繁多次的判断。

⑨: 更新fieldState状态, 比如maxTermFrequency, uniqueTermCount;

如果是情况2, 说明term之前出现过了

① 这时候就要调用postingsArray的intStarts数组了,这个数组记在了这个term在intPool的具体位置,intStart, 拿着这个IntStart就可以去intPool里面去读取其对应的值,更改当前的intUptos以及intUptoStarts;

② 如果这个term还在当前的doc的话,干两件事:

Ⅰ 更新postingsArray的termFreqs数组,词频+1;

Ⅱ 然后写入位置信息, writeProx(termID, fieldState.position-postings.lastPositions[termID]), 这个位置信息为了压缩是使用差值编码的;

③如果这个term在之前的doc出现过的话,干三件事:

Ⅰ 如果postings.termFreqs里termID对应的值为1 , 也就是说该term的词频为1的时候, writeVint(0, postings.lastDocCodes[termID]|1 writeVint(0, postings.termFreqs[termID]) 注意这里写的stream序号是0, 不是之前的1, 也就是说,第0个stream 存放 的是tf 信息以及lastDoc信息.

Ⅱ 对于当前的document, postings的termFreqs lastDocCodes, lastDocIDs 都需要做响应的更新。

Ⅲ 调用writeProx(termID, fieldState.position) 记录position信息。

Demo

public static void MAIN() throws IOException {
        // 0. Specify the analyzer for tokenizing text.
        //    The same analyzer should be used for indexing and searching
        StandardAnalyzer analyzer = new StandardAnalyzer();
        // 1. create the index
        Directory directory = FSDirectory.open(Paths.get("demoPath"));
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        IndexWriter w = new IndexWriter(directory, config);
        addDoc(w, "Lucene Action lucene");
        addDoc(w, "like Lucene and CPP");
        addDoc(w, "Lucene and lucene");

        w.close();
}


private static void addDoc(IndexWriter w, String title) throws IOException {
        Document doc = new Document();
        doc.add(new TextField("title", title, Field.Store.YES));

        w.addDocument(doc);
    }

这个小demo主要写三个doc,这三个doc下的title如下: Lucene Action lucenelike Lucene and CPPLucene and lucene ,看下检索引擎是怎么处理这些的:

1 处理“Lucene Action lucene" 下的第一个"lucene"

  1. bytePool 第0号位代表“lucene" 这个term的长度, 有6位就写6;
  2. bytePool 第1-6位下写入"lucene"这个term的unicode编码, 分别是108, 117, 99, 101, 110, 101;
  3. bytePool 第7位到第11位是代表"lucene"的第一个stream的一个slice,这个stream用来写入倒排链的,每个元素是上一个doc的docID与freq的组合值;目前由于是第一次出现这个元素,所以这个stream都是空值0;
  4. bytePool 第12位到第16位是代表"lucene"的第二个stream的一个slice, 这个stream是用来装在文档里出现的position的;由于目前"lucene"的位置是0, 所以第12位写入0<<1, 往左边移一位的目的是为了把最后一位作为标识位,标识是否有payload存在, 如果没有payload,最后一位一定是0, 如果有payload,最后一位是1, 关于payload后面再说,属于一种高级用法, 大多数情况我们不需要给文档加payload。
  5. intPool的第0位写入"lucene"这个term的第一个stream的下一次写入的起始位置;
  6. intPool的第1位写入"lucene"的第二个stream下次写入的其实位置, 因为位置12已经被写过了,所以这里是13;
  7. postingArray的textStarts数组的第0位标记为0, 因为这个term是从bytePool的第0位开始写的;
  8. postingArray的intStarts数组的第0位被标记位0, 因为这个term是从intPool的第0位写的;
  9. postingArray的byteStarts数组的第0位被标记位7, 因为当前term的倒排拉链stream是从第7位开始往后写的;
  10. postingArray的lastDocCodes的第0位被标记位0, 这个DocCode的运算规则是docID<<1.
  11. postingArray的lastPositions的第0位被标记位0, 是指这个term当前position;
  12. termFreqs的第0位被标记为1,表示这个term的词频目前为1;

实际上,postingsArray的textStart, intStarts, byteStarts置位以后是不会原地更改的,而lastDocCodes, lastPositions,lastDocIDs, termFreqs这些属性是会当处理到下一个文档时而发生更改的,所以可以认为前三个属性是全局的,后四个属性是文档级别的。

2 处理"Lucene Action lucene" 的"action"

接下来处理第二个term->action,为什么我们输入是Action,到这里就变成action了?被normalize了,这部分内容后续再说, 因为已经不属于在线索引的内容了,属于离线处理阶段的内容。这里已经用黄色来标识出了"action"这个term。由于这是一个新term,逻辑上也没什么变化,所以在叙述的时候有些地方就省了;

  1. bytePool 第17号位代表“action" 这个term的长度;
  2. bytePool 第18-23位下写入"action"这个term的unicode编码;
  3. bytePool 第24位到第28位是代表"action"的第一个stream的一个slice;
  4. bytePool 第29位到第33位是代表"lucene"的第二个stream的一个slice;由于目前"action"的位置是1, 所以第29位写入1<<1=2;
  5. intPool的第2位写入"action"这个term的第一个stream的下一次写入的起始位置24;
  6. intPool的第3位写入"action"的第二个stream下次写入的起始位置30;
  7. postingArray的textStarts数组的第1位标记为17, 因为这个term是从bytePool的第17位开始写的;
  8. postingArray的intStarts数组的第1位被标记位2, 因为这个term是从intPool的第2位写的;
  9. postingArray的byteStarts数组的第1位被标记位24, 因为当前term的倒排拉链stream是从第24位开始往后写的;
  10. postingArray的lastDocCodes的第1位被标记位0, 这个DocCode的运算规则是docID<<1.
  11. postingArray的lastPositions的第1位被标记位1, 是指这个term当前position;
  12. postingsArray 的termFreqs的第1位被标记为1,表示这个term的词频目前为1;
  13. postingsArray 的lastDocIDs 的第1位被标记位0, 就是当前docID;

3 处理"Lucene Action lucene" 的第二个 "lucene"

好了,我们遇到第一篇文档里面里面的第二个lucene了, 此时有如下更新:

  1. bytePool的位置13写入当前的position<<1 , 当前term所在的position是2, 所以写入2<<1 = 4;
  2. 更新intPool的第1个位置,从13更新到14;
  3. 更新postingArray的lastPosition的第0个元素,更新为2,表示"lucene"这个term的上一个position为2;
  4. 更新postingArray的termFreqs的第0个元素为2,表示"lucene"已经出现2次了;

4 处理"Like Lucene and Cpp" 的"Like"

我们现在处理第二篇文档, docID变成了1, 先处理“like"这同样是个新term,termID为2, 流程和第1和第2步大同小异:

  1. bytePool 第34号位到38号位代表“like" 这个term的本身;
  2. bytePool 第39位到第43位是代表“like"的第一个stream的一个slice;
  3. bytePool 第44位到第48位是代表"like"的第二个stream的一个slice;由于目前"action"的位置是1, 所以第29位写入1<<1=2;
  4. intPool的第4位和第5位分别代表两个stream在BytePool的下一个写入位置;
  5. postingArray的textStarts数组, intStarts, byteStarts 数组的第2位分别被标记为34, 4, 39;
  6. postingArray的lastDocCodes的第2位被标记位2, 这个DocCode的运算规则是docID<<1,也就是 1<<1=2;lastDocIDs 的第2位被标记位1, 就是当前docID 1 ;
  7. postingArray的lastPositions的第2位被标记位0, 是指这个term当前position;
  8. postingsArray 的termFreqs的第2位被标记为1;

5 处理"Like Lucene and Cpp" 的"lucene"

  1. "lucene"这个term再次出现了, 由于它的词频大于2, 所以在stream0 里先写入它的对应的 lastDocCodes 0, 代表这个term出现的上一个docID, 然后写入2, 代表该term的词频。 所以bytePool的7和8被分别更新成了0 和2 , 而IntPool的第0位也需要+2从7变成9, 代表下次从9写入。
  2. stream1 写入这个term的position: 1, 由于没有payload,所以实际写入1<<1;intPool对应位+1;
  3. 更新term0的lastDocCodes, 也就是用当前的docID - 上一个DocID 得到的差值 << 1 ,得出2;
  4. 更新term0的lastDocID, 也就是1,以及更新tf为1(这个termFreqs是文档级别的,不是全局的)

6 处理"Like Lucene and Cpp" 的"Cpp"

为啥不处理and, 因为and是停用词,不需要建入倒排拉链中, 所以跳过and直接建cpp,但在计算pos的时候, cpp仍然是3, 而不是2,这个需要清楚。

在处理"cpp"的时候还是比较常规的, 由于这是一个新的term,所以处理逻辑跟1 2 一致

  1. bytePool 第49号位到52号位代表“cpp" 这个term的本身;
  2. bytePool 第53位到第57位是代表“like"的第一个stream的一个slice;第58位到第62位是第二个stream的一个slice;由于目前"cpp"的位置是3, 所以第58位写入3<<1=6;同时intPool逻辑
  3. intPool的第4位和第5位分别代表两个stream在BytePool的下一个写入位置;
  4. postingArray的textStarts数组, intStarts, byteStarts 数组的第3位分别被标记为49, 6, 53;
  5. postingArray的lastDocCodes的第3位被标记位2, 这个DocCode的运算规则是docID<<1;
  6. postingArray的lastPositions的第3位被标记为3, 是指这个term当前position;termFreqs的第3位被标记为1;

7 处理"Lucene and lucene" 的第一个"lucene"

这是第三篇包含这个term的文档了,里面有个步骤有一点小差别:

  1. 差别就在于写入stream0 这一步, 之前由于在处理第二篇文档的时候, 它的上一篇文档的词频为2, 所以采用了先写入lastDocCode, 在写入tf这个策略, 但这次它上一篇文档的词频为1, 所以直接写入lastDocCodes|1, 由于它的lastDocCode是2, 所以写入2|1 = 3, 节省了一位;同时intPool对应的位+1变成10;
  2. stream1 写入这个term的position 0, 由于没有payload,所以实际写入0<<1=0, 在第15位写入, ;同时intPool对应位+1变成16;
  3. 更新term0的lastDocCodes, 也就是用当前的docID - 上一个DocID 得到的差值 << 1 ,得出2;
  4. 更新term0的lastDocID, 也就是2,以及更新tf为1;

8 处理"Lucene and lucene" 的第二个"lucene"

前面铺垫了这么久的slice机制终于要来了;

  1. 现在要在第二个stream里面写入当前term的pos信息了, 但是在写入的时候发现第16位为标记位16, 无法写入,这时候就要触发扩容机制了,扩出来的第二片slice的容量为14(这是个常数,详细的slize size array为{5, 14, 20, 30, 40, 40, 80, 80, 120, 200} , 也就是说从63到76这14个区间 这一片都是属于第二个slice的范围, 同时把标志位写入第76位,标志位为17(16|level)。扩出来以后,需要告诉第一个slice:第二个slice的位置从63位开始读,也就是要把这个数字写入到第一个slice的末尾处, 用四个byte来表示这个绝对位置, 但前3个byte已经有内容4,2,0了, 怎么办呢?腾笼换鸟,把这三字字节挪到新slice里面去,所以63到65的内容就是13到15的内容, 然后把这个绝对位置(forward_address) 写入到第13到第16位这里, 最多能表示2<<32 地址;然后写pos2 到66位这里, 2<<1 = 4 ;
  2. 其它部分也就跟之前大同小异了, intPool的第1位挪动到了第66位;
  3. 更新termFreqs 的第0位位2,lastPosition为2;

后续

现在倒排表在内存里生成的逻辑已经全部讲完了,为什么要弄这么复杂?为了内存节省和运行效率, 可以发现lucene很少去用一些基本的数据结构,或者现成的内存池方案等,把内存分配等完全把握在代码逻辑中;

后面一篇就开始讲落盘逻辑吧。