Lucene源码系列(九):可持久化的位图实现方案

1,369 阅读8分钟

背景

lucene的众多索引文件中包含了docValues和norm两种索引数据。docValues是字段值的列式存储,用以高效支持聚合统计类查询,而norm则是每篇文档中每个字段的一个标准化参数,最终计算相关性得分会用到。在索引构建过程中,对于同个字段的docValues和norm是存储在一起的。另外,对于某个字段,不是每个文档都包含该字段,因此,docValues和norm索引文件中就要记录包含对应字段的文档id集合,而存在性判断我们可以想到使用位图来解决,那是不是可以用之前介绍的几种位图实现来处理呢?理论上是可以的,但是我们想想会存在哪些问题?

  • 每个字段都需要在内存中维护一份完整的位图,如果索引比较多,字段比较多,那维护位图的内存就会很大。
  • 同一个字段的数据都是存储在一起的(简单理解成数组),因此我们需要知道要查找的目标id是第几个数据(索引下标)。如果用前面介绍的那些位图,则需要一个个遍历计算,性能比较低下。

那我们要怎么基于现有的位图实现来优化上面的两个问题呢?

  • 可以按需加载部分位图数据,控制内存的使用。部分数据其实就是分块。
  • 记录当前访问的index,表示第几个docId。这么做有个限制条件需要保证docId访问是递增的。

上面说的两点的实现就是本文要介绍的IndexedDISI。本文分析基于lucene源码版本9.1.0。

IndexedDISI介绍

IndexedDISI的位图结构和之前介绍的RoaringDocIdSet非常像,IndexedDISI也是对整个位图拆分成多个block,每个block是65536个bit。IndexedDISI的对block中的有效位存储方式按场景区分,有以下三种:

  • sparse场景:block的有效位小于4096,使用short数组
  • all场景:block是满的,则无需存储任何有效位
  • dense场景:其他情况使用FixedBitSet

IndexedDISI和RoaringDocIdSet最大的不同是,RoaringDocIdSet是全内存的,而IndexedDISI是支持持久化到磁盘的。在构建IndexedDISI时,每完成一个block,就把block的相关信息都持久化。在读取的时候,IndexedDISI在内存中最多只存在一个block(dense情况甚至不是一个完整的block),需要哪个block才加载相关的block信息,并且为了避免多次IO(一个个block遍历或者是dense场景在block内部遍历FixedBitSet),IndexedDISI引入了两个表来加速查询:

jumps

jumps其实就是有两个作用:

  • 用来加速定位目标block在文件中的位置。
  • 获取到某个block为止,有效位的上限(后面介绍查找的时候会用到,用作遍历停止的条件)

IndexedDISI_1.png

jumps的结构如上图所示,jumps就是一个int数组,blocki对应jumps的i*2和i*2+1两个元素,i*2存放是到blocki为止累计的有效位,i*2+1存储的是blocki在文件的起始位置。jumps是完全加载在内存中的,因此可以快速定位到每个block的起始位置,读取block的相关信息。

rank

在介绍FixedBitSet的时候,我们已经从源码层面解读了如何定位一个位置。所以,如果只是判断某个doc是否存在,则不需要rank来辅助。但是,IndexedDISI中维护一个index,表示当前doc的序号,如果没有rank的话,那就需要遍历所有的long来更新维护index变量,这样效率就比较低。

rank是一个字节数组,每两个元素组成一个short,因为一个block最多的bit是 2^16,为什么不直接用short数组呢,源码注释说byte数组持久化性能比较高,但是我们去翻源码发现其实底层实现应该是没有差别的,这个确实没理解。 rank存储的是当前block中累计的有效位数量,rank的结构依赖一个denseRankPower参数,表示的每1 << denseRankPower个bit计算一个rank值(两个字节),当denseRankPower=7的时候,表示每128位(2个long)计算一个rank值。

IndexedDISI_2.png

