Lucene源码系列(十三):内存中倒排信息的构建

2,806 阅读29分钟

背景

在全文匹配搜索引擎中,最核心的操作就是查找包含query词的文档。为了高效完成根据query词搜索相关文档,最最重要的就是要构造倒排索引。那倒排索引的结构是怎样的,其中到底有什么信息呢?Lucene的倒排信息有以下这些部分:

  • docid:term所属的文档id
  • freq:term在文档中出现的频率
  • position:term在文档中的位置,term在文档中是可以出现多次的
  • startOffset:term在文档中出现的某个位置的起始offset
  • endOffset:term在文档中出现的某个位置的结束offset(真正含义应该是tokenizer切分的位置,term是经过二次处理的到,后面会详细介绍)

我们看个例子,如下图所示,我们有两个文档,每个文档只有一个字段field0,简易的倒排结构如下图所示。

倒排序信息示意图.png

本文就是介绍这些倒排信息在内存中的存储结构,为什么要特地强调是内存中的结构,因为lucene在索引构建过程中先把索引信息存储在内存中,flush的时候才进行持久化到索引文件中,而内存中和索引文件中的倒排数据存储格式是不一样的,本文介绍的是内存中的存储结构,索引文件中的存储结构我们后面介绍。

注意:本文源码解析基于Lucene 9.1.0版本。

必看工具类

ByteBlockPool

从底层数据结构看,ByteBlockPool就是一个二维字节数组。

从宏观使用上看,ByteBlockPool是一个buffer集合,每次要写入数据需要从集合中取出buffer来进行写入,二维字节数组的一行就是一个buffer,创建ByteBlockPool之后buffer的大小就是固定的,对ByteBlockPool来说也就是每一行的大小是固定的。

往ByteBlockPool中写入数据的数据源叫做stream。当只有一个数据源为ByteBlockPool提供数据时,则简单地按顺序写入,把一个个buffer填满,如果buffer不够再创建新buffer即可。

但是如果是多个数据源为ByteBlockPool提供数据时,怎么在写入ByteBlockPool之后分别读取每个数据源的数据呢?

一种方案是以buffer的粒度为每个数据源分配空间,多个buffer用buffer最后的一部分空间指定下一个buffer的地址,这样就把同一个数据源的buffer串联起来,我们记录数据源的第一个buffer的位置就能获取数据源的所有数据。

但是这种方案有个问题,我们buffer是固定的,如果数据源的数据远小于buffer大小,则浪费了内存资源。

为了改善这个问题,ByteBlockPool中引入了slice的概念,使用更小的粒度为不同的数据源分配空间。同样地,同一个数据源的slice通过尾指针就能把slice串起来,我们记录每个数据源的第一个slice的地址就能获取指定数据源的全部数据。如下图所示:

ByteBlockPool概念图.png

那slice应该分配多大呢,这个谁也说不准,所以为slice指定了level的概念,level的大小决定了slice的大小,先分配小的slice,不够再分配大一点的slice,大于一定程度slice大小就固定了,其实就是一种简单的内存分配策略。

需要格外注意的是,slice链表最后一个slice的最后一个字节是结束标记,值是: 16 | level。slice刚分配除了这个标记之外都是0,所以如果在当前的slice中碰到了一个非0的,则说明到了slice的结束位置了,需要创建一个新的slice,从这个标记中取出当前slice的level,Lucene中用NEXT_LEVEL_ARRAY数组记录每个level的下一个level值,在数组LEVEL_SIZE_ARRAY中记录了level对应的slice大小,通过当前level从NEXT_LEVEL_ARRAY中获取下一个level值,再根据下一个level值从LEVEL_SIZE_ARRAY获取下一个slice的大小了。

知道了ByteBlockPool是个什么东西,我们再来看它的源码就简单多了。

常量

// 控制buffer的大小,也就是每行的大小。
public static final int BYTE_BLOCK_SHIFT = 15;

// buffer的大小
public static final int BYTE_BLOCK_SIZE = 1 << BYTE_BLOCK_SHIFT;

// 用来做位运算定位buffer中的位置
public static final int BYTE_BLOCK_MASK = BYTE_BLOCK_SIZE - 1;

// 下标是当前的level,值是下一个level的值。可以看到是从level 0开始的,到level 9之后就不变了。
public static final int[] NEXT_LEVEL_ARRAY = {1, 2, 3, 4, 5, 6, 7, 8, 9, 9};

// 下标是level,值是level对应的slice的大小。
public static final int[] LEVEL_SIZE_ARRAY = {5, 14, 20, 30, 40, 40, 80, 80, 120, 200};

// level 0 的slice
public static final int FIRST_LEVEL_SIZE = LEVEL_SIZE_ARRAY[0];

成员变量

// 真正用来存数据的空间。buffers[i]可以理解成第i个buffer
public byte[][] buffers = new byte[10][];

// 当前正在写入的buffer是第几个buffer(从二维数组的角度就是第几行)
private int bufferUpto = -1;

// 在buffer中的偏移量(从二维数组的角度就是第几列)
public int byteUpto = BYTE_BLOCK_SIZE;

// 当前正在使用的buffer
public byte[] buffer;

// 当前正在使用的buffer的起始位置在整个pool中的偏移量
public int byteOffset = -BYTE_BLOCK_SIZE;

// 用来创建新buffer的,也就是字节数组
private final Allocator allocator;

对于下一个要写入的红色的位置,各个成员变量对应的值如下::

ByteBlockPool成员变量示意图.png

内部类

buffer分配器抽象类:

// 分配和回收字节数组
public abstract static class Allocator {
  // buffer的大小  
  protected final int blockSize;

  protected Allocator(int blockSize) {
    this.blockSize = blockSize;
  }

  // 回收buffer
  public abstract void recycleByteBlocks(byte[][] blocks, int start, int end);

  public void recycleByteBlocks(List<byte[]> blocks) {
    final byte[][] b = blocks.toArray(new byte[blocks.size()][]);
    recycleByteBlocks(b, 0, b.length);
  }

  // 创建一个新buffer 
  public byte[] getByteBlock() {
    return new byte[blockSize];
  }
}

不支持回收的分配器实现类:

public static final class DirectAllocator extends Allocator {

  public DirectAllocator() {
    this(BYTE_BLOCK_SIZE);
  }

  public DirectAllocator(int blockSize) {
    super(blockSize);
  }

  @Override
  public void recycleByteBlocks(byte[][] blocks, int start, int end) {}
}

跟踪内存使用的分配器实现类:

public static class DirectTrackingAllocator extends Allocator {
  // 内存大小占用统计器  
  private final Counter bytesUsed;

  public DirectTrackingAllocator(Counter bytesUsed) {
    this(BYTE_BLOCK_SIZE, bytesUsed);
  }

  public DirectTrackingAllocator(int blockSize, Counter bytesUsed) {
    super(blockSize);
    this.bytesUsed = bytesUsed;
  }

  @Override
  public byte[] getByteBlock() {
    // 更新内存占用统计  
    bytesUsed.addAndGet(blockSize);
    return new byte[blockSize];
  }

  // 回收buffer 
  public void recycleByteBlocks(byte[][] blocks, int start, int end) {
    bytesUsed.addAndGet(-((end - start) * blockSize));
    for (int i = start; i < end; i++) {
      blocks[i] = null;
    }
  }
}

