Lucene源码系列(十):FST构建

1,741 阅读22分钟

背景

我们都知道检索引擎的核心是倒排,倒排就是term所在的文档列表(当然可以包含term在对应文档中的详细位置信息),但是怎么通过term来定位其倒排数据呢?这个问题的本质就是term映射倒排位置的问题,最容易想到的就是使用hashmap,key就是term,value就是term倒排的起始位置。这个方案能解决问题吗?当然可以解决,但是它有什么局限呢?hashmap内存占用比较大,跟term的数量和长度是成正比的。

那怎么优化呢?hashmap每个term都是单独存储的,但是现实情况是很多term有相同的部分,我们把相同的前缀只存储一份,那就是trie树了。那相同的前缀可以存储一份,相同的后缀岂不是也可以只存储一份,于是就到了本文要重点介绍的FST了。

FST的基本介绍网上有现有的资料,参见文末链接(写的很好),本文只会对FST做简单的介绍,主要是以源码解析为主,所以如果对FST完全不了解的话,看起来会比较吃力,最好先阅读下参考链接的材料。

本文涉及的源码来自lucene9.1.0版本。

FST基本介绍

FST其实就是一个带输出共享前缀后缀有向无环图,我们以下面例子构建的FST,结果如下图所示:

格式:key/value

bat/2
cat/5
deep/15
do/10
dog/3
dogs/2

FST_8.png

我们总结下FST的特点:

  • FST有唯一的起始节点(绿色节点,编号31)和终止节点(红色节点,编号-1)。
  • FST中节点的编号是节点持久化后存储的位置,终止节点比较特殊永远是-1。
  • 节点和边都可以带输出或者不带输出(边的斜杆右边是输出,斜杆左边是输入。节点的斜杆右边是输出,斜杆左边是节点编号),边的输出是output,节点的输出是finalOutput,具体什么区别下面介绍。
  • 整个图共享前缀与后缀。
  • FST有多个可接受的节点(蓝色+红色),可接受的节点的意思就是以该节点结束是个完整的路径。
  • 终止节点也是可接受节点。
  • 从起始节点到可接受节点结束的路径才是完整的路径

必看说明

  • finalOutput是在这种情况下产生的:前一个输入是当前输入的前缀,并且当前输入的value比前一个输入小。如上面例子中的do/10dog/3。这种情况下,如果要共享后缀,则output无法都放在边上,因为放在边上,dog至少就是10,所以就把do和dog公共的输出放在边上,do剩下的7放在节点中,如下图所示:

FST_9.png

  • 完整路径的输出是累加了路径经过的所有边的output,以及以最后一个节点的finalOutput。什么意思呢,我们看几个例子:

    • deep的输出是边d的output(2) + 边e的output (13)= 15。
    • dog的输出是**边d的output(2) **+ 节点编号10的finalOutput (1)= 3。注意,节点编号13的finalOutput不属于dog,因为编号13的finalOutput只属于do的输出。
  • FST中,边叫做arc,节点是node(有两种node)。

  • Lucene对于FST构建的输入要求必须是有序的,这样通过当前输入和前一个输入就可以确定哪些部分是肯定不变的,可以进行序列化。比如前一个输入是“bat”,当前输入是“cba”,则可以确定“at”是肯定不会变了(理论上是“bat”整个都不会变,但是英文b是属于root的,root是会变的),因为下一个输入肯定是比“cba”大的。

FST构建入口:FSTCompiler

主要成员变量

// 用来判断node的唯一性,序列化是从尾部开始的,如果要序列化的node已经序列化过了,则直接复用,从而达到了共享后缀的目的
private final NodeHash<T> dedupHash;

// 还没有序列化的节点都存储在frontier中,第0个是root节点,在最后进行序列化
private UnCompiledNode<T>[] frontier;

// 上一个序列化的node
long lastFrozenNode;

// 使用固定长度的方式存储arc的时候使用
int[] numBytesPerArc = new int[4];
int[] numLabelBytesPerArc = new int[numBytesPerArc.length];
// 如果使用的是固定长度的arc存储方式,会先把一些信息存储在这个buffer中,最后拷贝到fst中
final FixedLengthArcsBuffer fixedLengthArcsBuffer = new FixedLengthArcsBuffer();

// 一些统计信息
long arcCount;
long nodeCount;
long binarySearchNodeCount;
long directAddressingNodeCount;

// node所有arc的存储
final boolean allowFixedLengthArcs;
final float directAddressingMaxOversizingFactor;
long directAddressingExpansionCredit;

// 序列化的数据存储在bytes中
final BytesStore bytes;

数据结构

在构建过程中使用的数据结构主要是包括了node和arc两部分,node就是FST图中的节点,arc就是连接node的有向边。

node

FST在构建的过程中存在两种节点,一种是已经确定的并且序列化完成的CompiledNode,另一种是未确定的UnCompiledNode,未确定的含义是UnCompiledNode中的arc集合会增加,并且arc的输出会变化。

为什么需要两种node呢?CompiledNode占用的内存比UnCompiledNode要少,在构建的过程中确定不变的信息提前序列化可以减少内存的压力。

这两种节点都实现了Node接口,Node接口中只有一个方法,用来判断节点是CompiledNode还是UnCompiledNode:

interface Node {
  boolean isCompiled();
}
CompiledNode

对序列化完成的节点只需要存储一个节点编号,可以理解成节点在fst中的起始位置。

static final class CompiledNode implements Node {
  // 节点的编号,直接理解成是节点在fst中的起始位置
  long node;

  @Override
  public boolean isCompiled() {
    return true;
  }
}
UnCompiledNode

所有新增的节点都是UnCompiledNode。

static final class UnCompiledNode<T> implements Node {
  // 节点所属的FSTCompiler  
  final FSTCompiler<T> owner;
  // 节点总共有多少个arc,指的是出边
  int numArcs;
  // 所有的arc,注意是按顺序存储的
  Arc<T>[] arcs;
  // 节点的输出
  T output;
  // 是不是终止节点  
  boolean isFinal;
  // 经过这个节点的路径个数,注意这里不是从图中看到的入边的个数,因为FST使用共同的后缀
  long inputCount;

  // 节点距离root节点的距离,用来控制所有的arc是否使用相同长度存储的参数。
  final int depth;

  // 构造函数,默认只有一个arc
  UnCompiledNode(FSTCompiler<T> owner, int depth) {
    this.owner = owner;
    arcs = (Arc<T>[]) new Arc[1];
    arcs[0] = new Arc<>();
    output = owner.NO_OUTPUT;
    this.depth = depth;
  }

  public boolean isCompiled() {
    return false;
  }

  void clear() {
    numArcs = 0;
    isFinal = false;
    output = owner.NO_OUTPUT;
    inputCount = 0;
  }

  // 获取最后边的输出  
  T getLastOutput(int labelToMatch) {
    assert numArcs > 0;
    assert arcs[numArcs - 1].label == labelToMatch;
    return arcs[numArcs - 1].output;
  }

  // 新增一条出边
  // label: arc的输入
  // target: arc的目标节点  
  void addArc(int label, Node target) {
    // 是否需要扩容
    if (numArcs == arcs.length) {
      final Arc<T>[] newArcs = ArrayUtil.grow(arcs);
      for (int arcIdx = numArcs; arcIdx < newArcs.length; arcIdx++) {
        newArcs[arcIdx] = new Arc<>();
      }
      arcs = newArcs;
    }
    final Arc<T> arc = arcs[numArcs++];
    arc.label = label;
    arc.target = target;
    // 注意,新增的时候都是没有输出的,输出的值在prependOutput方法中设置
    arc.output = arc.nextFinalOutput = owner.NO_OUTPUT;
    arc.isFinal = false;
  }

  // 更新最后一条出边  
  void replaceLast(int labelToMatch, Node target, T nextFinalOutput, boolean isFinal) {
    assert numArcs > 0;
    final Arc<T> arc = arcs[numArcs - 1];
    assert arc.label == labelToMatch : "arc.label=" + arc.label + " vs " + labelToMatch;
    arc.target = target;
    // assert target.node != -2;
    arc.nextFinalOutput = nextFinalOutput;
    arc.isFinal = isFinal;
  }

  // 删除最后一条出边  
  void deleteLast(int label, Node target) {
    assert numArcs > 0;
    assert label == arcs[numArcs - 1].label;
    assert target == arcs[numArcs - 1].target;
    numArcs--;
  }

  // 设置最后一条出边的输出值  
  void setLastOutput(int labelToMatch, T newOutput) {
    assert owner.validOutput(newOutput);
    assert numArcs > 0;
    final Arc<T> arc = arcs[numArcs - 1];
    assert arc.label == labelToMatch;
    arc.output = newOutput;
  }

  // 更新所有出边的输出值
  void prependOutput(T outputPrefix) {
    assert owner.validOutput(outputPrefix);
    
    // 更新所有出边的输出值
    for (int arcIdx = 0; arcIdx < numArcs; arcIdx++) {
      arcs[arcIdx].output = owner.fst.outputs.add(outputPrefix, arcs[arcIdx].output);
      assert owner.validOutput(arcs[arcIdx].output);
    }

    // dog: 3
    // dogs: 2
    // 这种情况,dog是dogs的前缀,并且输出比dogs大,共享前缀输出是2,剩下的1需要保存在节点中,
    // 这里的if就是处理上面描述的这种情况
    if (isFinal) {
      output = owner.fst.outputs.add(outputPrefix, output);
      assert owner.validOutput(output);
    }
  }
}

arc

用来表示节点的有向边,带输出的。

// 边,term的每个字节都是边上的label
static class Arc<T> {
  // 边的输入
  int label; 
  // 指向的节点  
  Node target;
  // 是否指向final节点  
  boolean isFinal;
  // 边上的输出,这个输出是所有经过该arc的路径共享的 
  T output;
  // nextFinalOutput就是这个arc指向的node的output,它只对以该node为终止节点的查找才有用,区别于arc的output
  T nextFinalOutput;
}

新增输入

新增输入主要做以下几件事:

  • 寻找最长公共前缀
  • 序列化已经确定的前一个输入的不变的部分
  • 按照当前输入及其输出更新所有相关的arc或者node