如上图所示,对于图中所示的位图,我们看看rank是怎么得来的。我们例子中denseRanPower是7,所以两个long一个rank值。我们看到bits[0]和bits[1]中有效位一共是5,所以rank的第一个值是5,高8位0在第0个字节,低8位5在第1个字节。bits[2]和bits[3]中有效位一共是1,加上前一个rank值5,所以第二个rank值是6,高8位0在第2个字节,低8位6在第3个字节。bits[4]和bits[5]中有效位一共是2,加上前一个rank值6,所以第三个rank值是8,高8位0在第4个字节,低8位8在第5个字节。

我们看个例子,如果没有rank的话,我们当前要查找260,则index的维护需要从bits[0]遍历到bits[4]。但是有了rank之后,我们可以得到260所属的rank的前一个rank值,也就是6,然后再计算bits[4]就可以了。

IndexedDISI构建

IndexedDISI构建是一次性的,也就是说根据已知的所有docId集合来进行构建。

构建逻辑

整体调度

IndexedDISI_3.png

如上图所示,IndexedDISI构建就是按递增顺序遍历每个docId,判断docId是否属于当前block,如果是则把docId加入当前block中,否则就持久化当前block,开始下一个block的处理。要注意的是,在构建的过程中,每个block一开始都是用FixedBitSet来存储的,在flush的时候,会判断block的有效位如果小于4096,则转成short数组。

// 入口:从it中获取文档id,构建IndexedDISI,并持久化到out中
static short writeBitSet(DocIdSetIterator it, IndexOutput out) throws IOException {
  return writeBitSet(it, out, DEFAULT_DENSE_RANK_POWER);
}

// 重载方法,可以自定义denseRankPower
static short writeBitSet(DocIdSetIterator it, IndexOutput out, byte denseRankPower)
    throws IOException {
  // 记录起始的文件位置,后续jumps中记录的都是基于这个位置的偏移量  
  final long origo = out.getFilePointer(); 
  if ((denseRankPower < 7 || denseRankPower > 15) && denseRankPower != -1) {
    throw new IllegalArgumentException(
        "Acceptable values for denseRankPower are 7-15 (every 128-32768 docIDs). "
            + "The provided power was "
            + denseRankPower
            + " (every "
            + (int) Math.pow(2, denseRankPower)
            + " docIDs)");
  }
  // 总的文档总数  
  int totalCardinality = 0;
  // block中的文档总数  
  int blockCardinality = 0;
  final FixedBitSet buffer = new FixedBitSet(1 << 16);
  //   Integer.BYTES * 2 是因为把index和offset存在一起
  int[] jumps = new int[ArrayUtil.oversize(1, Integer.BYTES * 2)];
  int prevBlock = -1;
  int jumpBlockIndex = 0;
  // 从it遍历所有的文档id
  for (int doc = it.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = it.nextDoc()) {
    // 计算doc属于哪个block  
    final int block = doc >>> 16;
    if (prevBlock != -1 && block != prevBlock) { // 当前doc属于下一个block
      // 更新jumps
      jumps =
          addJumps(
              jumps,
              out.getFilePointer() - origo,
              totalCardinality,
              jumpBlockIndex,
              prevBlock + 1);
      jumpBlockIndex = prevBlock + 1;
      // 持久化block
      flush(prevBlock, buffer, blockCardinality, denseRankPower, out);
      // 重置一些信息为下个block做准备
      buffer.clear(0, buffer.length());
      totalCardinality += blockCardinality;
      blockCardinality = 0;
    }
    buffer.set(doc & 0xFFFF);
    blockCardinality++;
    prevBlock = block;
  }
  if (blockCardinality > 0) { // 如果还有没有处理的数据
    jumps =
        addJumps(
            jumps, out.getFilePointer() - origo, totalCardinality, jumpBlockIndex, prevBlock + 1);
    totalCardinality += blockCardinality;
    flush(prevBlock, buffer, blockCardinality, denseRankPower, out);
    buffer.clear(0, buffer.length());
    prevBlock++;
  }
  final int lastBlock = prevBlock == -1 ? 0 : prevBlock; 
  // 最后一个block是空的
  jumps =
      addJumps(jumps, out.getFilePointer() - origo, totalCardinality, lastBlock, lastBlock + 1);
  buffer.set(DocIdSetIterator.NO_MORE_DOCS & 0xFFFF);
  flush(DocIdSetIterator.NO_MORE_DOCS >>> 16, buffer, 1, denseRankPower, out);
  // 持久化jumps
  return flushBlockJumps(jumps, lastBlock + 1, out);
}