核心方法

  • reset

    重置整个ByteBlockPool

      public void reset() {
        reset(true, true);
      }
    
      // zeroFillBuffers:是否把所有的buffer都填充为0
      // reuseFirst:是否需要保留复用第一个buffer
      public void reset(boolean zeroFillBuffers, boolean reuseFirst) {
        // 如果pool使用过,才需要进行重置  
        if (bufferUpto != -1) {
          // 为所有的用过的buffer填充0
          if (zeroFillBuffers) {
            for (int i = 0; i < bufferUpto; i++) {
              Arrays.fill(buffers[i], (byte) 0);
            }
            // 最后一个使用到的buffer只填充用过的部分
            Arrays.fill(buffers[bufferUpto], 0, byteUpto, (byte) 0);
          }
    
          // 除了第一个buffer,其他buffer都回收  
          if (bufferUpto > 0 || !reuseFirst) {
            final int offset = reuseFirst ? 1 : 0;
            allocator.recycleByteBlocks(buffers, offset, 1 + bufferUpto);
            Arrays.fill(buffers, offset, 1 + bufferUpto, null);
          }
          // 如果需要复用第一个buffer,则重置相关参数  
          if (reuseFirst) {
            bufferUpto = 0;
            byteUpto = 0;
            byteOffset = 0;
            buffer = buffers[0];
          } else {
            bufferUpto = -1;
            byteUpto = BYTE_BLOCK_SIZE;
            byteOffset = -BYTE_BLOCK_SIZE;
            buffer = null;
          }
        }
      }
    
  • nextBuffer

    创建下一个buffer。这个方法重点关注的是新建buffer之后,放弃上一个buffer的剩余空间,下一次写入就直接写入到新buffer。

    public void nextBuffer() {
        // 如果buffer数组满了,则进行扩容  
        if (1 + bufferUpto == buffers.length) {
            byte[][] newBuffers =
                new byte[ArrayUtil.oversize(buffers.length + 1, NUM_BYTES_OBJECT_REF)][];
            System.arraycopy(buffers, 0, newBuffers, 0, buffers.length);
            buffers = newBuffers;
        }
        // 创建下一个buffer
        // 这里重点关注下,创建新buffer之后所有位置参数都定位到新buffer,所以放弃了上一个buffer的剩余空间。
        buffer = buffers[1 + bufferUpto] = allocator.getByteBlock();
        bufferUpto++;
        byteUpto = 0;
        byteOffset += BYTE_BLOCK_SIZE;
    }
    
  • nextSlice

    通过指定的大小创建下一个slice。这个方法只会在创建数据源第一个slice的时候调用,size就是level 0的大小。对于同一个数据源,从第2个slice开始就是由数组NEXT_LEVEL_ARRAYLEVEL_SIZE_ARRAY控制slice的大小了。

    // size:要创建的slice大小 
    public int newSlice(final int size) {
        // 如果当前buffer剩余空间无法容纳新的slice,则创建一个新的buffer
        if (byteUpto > BYTE_BLOCK_SIZE - size) nextBuffer();
        final int upto = byteUpto;
        byteUpto += size;
        // slice结束的哨兵,要理解成level=0,哨兵是  16|level 
        buffer[byteUpto - 1] = 16;
        return upto;
    }
    
  • readBytes

    从pool的offset处读取bytesLength个字节到bytes中bytesOffset的位置。可以处理跨buffer的情况。

    public void readBytes(final long offset, final byte[] bytes, int bytesOffset, int bytesLength) {
      // 剩余需要读取的数据量  
      int bytesLeft = bytesLength;
      // 定位到从哪个buffer开始  
      int bufferIndex = (int) (offset >> BYTE_BLOCK_SHIFT);
      // 定位到buffer的位置  
      int pos = (int) (offset & BYTE_BLOCK_MASK);
      // 读取所需的数据  
      while (bytesLeft > 0) {
        byte[] buffer = buffers[bufferIndex++];
        int chunk = Math.min(bytesLeft, BYTE_BLOCK_SIZE - pos);
        System.arraycopy(buffer, pos, bytes, bytesOffset, chunk);
        bytesOffset += chunk;
        bytesLeft -= chunk;
        pos = 0;
      }
    }
    
  • setBytesRef(BytesRefBuilder builder, BytesRef result, long offset, int length)

    从pool的offset开始,取length个字节到result中。

    void setBytesRef(BytesRefBuilder builder, BytesRef result, long offset, int length) {
      result.length = length;
    
      // offset是在哪个buffer  
      int bufferIndex = (int) (offset >> BYTE_BLOCK_SHIFT);
      byte[] buffer = buffers[bufferIndex];
      // offset在buffer中的位置  
      int pos = (int) (offset & BYTE_BLOCK_MASK);
      // 如果所需内容在一个buffer内  
      if (pos + length <= BYTE_BLOCK_SIZE) {
        // 直接设置buffer的offset和length  
        result.bytes = buffer;
        result.offset = pos;
      } else {
        // 跨buffer通过readBytes方法读取
        builder.grow(length);
        result.bytes = builder.get().bytes;
        result.offset = 0;
        readBytes(offset, result.bytes, 0, length);
      }
    }
    
  • setBytesRef(BytesRef term, int textStart)

    在倒排的场景中,会把字段中所有的term都写入pool中(写入逻辑在BytesRefHash#add,后面我们会介绍BytesRefHash),这个方法就是从pool的textStart位置读取term,读取的时候先读term的长度,term的长度是1字节或者两字节,要根据当前的标志位来判断。

    public void setBytesRef(BytesRef term, int textStart) {
      // 定位到 textStart 属于哪个buffer
      final byte[] bytes = term.bytes = buffers[textStart >> BYTE_BLOCK_SHIFT];
      // 定位到 textStart 在buffer中的位置
      int pos = textStart & BYTE_BLOCK_MASK;
      
      if ((bytes[pos] & 0x80) == 0) { // 如果是一个字节的长度  
        term.length = bytes[pos];
        term.offset = pos + 1;
      } else { // 2字节长度
        term.length = ((short) BitUtil.VH_BE_SHORT.get(bytes, pos)) & 0x7FFF;
        term.offset = pos + 2;
      }
    }
    
  • allocSlice

    从给定的buffer和buffer的offset处分配一个新的slice。

    注意这个方法被调用时说明我们已经到了上一个slice的最后一个字节了,这个字节就是结束的哨兵,值是 16 | currentLevel。

    // 注意:slice就是当前要分配新slice所在的buffer,upto就是要分配slice的位置。
    // 这里我觉得这个参数名取的非常不好,改成buffer好非常多。
    public int allocSlice(final byte[] slice, final int upto) {
      // buffer中新分配的slice都是填充0,而且每个slice的最后一个字节是:  16 | currentLevel 。
      // 所以,如果需要执行这个方法,肯定是碰到了当前slice的最后一个字节了,所以才需要执行这个方法。  
      // 因此,level其实是目前的已经达到的level。
      final int level = slice[upto] & 15;
      // 获取下一个level
      final int newLevel = NEXT_LEVEL_ARRAY[level];
      // 获取下一个level的slice的大小  
      final int newSize = LEVEL_SIZE_ARRAY[newLevel];
    
      // 如果当前buffer无法容纳新slice的大小,则需要重新分配一个buffer
      if (byteUpto > BYTE_BLOCK_SIZE - newSize) {
        // 注意nextBuffer会丢弃当前buffer剩余空间
        nextBuffer();
      }
      // 当前buffer的在数组中的下标
      final int newUpto = byteUpto;
      // 当前buffer中的offset
      final int offset = newUpto + byteOffset;
      byteUpto += newSize;
    
      // 获取当前upto前面三个字节,也就是哨兵前面的3个字节。这是为了把这部分空间留出来,设置下一个slice的地址。
      // 注意是小端读取,通过与运算把哨兵字节(upto指向的字节)的过滤了。
      int past3Bytes = ((int) BitUtil.VH_LE_INT.get(slice, upto - 3)) & 0xFFFFFF;
      // 把这三个字节写入新slice的开头
      BitUtil.VH_LE_INT.set(buffer, newUpto, past3Bytes);
    
      // 把新slice的地址写入前一个slice腾出三个字节的地方,包括了最后一个哨兵字节,总共地址是4个字节。
      BitUtil.VH_LE_INT.set(slice, upto - 3, offset);
    
      // 当前的slice加入哨兵,可以看到这里把当前的level也存起来了,和前面通过当前level获取新level呼应
      buffer[byteUpto - 1] = (byte) (16 | newLevel);
      // 放回当前slice可以写的位置
      return newUpto + 3;
    }
    

IntBlockPool

IntBlockPool在使用上和ByteBlockPool非常类似,这里我们就不重复分析了。这里列出来,是因为我们后面介绍倒排内存结构存储会用到。IntBlockPool在倒排构建中是用来存储每个term在ByteBlockPool中下一个要写入的位置,每个term对IntBlockPool都是一个数据源。

BytesRefHash

BytesRefHash就把它理解成是一个Map<BytesRef, Interge>。它有两个特点:

  • 添加新pair时,只提供key,value是在新增的时候为key分配的id,是从0递增的整型。
  • 不仅是O(1)时间通过key找value,也是O(1)时间通过value找key。这种实现我们可以借鉴。

成员变量

// 存储所有key值,也就是BytesRef
final ByteBlockPool pool;
// 下标是每个BytesRef的id,值是在pool中的起始位置,通过这个变量实现了O(1)时间通过value找key
int[] bytesStart;

// 哈希表的最大容量
private int hashSize;
// 超过这个值,需要rehash
private int hashHalfSize;
// 用来获取BytesRef哈希值在ids数组中的下标
private int hashMask;
// 目前哈希表中的总个数
private int count;
// 辅助变量,在清空或者压缩的时候使用,不用关注
private int lastCount = -1;
// 下标是BytesRef哈希值与hashMask的&操作,值是BytesRef的id
private int[] ids;
// bytesStart数组初始化,重置,扩容的辅助类,不用关注
private final BytesStartArray bytesStartArray;
// 统计内存占用,不用关注
private Counter bytesUsed;

核心方法

O(1)时间通过value找key的方法。

public BytesRef get(int bytesID, BytesRef ref) {
  // 从pool的  bytesStart[bytesID] 的位置读取ref,注意会先读长度,再读内容,详见ByteBlockPool
  pool.setBytesRef(ref, bytesStart[bytesID]);
  return ref;
}

O(1)时间通过key找value的方法。

public int find(BytesRef bytes) {
  return ids[findHash(bytes)];
}

因为底层数据是当成哈希表使用,所以数组中存在空隙。在数据全部处理完之后,要进行持久化,先压缩id数组返回进行下一步的处理:

public int[] compact() {
  // 如果存在空节点,则upto会停留在空节点的下标,等待填充  
  int upto = 0;
  // 从前往后找
  for (int i = 0; i < hashSize; i++) {
    // 如果当前的节点非空,则需要判断在其之前是否有空间点可以填充  
    if (ids[i] != -1) {
      // upto < i 说明存在空节点,则把当前节点移动到空节点
      if (upto < i) {
        ids[upto] = ids[i];
        ids[i] = -1;
      }
      // 非空隙才更新,碰到空隙就停留  
      upto++;
    }
  }

  assert upto == count;
  lastCount = count;
  return ids;
}

按ByteRef对ids数组排序。在倒排最后要落盘持久化的时候,会先对所有的term排序,因为term字典(FST)需要term列表是有序的:

public int[] sort() {
  final int[] compact = compact();
  // lucene实现的排序算法的框架,get方法就是获取比较的值来进行比较  
  new StringMSBRadixSorter() {

    BytesRef scratch = new BytesRef();

    @Override
    protected void swap(int i, int j) {
      int tmp = compact[i];
      compact[i] = compact[j];
      compact[j] = tmp;
    }

    @Override
    protected BytesRef get(int i) {
      pool.setBytesRef(scratch, bytesStart[compact[i]]);
      return scratch;
    }
  }.sort(0, count);
  return compact;
}

id指定的BytesRef是否和b相等:

private boolean equals(int id, BytesRef b) {
  final int textStart = bytesStart[id];
  final byte[] bytes = pool.buffers[textStart >> BYTE_BLOCK_SHIFT];
  int pos = textStart & BYTE_BLOCK_MASK;
  final int length;
  final int offset;
  if ((bytes[pos] & 0x80) == 0) {
    length = bytes[pos];
    offset = pos + 1;
  } else {
    length = ((short) BitUtil.VH_BE_SHORT.get(bytes, pos)) & 0x7FFF;
    offset = pos + 2;
  }
  return Arrays.equals(bytes, offset, offset + length, b.bytes, b.offset, b.offset + b.length);
}

在清空哈希表之后,需要收缩哈希表:

private boolean shrink(int targetSize) {
  int newSize = hashSize;
  // 如果现有的容量大于等于8才需要收缩  
  while (newSize >= 8 && newSize / 4 > targetSize) {
    newSize /= 2;
  }
  // 如果需要收缩  
  if (newSize != hashSize) {
    bytesUsed.addAndGet(Integer.BYTES * -(hashSize - newSize));
    hashSize = newSize;
    ids = new int[hashSize];
    Arrays.fill(ids, -1);
    hashHalfSize = newSize / 2;
    hashMask = newSize - 1;
    return true;
  } else {
    return false;
  }
}

清空哈希表:

public void clear(boolean resetPool) {
  lastCount = count;
  count = 0;
  if (resetPool) {
    pool.reset(false, false); 
  }
  bytesStart = bytesStartArray.clear();
  // 收缩成功则ids数组就已经重置了  
  if (lastCount != -1 && shrink(lastCount)) {
    return;
  }
  Arrays.fill(ids, -1);
}

public void clear() {
  clear(true);
}

获取bytes在ids数组中的下标:

private int findHash(BytesRef bytes) {
  // 获取bytes的哈希值
  int code = doHash(bytes.bytes, bytes.offset, bytes.length);

  // 获取在ids中的下标
  int hashPos = code & hashMask;
  int e = ids[hashPos];
  // 如果哈希的下标已经有值了,并且不是bytes,则表示冲突了  
  if (e != -1 && !equals(e, bytes)) {
    // 冲突了,采用线性探测循环往后找
    do {
      code++;
      hashPos = code & hashMask;
      e = ids[hashPos];
    } while (e != -1 && !equals(e, bytes));
  }

  return hashPos;
}

哈希表新增一个BytesRef,如果是新加入的BytesRef,则返回返回为其分配的id,否则返回BytesRef的(-(id)- 1)。

ByteRefHash中添加term流程图.png

public int add(BytesRef bytes) {
  assert bytesStart != null : "Bytesstart is null - not initialized";
  final int length = bytes.length;
  // 获取在ids中的下标
  final int hashPos = findHash(bytes);
  int e = ids[hashPos];

  // 如果bytes是新加入的  
  if (e == -1) {
    // 加2是因为长度最多用两个字节存储  
    final int len2 = 2 + bytes.length;
    // 如果当前buffer存不下,则获取下一个buffer  
    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;
    // 当前的bytestarts满了,则扩容  
    if (count >= bytesStart.length) {
      bytesStart = bytesStartArray.grow();
      assert count < bytesStart.length + 1 : "count: " + count + " len: " + bytesStart.length;
    }
    // id是递增的  
    e = count++;

    // 记录bytes在pool中的offset  
    bytesStart[e] = bufferUpto + pool.byteOffset;

    // 先存term长度,再存term.
    // 小于128用1个字节,否则用2个字节。  
    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
      BitUtil.VH_BE_SHORT.set(buffer, bufferUpto, (short) (length | 0x8000));
      pool.byteUpto += length + 2;
      System.arraycopy(bytes.bytes, bytes.offset, buffer, bufferUpto + 2, length);
    }
    assert ids[hashPos] == -1;
    // 把id存起来  
    ids[hashPos] = e;

    // 如果哈希表过半,则rehash  
    if (count == hashHalfSize) {
      rehash(2 * hashSize, true);
    }
    return e;
  }
  // 如果bytes是已经存在的  
  return -(e + 1);
}