// 需要保证调用add的intput是有序的
// 默认不支持相同的 input,除非自己实现output的merge方法
public void add(IntsRef input, T output) throws IOException {
  // NO_OUTPUT必须是单例,这是为了所有相同的节点(节点包括了输出的属性)都只存储一份
  if (output.equals(NO_OUTPUT)) {
    output = NO_OUTPUT;
  }

  // 空的输入,一般不会出现这种特殊情况。
  if (input.length == 0) {
    // frontier[0]是根节点  
    frontier[0].inputCount++;
    frontier[0].isFinal = true;
    fst.setEmptyOutput(output);
    return;
  }

  // 寻找最长公共前缀
  int pos1 = 0;
  int pos2 = input.offset;
  final int pos1Stop = Math.min(lastInput.length(), input.length);
  while (true) { 
    // 第一个是根节点,无条件inputCount++
    frontier[pos1].inputCount++;
    // 如果比对到了末尾或者当前的字符不相等,则已经找到了最长公共前缀  
    if (pos1 >= pos1Stop || lastInput.intAt(pos1) != input.ints[pos2]) {
      break;
    }
    pos1++;
    pos2++;
  }
  // 非最长公共前缀的第一个字符的位置 
  final int prefixLenPlus1 = pos1 + 1;

  // 如果frontier大小不足,则需要扩容(+1是有个根节点)
  if (frontier.length < input.length + 1) {
    final UnCompiledNode<T>[] next = ArrayUtil.grow(frontier, input.length + 1);
    for (int idx = frontier.length; idx < next.length; idx++) {
      next[idx] = new UnCompiledNode<>(this, idx);
    }
    frontier = next;
  }
    
  // 冻结前一个输入不会变化的部分,前一个输入从prefixLenPlus1开始的部分肯定是不会再改变的 
  freezeTail(prefixLenPlus1);

  // 非公共前缀部分加入frontier,这里就保证的前缀是共享的,只有一份
  for (int idx = prefixLenPlus1; idx <= input.length; idx++) {
    frontier[idx - 1].addArc(input.ints[input.offset + idx - 1], frontier[idx]);
    frontier[idx].inputCount++;
  }

  final UnCompiledNode<T> lastNode = frontier[input.length];
  // 在保证输入有序的前提下,下面这个逻辑的if永远是true
  if (lastInput.length() != input.length || prefixLenPlus1 != input.length + 1) {
    lastNode.isFinal = true;
    lastNode.output = NO_OUTPUT;
  }

  // 更新每个arc的输出  
  // 第0个是root节点
  for (int idx = 1; idx < prefixLenPlus1; idx++) {
    final UnCompiledNode<T> node = frontier[idx];
    final UnCompiledNode<T> parentNode = frontier[idx - 1];

    // parentNode的最后一个边的输出就是目前位置公共前缀的输出值,需要被调整
    final T lastOutput = parentNode.getLastOutput(input.ints[input.offset + idx - 1]);
    assert validOutput(lastOutput);

    // 当前输入和上一个输入在当前节点的输出值的前缀(数值类型就是min)  
    final T commonOutputPrefix;
    // 扣除公共输出之后,剩下的输出值  
    final T wordSuffix;

    if (lastOutput != NO_OUTPUT) {
      commonOutputPrefix = fst.outputs.common(output, lastOutput);
      assert validOutput(commonOutputPrefix);
      wordSuffix = fst.outputs.subtract(lastOutput, commonOutputPrefix);
      assert validOutput(wordSuffix);
      // 更新公共前缀的输出值  
      parentNode.setLastOutput(input.ints[input.offset + idx - 1], commonOutputPrefix);
      // 剩余的输出值记录到当前的节点的所有的arc中
      node.prependOutput(wordSuffix);
    } else {
      commonOutputPrefix = wordSuffix = NO_OUTPUT;
    }

    output = fst.outputs.subtract(output, commonOutputPrefix);
    assert validOutput(output);
  }

  if (lastInput.length() == input.length && prefixLenPlus1 == 1 + input.length) {
    // 相同的输入,结果做merge,默认的没有实现merge方法,有需要需要自定义
    lastNode.output = fst.outputs.merge(lastNode.output, output);
  } else { // 剩下的输出值都放在第一个新增arc上
    frontier[prefixLenPlus1 - 1].setLastOutput(
        input.ints[input.offset + prefixLenPlus1 - 1], output);
  }

  // 保存上一个处理完的输入,为了和下一个输入算最长前缀
  lastInput.copyInts(input);
}

序列化入口

这个方法的大部分逻辑是在判断是否要进行剪枝以及真正执行剪枝,这部分在lucene所有场景中都没有真正使用,所以我们也直接略过。