// offset: 当前block在out中的偏移量
// index:当前block的第一个文档的序号
private static int[] addJumps(int[] jumps, long offset, int index, int startBlock, int endBlock) {
  assert offset < Integer.MAX_VALUE
      : "Logically the offset should not exceed 2^30 but was >= Integer.MAX_VALUE";
  jumps = ArrayUtil.grow(jumps, (endBlock + 1) * 2);
  for (int b = startBlock; b < endBlock; b++) {
    jumps[b * 2] = index;
    jumps[b * 2 + 1] = (int) offset;
  }
  return jumps;
}

private static short flushBlockJumps(int[] jumps, int blockCount, IndexOutput out)
    throws IOException {
  if (blockCount== 2) { // 如果只有一个block,则不需要jumps
    blockCount = 0;
  }
  for (int i = 0; i < blockCount; i++) {
    out.writeInt(jumps[i * 2]); // index
    out.writeInt(jumps[i * 2 + 1]); // offset
  }
  // As there are at most 32k blocks, the count is a short
  // The jumpTableOffset will be at lastPos - (blockCount * Long.BYTES)
  return (short) blockCount;
}

block持久化

块的持久化逻辑主要是判断要使用什么结构来存储,具体逻辑如下图所示:

IndexedDISI_4.png

// 持久化block
private static void flush(
    int block, FixedBitSet buffer, int cardinality, byte denseRankPower, IndexOutput out)
    throws IOException {
  assert block >= 0 && block < 65536;
  // 持久化block id  
  out.writeShort((short) block);
  assert cardinality > 0 && cardinality <= 65536;
  // 因为一个block最多有65536个文档,减1是为了避免short溢出,读取的时候+1就能还原
  out.writeShort((short) (cardinality - 1));
  if (cardinality > MAX_ARRAY_LENGTH) {
    if (cardinality != 65536) { // 不是所有的doc都存在
      if (denseRankPower != -1) {
        final byte[] rank = createRank(buffer, denseRankPower);
        out.writeBytes(rank, rank.length);
      }
      // 记录docid位图  
      for (long word : buffer.getBits()) {
        out.writeLong(word);
      }
    }
  } else { // 稀疏情况,直接用short数组存储
    BitSetIterator it = new BitSetIterator(buffer, cardinality);
    for (int doc = it.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = it.nextDoc()) {
      out.writeShort((short) doc);
    }
  }
}

private static byte[] createRank(FixedBitSet buffer, byte denseRankPower) {
  // 几个long记录一个rank  
  final int longsPerRank = 1 << (denseRankPower - 6);
  final int rankMark = longsPerRank - 1;
  // 用来确定当前long属于哪个rank  
  final int rankIndexShift = denseRankPower - 7; // 6 for the long (2^6) + 1 for 2 bytes/entry
  final byte[] rank = new byte[DENSE_BLOCK_LONGS >> rankIndexShift];
  final long[] bits = buffer.getBits();
  int bitCount = 0;
  // 遍历block中所有的long  
  for (int word = 0; word < DENSE_BLOCK_LONGS; word++) {
    if ((word & rankMark) == 0) {
      rank[word >> rankIndexShift] = (byte) (bitCount >> 8);
      rank[(word >> rankIndexShift) + 1] = (byte) (bitCount & 0xFF);
    }
    // bitCount是累加的
    bitCount += Long.bitCount(bits[word]);
  }
  return rank;
}