没有bytes,但是为offset分配一个id:

public int addByPoolOffset(int offset) {
  assert bytesStart != null : "Bytesstart is null - not initialized";
  // final position
  int code = offset;
  int hashPos = offset & hashMask;
  int e = ids[hashPos];
  if (e != -1 && bytesStart[e] != offset) {
    do {
      code++;
      hashPos = code & hashMask;
      e = ids[hashPos];
    } while (e != -1 && bytesStart[e] != offset);
  }
  if (e == -1) {
    if (count >= bytesStart.length) {
      bytesStart = bytesStartArray.grow();
      assert count < bytesStart.length + 1 : "count: " + count + " len: " + bytesStart.length;
    }
    e = count++;
    bytesStart[e] = offset;
    assert ids[hashPos] == -1;
    ids[hashPos] = e;

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

ParallelPostingsArray

在介绍ParallelPostingsArray之前,需要理解一个stream的概念。stream可以理解成term倒排信息的数据源,在当前的版本中最多2个stream,最少1个。

一个term根据索引选项的不同,可以有多个stream。如果索引选项是DOCS_AND_FREQS_AND_POSITIONS或者DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS,则有两个stream,否则只有一个。stream中包含的信息如下:

stream 0:doc,freq

stream 1:position,payload,startOffset,endOffset

为什么要区分不同的stream呢?

因为对于同一个term的不同的文档中stream1的这些信息是存储在一起的,所以我们需要先知道freq是多少,才能正确获取term在指定文档中的相关倒排信息。但是doc和freq必须是一个文档处理完了才能正确得到,而其他的信息是在处理过程中就可以生成的,所以就区分这二者。

// 下标是termID,值是termID所对应的term在ByteBlockPool中起始offset  
final int[] textStarts; 
// 下标是termID,值是termID对应stream记录在IntBlockPool中当前写入位置的offset
final int[] addressOffset; 
// 下标是termID,值是该term的stream在ByteBlockPool中的下一个可以写入的位置
final int[] byteStarts; 

ParallelPostingsArray中这三个属性对于字段中的同一个term是不变的。

ParallelPostingsArray有两个子类,分别是用来处理倒排和词向量的,本文只关注倒排的部分。

在子类FreqProxPostingsArray中,有另外的一些信息,这些信息是term每处理一次都会变化的:

// 下标是termID,值是termID对应的在当前处理文档中的频率
int[] termFreqs;
// 下标是termID,值是上一个出现这个term的文档id
int[] lastDocIDs;
// 下标是termID,值是上一个出现这个term的文档id的编码:docId << 1
int[] lastDocCodes; 
// 下标是termID,值是term在当前文档中上一次出现的position
int[] lastPositions;
// 下标是termID,值是term在当前文档中上一次出现的startOffset(注意这里源码注释不对)
int[] lastOffsets; 

构建逻辑

从宏观来看,构建的流程非常清晰,分为3步走:

  1. 构建的入口在TermsHashPerField#add(BytesRef, int),输入是term和文档Id
  2. 如果term是第一次出现,则需要走TermsHashPerField#initStreamSlices初始化相关stream,然后走FreqProxTermsWriterPerField#newTerm处理倒排信息。
  3. 如果term之前出现过,则需要走TermsHashPerField#positionStreamSlice定位到term的写入位置,然后走FreqProxTermsWriterPerField#addTerm处理倒排信息。

TermsHashPerField

构建的核心逻辑在TermsHashPerField中,每个field对应了一个TermsHashPerField,field的所有倒排数据都存储在TermsHashPerField中。

成员变量

// 倒排和词向量都会处理term信息,所以倒排和词向量是使用责任链的模式实现,nextPerField就是下一个要处理term信息的组件
private final TermsHashPerField nextPerField;
// 记录term在bytePool中下一个要写入的位置
private final IntBlockPool intPool;
// 记录term和term的相关的倒排信息,倒排信息是以stream的方式写入的
final ByteBlockPool bytePool;
// intPool中当前使用的buffer
private int[] termStreamAddressBuffer;
// intPool中当前使用的buffer当前定位到的offset
private int streamAddressOffset;
// term的信息分为几个数据源
private final int streamCount;
// 字段名称
private final String fieldName;
// 索引选项:是否记录频率,是否记录position,是否记录offset等
final IndexOptions indexOptions;
// 为term分配唯一id,并且用来判断term是不是第一次出现
private final BytesRefHash bytesHash;
// 倒排内存结构构建的辅助类
ParallelPostingsArray postingsArray;
// 所有的文档都处理完之后,按照term大小对termid进行排序,持久化的时候是按照term顺序处理的
private int[] sortedTermIDs;

构建相关方法

倒排构建的入口

termBytes是term,docID是term所在的文档id。

void add(BytesRef termBytes, final int docID) throws IOException {
  // bytesHash第一次遇到的term会返回大于等于0的termID
  int termID = bytesHash.add(termBytes);
  
  if (termID >= 0) { 
    // term第一次出现,则初始化各个stream的第一个slice
    initStreamSlices(termID, docID);
  } else {
    // term已经出现过了,则定位到要继续写入的位置  
    termID = positionStreamSlice(termID, docID);
  }
  // 如果有需要则调用下一个TermsHashPerField组件,当前版本只有TermVectorsConsumerPerField
  if (doNextCall) {
    nextPerField.add(postingsArray.textStarts[termID], docID);
  }
}
第一次出现term的处理逻辑

如果是第一个碰到的term,则需要为term初始化stream的slice,整体逻辑如下图所示:

TermsHashPerfield中initStreamSlice方法流程图.png

private void initStreamSlices(int termID, int docID) throws IOException {
  // intPool的当前buffer不足,则需要获取下一个buffer。
  // 这里需要注意的是,这样判断确保了termID对应的intPool中的数据都是相连的,是为了读取的时候考虑的  
  if (streamCount + intPool.intUpto > IntBlockPool.INT_BLOCK_SIZE) {
    intPool.nextBuffer();
  }

  // 这个2没明白,我的理解是不需要这个2。
  // 这里需要注意的是,这样判断确保了termID对应的bytePool中的所有的stream的第一个slice都是相连的,
  // 是为了读取的时候考虑的
  if (ByteBlockPool.BYTE_BLOCK_SIZE - bytePool.byteUpto
      < (2 * streamCount) * ByteBlockPool.FIRST_LEVEL_SIZE) {
    bytePool.nextBuffer();
  }
  
  termStreamAddressBuffer = intPool.buffer;
  streamAddressOffset = intPool.intUpto;
  intPool.intUpto += streamCount;

  // 记录termID在intPool中的位置  
  postingsArray.addressOffset[termID] = streamAddressOffset + intPool.intOffset;

  for (int i = 0; i < streamCount; i++) {
    final int upto = bytePool.newSlice(ByteBlockPool.FIRST_LEVEL_SIZE);
    // intPool中的buffer记录的是每个stream下一个要写入的位置  
    termStreamAddressBuffer[streamAddressOffset + i] = upto + bytePool.byteOffset;
  }
  // 记录stream在bytePool中的起始位置,这个信息是用来读取时候使用的
  postingsArray.byteStarts[termID] = termStreamAddressBuffer[streamAddressOffset];
  // 第一次出现的term的处理逻辑,在TermsHashPerField是个抽象方法。
  // 上面流程图中大部分逻辑在这个方法中。  
  newTerm(termID, docID);
}
已有term的处理逻辑

定位到term的stream所在的slice,把term的新增的倒排信息写入,整体逻辑如下图所示:

TermsHashPerField中的positionStreamSlice方法流程图.png

private int positionStreamSlice(int termID, final int docID) throws IOException {
  termID = (-termID) - 1;
  // termID在intPool中的起始位置  
  int intStart = postingsArray.addressOffset[termID];
  // 根据 intStart 获取termID在intPool的buffer和offset
  termStreamAddressBuffer = intPool.buffers[intStart >> IntBlockPool.INT_BLOCK_SHIFT];
  streamAddressOffset = intStart & IntBlockPool.INT_BLOCK_MASK;
  // 抽象方法,具体新增已有term的信息逻辑在子类实现  
  addTerm(termID, docID);
  return termID;
}
写入stream的slice
final void writeByte(int stream, byte b) {
  // streamAddress是stream在intPool中的位置
  int streamAddress = streamAddressOffset + stream;
  // 从intPool中读取stream下一个要写入的位置  
  int upto = termStreamAddressBuffer[streamAddress];
  byte[] bytes = bytePool.buffers[upto >> ByteBlockPool.BYTE_BLOCK_SHIFT];
  int offset = upto & ByteBlockPool.BYTE_BLOCK_MASK;
  // 如果碰到了slice的哨兵  
  if (bytes[offset] != 0) {
    // 创建下一个slice,并返回写入的地址
    offset = bytePool.allocSlice(bytes, offset);
    bytes = bytePool.buffer;
    termStreamAddressBuffer[streamAddress] = offset + bytePool.byteOffset;
  }
  // 在slice写入数据  
  bytes[offset] = b;
  (termStreamAddressBuffer[streamAddress])++;
}

FreqProxTermsWriterPerField

FreqProxTermsWriterPerField是TermsHashPerField的一个子类,具体的写入逻辑都在这个类中实现。

处理position和payLoad信息

pos和payload混合存储示意图.png

如上图所示,当不存在payload信息时,单独存储pos的差值左移一位,这样用最后一位0表示后面没有payload数据。如果存在payload数据,则先存储pos差值左移一位 | 1,这样用最后一位1表示后面有payload数据,然后再写payload长度,类型是Vint,最后写入payload的数据。

// proxCode:term的position
void writeProx(int termID, int proxCode) {
  // 如果该term没有payLoad信息,则把proxCode << 1 追加到stream 1,注意没有payLoad最后一位肯定是0
  if (payloadAttribute == null) {
    writeVInt(1, proxCode << 1);
  } else {
    BytesRef payload = payloadAttribute.getPayload();
    
    if (payload != null && payload.length > 0) { // 如果该position的term存在payLoad信息  
      // 注意有payLoad的时候,最后一位肯定是1
      writeVInt(1, (proxCode << 1) | 1);
      // payLoad的长度  
      writeVInt(1, payload.length);
      // payLoad的内容  
      writeBytes(1, payload.bytes, payload.offset, payload.length);
      sawPayloads = true;
    } else { // 如果该position的term不存在payLoad信息  
      writeVInt(1, proxCode << 1); 
    }
  }
  // 更新term在当前处理文档中上一次出现的position
  freqProxPostingsArray.lastPositions[termID] = fieldState.position;
}

处理term的startOffset和endOffset

这里需要特别注意的一个点是,一开始很容易以为endOffset-startOffset就是term的长度,其实不是的。比如在同义词场景中,startOffset和endOffset记录的是原词的偏移位置,但是生成的token流中,原词的同义词的startOffset和endOffset是和原词一样的,但是endOffset-startOffset却不是term的长度,这也是为什么倒排表中还要额外存储term的长度信息。我们看个例子:

public class TokenStreamDemo {
    public static void main(String[] args) throws IOException {
        Analyzer analyzer = new Analyzer() {
            @Override
            protected TokenStreamComponents createComponents(String fieldName) {
                LetterTokenizer letterTokenizer = new LetterTokenizer();
                SynonymMap.Builder builder = new SynonymMap.Builder();
                // 设置hello的两个同义词:hi和ha
                builder.add(new CharsRef("hello"), new CharsRef("hi"), true);
                builder.add(new CharsRef("hello"), new CharsRef("ha"), true);

                SynonymMap synonymMap = null;
                try {
                    synonymMap = builder.build();
                } catch (IOException e) {
                    e.printStackTrace();
                }

                SynonymGraphFilter synonymGraphFilter = new SynonymGraphFilter(letterTokenizer, synonymMap, true);
                return new TokenStreamComponents(letterTokenizer, synonymGraphFilter);
            }
        };

        TokenStream tokenStream = analyzer.tokenStream("test", "hello world");
        tokenStream.reset();
        CharTermAttribute termAttr = tokenStream.addAttribute(CharTermAttribute.class);
        OffsetAttribute offsetAttr = tokenStream.addAttribute(OffsetAttribute.class);
        while(tokenStream.incrementToken()) {
            System.out.printf("%s startOffset=%d endOffset=%d\n", termAttr.toString(), offsetAttr.startOffset(), offsetAttr.endOffset());
        }
    }
}

输出结果如下,我们可以看出“hello”被分成了3个词,包括“hello”自身和两个同义词,他们的startOffset和endOffset都是相同的,但是显然endOffset-startOffset并不是“hi”和“ha”的长度。

hi startOffset=0 endOffset=5
ha startOffset=0 endOffset=5
hello startOffset=0 endOffset=5
world startOffset=6 endOffset=11

理解了startOffset和endOffset之后,我们看下它们是怎么处理的:

// 有时候,我们对一个Document添加了相同的Field,则在处理时是把这些Field的内容拼起来,
// offsetAccum记录的就是当前field在拼接中的偏移量
void writeOffsets(int termID, int offsetAccum) {
  // 记录的所有相同Field拼接之后的offset  
  final int startOffset = offsetAccum + offsetAttribute.startOffset();
  final int endOffset = offsetAccum + offsetAttribute.endOffset();
  // 差值存储startOffset
  writeVInt(1, startOffset - freqProxPostingsArray.lastOffsets[termID]);
  // 存储endOffset  
  writeVInt(1, endOffset - startOffset);
  // 更新term在当前处理文档中的上一次出现的startOffset  
  freqProxPostingsArray.lastOffsets[termID] = startOffset;
}

第一次碰到term的处理逻辑

void newTerm(final int termID, final int docID) {
  final FreqProxPostingsArray postings = freqProxPostingsArray;
  // 记录term上一次出现的文档
  postings.lastDocIDs[termID] = docID;
  if (!hasFreq) {
    assert postings.termFreqs == null;
    postings.lastDocCodes[termID] = docID;
    fieldState.maxTermFrequency = Math.max(1, fieldState.maxTermFrequency);
  } else {
    postings.lastDocCodes[termID] = docID << 1; 
    postings.termFreqs[termID] = getTermFreq();
    if (hasProx) {
      writeProx(termID, fieldState.position);
      if (hasOffsets) {
        writeOffsets(termID, fieldState.offset);
      }
    } else {
      assert !hasOffsets;
    }
    fieldState.maxTermFrequency =
        Math.max(postings.termFreqs[termID], fieldState.maxTermFrequency);
  }
  fieldState.uniqueTermCount++;
}

已有term新增倒排信息处理逻辑

doc和freq混合存储示意图.png

如上图所示,当频率为1时,单独存储docID的差值左移一位 | 1,这样用最后一位1表示term在这个文档中的频率是1。如果频率大于1,则先存储docID差值左移一位,这样用最后一位0表示后面有单独的频率数据,然后再写频率,类型是Vint。

void addTerm(final int termID, final int docID) {
  final FreqProxPostingsArray postings = freqProxPostingsArray;

  if (!hasFreq) {
    // 默认的实现TermFrequencyAttribute 是返回1,如果是自定义实现,则必须在索引选项中加入频率
    if (termFreqAtt.getTermFrequency() != 1) {
      throw new IllegalStateException(
          "field \""
              + getFieldName()
              + "\": must index term freq while using custom TermFrequencyAttribute");
    }
    if (docID != postings.lastDocIDs[termID]) { // 上一个文档处理结束
      writeVInt(0, postings.lastDocCodes[termID]);
      postings.lastDocCodes[termID] = docID - postings.lastDocIDs[termID];
      postings.lastDocIDs[termID] = docID;
      fieldState.uniqueTermCount++;
    }
  } else if (docID != postings.lastDocIDs[termID]) { // 上一个文档处理结束
    if (1 == postings.termFreqs[termID]) { // 如果频率是1,则和左移一位的文档id一起存储
      writeVInt(0, postings.lastDocCodes[termID] | 1);
    } else { // 如果频率大于1,则先存文档id,注意左移一位的文档id
      writeVInt(0, postings.lastDocCodes[termID]);
      writeVInt(0, postings.termFreqs[termID]);
    }

    // 记录在当前文档中出现的频率,默认实现是1
    postings.termFreqs[termID] = getTermFreq();
    // 更新最大的term的频率  
    fieldState.maxTermFrequency =
        Math.max(postings.termFreqs[termID], fieldState.maxTermFrequency);
    // 文档id编码也是差值存储  
    postings.lastDocCodes[termID] = (docID - postings.lastDocIDs[termID]) << 1;
    postings.lastDocIDs[termID] = docID;
    if (hasProx) {
      writeProx(termID, fieldState.position);
      if (hasOffsets) {
        // 第一次出现的offset是0  
        postings.lastOffsets[termID] = 0;
        writeOffsets(termID, fieldState.offset);
      }
    } else {
      assert !hasOffsets;
    }
    // 出现的term的个数+1  
    fieldState.uniqueTermCount++;
  } else { // 如果不是在处理新的文档,则追加term的信息即可
    // 更新term的频率  
    postings.termFreqs[termID] = Math.addExact(postings.termFreqs[termID], getTermFreq());
    // 更新最大的term的频率  
    fieldState.maxTermFrequency =
        Math.max(fieldState.maxTermFrequency, postings.termFreqs[termID]);
    if (hasProx) {
      // position也是差值存储  
      writeProx(termID, fieldState.position - postings.lastPositions[termID]);
      if (hasOffsets) {
        writeOffsets(termID, fieldState.offset);
      }
    }
  }
}

构建demo

public class InvertDemo {
    public static void main(String[] args) throws IOException {
        Directory directory = new ByteBuffersDirectory();
        WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);

        addDoc(indexWriter, "lucene written in java");
        addDoc(indexWriter, "lucene action learn lucene");
        
        indexWriter.flush();
        indexWriter.commit();
    }
    
    private static void addDoc(IndexWriter indexWriter, String content) throws IOException {
        FieldType fieldType = new FieldType();
        fieldType.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
        fieldType.setStored(true);

        Document document = new Document();
        document.add(new Field("field0", content, fieldType));
        indexWriter.addDocument(document);
    }
}

特殊说明

  • 在我们的例子中,term都是小写英文,使用的是ISO-8859-1编码(ASCII编码的扩充集),使用的是单字节编码,后面我们直接说是ASCII编码,好理解一些。

  • 我们的示例有两个文档,对于相同的term我们用相同的颜色标识。需要注意的是“lucene”在两个文档中出现了三次,其他term都是1次。

  • bytePool存储的是字段中所有term本身以及term的倒排信息

  • intPool存储的是term在bytePool中下一个可以写入的位置。注意term的多个stream是相连的。

  • postingsArray.addressOffset存储的是term第一个stream在intPool中的位置。

  • postingsArray.textStarts存储的是term本身在bytePool中的起始位置。

  • postingsArray.byteStarts存储的是term第一个stream在bytePool中的起始位置。

  • postingsArray.termFreqs存储的是term在当前文档中到目前为止出现的频率。

  • postingsArray.lastDocIDs存储的是term出现的上一个文档id。

  • postingsArray.lastDocCodes存储的是term出现的上一个文档id左移一位。

  • postingsArray.lastPositions存储的是term在当前文档中上一次出现的position。

  • postingsArray.lastOffsets存储的是term在当前文档中上一次出现的startOffset。

  • position存的是差值(和上一次出现的position的差值),然后position的差值和payload混合存储。

  • docID是差值存储,然后和freq一起存

处理doc0中的第1个term:lucene

倒排内存结构1.png

  • doc0中的“lucene”是第1个出现的term,BytesRefHash#add中会为其分配termID=0

  • bytePool的第0位是6,存储是term “lucene”的长度。第1~6位存储的是“lucene”的ASCII编码。

  • bytePool的第7~11位存储的是term “lucene” 的stream0的第一个slice,第11位的16是slice结束的哨兵: 16 << 1 | level,当前level是0,所以哨兵是16。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。

  • bytePool的第12~16位是term “lucene” 的stream1的第一个slice,第16位的16是slice结束的哨兵。

  • bytePool的第12位是 pos << 1 的值:0。

  • bytePool的第13位是startOffset的值:0。

  • bytePool的第14位是endOffset - startOffset的值:6。

  • intPool的第0位是7,表示term “lucene”在bytePool中的stream0下一次可以写入的位置。

  • intPool的第1位是15,表示term “lucene”在bytePool中的stream1下一次可以写入的位置。

  • postingsArray.addressOffset的第0位是0,表示term “lucene”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “lucene”的stream当前可以写入的位置。

  • postingsArray.textStarts的第0位是0,表示term “lucene”本身在在bytePool中存储的起始位置。

  • postingsArray.byteStarts的第0位是7,表示term “lucene”的stream0的第一个slice在bytePool中的位置。

  • postingsArray.termFreqs的第0位是1,表示目前位置,在当前字段中,term “lucene”出现了1次。

  • postingsArray.lastDocIds的第0位是0,表示在term “lucene”上一次出现的docId。

  • postingsArray.lastDocCodes的第0位是0,表示的是lastDocIds[0] << 1。

  • postingsArray.lastPoistions的第0位是0,表示的是term “lucene”在当前字段中上次出现的position。

  • postingsArray.lastOffset的第0位是0,表示的是term “lucene”在当前字段中上次出现的startOffset。

处理doc0中的第2个term:written

倒排内存结构2.png

  • doc0中的“written”是第2个出现的term,BytesRefHash#add中会为其分配termID=1

  • bytePool的第17位是7,存储是term “written”的长度。第18~24位存储的是“written”的ASCII编码。

  • bytePool的第25~29位存储的是term “written”的 stream0的第一个slice,第29位的16是slice结束的哨兵。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。

  • bytePool的第30~34位是term “written”的 stream1的第一个slice,第34位的16是slice结束的哨兵。

  • bytePool的第30位是 pos << 1 的值:2。

  • bytePool的第31位是term “written” 的 startOffset的值:7。

  • bytePool的第32位是term “written” 的endOffset - startOffset的值:7。

  • intPool的第2位是25,表示term “written”在bytePool中的stream0下一次可以写入的位置。

  • intPool的第3位是33,表示term “written”在bytePool中的stream1下一次可以写入的位置。

  • postingsArray.addressOffset的第1位是2,表示term “written”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “written”的stream当前可以写入的位置。

  • postingsArray.textStarts的第1位是17,表示term “written”本身在在bytePool中存储的起始位置。

  • postingsArray.byteStarts的第1位是25,表示term “written”的stream0的第一个slice在bytePool中的位置。

  • postingsArray.termFreqs的第1位是1,表示目前位置,在当前字段中,term “written”出现了1次。

  • postingsArray.lastDocIds的第1位是0,表示在term “written”上一次出现的docId。

  • postingsArray.lastDocCodes的第1位是0,表示的是lastDocIds[0] << 1。

  • postingsArray.lastPoistions的第1位是1,表示的是term “written”在当前字段中上次出现的position。

  • postingsArray.lastOffset的第1位是7,表示的是term “written”在当前字段中上次出现的startOffset。

处理doc0中的第3个term:in

倒排内存结构3.png

  • doc0中的“in”是第3个出现的term,BytesRefHash#add中会为其分配termID=2

  • bytePool的第35位是2,存储是term “in”的长度。第36~37位存储的是“in”的ASCII编码。

  • bytePool的第38~42位存储的是term “in”的 stream0的第一个slice,第42位的16是slice结束的哨兵。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。

  • bytePool的第43~47位是term “in”的 stream1的第一个slice,第47位的16是slice结束的哨兵。

  • bytePool的第43位是 pos << 1 的值:4。

  • bytePool的第44位是term “in” 的 startOffset的值:15。

  • bytePool的第45位是term “in” 的endOffset - startOffset的值:2。

  • intPool的第4位是38,表示term “in”在bytePool中的stream0下一次可以写入的位置。

  • intPool的第5位是46,表示term “in”在bytePool中的stream1下一次可以写入的位置。

  • postingsArray.addressOffset的第2位是4,表示term “in”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “in”的stream当前可以写入的位置。

  • postingsArray.textStarts的第2位是35,表示term “in”本身在在bytePool中存储的起始位置。

  • postingsArray.byteStarts的第2位是38,表示term “in”的stream0的第一个slice在bytePool中的位置。

  • postingsArray.termFreqs的第2位是1,表示目前位置,在当前字段中,term “in”出现了1次。

  • postingsArray.lastDocIds的第2位是0,表示在term “in”上一次出现的docId。

  • postingsArray.lastDocCodes的第2位是0,表示的是lastDocIds[0] << 1。

  • postingsArray.lastPoistions的第2位是2,表示的是term “in”在当前字段中上次出现的position。

  • postingsArray.lastOffset的第2位是15,表示的是term “in”在当前字段中上次出现的startOffset。

处理doc0中的第3个term:java

倒排内存结构4.png

  • doc0中的“java”是第4个出现的term,BytesRefHash#add中会为其分配termID=3

  • bytePool的第48位是4,存储是term “java”的长度。第49~52位存储的是“java”的ASCII编码。

  • bytePool的第53~57位存储的是term “java”的 stream0的第一个slice,第57位的16是slice结束的哨兵。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。

  • bytePool的第58~62位是term “java”的 stream1的第一个slice,第62位的16是slice结束的哨兵。

  • bytePool的第58位是 pos << 1 的值:6。

  • bytePool的第59位是term “java” 的 startOffset的值:18。

  • bytePool的第60位是term “java” 的endOffset - startOffset的值:4。

  • intPool的第6位是53,表示term “java”在bytePool中的stream0下一次可以写入的位置。

  • intPool的第7位是61,表示term “java”在bytePool中的stream1下一次可以写入的位置。

  • postingsArray.addressOffset的第3位是6,表示term “java”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “java”的stream当前可以写入的位置。

  • postingsArray.textStarts的第3位是48,表示term “java”本身在在bytePool中存储的起始位置。

  • postingsArray.byteStarts的第3位是53,表示term “java”的stream0的第一个slice在bytePool中的位置。

  • postingsArray.termFreqs的第3位是1,表示目前位置,在当前字段中,term “java”出现了1次。

  • postingsArray.lastDocIds的第3位是0,表示在term “java”上一次出现的docId。

  • postingsArray.lastDocCodes的第3位是0,表示的是lastDocIds[0] << 1。

  • postingsArray.lastPoistions的第3位是3,表示的是term “java”在当前字段中上次出现的position。

  • postingsArray.lastOffset的第3位是18,表示的是term “java”在当前字段中上次出现的startOffset。

处理doc1中的第1个term:lucene

倒排内存结构6.png

  • term “lucene”在这个字段已经出现过了,因此我们只需要追加它的信息。

  • 因为doc0已经处理结束了,我们能够准确得到term “lucene”在doc0中的频率,所以需要把这部分信息记录到term “lucene”的stream0中。

  • bytePool的第7位是1,存储是docID和频率的复合体:doc0 << 1 | 1。

  • intPool的第0位更新为8,表示term “lucene”在bytePool中的stream0下一次可以写入的位置。

  • bytePool的第15位是 pos << 1 的值:0。

  • 当我们要写第16位时,发现第16位非空,到了slice的哨兵位置了,值为16, currentLevel=16 & 15 = 0,可以从NEXT_LEVEL_ARRAY得到下一个level是1,进一步从LEVEL_SIZE_ARRAY得到下一个slice的大小为14。所以我们从bytePool中申请新的slice,位置为第63~76位。此时,我们把当前slice哨兵位置的前三个字节(0,6,0)拷贝到新的slice中的第63~65位,哨兵清空,把第2个slice的起始位置以小端(63,0,0,0)的方式存储到当前slice的最后四个字节(第13~16位)。

  • bytePool的第66位是term “lucene”的 startOffset的值:0。

  • bytePool的第67位是term “lucene”的endOffset - startOffset的值:6。

  • intPool的第1位是68,表示term “lucene”在bytePool中的stream1下一次可以写入的位置。

  • postingsArray.termFreqs的第0位是1,表示目前位置,在当前字段中,term “lucene”出现了1次。

  • postingsArray.lastDocIds的第0位是1,表示在term “lucene”上一次出现的docId。

  • postingsArray.lastDocCodes的第0位是2,表示的是lastDocIds[0] << 1。

  • postingsArray.lastPoistions的第0位是0,表示的是term “lucene”在当前字段中上次出现的position。

  • postingsArray.lastOffset的第0位是0,表示的是term “lucene”在当前字段中上次出现的startOffset。

处理doc1中的第2个term:action

倒排内存结构7.png

  • doc1中的“action”是第5个出现的term,BytesRefHash#add中会为其分配termID=4

  • bytePool的第48位是6,存储是term “action”的长度。第78~83位存储的是“action”的ASCII编码。

  • bytePool的第84~88位存储的是term “action”的 stream0的第一个slice,第88位的16是slice结束的哨兵。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。

  • bytePool的第89~93位是term “action”的 stream1的第一个slice,第62位的16是slice结束的哨兵。

  • bytePool的第89位是 pos << 1 的值:2。

  • bytePool的第90位是term “action” 的 startOffset的值:7。

  • bytePool的第91位是term “action” 的endOffset - startOffset的值:6。

  • intPool的第8位是84,表示term “action”在bytePool中的stream0下一次可以写入的位置。

  • intPool的第9位是92,表示term “action”在bytePool中的stream1下一次可以写入的位置。

  • postingsArray.addressOffset的第4位是8,表示term “action”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “action”的stream当前可以写入的位置。

  • postingsArray.textStarts的第4位是77,表示term “action”本身在在bytePool中存储的起始位置。

  • postingsArray.byteStarts的第4位是84,表示term “action”的stream0的第一个slice在bytePool中的位置。

  • postingsArray.termFreqs的第4位是1,表示目前位置,在当前字段中,term “action”出现了1次。

  • postingsArray.lastDocIds的第4位是1,表示在term “action”上一次出现的docId。

  • postingsArray.lastDocCodes的第4位是2,表示的是lastDocIds[0] << 1。

  • postingsArray.lastPoistions的第4位是1,表示的是term “action”在当前字段中上次出现的position。

  • postingsArray.lastOffset的第4位是7,表示的是term “action”在当前字段中上次出现的startOffset。

处理doc1中的第3个term:learn

倒排内存结构8.png

  • doc1中的“learn”是第6个出现的term,BytesRefHash#add中会为其分配termID=5

  • bytePool的第94位是5,存储是term “learn”的长度。第95~99位存储的是“action”的ASCII编码。

  • bytePool的第100~104位存储的是term “learn”的 stream0的第一个slice,第104位的16是slice结束的哨兵。stream0存储的是文档和频率,因为当前的文档还没处理完成所以stream0目前是空的。

  • bytePool的第105~109位是term “learn”的 stream1的第一个slice,第109位的16是slice结束的哨兵。

  • bytePool的第105位是 pos << 1 的值:4。

  • bytePool的第106位是term “learn” 的 startOffset的值:14。

  • bytePool的第107位是term “learn” 的endOffset - startOffset的值:5。

  • intPool的第10位是100,表示term “learn”在bytePool中的stream0下一次可以写入的位置。

  • intPool的第11位是108,表示term “learn”在bytePool中的stream1下一次可以写入的位置。

  • postingsArray.addressOffset的第5位是10,表示term “learn”在intPool中的位置,通过这个位置加上stream的值就可以从intPool中获取term “learn”的stream当前可以写入的位置。

  • postingsArray.textStarts的第5位是94,表示term “learn”本身在在bytePool中存储的起始位置。

  • postingsArray.byteStarts的第5位是100,表示term “learn”的stream0的第一个slice在bytePool中的位置。

  • postingsArray.termFreqs的第5位是1,表示目前位置,在当前字段中,term “learn”出现了1次。

  • postingsArray.lastDocIds的第5位是1,表示在term “learn”上一次出现的docId。

  • postingsArray.lastDocCodes的第5位是2,表示的是lastDocIds[0] << 1。

  • postingsArray.lastPoistions的第5位是2,表示的是term “learn”在当前字段中上次出现的position。

  • postingsArray.lastOffset的第5位是14,表示的是term “learn”在当前字段中上次出现的startOffset。

处理doc1中的第4个term:lucene

倒排内存结构9.png

  • doc1中的第2个“lucene”之前出现过。所以也是在stream中追加信息。

  • bytePool的第68位是 (pos - postingsArray.lastPoistions[0]) << 1 的值:6。

  • bytePool的第69位是term “lucene” 的 startOffset - postingsArray.lastOffset[0]的值:20。

  • bytePool的第70位是term “lucene” 的endOffset - startOffset的值:6。

  • intPool的第0位是8,表示term “lucene”在bytePool中的stream0下一次可以写入的位置。

  • intPool的第1位是71,表示term “lucene”在bytePool中的stream1下一次可以写入的位置。

  • postingsArray.termFreqs的第0位是2,表示目前位置,在当前字段中,term “lucene”出现了1次。

  • postingsArray.lastDocIds的第0位是1,表示在term “lucene”上一次出现的docId。

  • postingsArray.lastDocCodes的第0位是2,表示的是lastDocIds[0] << 1。

  • postingsArray.lastPoistions的第0位是3,表示的是term “lucene”在当前字段中上次出现的position。

  • postingsArray.lastOffset的第0位是20,表示的是term “lucene”在当前字段中上次出现的startOffset。

总结

到这里,倒排数据在内存中的构建过程就介绍结束了,不知道有没有人发现一个问题,在我们构建demo中,term在doc1中的文档id和频率没有写入bytePool,那这个信息是不是丢失了?这作为遗留问题,我们后面介绍读取的时候进行揭秘。