// 确定不变的部分可以进行序列化,序列化是从后往前处理的
// 这个方法涉及到剪枝的逻辑判断,在lucene自己的使用场景中,都没有用到剪枝,所以这里我们也不会展开讲剪枝的部分
private void freezeTail(int prefixLenPlus1) throws IOException {、
  // root是第0个节点,root是最后进行序列化的,所以最多序列化第一个节点  
  final int downTo = Math.max(1, prefixLenPlus1);
  // 从后往前处理  
  for (int idx = lastInput.length(); idx >= downTo; idx--) {

    boolean doPrune = false;
    boolean doCompile = false;

    final UnCompiledNode<T> node = frontier[idx];
    final UnCompiledNode<T> parent = frontier[idx - 1];

    if (node.inputCount < minSuffixCount1) {
      doPrune = true;
      doCompile = true;
    } else if (idx > prefixLenPlus1) {
      if (parent.inputCount < minSuffixCount2
          || (minSuffixCount2 == 1 && parent.inputCount == 1 && idx > 1)) {
        doPrune = true;
      } else {
        doPrune = false;
      }
      doCompile = true;
    } else {
      doCompile = minSuffixCount2 == 0;
    }

    if (node.inputCount < minSuffixCount2
        || (minSuffixCount2 == 1 && node.inputCount == 1 && idx > 1)) {
      for (int arcIdx = 0; arcIdx < node.numArcs; arcIdx++) {
        @SuppressWarnings({"rawtypes", "unchecked"})
        final UnCompiledNode<T> target = (UnCompiledNode<T>) node.arcs[arcIdx].target;
        target.clear();
      }
      node.numArcs = 0;
    }

    if (doPrune) {
      node.clear();
      parent.deleteLast(lastInput.intAt(idx - 1), node);
    } else {
      if (minSuffixCount2 != 0) {
        compileAllTargets(node, lastInput.length() - idx);
      }
      final T nextFinalOutput = node.output;

      final boolean isFinal = node.isFinal || node.numArcs == 0;

      if (doCompile) { // 重点关注这个,先把节点序列化好,然后再用CompiledNode替换UnCompiledNode
        parent.replaceLast(
            lastInput.intAt(idx - 1),
            compileNode(node, 1 + lastInput.length() - idx),
            nextFinalOutput,
            isFinal);
      } else {
        parent.replaceLast(lastInput.intAt(idx - 1), node, nextFinalOutput, isFinal);
        frontier[idx] = new UnCompiledNode<>(this, idx);
      }
    }
  }
}

节点编码

节点编码的逻辑中最主要的是复用相同的节点,从而实现了共享后缀的目的,真正编码的逻辑在FST.addNode中实现。

private CompiledNode compileNode(UnCompiledNode<T> nodeIn, int tailLength) throws IOException {
  final long node;
  // 当前字节buffer的起始位置  
  long bytesPosStart = bytes.getPosition();
  // 整个if是为了判断是否需要共享节点 ,这部分的逻辑决定了是否要使用共享后缀
  if (dedupHash != null  // 能够共享的前提是存在dedupHash
      && (doShareNonSingletonNodes/*doShareNonSingletonNodes表示是否需要共享节点*/ 
          || nodeIn.numArcs <= 1 /*如果节点只有一个出边*/))
      && tailLength <= shareMaxTailLength) { // 在配置的最长公共共享后缀范围之内
    if (nodeIn.numArcs == 0) {// 如果节点没有边,一般是final节点
      // 真正序列化是通过 fst.addNode,返回的node是节点在fst中flag的位置
      node = fst.addNode(this, nodeIn);
      lastFrozenNode = node;
    } else {
      // dedupHash 会判断nodeIn是否序列化过,如果序列化过则共享已序列化的即可,否则使用fst.addNode进行序列化
      // 这里是共享后缀的实现  
      node = dedupHash.add(this, nodeIn);
    }
  } else {
    // 不需要处理共享的情况,直接是用fst.addNode序列化  
    node = fst.addNode(this, nodeIn);
  }
  assert node != -2;

  // 序列化之后buffer的位置  
  long bytesPosEnd = bytes.getPosition();
    
  if (bytesPosEnd != bytesPosStart) { // 说明新序列化节点了
    assert bytesPosEnd > bytesPosStart;
    lastFrozenNode = node;
  }

  nodeIn.clear();

  // 序列化之后的节点只有一个编号(在fst中的起始位置)  
  final CompiledNode fn = new CompiledNode();
  fn.node = node;
  return fn;
}

完成构建

最后就是构建root节点。

public FST<T> compile() throws IOException {

  final UnCompiledNode<T> root = frontier[0];

  // minimize nodes in the last word's suffix
  freezeTail(0);
    
  // 剪枝操作不用理  
  if (root.inputCount < minSuffixCount1
      || root.inputCount < minSuffixCount2
      || root.numArcs == 0) {
    if (fst.emptyOutput == null) {
      return null;
    } else if (minSuffixCount1 > 0 || minSuffixCount2 > 0) {
      return null;
    }
  } else {
    if (minSuffixCount2 != 0) {
      compileAllTargets(root, lastInput.length());
    }
  }
  // 完成构建
  fst.finish(compileNode(root, lastInput.length()).node);

  return fst;
}

FST序列化及持久化:FST

序列化

序列化是以node为粒度,确定不会因为后续输入而改变的node可以进行序列化,node的序列化就是存储其所有的arc,node中所有的arc是存储在一起,至于存储arc的哪些信息以及所有arc是怎么存储的,后面会详细介绍。

arc的序列化字段

序列化的arc是个5元组(但是并非每个属性都会存在),5元组中的每个字段的含义如下(按照存储的顺序):

flag:arc的标记,具体含义见下面介绍。

label :arc的输入

output:arc的输出,这是所有经过这个arc的路径共享的输出

finalOutput:也是arc的输出,但是只对是BIT_ARC_HAS_FINAL_OUTPUT条件下的输入有效