持久化结构

IndexedDISI.png

从上面的构建逻辑,我们可以得到IndexedDISI的整体结构。IndexedDISI是有多个blockInfo和jumps组成的。每个blockInfo都有一下信息:

  • blockId:block的编号
  • cardinality-1:block中有效位总数-1
  • blockData:三种场景有三种block结构
    • dense:rank数组+FixedBitSet
    • sparse:short数组
    • all:空

IndexedDISI查找

成员变量

// block的起始偏移量,因为在block中存储的都是doc的偏移量,真正的doc是起始偏移量+偏移量
int block = -1;
// block在文件中结束的位置
long blockEnd;
// dense块中位图的起始位置
long denseBitmapOffset = -1;
int nextBlockIndex = -1;
// 枚举类,主要就是三种block存储格式的读取方法
Method method;

// 当前读到的doc
int doc = -1;
// 当前读到的doc是第几个doc
int index = -1;

// SPARSE 块读取使用,表示block中是否存在要查找的目标
boolean exists;

// dense块中位图的一个word
long word;
// dense块第几个word
int wordIndex = -1;
// number of one bits encountered so far, including those of `word`
int numberOfOnes;
// Used with rank for jumps inside of DENSE as they are absolute instead of relative
int denseOrigoIndex;

// ALL variables
int gap;

查找目标docId

要查找目标docId,必须先知道要找的docId属于哪个block,然后把block加载到内存中,再根据不同的block存储结构实现block内的查找。

// 查找满足大于等于target的第一个docId
public int advance(int target) throws IOException {
  // 目标targe所在的block  
  final int targetBlock = target & 0xFFFF0000;
  if (block < targetBlock) { // 需要往后面找block
    advanceBlock(targetBlock);
  }
  if (block == targetBlock) { // 如果当前在处理的block就是目标block
    if (method.advanceWithinBlock(this, target)) { // 如果在当前block中找到了
      return doc;
    }
    // 读取下一个block的头部,准备在下一个block中查找  
    readBlockHeader();
  }
  boolean found = method.advanceWithinBlock(this, block);
  assert found;
  return doc;
}

// 查找target
public boolean advanceExact(int target) throws IOException {
  // 目标targe所在的block    
  final int targetBlock = target & 0xFFFF0000;
  if (block < targetBlock) {
    advanceBlock(targetBlock);
  }
  boolean found = block == targetBlock && method.advanceExactWithinBlock(this, target);
  this.doc = target;
  return found;
}

查找目标block

private void advanceBlock(int targetBlock) throws IOException {
  // 计算blockId  
  final int blockIndex = targetBlock >> 16;
  // 如果目标的block 大于等于 当前block+2,则使用jumps查找
  if (jumpTable != null && blockIndex >= (block >> 16) + 2) {
    // 防止超出jumps范围
    final int inRangeBlockIndex =
        blockIndex < jumpTableEntryCount ? blockIndex : jumpTableEntryCount - 1;
    final int index = jumpTable.readInt(inRangeBlockIndex * Integer.BYTES * 2);
    final int offset = jumpTable.readInt(inRangeBlockIndex * Integer.BYTES * 2 + Integer.BYTES);
    this.nextBlockIndex = index - 1; // -1 to compensate for the always-added 1 in readBlockHeader
    // 定位到文件中block所在的位置
    slice.seek(offset);
    // 读取新block的头部  
    readBlockHeader();
    return;
  }

  // 作为兜底,使用迭代的方式一个个block往后找
  do {
    slice.seek(blockEnd);
    readBlockHeader();
  } while (block < targetBlock);
}

读取block头部

