Lucene源码系列(十六):term字典的构建

1,460 阅读18分钟

背景

全文检索引擎最核心的两块数据就是term字典和term的倒排索引,我们在前面的文章中已经非常详细地介绍了倒排索引数据结构,根据倒排索引我们可以非常快速的获取跟term相关的文档,但是如何根据term来获取term的倒排索引数据呢?这就需要term字典了。

term字典从字面上的意思理解即可,它就是一个term的字典,可以简单理解成map,key就是term,value就是term倒排数据的位置。在Lucene中是一个Field所有的term组成一个字典,根据term可以从字典中获取term对应的倒排数据的位置,比如term在哪些文档中出现,在各个文档中的频率等等,这样就可以通过一些相关性算法计算每个doc的相关性得分,从而排序得到相关性文档的排序列表。所以,term字典设计的首要目标就是速度要快,否则就成为了整个检索性能的瓶颈。

如果看过我之前的文章的话,可能大概知道term字典就是用FST数据结构来存储的,那可能有人就会认为,我用FST直接就存储了所有的term不就行了。这么想也没问题,但是,如果深入理解了FST的构建存储以及查找就会发现,FST要保证查找性能的话,需要全部加载到内存中,否则会有很多的随机IO消耗导致性能下降。

那对于数据量比较大的Field的term集合,我们怎么来构建字典呢?既然一个FST放不下,那我们就多用几个FST,每个FST之间的term是有序的,那怎么知道要查找的term在哪个FST中呢,我们可以把所有FST的第一个term拿出来,构建一个上层的FST作为索引,如果两层不够,那就再往上1层,以此类推。这样只有底层的LeafFST的输出是term的倒排数据地址,其他层次中的FST的输出都是term在下层的FST的地址,逻辑上的结构如下图所示:

term字典-1.png

上面的方案,理论上也是可行的。Lucene中综合考虑了查找性能和存储的空间大小,方案更为复杂,不过它把索引结构用FST压缩成1层了,通过FST可以定位到block,关于性能和存储空间的权衡也体现在block中。整体结构如下所示:

term字典-2.png

这里先简单说明下block,实际上会更复杂一些,后面详细介绍。一定数量的子block和term构建一个block,block中的所有term是用一个buffer存储的,因此在block中的term的查询是需要遍历的,通过控制block的大小可以控制遍历的速度。所以Lucene的字典树就是通过FST快速定位到term所在的block,然后在block中再通过遍历的方式查找。

注意:本文源码参考lucene 9.1.0版本

详解block

完全用纯文字描述解释block非常困难,所以先介绍一些必要的概念之后,我们会用一个例子来做说明,跟着看我相信还是好理解的。

Lucene在生成term字典的时候是按照Field生成的,按序遍历Field的term集合,根据一定的条件生成block。一个block至少需要有minItemsInBlock个entry,entry可以是term也可以是block,也就是block可以嵌套block,同时同属一个block的entry具有相同的前缀(需要注意,前缀长度为0是一种特殊情况)。在block生成的过程中用到以下几个变量,需要提前了解下:

  • pending:是一个栈,实际上就是一个List,用来存放处理过程中的term或者block的。
  • prefixStarts:是一个数组,prefixStarts[i]表示从pending[prefixStarts[i]]到栈顶的term集合的最长公共前缀是i+1。
  • lastTerm:上一次处理的term
  • prefixLength:表示当前要处理的term和lastTerm的最长公共前缀长度。

当要处理一个term的时候,先计算prefixLength,然后从pending中判断公共前缀长度大于prefixLength的term集合是否满足生成block的条件,形式化描述判断是否要生成一个block的条件是:

pending.sizeprefixStarts[i]minIntemsInBlockpending.size - prefixStarts[i] \ge minIntemsInBlock

其中i{lastTerm.lengh1,...,prefixLength}i\in \{lastTerm.lengh - 1,...,prefixLength\} ,注意i遍历是从大到小的,也就是优先判断较长的公共前缀。

block构建的例子

假设我们要处理的term集合是:{“abca”,“abcb”,“abcc”,“abda”,“abea”,“abfa”,“agaa”,“agab”}

假设minItemsInBlock=3

1 初始化

如下图所示,绿色的term表示下一个要处理的term,pending栈为空,prefixStarts都是0,prefixLength为0,lastTerm为empty。

term字典-3.png

2 处理 “abca”

  1. 更新prefixLength:当前处理的“abca”和lastTerm=empty的最长公共前缀长度为0
  2. 生成block:不满足条件
  3. 更新prefixStarts:从prefixLength=0下标开始,所有的 值更新为pending的大小0
  4. 更新lastTerm:“abca”
  5. 把“abca”加入pending中

结果如下图所示:

term字典-4.png

3 处理“abcb”

  1. 更新prefixLength:当前处理的“abcb”和lastTerm="abca"的最长公共前缀长度为3
  2. 生成block:不满足条件
  3. 更新prefixStarts:从prefixLength=3下标开始,所有的值更新为pending的大小1
  4. 更新lastTerm:“abcb”
  5. 把“abcb”加入pending中

结果如下图所示:

term字典-5.png

4 处理“abcc”

  1. 更新prefixLength:当前处理的“abcc”和lastTerm="abcb"的最长公共前缀长度为3
  2. 生成block:不满足条件
  3. 更新prefixStarts:从prefixLength=3下标开始,所有的值更新为pending的大小2
  4. 更新lastTerm:“abcc”
  5. 把“abcc”加入pending中

结果如下图所示:

term字典-6.png

5 处理“abda”

  1. 更新prefixLength:当前处理的“abda”和lastTerm="abcc"的最长公共前缀长度为2
  2. 生成block:当i=2i=2的时候,满足生成block的条件,可以生成block的term集合是从下标prefixStarts[2]=0开始到栈顶的,此时block公共前缀长度是i+1=3i+1=3,也就是“abc”,我们用“abc*”表示以“abc”为公共前缀的block。生成的block加入到pending中。
  3. 更新prefixStarts:从prefixLength=2下标开始,所有的值更新为pending的大小1
  4. 更新lastTerm:“abda”
  5. 把“abda”加入pending中