target:arc指向的下一个节点

flag中的含义

flag是由多个参数混合定义的:

参数名称参数值参数描述
BIT_FINAL_ARC1arc是某个输入的最后一个label,也就是arc指向了一个可接受的节点。
BIT_LAST_ARC2arc是node中的最后一个arc
BIT_TARGET_NEXT4arc的target是上次编译的节点,如果是的话说明两个节点存储位置相邻,不用再特地记录arc的target
BIT_STOP_NODE8arc是否指向了终止节点
BIT_ARC_HAS_OUTPUT16arc有output
BIT_ARC_HAS_FINAL_OUTPUT32arc有finalOutput

node中arc的存储格式

node的序列化,最主要的就是存储node所有的arc,lucene目前对于node序列化的存储格式一共有三种方式,也对应了使用FST的时候三种查找arc的方式:

线性查找

node的所有的arc都存储在一起,查找特定的arc的时候就使用遍历的方式查找。

arc_store_2.png

如上图所示,arc是5元组,但是并不是每个信息都存在,所以这种存储方式每个arc占用的存储空间大小不一定一样(图中不同颜色的arc序列化的空间大小就是不一样的),只能通过遍历的方式查找特定的label。

二分查找

每个arc按照固定大小存储,并且是按label大小顺序连续存储在一起,所以查找的时候可以使用二分查找来提高查找效率。

arc_store_3.png

如上图所示,这种存储方式中所有的arc都按照相同大小来存储(大小决定于node中占用空间最大的arc),并且label是有序的,所以可以通过二分查找。这种存储方式需要三个head信息:

binarySearchFlag:标记该node使用二分存储格式存储所有的arc

arcNum:arc总数

maxBytesPerArc:每个arc占用的存储空间大小

直接寻址

lucene中对于node的arc直接寻址方式其实并不是字面的意思,并没有把每个arc存储的起始地址都记录起来,而是需要借助位图间接计算得到,具体怎么做我们看个例子。

arc_store.png

假设我们要序列化上图中的红色节点,它有四个arc,label分别是b,c,d,g,我们看lucene中的直接寻址怎么做的:

  1. 计算最小label和最大label的差值,这里就是ascii编码的差值:g - b = 5

  2. 用一个位图记录b到g范围内的存在的label(至少一个字节,大于g的都设置为0):

        g f e d c b
    0 0 1 0 0 1 1 1
    
  3. 存储的的时候,只有第一个label的值真正存储在arc中,其他label不需要存储,可以通过这个位图计算得到。每个arc还是按照固定大小存储在一起,查找的时候先查找位图,看目标arc是否存在,如果存在,计算在其位置右边有几个1,就能获取arc所在的位置,所以直接寻址的意思是可以通过位图计算得到arc的位置。

arc_store_4.png

directAddressingFlag:直接寻址的标记

labelRange:最大label和最小label的差值

maxBytesPerArcWithoutLabel:不含label的每个arc的占用的存储空间大小

bitset:记录label的位图

firstLabel:第一个arc的label值

序列化入口

不管arc是使用哪种存储方式,在一开始都是按照线性查找的存储方式先序列化到fst的缓存中,后面在根据具体的存储方式,借助fixedLengthArcsBuffer进行转化。