private void readBlockHeader() throws IOException {
  // block编号左移16位  
  block = Short.toUnsignedInt(slice.readShort()) << 16;
  assert block >= 0;
  // 还记得构建的时候为了防止short溢出,做了-1的操作,这边+1还原  
  final int numValues = 1 + Short.toUnsignedInt(slice.readShort());
  index = nextBlockIndex;
  nextBlockIndex = index + numValues;
  if (numValues <= MAX_ARRAY_LENGTH) {
    method = Method.SPARSE;
    blockEnd = slice.getFilePointer() + (numValues << 1);
  } else if (numValues == 65536) {
    method = Method.ALL;
    blockEnd = slice.getFilePointer();
    gap = block - index - 1;
  } else {
    method = Method.DENSE;
    denseBitmapOffset =
        slice.getFilePointer() + (denseRankTable == null ? 0 : denseRankTable.length);
    // 1 << 13 是因为一个block是1 << 16个bit,文件的偏移量是字节,所以是  1 << 13
    blockEnd = denseBitmapOffset + (1 << 13);

    if (denseRankPower != -1) { // 需要读取rank信息
      slice.readBytes(denseRankTable, 0, denseRankTable.length);
    }
    wordIndex = -1;
    numberOfOnes = index + 1;
    denseOrigoIndex = numberOfOnes;
  }
}

块内查找

因为block有三种存储格式,sparse,dense和all,因此就对应有三种不同的查找策略。三种查找策略都实现了Method的枚举。

块内查找的接口Method

enum Method {
  // 在block中查找大于等于target的第一个doc,至少需要执行一次查找,即使当前doc >= target
  abstract boolean advanceWithinBlock(IndexedDISI disi, int target) throws IOException;

  // 在block中查找target,注意如果当前doc就是target,则无需查找
  abstract boolean advanceExactWithinBlock(IndexedDISI disi, int target) throws IOException;
}

sparse块查找

sparse块的存储结构是short数组,目前的实现是遍历查找,但是注释了todo以后预计会改成二分查找。

SPARSE {
  @Override
  boolean advanceWithinBlock(IndexedDISI disi, int target) throws IOException {
    // 目标在block中的id,也就是偏移量 
    final int targetInBlock = target & 0xFFFF;
    // TODO: binary search
    for (; disi.index < disi.nextBlockIndex; ) {
      int doc = Short.toUnsignedInt(disi.slice.readShort());
      disi.index++;
      if (doc >= targetInBlock) { // 如果找到了
        // 因为在block中存的是doc在block中的偏移量,所以需要还原  
        disi.doc = disi.block | doc;
        disi.exists = true;
        return true;
      }
    }
    return false;
  }

  @Override
  boolean advanceExactWithinBlock(IndexedDISI disi, int target) throws IOException {
    final int targetInBlock = target & 0xFFFF;
    // TODO: binary search
    if (target == disi.doc) {
      return disi.exists;
    }
    for (; disi.index < disi.nextBlockIndex; ) {
      int doc = Short.toUnsignedInt(disi.slice.readShort());
      disi.index++;
      if (doc >= targetInBlock) {
        if (doc != targetInBlock) {
          disi.index--;
          disi.slice.seek(disi.slice.getFilePointer() - Short.BYTES);
          break;
        }
        disi.exists = true;
        return true;
      }
    }
    disi.exists = false;
    return false;
  }
}

dense块查找

为了快速维护index这个变量,dense的逻辑会显得比较复杂。