结果如下图所示:

term字典-7.png

6 处理“abea”

  1. 更新prefixLength:当前处理的“abea”和lastTerm="abda"的最长公共前缀长度为2
  2. 生成block:不满足生成block的条件
  3. 更新prefixStarts:从prefixLength=2下标开始,所有的值更新为pending的大小2
  4. 更新lastTerm:“abea”
  5. 把“abea”加入pending中

结果如下图所示:

term字典-9.png

7 处理“abfa”

  1. 更新prefixLength:当前处理的“abfa”和lastTerm="abea"的最长公共前缀长度为2
  2. 生成block:不满足生成block的条件
  3. 更新prefixStarts:从prefixLength=2下标开始,所有的值更新为pending的大小3
  4. 更新lastTerm:“abfa”
  5. 把“abfa”加入pending中

结果如下图所示:

term字典-10.png

8 处理“agaa”

  1. 更新prefixLength:当前处理的“agaa”和lastTerm="abfa"的最长公共前缀长度为1
  2. 生成block:当i=1i=1的时候,满足生成block的条件,可以生成block的term集合是从下标prefixStarts[1]=0开始到栈顶的,此时block公共前缀长度是i+1=i+1=2,也就是“ab”,我们用“ab*”表示以“ab”为公共前缀的block。生成的block加入到pending中。
  3. 更新prefixStarts:从prefixLength=2下标开始,所有的值更新为pending的大小1
  4. 更新lastTerm:“abfa”
  5. 把“abfa”加入pending中

结果如下图所示:

term字典-11.png

9 处理“agab”

  1. 更新prefixLength:当前处理的“agab”和lastTerm="agaa"的最长公共前缀长度为1
  2. 生成block:不满足生成block的条件
  3. 更新prefixStarts:从prefixLength=1下标开始,所有的值更新为pending的大小2
  4. 更新lastTerm:“agab”
  5. 把“agab”加入pending中

结果如下图所示:

term字典-13.png

finish

finish的时候会假装处理一个empty的term

  1. 更新prefixLength:当前处理的empty和lastTerm="agab"的最长公共前缀长度为0
  2. 生成block:当i=0i=0的时候,满足生成block的条件,可以生成block的term集合是从下标prefixStarts[0]=0开始到栈顶的,此时block公共前缀长度是i+1=i+1=1,也就是“a”,我们用“a*”表示以“a”为公共前缀的block。生成的block加入到pending中。
  3. 此时所有的tyerm都已经处理完了,pending中的entry只剩下一个,这个肯定是block,就是多层索引逻辑结构的根block。

结果如下图所示:

term字典-14.png

最终,这个例子的term字典逻辑结构如下所示:

term字典-15.png

那怎么在字典中查找呢?我们简单举个例子,比如我们要查找“abca”,则在FST树上开始找,一直找到“c”,然后找“a”的时候发现FST中没有边了,说明“abca”如果存在的话,只能在“abc*”指向的block中,则进入block中遍历即可。

两种特殊情况

情况一

如果所有的term都没有公共前缀,则pending会持续膨胀,我甚至觉得这都是一种可攻击的漏洞,pending会无限消耗内存。这里我觉得Lucene应该处理一下,可以在prefixLength为0的时候判断下pending的大小,合适的情况可以提前生成一个block,这个可能是一个参与Lucene开源MR提交的机会。 这里理解有误,因为前缀判断是使用byte的,所以这个数量是有限的,不会持续膨胀。

情况二

如果具有公共前缀的term集合特别大,极端情况所有的term都有一样的公共前缀,这样不是又退化成一个FST了吗?Lucene中针对这种情况使用一个参数来判断maxItemsInBlock来判断block是否过大,如果待构建的一个block中的entry的个数大于maxItemsInBlock,则需要把block拆成多个floorBlock,但是逻辑结构上看,所有的floorBlock还是以整体的一个block对外呈现。

我们看个例子,假设当前的minItemsInBlock=2minItemsInBlock=2maxItemsInBlock=3maxItemsInBlock=3,现在对pending栈中的4个entry构建block,如果4个entry构建成一个block就超出了maxItemsInBlock的限制,所以需要拆分成多个floorBlock。如下图所示,可以拆分成两个floorBlock,存储是按照floorBlock存储的,但是逻辑上看还是只有一个block。

需要注意的是floorBlock中的floorLabel,第一个floorBlock没有floorLabel,之后的floorLabel就是第一个entry的suffix的第一个label。存储的时候是把floorLabel拼接在prefix后面一起存储。floorLabel的作用是查找的时候界定要查找的目标在哪个floorBlock中,后面我们讲查找的时候再细说。

term字典-16.png

源码分析

从上面的介绍,我们可以粗粒度的了解Lucene term字典的逻辑结构,但是深究细节起来可能觉得一切都很模糊,那具象化的感受我们就要从源码层面来分析了。

pending栈中的entry

private static class PendingEntry {
  // 用来区分entry是term还是一个block  
  public final boolean isTerm;

  protected PendingEntry(boolean isTerm) {
    this.isTerm = isTerm;
  }
}

PendingEntry它有两个继承类:

PendingTerm

private static final class PendingTerm extends PendingEntry {
  // term  
  public final byte[] termBytes;
  // term的倒排索引的元信息
  public final BlockTermState state;

  public PendingTerm(BytesRef term, BlockTermState state) {
    super(true);
    this.termBytes = new byte[term.length];
    System.arraycopy(term.bytes, term.offset, termBytes, 0, term.length);
    this.state = state;
  }

  @Override
  public String toString() {
    return "TERM: " + brToString(termBytes);
  }
}

PendingBlock

成员变量
  // 如果是floorBlock,则prefix是 相同前缀+leadingLabel,否则prefix只是相同前缀  
  public final BytesRef prefix;
  // block在tim索引文件中的起始位置  
  public final long fp;
  // PendingBlock自身的index信息
  public FST<BytesRef> index;
  // PendingBlock中子block的index信息  
  public List<FST<BytesRef>> subIndices;
  // block中是否含有term
  public final boolean hasTerms;
  // 是否是floorBlock  
  public final boolean isFloor;
  // 如果是floorBlock则是leadingLabel,否则就是-1  
  public final int floorLeadByte;