// nodeIn:待序列化的节点
long addNode(FSTCompiler<T> fstCompiler, FSTCompiler.UnCompiledNode<T> nodeIn)
    throws IOException {
  T NO_OUTPUT = outputs.getNoOutput();

  if (nodeIn.numArcs == 0) { // 没有出边
    if (nodeIn.isFinal) { // 如果是终结节点,不带arc的终结节点并没有真正存储,所有指向该节点的arc在flag中做标记
      return FINAL_END_NODE;
    } else {
      return NON_FINAL_END_NODE; // 剪枝才会出现这种情况,不需要考虑
    }
  }
  // 当前node序列化存储的的起始位置 
  final long startAddress = fstCompiler.bytes.getPosition();

  // 是否需要使用固定大小存储arc信息  
  final boolean doFixedLengthArcs = shouldExpandNodeWithFixedLengthArcs(fstCompiler, nodeIn);
  if (doFixedLengthArcs) {

    if (fstCompiler.numBytesPerArc.length < nodeIn.numArcs) { // 空间不足,扩容
      fstCompiler.numBytesPerArc = new int[ArrayUtil.oversize(nodeIn.numArcs, Integer.BYTES)];
      fstCompiler.numLabelBytesPerArc = new int[fstCompiler.numBytesPerArc.length];
    }
  }

  // 更新arc总数  
  fstCompiler.arcCount += nodeIn.numArcs;

  // 最后一个arc的下标 
  final int lastArc = nodeIn.numArcs - 1;

  // 当前处理的node中前一个arc序列化的起始位置,这个是用来计算每个arc的序列化长度  
  long lastArcStart = fstCompiler.bytes.getPosition();
  // arc序列化长度最大值  
  int maxBytesPerArc = 0;
  // arc扣除label之后的序列化长度的最大值  
  int maxBytesPerArcWithoutLabel = 0;
  for (int arcIdx = 0; arcIdx < nodeIn.numArcs; arcIdx++) { // 遍历处理每一个arc
    final FSTCompiler.Arc<T> arc = nodeIn.arcs[arcIdx];
    final FSTCompiler.CompiledNode target = (FSTCompiler.CompiledNode) arc.target;
    int flags = 0;

    // 如果是node的最后一个arc
    if (arcIdx == lastArc) {
      flags += BIT_LAST_ARC;
    }

    // 如果上一个序列化的node刚好是arc的target,并且不使用固定长度的存储方式。
    // 一定不能是固定长度的存储方式,因为固定长度的存储方式无法保证arc是相邻的,因为可能有填充空间。
    if (fstCompiler.lastFrozenNode == target.node && !doFixedLengthArcs) {
      flags += BIT_TARGET_NEXT;
    }

    if (arc.isFinal) { // 如果arc指向了终止节点  
      flags += BIT_FINAL_ARC;
      if (arc.nextFinalOutput != NO_OUTPUT) { // 如果arc存在finaloutput
        flags += BIT_ARC_HAS_FINAL_OUTPUT;
      }
    } else {
      assert arc.nextFinalOutput == NO_OUTPUT;
    }

    // 只有终止节点的编号才是-1,也只有终止节点是没有arc的  
    boolean targetHasArcs = target.node > 0;

    if (!targetHasArcs) { // 如果arc指向了终止节点
      flags += BIT_STOP_NODE;
    }

    if (arc.output != NO_OUTPUT) { // arc存在output
      flags += BIT_ARC_HAS_OUTPUT;
    }

    // 1. 序列化flag  
    fstCompiler.bytes.writeByte((byte) flags);
    // label序列化的起始位置,用来计算label大小的  
    long labelStart = fstCompiler.bytes.getPosition();
    // 2. 序列化label  
    writeLabel(fstCompiler.bytes, arc.label);
    int numLabelBytes = (int) (fstCompiler.bytes.getPosition() - labelStart);

    if (arc.output != NO_OUTPUT) { // 3.序列化output
      outputs.write(arc.output, fstCompiler.bytes);
    }

    if (arc.nextFinalOutput != NO_OUTPUT) { // 4. 序列化finalOutput
      outputs.writeFinalOutput(arc.nextFinalOutput, fstCompiler.bytes);
    }

    if (targetHasArcs && (flags & BIT_TARGET_NEXT) == 0) { // 5. 序列化arc的target
      assert target.node > 0;
      fstCompiler.bytes.writeVLong(target.node);
    }

    if (doFixedLengthArcs) { // 如果要使用固定长度的方式存储arc,需要计算一些信息 
      int numArcBytes = (int) (fstCompiler.bytes.getPosition() - lastArcStart);
      fstCompiler.numBytesPerArc[arcIdx] = numArcBytes;
      fstCompiler.numLabelBytesPerArc[arcIdx] = numLabelBytes;
      lastArcStart = fstCompiler.bytes.getPosition();
      maxBytesPerArc = Math.max(maxBytesPerArc, numArcBytes); 
      maxBytesPerArcWithoutLabel =
          Math.max(maxBytesPerArcWithoutLabel, numArcBytes - numLabelBytes);
    }
  }

  if (doFixedLengthArcs) {
    // 因为构建的输入是有序的,所以最后一个arc的label-第一个arc的label可以判断label的最大差值
    // 这个差值会用来算需要记录在这个差值范围内那些lablel存在的位图 (直接寻址使用)
    int labelRange = nodeIn.arcs[nodeIn.numArcs - 1].label - nodeIn.arcs[0].label + 1;
    assert labelRange > 0;
    //  shouldExpandNodeWithDirectAddressing是根据直接寻址和二分法所需的空间做权衡决定使用哪种存储方式 
    if (shouldExpandNodeWithDirectAddressing( 
        fstCompiler, nodeIn, maxBytesPerArc, maxBytesPerArcWithoutLabel, labelRange)) {
      // 直接寻址的存储方式
      writeNodeForDirectAddressing(
          fstCompiler, nodeIn, startAddress, maxBytesPerArcWithoutLabel, labelRange);
      fstCompiler.directAddressingNodeCount++;
    } else { // 二分法的存储方式
      writeNodeForBinarySearch(fstCompiler, nodeIn, startAddress, maxBytesPerArc);
      fstCompiler.binarySearchNodeCount++;
    }
  }

  final long thisNodeAddress = fstCompiler.bytes.getPosition() - 1;
  fstCompiler.bytes.reverse(startAddress, thisNodeAddress);
  fstCompiler.nodeCount++;
  return thisNodeAddress;
}

直接寻址序列化