DENSE {
  @Override
  boolean advanceWithinBlock(IndexedDISI disi, int target) throws IOException {
    // 目标在block中的id,也就是偏移量
    final int targetInBlock = target & 0xFFFF;
    // 目标在block中的第几个long  
    final int targetWordIndex = targetInBlock >>> 6;

    // (1 << (disi.denseRankPower - 6) 表示的几个word一个rank
    // targetWordIndex - disi.wordIndex 表示当前的word和目标word的距离
    // 所以这个条件是说   当前的word和目标word的距离 超过一个 rank 包含的word数,就使用rank来加速查询
    if (disi.denseRankPower != -1
        && targetWordIndex - disi.wordIndex >= (1 << (disi.denseRankPower - 6))) {
      rankSkip(disi, targetInBlock);
    }

    // 目标word和当前word在同一个rank范围中的话,就需要遍历
    for (int i = disi.wordIndex + 1; i <= targetWordIndex; ++i) {
      disi.word = disi.slice.readLong();
      disi.numberOfOnes += Long.bitCount(disi.word);
    }
    disi.wordIndex = targetWordIndex;

    // 这里需要注意下java的>>>运算符,  x >>> y相当于是 x >>> (y % 64)  
    long leftBits = disi.word >>> target;
    if (leftBits != 0L) {
      disi.doc = target + Long.numberOfTrailingZeros(leftBits);
      disi.index = disi.numberOfOnes - Long.bitCount(leftBits);
      return true;
    }

    // 如果在指定的word中没有找到,则需要以遍历的方式往后查找
    while (++disi.wordIndex < 1024) { // 一个block最多1024个long  
      disi.word = disi.slice.readLong();
      if (disi.word != 0) {
        disi.index = disi.numberOfOnes;
        disi.numberOfOnes += Long.bitCount(disi.word);
        disi.doc = disi.block | (disi.wordIndex << 6) | Long.numberOfTrailingZeros(disi.word);
        return true;
      }
    }
    // No set bits in the block at or after the wanted position.
    return false;
  }

  @Override
  boolean advanceExactWithinBlock(IndexedDISI disi, int target) throws IOException {
    final int targetInBlock = target & 0xFFFF;
    final int targetWordIndex = targetInBlock >>> 6;

    // If possible, skip ahead using the rank cache
    // If the distance between the current position and the target is < rank-longs
    // there is no sense in using rank
    if (disi.denseRankPower != -1
        && targetWordIndex - disi.wordIndex >= (1 << (disi.denseRankPower - 6))) {
      rankSkip(disi, targetInBlock);
    }

    for (int i = disi.wordIndex + 1; i <= targetWordIndex; ++i) {
      disi.word = disi.slice.readLong();
      disi.numberOfOnes += Long.bitCount(disi.word);
    }
    disi.wordIndex = targetWordIndex;

    long leftBits = disi.word >>> target;
    disi.index = disi.numberOfOnes - Long.bitCount(leftBits);
    return (leftBits & 1L) != 0;
  }
}

  // targetInBlock是在block中编号
  private static void rankSkip(IndexedDISI disi, int targetInBlock) throws IOException {
    assert disi.denseRankPower >= 0 : disi.denseRankPower;
    // 计算targetInBlock的rank下标
    final int rankIndex = targetInBlock >> disi.denseRankPower; 

    // 获取rank的值  
    final int rank =
        (disi.denseRankTable[rankIndex << 1] & 0xFF) << 8
            | (disi.denseRankTable[(rankIndex << 1) + 1] & 0xFF);

    // targetInBlock是第几个long
    final int rankAlignedWordIndex = rankIndex << disi.denseRankPower >> 6;
    disi.slice.seek(disi.denseBitmapOffset + rankAlignedWordIndex * Long.BYTES);
    long rankWord = disi.slice.readLong();
    int denseNOO = rank + Long.bitCount(rankWord);

    disi.wordIndex = rankAlignedWordIndex;
    disi.word = rankWord;
    disi.numberOfOnes = disi.denseOrigoIndex + denseNOO;
  }

all块查找

最简单了,因为block中肯定是存在目标docId的。

ALL {
  @Override
  boolean advanceWithinBlock(IndexedDISI disi, int target) {
    disi.doc = target;
    disi.index = target - disi.gap;
    return true;
  }

  @Override
  boolean advanceExactWithinBlock(IndexedDISI disi, int target) {
    disi.index = target - disi.gap;
    return true;
  }
};