PendingBlock中的成员变量需要重点介绍下index和subIndices,subIndices中存放的就是block中所有的PendingBlock的index信息,因此最主要的就是要来理解下index信息。index就是FST,输入就是各个block的prefix,输出根据是否是floorBlock有所区别。如下图所示:

term字典-17.png

各个字段的意思:

  • fp:block在tim索引文件中的起始位置
  • hasTerms:block中是否含有term
  • isFloor:是否拆分成多个floorBlock
  • floorBlockNumber:总共有多少个floorBLock
  • floorBlockMetaData:floorBlock的元信息
    • floorLeadByte:floorBlock中的第一个entry的suffix的第一个byte
    • fpDelta:和第一个floorBlock的fp的差值
核心方法

PendingBlock中的核心方法就是构建block的index。这里需要重点理解以下几点:

  • 方法参数blocks中非空的情况,只有当前block被拆成多个floorBlock的时候,所有的floorBlock需要和第一个floorBlock合并成一个index,因此,虽然过大的block被拆成了多个floorBlock,但是逻辑上还是一个block。
  • 所有子block中的index信息会被合并到当前block的index中,因此,最终完整的index就是最后构建的rootBlock的index。
  public void compileIndex(
      List<PendingBlock> blocks, // 如果block被拆成了多个floorBlock,并且当前pendingBlock是所有floorBlock的                                    // 第一个,则block不为空,并且和当前pendingBlock组成一个完整的block
      ByteBuffersDataOutput scratchBytes,
      IntsRefBuilder scratchIntsRef)
      throws IOException {
    scratchBytes.writeVLong(encodeOutput(fp, hasTerms, isFloor));
    if (isFloor) { // 如果是floorBlock
      // 剩下的floorBlock的个数,不包括第一个floorBlock  
      scratchBytes.writeVInt(blocks.size() - 1);
      for (int i = 1; i < blocks.size(); i++) {
        PendingBlock sub = blocks.get(i);
        scratchBytes.writeByte((byte) sub.floorLeadByte);
        scratchBytes.writeVLong((sub.fp - fp) << 1 | (sub.hasTerms ? 1 : 0));
      }
    }

    final ByteSequenceOutputs outputs = ByteSequenceOutputs.getSingleton();
    final FSTCompiler<BytesRef> fstCompiler =
        new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE1, outputs)
            .shouldShareNonSingletonNodes(false)
            .build();

    final byte[] bytes = scratchBytes.toArrayCopy();
    assert bytes.length > 0;
    fstCompiler.add(Util.toIntsRef(prefix, scratchIntsRef), new BytesRef(bytes, 0, bytes.length));
    scratchBytes.reset();
    // 当前的index包含了所有floorBlock的subIndices
    for (PendingBlock block : blocks) {
      if (block.subIndices != null) {
        for (FST<BytesRef> subIndex : block.subIndices) {
          append(fstCompiler, subIndex, scratchIntsRef);
        }
        block.subIndices = null;
      }
    }
    // 当前pendingBlock的index,如果是leafBlock,则只包含leafBlock的前缀,否则包含了所有的subIndex
    index = fstCompiler.compile();
  }

  // 将subIndex中的所有数据都追加到fstCompiler中
  private void append(
      FSTCompiler<BytesRef> fstCompiler, FST<BytesRef> subIndex, IntsRefBuilder scratchIntsRef)
      throws IOException {
    final BytesRefFSTEnum<BytesRef> subIndexEnum = new BytesRefFSTEnum<>(subIndex);
    BytesRefFSTEnum.InputOutput<BytesRef> indexEnt;
    while ((indexEnt = subIndexEnum.next()) != null) {
      fstCompiler.add(Util.toIntsRef(indexEnt.input, scratchIntsRef), indexEnt.output);
    }
  }

term字典构建的入口

term字典最终落盘的索引文件的后缀分别是tim,tip和tmd。

  • tip:所有字段的rootBlock的index
  • tim:所有字段的block数据
  • tmd:用来读取的一些元信息

term字典的构建入口在write方法中,write方法比较简单,就是遍历所有的Field,处理Field的所有term构建term字典。真正执行构建字典的逻辑全部在TermsWriter中。

public final class Lucene90BlockTreeTermsWriter extends FieldsConsumer {

  // 默认的minItemsInBlock的值
  public static final int DEFAULT_MIN_BLOCK_SIZE = 25;

  // 默认的maxItemsInBlock的值
  public static final int DEFAULT_MAX_BLOCK_SIZE = 48;

  // tmd索引文件  
  private final IndexOutput metaOut;
  // tim索引文件  
  private final IndexOutput termsOut;
  // tip索引文件  
  private final IndexOutput indexOut;
  // 当前segment中的文档总数  
  final int maxDoc;
  // 生成一个block的最低entry要求,最后一个block除外  
  final int minItemsInBlock;
  // block中entry最多不超过maxItemsInBlock
  final int maxItemsInBlock;
  // 倒排索引生成
  final PostingsWriterBase postingsWriter;
  // 字段信息  
  final FieldInfos fieldInfos;

  // 存储每个字段在tmd中的数据,最后一次性写入tmd  
  private final List<ByteBuffersDataOutput> fields = new ArrayList<>();