private void writeNodeForDirectAddressing(
    FSTCompiler<T> fstCompiler,
    FSTCompiler.UnCompiledNode<T> nodeIn,
    long startAddress,
    int maxBytesPerArcWithoutLabel,
    int labelRange) {
  // 1字节的标志位:ARCS_FOR_DIRECT_ADDRESSING
  // vint类型的labelRange:最多5字节
  // vint类型的maxBytesPerArcWithoutLabel:最多5字节
  int headerMaxLen = 11;
  // 只需要记录第一个label,配合labelRange,可以使用一个位图来记录所有的label,numPresenceBytes就是位图需要的字节数
  int numPresenceBytes = getNumPresenceBytes(labelRange);
  // 当前fst的位置,要从这个位置往前读
  long srcPos = fstCompiler.bytes.getPosition();
  // 第一个label + 所有arc的空间  
  int totalArcBytes =
      fstCompiler.numLabelBytesPerArc[0] + nodeIn.numArcs * maxBytesPerArcWithoutLabel;
  //  当前node需要的总空间 
  int bufferOffset = headerMaxLen + numPresenceBytes + totalArcBytes;
  // node的序列化数据先存储到buffer中
  byte[] buffer = fstCompiler.fixedLengthArcsBuffer.ensureCapacity(bufferOffset).getBytes();
  // 从fst中获取所有已经序列化的arc信息,存储到buffer中
  for (int arcIdx = nodeIn.numArcs - 1; arcIdx >= 0; arcIdx--) {
    // bufferOffset始终指向当前arc应该在buffer中的起始位置,注意这是这里没有预留存储label的空间
    bufferOffset -= maxBytesPerArcWithoutLabel;
    // 当前arc的序列化长度  
    int srcArcLen = fstCompiler.numBytesPerArc[arcIdx];
    // srcPos定位到当前arc的起始位置  
    srcPos -= srcArcLen;
    int labelLen = fstCompiler.numLabelBytesPerArc[arcIdx];
    // 复制flag
    fstCompiler.bytes.copyBytes(srcPos, buffer, bufferOffset, 1);
    // 跳过label,拷贝其他信息
    int remainingArcLen = srcArcLen - 1 - labelLen;
    if (remainingArcLen != 0) {
      fstCompiler.bytes.copyBytes(
          srcPos + 1 + labelLen, buffer, bufferOffset + 1, remainingArcLen);
    }
    if (arcIdx == 0) { // 只需要存储第一个label
      bufferOffset -= labelLen; // 预留label空间
      fstCompiler.bytes.copyBytes(srcPos + 1, buffer, bufferOffset, labelLen);
    }
  }
  assert bufferOffset == headerMaxLen + numPresenceBytes;

  // node中关于arc的元信息head写入fixedLengthArcsBuffer
  fstCompiler
      .fixedLengthArcsBuffer
      .resetPosition()
      .writeByte(ARCS_FOR_DIRECT_ADDRESSING)
      .writeVInt(labelRange) 
      .writeVInt(
          maxBytesPerArcWithoutLabel); 
  int headerLen = fstCompiler.fixedLengthArcsBuffer.getPosition();

  // 下面是准备把直接寻址的序列化信息拷贝到fstCompiler.bytes中
  // 直接寻址完整拷贝到 fstCompiler.bytes 的结束位置
  long nodeEnd = startAddress + headerLen + numPresenceBytes + totalArcBytes;
  // 当前fstCompiler.bytes的位置  
  long currentPosition = fstCompiler.bytes.getPosition();
  if (nodeEnd >= currentPosition) { // 说明直接寻址占用的空间比线性查找的空间大,需要把位置往后挪
    fstCompiler.bytes.skipBytes((int) (nodeEnd - currentPosition));
  } else { // 说明直接寻址占用的空间比线性查找的空间小,需要把位置往前挪
    fstCompiler.bytes.truncate(nodeEnd);
  }

  // head写入fst
  long writeOffset = startAddress;
  fstCompiler.bytes.writeBytes(
      writeOffset, fstCompiler.fixedLengthArcsBuffer.getBytes(), 0, headerLen);
  writeOffset += headerLen;

  // 写入arc的位图信息
  writePresenceBits(fstCompiler, nodeIn, writeOffset, numPresenceBytes);
  writeOffset += numPresenceBytes;

  // 写入第一个label和其他arc信息
  fstCompiler.bytes.writeBytes(
      writeOffset, fstCompiler.fixedLengthArcsBuffer.getBytes(), bufferOffset, totalArcBytes);
}

writePresenceBits中是以位图的方式记录所有存在的label,值得特别说明的是这里并没有用我们以前介绍的那些现有的位图实现,主要原因我觉得还是不想浪费空间,我们之前介绍的所有位图实现方式都是使用long来存储,而这里的实现是使用byte。

private void writePresenceBits(
    FSTCompiler<T> fstCompiler,
    FSTCompiler.UnCompiledNode<T> nodeIn,
    long dest,
    int numPresenceBytes) {
  long bytePos = dest;
  // 第一个arc肯定是存在的
  byte presenceBits = 1; 
  // 记录位图的下标
  int presenceIndex = 0;
  // 记录前一个label  
  int previousLabel = nodeIn.arcs[0].label;
  for (int arcIdx = 1; arcIdx < nodeIn.numArcs; arcIdx++) {
    int label = nodeIn.arcs[arcIdx].label;
    assert label > previousLabel;
    // 当前arc的label所属的下标  
    presenceIndex += label - previousLabel;
    // 跳过位图的空byte  
    while (presenceIndex >= Byte.SIZE) {
      // 记录位图  
      fstCompiler.bytes.writeByte(bytePos++, presenceBits);
      presenceBits = 0;
      presenceIndex -= Byte.SIZE;
    }
    // 设置对应位图的标记
    presenceBits |= 1 << presenceIndex;
    previousLabel = label;
  }
  assert presenceIndex == (nodeIn.arcs[nodeIn.numArcs - 1].label - nodeIn.arcs[0].label) % 8;
  assert presenceBits != 0; // The last byte is not 0.
  assert (presenceBits & (1 << presenceIndex)) != 0; // The last arc is always present.
  // 记录位图  
  fstCompiler.bytes.writeByte(bytePos++, presenceBits);
  assert bytePos - dest == numPresenceBytes;
}

二分法序列化

二分法存储方式其实就是把线性查找的存储方式中每个arc的存储空间都按照最大的arc使用的存储空间对齐填充,所以逻辑就是先计算二分法存储空间的总大小,从后往前分配固定大小的空间给所有的arc。

private void writeNodeForBinarySearch(
    FSTCompiler<T> fstCompiler,
    FSTCompiler.UnCompiledNode<T> nodeIn,
    long startAddress,
    int maxBytesPerArc) {
  // 写入node中arc的元信息head
  fstCompiler
      .fixedLengthArcsBuffer
      .resetPosition()
      .writeByte(ARCS_FOR_BINARY_SEARCH)
      .writeVInt(nodeIn.numArcs)
      .writeVInt(maxBytesPerArc);
  int headerLen = fstCompiler.fixedLengthArcsBuffer.getPosition();

  long srcPos = fstCompiler.bytes.getPosition();
  long destPos = startAddress + headerLen + nodeIn.numArcs * maxBytesPerArc;
  assert destPos >= srcPos;
  if (destPos > srcPos) {
    fstCompiler.bytes.skipBytes((int) (destPos - srcPos));
    // 固定长度的存储逻辑比较简单,按照单个arc所需的最大空间存储每个arc信息,
    // 再加上arc的label是有序的,这样就可以使用二分法根据label查找arc的信息
    for (int arcIdx = nodeIn.numArcs - 1; arcIdx >= 0; arcIdx--) {
      destPos -= maxBytesPerArc;
      int arcLen = fstCompiler.numBytesPerArc[arcIdx];
      srcPos -= arcLen;
      if (srcPos != destPos) {
        fstCompiler.bytes.copyBytes(srcPos, destPos, arcLen);
      }
    }
  }

  // 写入head
  fstCompiler.bytes.writeBytes(
      startAddress, fstCompiler.fixedLengthArcsBuffer.getBytes(), 0, headerLen);
}

结束构建

结束构建就是记录FST的起始位置,为了查找做准备。

void finish(long newStartNode) throws IOException {
  assert newStartNode <= bytes.getPosition();
  if (startNode != -1) {
    throw new IllegalStateException("already finished");
  }
    
  // 只有用来构建的输入只有一个空输入的情况才会满足这个if条件  
  if (newStartNode == FINAL_END_NODE && emptyOutput != null) {
    newStartNode = 0;
  }
  startNode = newStartNode;
  bytes.finish();
}

持久化

持久化一般就是把FST落盘到文件中,在元信息文件中存储如空输入及root节点位置等信息,其他信息存储到数据文件中。

// metaOut:元信息输出流
// out:数据输出流
public void save(DataOutput metaOut, DataOutput out) throws IOException {
  // startNode如果是-1表示还没有完成构建,拒绝持久化半成品
  if (startNode == -1) {
    throw new IllegalStateException("call finish first");
  }
  // 持久化头部  
  CodecUtil.writeHeader(metaOut, FILE_FORMAT_NAME, VERSION_CURRENT);

  if (emptyOutput != null) { // 如果存在空输入
    // 写个1表示有空输入
    metaOut.writeByte((byte) 1);

    // Serialize empty-string output:
    ByteBuffersDataOutput ros = new ByteBuffersDataOutput();
    outputs.writeFinalOutput(emptyOutput, ros);
    byte[] emptyOutputBytes = ros.toArrayCopy();
    int emptyLen = emptyOutputBytes.length;

    // 如果输出是多字节,则是逆序存储
    final int stopAt = emptyLen / 2;
    int upto = 0;
    while (upto < stopAt) {
      final byte b = emptyOutputBytes[upto];
      emptyOutputBytes[upto] = emptyOutputBytes[emptyLen - upto - 1];
      emptyOutputBytes[emptyLen - upto - 1] = b;
      upto++;
    }
    metaOut.writeVInt(emptyLen);
    metaOut.writeBytes(emptyOutputBytes, 0, emptyLen);
  } else {
    metaOut.writeByte((byte) 0); // 0表示不存在空输入
  }
  final byte t;
  if (inputType == INPUT_TYPE.BYTE1) {
    t = 0;
  } else if (inputType == INPUT_TYPE.BYTE2) {
    t = 1;
  } else {
    t = 2;
  }
  metaOut.writeByte(t); // 存储输入的label的类型
  metaOut.writeVLong(startNode); // root flag的位置
  if (bytes != null) { // 本fst是来自输入构建的
    long numBytes = bytes.getPosition();
    metaOut.writeVLong(numBytes);
    bytes.writeTo(out);
  } else { // 本fst是来自其他已有的fst
    assert fstStore != null;
    fstStore.writeTo(out);
  }
}

总结

本文主要是从源码层面详细介绍FST的构建逻辑,下一篇文章我们一起看个例子。

参考链接