  public Lucene90BlockTreeTermsWriter(
      SegmentWriteState state,
      PostingsWriterBase postingsWriter,
      int minItemsInBlock,
      int maxItemsInBlock)
      throws IOException {
    validateSettings(minItemsInBlock, maxItemsInBlock);

    this.minItemsInBlock = minItemsInBlock;
    this.maxItemsInBlock = maxItemsInBlock;

    this.maxDoc = state.segmentInfo.maxDoc();
    this.fieldInfos = state.fieldInfos;
    this.postingsWriter = postingsWriter;

    final String termsName =
        IndexFileNames.segmentFileName(
            state.segmentInfo.name,
            state.segmentSuffix,
            Lucene90BlockTreeTermsReader.TERMS_EXTENSION);
    termsOut = state.directory.createOutput(termsName, state.context);
    boolean success = false;
    IndexOutput metaOut = null, indexOut = null;
    try {
      CodecUtil.writeIndexHeader(
          termsOut,
          Lucene90BlockTreeTermsReader.TERMS_CODEC_NAME,
          Lucene90BlockTreeTermsReader.VERSION_CURRENT,
          state.segmentInfo.getId(),
          state.segmentSuffix);

      final String indexName =
          IndexFileNames.segmentFileName(
              state.segmentInfo.name,
              state.segmentSuffix,
              Lucene90BlockTreeTermsReader.TERMS_INDEX_EXTENSION);
      indexOut = state.directory.createOutput(indexName, state.context);
      CodecUtil.writeIndexHeader(
          indexOut,
          Lucene90BlockTreeTermsReader.TERMS_INDEX_CODEC_NAME,
          Lucene90BlockTreeTermsReader.VERSION_CURRENT,
          state.segmentInfo.getId(),
          state.segmentSuffix);

      final String metaName =
          IndexFileNames.segmentFileName(
              state.segmentInfo.name,
              state.segmentSuffix,
              Lucene90BlockTreeTermsReader.TERMS_META_EXTENSION);
      metaOut = state.directory.createOutput(metaName, state.context);
      CodecUtil.writeIndexHeader(
          metaOut,
          Lucene90BlockTreeTermsReader.TERMS_META_CODEC_NAME,
          Lucene90BlockTreeTermsReader.VERSION_CURRENT,
          state.segmentInfo.getId(),
          state.segmentSuffix);

      postingsWriter.init(metaOut, state);

      this.metaOut = metaOut;
      this.indexOut = indexOut;
      success = true;
    } finally {
      if (!success) {
        IOUtils.closeWhileHandlingException(metaOut, termsOut, indexOut);
      }
    }
  }

  // 字典构建的入口  
  // fields:在介绍《内存中倒排信息的读取》有详细介绍过FreqProxFields
  // norms:归一化数据,以后单独介绍  
  public void write(Fields fields, NormsProducer norms) throws IOException {
    String lastField = null;
    for (String field : fields) { // 遍历所有的字段
      assert lastField == null || lastField.compareTo(field) < 0;
      lastField = field;

      Terms terms = fields.terms(field);
      if (terms == null) {
        continue;
      }
      // 获取字段的所有的term的迭代器
      TermsEnum termsEnum = terms.iterator();
      // 字典树构建的核心类  
      TermsWriter termsWriter = new TermsWriter(fieldInfos.fieldInfo(field));
      while (true) { // 处理field所有的term
        BytesRef term = termsEnum.next();
        if (term == null) {
          break;
        }
        termsWriter.write(term, termsEnum, norms);
      }

      termsWriter.finish();
    }
  }

  // 用一个long表示多种信息
  // fp:block在tim索引文件中的起始位置
  // hasTerms: block中是否存在term
  // isFloor:block是否是floorBlock  
  static long encodeOutput(long fp, boolean hasTerms, boolean isFloor) {
    assert fp < (1L << 62);
    return (fp << 2)
        | (hasTerms ? Lucene90BlockTreeTermsReader.OUTPUT_FLAG_HAS_TERMS : 0)
        | (isFloor ? Lucene90BlockTreeTermsReader.OUTPUT_FLAG_IS_FLOOR : 0);
  }

  private final ByteBuffersDataOutput scratchBytes = ByteBuffersDataOutput.newResettableInstance();
  private final IntsRefBuilder scratchIntsRef = new IntsRefBuilder();

  static final BytesRef EMPTY_BYTES_REF = new BytesRef();

  private boolean closed;

  @Override
  public void close() throws IOException {
    if (closed) {
      return;
    }
    closed = true;

    boolean success = false;
    try {
      // tmd索引文件写入字段的个数  
      metaOut.writeVInt(fields.size());
      for (ByteBuffersDataOutput fieldMeta : fields) {
        fieldMeta.copyTo(metaOut);
      }
      CodecUtil.writeFooter(indexOut);
      // tmd记录tip文件的总大小,在读取的时候会先校验大小是否符合预期
      metaOut.writeLong(indexOut.getFilePointer());
      CodecUtil.writeFooter(termsOut);
      // tmd记录tim文件的总大小,在读取的时候会先校验大小是否符合预期
      metaOut.writeLong(termsOut.getFilePointer());
      CodecUtil.writeFooter(metaOut);
      success = true;
    } finally {
      if (success) {
        IOUtils.close(metaOut, termsOut, indexOut, postingsWriter);
      } else {
        IOUtils.closeWhileHandlingException(metaOut, termsOut, indexOut, postingsWriter);
      }
    }
  }

  private static void writeBytesRef(DataOutput out, BytesRef bytes) throws IOException {
    out.writeVInt(bytes.length);
    out.writeBytes(bytes.bytes, bytes.offset, bytes.length);
  }
}

block构建

成员变量
  private final FieldInfo fieldInfo;
  // 统计Field中的term总数  
  private long numTerms;
    
  // Field出现的所有doc  
  final FixedBitSet docsSeen;
    
  // 所有term频率总和  
  long sumTotalTermFreq;
    
  // 所有term文档的总频率  
  long sumDocFreq;
    
  // 前一个处理的term
  private final BytesRefBuilder lastTerm = new BytesRefBuilder();
    
  // pending中从第prefixStarts[i]个entry到栈顶entry的公共前缀长度为i+1  
  private int[] prefixStarts = new int[8];

  // 存放处理过程中的term或者是block
  private final List<PendingEntry> pending = new ArrayList<>();

  // 构建block的时候使用。不需要生成floorBlock的时候只临时存储block。
  // 如果block需要拆成多个floorBlock,则会临时存储所有的floorBlock
  private final List<PendingBlock> newBlocks = new ArrayList<>();

  // 第一个出现的term,其实就是Field中最小的term  
  private PendingTerm firstPendingTerm;
  // 最后一个出现的term,其实就是Field中最大的term  
  private PendingTerm lastPendingTerm;

  // 记录block中所有entry除了最长公共前缀外剩下的suffix的长度
  private final ByteBuffersDataOutput suffixLengthsWriter =
      ByteBuffersDataOutput.newResettableInstance();
  // 记录block中所有entry的suffix  
  private final BytesRefBuilder suffixWriter = new BytesRefBuilder();
  // 记录所有term的统计信息:出现的文档总数+出现的频率总数
  private final ByteBuffersDataOutput statsWriter = ByteBuffersDataOutput.newResettableInstance();
  // 每个字段在tmd索引文件中的信息的临时存储器
  private final ByteBuffersDataOutput metaWriter = ByteBuffersDataOutput.newResettableInstance();
  // 压缩用的临时存储器  
  private final ByteBuffersDataOutput spareWriter = ByteBuffersDataOutput.newResettableInstance();
  // 临时存储器
  private byte[] spareBytes = BytesRef.EMPTY_BYTES;
  // LZ4压缩算法使用
  private LZ4.HighCompressionHashTable compressionHashTable;
核心方法
write

block构建的最顶层的调度方法,主要做3件事:

  • 使用postingsWriter构建term的倒排信息,返回关于倒排信息的元信息state(之前的文章详细介绍过了)
  • 调用pushTerm构建block
  • 更新一些统计信息
  public void write(BytesRef text, TermsEnum termsEnum, NormsProducer norms) throws IOException {
    // 构建term的倒排信息,返回的state存储了term在倒排索引中的起始位置等元信息
    BlockTermState state = postingsWriter.writeTerm(text, termsEnum, docsSeen, norms);
    if (state != null) {
      // 判断在term加入pending之前是否可以生成block 
      pushTerm(text);
      // term封装成PendingTerm进入pending
      PendingTerm term = new PendingTerm(text, state);
      pending.add(term);
      // 更新所有term出现的总文档频率
      sumDocFreq += state.docFreq;
      // 更新所有term出现的总频率  
      sumTotalTermFreq += state.totalTermFreq;
      // 更新term总数  
      numTerms++;
      if (firstPendingTerm == null) {
        firstPendingTerm = term;
      }
      lastPendingTerm = term;
    }
  }
pushTerm

pushTerm是构建block的入口,这个方法如果没看我前面举的例子,比较难理解,所以需要好好理解下我举的例子再来看会比较顺。pushTerm其实只做两件事:

  • 判断pending中的entry是否满足block的构建条件
  • 更新维护prefixStarts数组,prefixStarts是用来快速判断是否满足block构建的重要数据
  // 判断pending中的entry是否满足block的构建
  // 这个函数的逻辑可以对照这我们前面的构建的例子来看会比较好理解,否则看起来比较费劲。
  private void pushTerm(BytesRef text) throws IOException {
    // 计算text和栈顶entry之间的第一个不匹配的位置,得到的就是相同前缀的长度
    int prefixLength =
        Arrays.mismatch(
            lastTerm.bytes(),
            0,
            lastTerm.length(),
            text.bytes,
            text.offset,
            text.offset + text.length);
    if (prefixLength == -1) { 
      assert lastTerm.length() == 0;
      prefixLength = 0;
    }

    // 从最长前缀开始查找,满足minItemsInBlock个就生成一个或者多个block
    for (int i = lastTerm.length() - 1; i >= prefixLength; i--) {
      // 满足公共前缀长度为i+1的entry个数为prefixTopSize
      int prefixTopSize = pending.size() - prefixStarts[i];
      if (prefixTopSize >= minItemsInBlock) { // 如果满足可以构建block的阈值
        writeBlocks(i + 1, prefixTopSize);  
        prefixStarts[i] -= prefixTopSize - 1;
      }
    }

    if (prefixStarts.length < text.length) { // prefixStarts数组始终至少的当前最长的term的大小
      prefixStarts = ArrayUtil.grow(prefixStarts, text.length);
    }

    for (int i = prefixLength; i < text.length; i++) { // 从prefixLength开始的共同前缀都是0
      prefixStarts[i] = pending.size();
    }

    lastTerm.copyBytes(text);
  }
writeBlocks

在writeBlocks中就会判断是否需要把block做floorBlock切分以及block的index的构建。

  • 如果block太大,则需要切分成多个floorBlock
  • 通过PendingBlock#compileIndex构建block的index
  // prefixLength: 当前block中的所有的entry的公共前缀长度  
  // count:当前block中的entry的个数
  void writeBlocks(int prefixLength, int count) throws IOException {
    int lastSuffixLeadLabel = -1;
    boolean hasTerms = false; // 当前的block是否包含PendingTerm
    boolean hasSubBlocks = false; // 当前的block是否包含PendingBlock

    // 当前block在List中的起始下标  
    int start = pending.size() - count;
    // 当前block在List中的结束下标  
    int end = pending.size();
    // 如果一个block太大的话,会分成多个floorBlock,nextBlockStart记录的是floorBlock在List中的起始下标  
    int nextBlockStart = start;
    int nextFloorLeadLabel = -1;

    for (int i = start; i < end; i++) { // 处理block中的所有的Entry
      PendingEntry ent = pending.get(i);
      // 共享前缀后面的第一个label  
      int suffixLeadLabel;
      if (ent.isTerm) { // 处理PendingTerm
        PendingTerm term = (PendingTerm) ent;
        if (term.termBytes.length == prefixLength) { // 没有后缀
          suffixLeadLabel = -1;
        } else {   
          suffixLeadLabel = term.termBytes[prefixLength] & 0xff;
        }
      } else { // 处理PendingBlock
        PendingBlock block = (PendingBlock) ent;
        suffixLeadLabel = block.prefix.bytes[block.prefix.offset + prefixLength] & 0xff;
      }

      if (suffixLeadLabel != lastSuffixLeadLabel) {
        // 当前block中的entry个数  
        int itemsInBlock = i - nextBlockStart;
        // itemsInBlock >= minItemsInBlock : block中的entry个数满足最低要求
        // end - nextBlockStart > maxItemsInBlock : 如果block中的entry个数可能超出最大限制
        if (itemsInBlock >= minItemsInBlock && end - nextBlockStart > maxItemsInBlock) {
          // 说明当前的block被拆成了多个floorBlock  
          boolean isFloor = itemsInBlock < count;
          newBlocks.add(
              writeBlock(
                  prefixLength,
                  isFloor,
                  nextFloorLeadLabel, // 第一个floorBlock和普通的block都是-1,
                                      // 其他情况就是floorBlock第一个term的后缀的第一个label
                  nextBlockStart,
                  i,
                  hasTerms,
                  hasSubBlocks));

          hasTerms = false;
          hasSubBlocks = false;
          nextFloorLeadLabel = suffixLeadLabel;
          nextBlockStart = i;
        }

        lastSuffixLeadLabel = suffixLeadLabel;
      }

      if (ent.isTerm) {
        hasTerms = true;
      } else {
        hasSubBlocks = true;
      }
    }
    // 处理最后剩余的entry
    if (nextBlockStart < end) {
      int itemsInBlock = end - nextBlockStart;
      boolean isFloor = itemsInBlock < count;
      newBlocks.add(
          writeBlock(
              prefixLength,
              isFloor,
              nextFloorLeadLabel,
              nextBlockStart,
              end,
              hasTerms,
              hasSubBlocks));
    }
    // 这里需要注意下,如果newBlocks的size大于1,说明肯定存在floorBlock,
    // 则把所有的floorBlock的信息都集中到第一个floorBlock中
    PendingBlock firstBlock = newBlocks.get(0);
    // 把所有的floorBlock的index都合并到第一个floorBlock的index中,因此逻辑上其实还是只有一个block 
    firstBlock.compileIndex(newBlocks, scratchBytes, scratchIntsRef);
    // 已经处理好的的pendingEntry删除  
    pending.subList(pending.size() - count, pending.size()).clear();
    // 新生成的pendingBlock加入pending列表中  
    pending.add(firstBlock);
    newBlocks.clear();
  }
writeBlock

因为block是中的公共最长前缀已经存储在index中了,所以只需要再存储每个entry的suffix就可以了。

suffixLengthsWriter,suffixWriter,statsWriter分别用来临时记录所有suffix的长度,suffix的信息,term的统计信息,最后会写入到tim索引文件中。

  private PendingBlock writeBlock(
      int prefixLength, // 共享前缀长度
      boolean isFloor,  // 是否是floorBlock
      int floorLeadLabel, // floorBlock的第一个term的后缀的第一个label
      int start, // block在pendingList中的起始下标
      int end,   // block在pendingList中的结束下标
      boolean hasTerms, // block是否存在pendingTerm entry
      boolean hasSubBlocks) // block是否存在pendingBlock entry
      throws IOException {
    // 记录当前block在tim索引文件中的起始位置
    long startFP = termsOut.getFilePointer();
    // 存在floorLeadLabel的前提是block必须是floorBlock,并且floorLeadLabel != 1  
    boolean hasFloorLeadLabel = isFloor && floorLeadLabel != -1;
    // 整个block的prefix,如果不是floorBlock,则就是block的共享前缀,否则需要加上floorLeadLabel
    final BytesRef prefix = new BytesRef(prefixLength + (hasFloorLeadLabel ? 1 : 0));
    // 先拷贝共享前缀  
    System.arraycopy(lastTerm.get().bytes, 0, prefix.bytes, 0, prefixLength);
    prefix.length = prefixLength;

    // block的头部信息: block的entry个数左移一位,用最后一位标记floorBlock是否是block的最后一个子block
    // 当然,没有拆分floorBlock的block,也可以认为只有一个floorBlock的block  
    int numEntries = end - start;
    int code = numEntries << 1;
    if (end == pending.size()) {
      code |= 1;
    }
    // tim索引文件记录code  
    termsOut.writeVInt(code);
    boolean isLeafBlock = hasSubBlocks == false;
    final List<FST<BytesRef>> subIndices;
    // 是否是第一个处理的term  
    boolean absolute = true;

    if (isLeafBlock) { // block中的都是PendingTerm
      subIndices = null;
      StatsWriter statsWriter =
          new StatsWriter(this.statsWriter, fieldInfo.getIndexOptions() != IndexOptions.DOCS);
      for (int i = start; i < end; i++) {
        PendingEntry ent = pending.get(i);
        PendingTerm term = (PendingTerm) ent;
        BlockTermState state = term.state;
        // 后缀长度  
        final int suffix = term.termBytes.length - prefixLength;
        // 记录后缀长度  
        suffixLengthsWriter.writeVInt(suffix);
        // 记录后缀内容  
        suffixWriter.append(term.termBytes, prefixLength, suffix);
        // 记录term的统计信息
        statsWriter.add(state.docFreq, state.totalTermFreq);

        // 记录term在倒排文件中的位置信息
        postingsWriter.encodeTerm(metaWriter, fieldInfo, state, absolute);
        absolute = false;
      }
      statsWriter.finish();
    } else {
      // Block has at least one prefix term or a sub block:
      subIndices = new ArrayList<>();
      StatsWriter statsWriter =
          new StatsWriter(this.statsWriter, fieldInfo.getIndexOptions() != IndexOptions.DOCS);
      for (int i = start; i < end; i++) {
        PendingEntry ent = pending.get(i);
        if (ent.isTerm) { // PendingTerm同上
          PendingTerm term = (PendingTerm) ent;
          BlockTermState state = term.state;
          final int suffix = term.termBytes.length - prefixLength;
          suffixLengthsWriter.writeVInt(suffix << 1);
          suffixWriter.append(term.termBytes, prefixLength, suffix);
          statsWriter.add(state.docFreq, state.totalTermFreq);
          postingsWriter.encodeTerm(metaWriter, fieldInfo, state, absolute);
          absolute = false;
        } else {
          PendingBlock block = (PendingBlock) ent;
          // PendingBlock的suffix就只有一个label  
          final int suffix = block.prefix.length - prefixLength;
          // 最后一位是1表示是个PendingBlock entry
          suffixLengthsWriter.writeVInt((suffix << 1) | 1);
          suffixWriter.append(block.prefix.bytes, prefixLength, suffix);

          suffixLengthsWriter.writeVLong(startFP - block.fp);
          // 记录子block的index  
          subIndices.add(block.index);
        }
      }
      statsWriter.finish();
    }
    
    // 这下面一大段逻辑都是决定用不用压缩,用什么压缩方式,可以简单看下
    // 默认不使用任何压缩方式 
    // 如果使用了压缩的话,suffix会从suffixWriter中压缩之后放在spareWriter中  
    CompressionAlgorithm compressionAlg = CompressionAlgorithm.NO_COMPRESSION;
    // 每个term的suffix平均长度大于2并且公共最长前缀大于2才考虑使用压缩算法  
    if (suffixWriter.length() > 2L * numEntries && prefixLength > 2) {
      if (suffixWriter.length() > 6L * numEntries) { // 如果suffix的平均长度大于6,尝试使用LZ4压缩算法
        if (compressionHashTable == null) {
          compressionHashTable = new LZ4.HighCompressionHashTable();
        }
        LZ4.compress(
            suffixWriter.bytes(), 0, suffixWriter.length(), spareWriter, compressionHashTable);
        // LZ4压缩算法是否把空间减小25%,如果达到这个要求,才使用LZ4压缩算法  
        if (spareWriter.size() < suffixWriter.length() - (suffixWriter.length() >>> 2)) { // 
          compressionAlg = CompressionAlgorithm.LZ4;
        }
      }
      if (compressionAlg == CompressionAlgorithm.NO_COMPRESSION) { // 如果还没有使用任何压缩算法
        spareWriter.reset();
        if (spareBytes.length < suffixWriter.length()) {
          spareBytes = new byte[ArrayUtil.oversize(suffixWriter.length(), 1)];
        }
        // 尝试使用  LOWERCASE_ASCII 压缩算法
        if (LowercaseAsciiCompression.compress(
            suffixWriter.bytes(), suffixWriter.length(), spareBytes, spareWriter)) {
          compressionAlg = CompressionAlgorithm.LOWERCASE_ASCII;
        }
      }
    }
    // 倒数第3位用来表示否是否leafBlock
    // 倒数2位用来记录压缩算法编号  
    long token = ((long) suffixWriter.length()) << 3;
    if (isLeafBlock) {
      token |= 0x04;
    }
    token |= compressionAlg.code;
    // tim索引文件记录token
    termsOut.writeVLong(token);
    // tim索引文件记录suffix信息,使用压缩的话再spareWriter中,否则在suffixWriter中  
    if (compressionAlg == CompressionAlgorithm.NO_COMPRESSION) {
      termsOut.writeBytes(suffixWriter.bytes(), suffixWriter.length());
    } else {
      spareWriter.copyTo(termsOut);
    }
    suffixWriter.setLength(0);
    spareWriter.reset();

    // Write suffix lengths
    final int numSuffixBytes = Math.toIntExact(suffixLengthsWriter.size());
    spareBytes = ArrayUtil.grow(spareBytes, numSuffixBytes);
    suffixLengthsWriter.copyTo(new ByteArrayDataOutput(spareBytes));
    suffixLengthsWriter.reset();
    // 先记录所有suffix长度数据的总大小,左移一位,用最后一位记录是否所有的值都相等
    // 如果所有值都相同,则只写一个值,否则全部记录。  
    if (allEqual(spareBytes, 1, numSuffixBytes, spareBytes[0])) {
      termsOut.writeVInt((numSuffixBytes << 1) | 1);
      termsOut.writeByte(spareBytes[0]);
    } else {
      termsOut.writeVInt(numSuffixBytes << 1);
      termsOut.writeBytes(spareBytes, numSuffixBytes);
    }

    // 记录所有term的统计信息
    final int numStatsBytes = Math.toIntExact(statsWriter.size());
    termsOut.writeVInt(numStatsBytes);
    statsWriter.copyTo(termsOut);
    statsWriter.reset();

    // 记录term的倒排索引的位置信息
    termsOut.writeVInt((int) metaWriter.size());
    metaWriter.copyTo(termsOut);
    metaWriter.reset();

    if (hasFloorLeadLabel) { // 把floorLeadLabel也作为block的prefix的一部分
      prefix.bytes[prefix.length++] = (byte) floorLeadLabel;
    }

    return new PendingBlock(prefix, startFP, hasTerms, isFloor, floorLeadLabel, subIndices);
  }
finish

结束一个Field的term字典的构建:

  • 构建最后的rootBlock
  • term字典的元信息的持久化
  • rootBlock的index持久化到tip索引文件中
  public void finish() throws IOException {
    if (numTerms > 0) {
      // push一个空term,可以让pending中的满足block的entry先生成block  
      pushTerm(new BytesRef());
      // 第二次push空term确保pending中的entry都是没有公共前缀的  
      pushTerm(new BytesRef());
      // 为pending中剩下的所有entry构建block  
      writeBlocks(0, pending.size());

      // 此时pending中只剩下一个rootBlock
      assert pending.size() == 1 && !pending.get(0).isTerm
          : "pending.size()=" + pending.size() + " pending=" + pending;
      final PendingBlock root = (PendingBlock) pending.get(0);
      assert root.prefix.length == 0;
      final BytesRef rootCode = root.index.getEmptyOutput();
      assert rootCode != null;

      ByteBuffersDataOutput metaOut = new ByteBuffersDataOutput();
      fields.add(metaOut);

      // 记录字段编号  
      metaOut.writeVInt(fieldInfo.number);
      // 记录字段中的term总数  
      metaOut.writeVLong(numTerms);
      // 记录empty term的信息
      metaOut.writeVInt(rootCode.length);
      metaOut.writeBytes(rootCode.bytes, rootCode.offset, rootCode.length);
      assert fieldInfo.getIndexOptions() != IndexOptions.NONE;
      if (fieldInfo.getIndexOptions() != IndexOptions.DOCS) {
        // 记录Field的所有term的频率总数  
        metaOut.writeVLong(sumTotalTermFreq);
      }
      // 所有term出现的文档频率总数  
      metaOut.writeVLong(sumDocFreq);
      // 所有term出现的文档总数 
      metaOut.writeVInt(docsSeen.cardinality());
      // 记录Field中的第一个term  
      writeBytesRef(metaOut, new BytesRef(firstPendingTerm.termBytes));
      // 记录Field中的最后一个term  
      writeBytesRef(metaOut, new BytesRef(lastPendingTerm.termBytes));
      // 记录在tip文件中的起始位置
      metaOut.writeVLong(indexOut.getFilePointer());
      // 记录index,index就是FST,FST的持久化在我们介绍FST的时候已经介绍过了
      root.index.save(metaOut, indexOut);
    } else {
      assert sumTotalTermFreq == 0
          || fieldInfo.getIndexOptions() == IndexOptions.DOCS && sumTotalTermFreq == -1;
      assert sumDocFreq == 0;
      assert docsSeen.cardinality() == 0;
    }
  }

索引文件格式

tip

tip索引文件存储的是所有字段的字典树的结构,一个字段一个字典树,字典树的数据结构是FST,tip的数据文件格式其实就是所有字段的FST字典的结构存储在一起,如下图所示:

tip.png

字段详解

Header

文件头部信息,主要是包括:

  • 文件头魔数(同一lucene版本所有文件相同)
  • 该文件使用的codec名称:BlockTreeTermsIndex
  • codec版本
  • segment id(也是Segment_N文件中的N)
  • segment后缀名(一般为空)
FSTIndex

输入是block的最长公共前缀,输出是block的位置等元信息。

详细的FST存储结构我们之前的文章已经介绍过了。

Footer

文件尾,主要包括

  • 文件尾魔数(同一个lucene版本所有文件一样)
  • 0
  • 校验码

tmd

tmd索引文件存储的所有字段的字典树的元信息,元信息最重要的是可以定位到字段所对应的在tip索引文件中的FSTIndex的位置。整体的tmd的索引文件格式如下图所示:

tmd.png

字段详解

Header

文件头部信息,主要是包括:

  • 文件头魔数(同一lucene版本所有文件相同)
  • 该文件使用的codec名称:BlockTreeTermsMeta
  • codec版本
  • segment id(也是Segment_N文件中的N)
  • segment后缀名(一般为空)
PostingBlockSize

倒排索引文件中各种倒排信息存储的blockSize,详细请看我之前倒排相关的文章。

NumFields

总的字段个数

FieldStats

字段的统计信息和一些字典树的元信息:

  • FieldNumber:总的字段个数
  • NumTerms:总的term的个数
  • RootCodeLength:rootBlock的大小
  • RootCode:rootBlock的元信息,重点是起始位置,配合RootCodeLength就能得到rootBlock
  • SumTotalTermFreq:字段中所有term的总频率
  • SumDocFreq:字段中所有term出现的doc的总频率
  • DocCount:字段出现的doc总数
  • MinTerm:字段中最小的term
  • MaxTerm:字段中最大的term
  • IndexStartFP:字段的FSTIndex在tip文件中的起始位置
  • FSTMetaData:字段的FSTIndex的元信息,以下这些信息均来自FST的构建
    • emptyOutput:FST的emptyOutput
    • intputType:FST的inputType
    • startNode:FST的根节点的位置
    • fstSize:FST的大小
TermIndexLength

tip文件的总大小,读取的时候会做校验

TermDictLength

tim文件的总大小,读取的时候会做校验

Footer

文件尾,主要包括

  • 文件尾魔数(同一个lucene版本所有文件一样)
  • 0
  • 校验码

tim

tim.png

字段详解

Header

文件头部信息,主要是包括:

  • 文件头魔数(同一lucene版本所有文件相同)
  • 该文件使用的codec名称:BlockTreeTermsDict
  • codec版本
  • segment id(也是Segment_N文件中的N)
  • segment后缀名(一般为空)
FieldDict

FieldDict中包含了这个字段所有的block信息。在Lucene的官方文档中区分了两种block:OuterNode和InnerNode。其实也就是LeafBlock和包含嵌套block的block的区别,这些可以自己看。

  • EntryCountCode:是EntryCount和IsLastBlock组成的int类型。

    • EntryCount:前31位存储block中的entry总数
    • IsLastBlock:最后一位存储是否是最后一个block。如果block没有拆成多个floorBlock,则肯定是最后一个block,否则只有最后一个floorBlock才为1。
  • Token:是SuffixTotalSize,IsLeafBlock,CompressionAlg组成的long类型。

    • SuffixTotalSize:block中所有suffix的总长度
    • IsLeafBlock:是否是LeafBlock,也就是block中是否只有term。
    • CompressionAlg:后面suffix字段的压缩算法。
  • Suffix:所有suffix连续存储在一起

  • SuffixLengthSizeCode:SuffixLengthSize和IsAllEqual组成的int类型。

    • SuffixLengthSize:所有suffix的length都存储在一个buffer中,SuffixLengthSize记录的是这个buffer的大小。
    • IsAllEqual:记录suffix的buffer中的所有byte是否都相等,如果都相等,则SuffixLengthInfo中只存储一个byte,否则存储完整的buffer。
  • SuffixLengthInfo:suffix length信息

  • StatsLength:Stats buffer的大小。下面的TermStats都是存储在一个buffer中的。

  • TermStats:term的统计信息

    • DocFreq:多少doc出现了这个term
    • TotalTermFreq:term出现的总频率
  • TermMetaData:term的倒排元信息,这个就是用来获取term倒排信息的。

    • DocMetaData:分为两种情况

      • 如果当前term的docFreq == 1 且 前一个term的docFreq == 1 且 当前term的docStartFP == 前一个term的docStartFP

        一个long类型,前63位是当前term和前一个term的SingletonDocID的差值,最后一位是1,表示这种情况。

      • 其他情况

        一个long类型,前63位是当前term在doc索引文件的起始位置和前一个term的差值,最后一位是0,表示这种情况。

        如果docFreq == 1,则存储SingletonDocID。

    • PosStartDelta:term在pos索引文件的起始位置,是和前一个term的差值

    • PayStartDelta:term在pay索引文件的起始位置,是和前一个term的差值

    • LastPosBlockOffset:term的position最后一个block的offset

    • SkipOffset:term跳表的起始位置

Footer

文件尾,主要包括

  • 文件尾魔数(同一个lucene版本所有文件一样)
  • 0
  • 校验码