本文的代码以lucene-core 6.3.0为准,包含构建空FST的
addNode函数,pack压缩函数等整个类所有代码的解析。转载请注明出处。
0 基本信息
lucene的FST的二进制存储和查询方式都是由FST这个类来实现。如果要读这些代码,首先需要了解这个类里面的一些基本的东西。
- 整个FST存到了
BytesStore类的bytes对象里,bytes相当于一个大的字节数组,提供正向遍历和反向遍历数组的方式,bytes中存的是Node的Arc的内容,包括label,output,nextFinalOutput,target等。 - 在
Arc中,target是指Arc指向的Node,这个值可以是Node的ID,也就是ord,或者Node的Address。 nodeAddress存的是Node ID->Address的映射,inCounts存的是Node ID -> Node的入度(指向该Node的Arc数量),这两个类都是GrowableWriter,包了一下PackedInts,关于PackedInts可以参考下作者@iteye_14612的PackedInts源码分析。cachedRootArcs缓存一部分从根节点出发的Arc,INPUT_TYPE是Arc的label所占用的字节数。- 文中多次提到Node的Arc会按照label的字典序存,这与Builder构建FST有关,可以看下我的另一篇Lucene源码分析 - FST-Builder。
1 源码分析
先看下构建空的FST。addNode函数把Builder中的UnCompiledNode写入FST的bytes对象,写入的这个Node的ID就是nodeCount,这里把Node的Arc顺序写到字节数组中,由于FST的Builder是按照label的字典序调用构建的FST,所以这里的Arc也是按照label排序的,代码如下:
// 处理没有Arc的空节点
if (nodeIn.numArcs == 0) {
if (nodeIn.isFinal) {
return FINAL_END_NODE;
} else {
return NON_FINAL_END_NODE;
}
}
// 记录当前Node在Address
final long startAddress = builder.bytes.getPosition();
// 判断是否用FIXED_ARRAY方式存Arc的信息
final boolean doFixedArray = shouldExpand(builder, nodeIn);
if (doFixedArray) {
if (builder.reusedBytesPerArc.length < nodeIn.numArcs) {
//reusedBytesPerArc 用来存下每个Arc的字节长度
builder.reusedBytesPerArc = new int[ArrayUtil.oversize(nodeIn.numArcs, 1)];
}
}
1.1 什么是 FIXED ARRAY
如果这里的doFixedArray是true,那么每个Arc都用一样的字节长度(后文都用长度代替),由于有的Arc可能有output或nextFinalOutput,而有的却没有,所以每个Arc的长度可能是不一样。FIXED_ARRAY方式下,每个Arc的存储长度都用最长的那个Arc的长度。
很明显,这种FIXED_ARRAY方式会导致Arc与Arc之间存在一些内存碎片,好处就是能快速定位Node下的第N个Arc的位置,所以在查询Node下是否有Arc的label匹配一个确定的值的时候,可以用二分查找,这样就是空间换了时间。
当然用这种模式也是有条件的,shouldExpand函数中给出了条件,比如Node下Arc数量要超过10,Arc数量太少的话,二分的效果比起遍历没有优势,而且还浪费了一些内存,反而亏了。
1.2 存储Node的Arc
接下来是遍历这个Node的所有的Arc,并且把每个Arc的信息等写入bytes,前后两个Arc紧挨着存的,没有内存空隙:
for(int arcIdx=0;arcIdx<nodeIn.numArcs;arcIdx++) {
... //省略flags处理逻辑
// 存标志位信息
builder.bytes.writeByte((byte) flags);
writeLabel(builder.bytes, arc.label);
// 这里可以看到,每个Arc的长度不一样,主要是下面这三个write的内容不一样
if (arc.output != NO_OUTPUT) {
outputs.write(arc.output, builder.bytes);
}
if (arc.nextFinalOutput != NO_OUTPUT) {
outputs.writeFinalOutput(arc.nextFinalOutput, builder.bytes);
}
if (targetHasArcs && (flags & BIT_TARGET_NEXT) == 0) {
builder.bytes.writeVLong(target.node);
}
if (doFixedArray) {
// 记录下每个Arc的长度
builder.reusedBytesPerArc[arcIdx] = (int) (builder.bytes.getPosition() - lastArcStart);
lastArcStart = builder.bytes.getPosition();
// 记录最长的Arc的长度
maxBytesPerArc = Math.max(maxBytesPerArc, builder.reusedBytesPerArc[arcIdx]);
}
}
1.3 将Arc紧邻而存转变成 FIXED ARRAY 存储
所有Arc存下之后,如果doFixedArray为true,由于前面Arc内容写入bytes的时候是紧挨着写入的,所以还需要调整一下,每个Arc的长度要调整为最长的Arc的长度,所以从最后一个Arc开始,基本上每个Arc都需要向后移动到正确的位置上。代码如下:
if (doFixedArray) {
final int MAX_HEADER_SIZE = 11; // header(byte) + numArcs(vint) + numBytes(vint)
byte header[] = new byte[MAX_HEADER_SIZE];
ByteArrayDataOutput bad = new ByteArrayDataOutput(header);
bad.writeByte(ARCS_AS_FIXED_ARRAY); // 写入标志位,代表ARC是FIXED_ARRAY
bad.writeVInt(nodeIn.numArcs); // 写入Arc的数量
bad.writeVInt(maxBytesPerArc); // 写入最大的边的长度
int headerLen = bad.getPosition();
final long fixedArrayStart = startAddress + headerLen;
//记录最后一个Arc的Address,注意这里是反向读取
long srcPos = builder.bytes.getPosition();
//记录FIXED_ARRAY方式下,最后一个Arc调整之后的Address
long destPos = fixedArrayStart + nodeIn.numArcs*maxBytesPerArc;
if (destPos > srcPos) {
// bytes 移动到调整的位置
builder.bytes.skipBytes((int) (destPos - srcPos));
for(int arcIdx=nodeIn.numArcs-1;arcIdx>=0;arcIdx--) { // 从最后一个Arc开始调整
// 计算应该调整后的位置
destPos -= maxBytesPerArc;
srcPos -= builder.reusedBytesPerArc[arcIdx];
if (srcPos != destPos) {
// 如果Arc调整后的位置与当前所处位置相比发生了变化,那么就移动Arc到调整之后的位置
builder.bytes.copyBytes(srcPos, destPos, builder.reusedBytesPerArc[arcIdx]);
}
}
}
// 写header
builder.bytes.writeBytes(startAddress, header, 0, headerLen);
}
1.4 反向存储
最后将当前Node的Address写入nodeAddress对象就完成了对Node的写入。但是在写nodeAddress之前有一行代码:
builder.bytes.reverse(startAddress, thisNodeAddress);
这里会将Node存储的Address和终止位置之间的内容翻转,这里之所以这么做,是读取构建好的FST的时候,用的ReverseBytesReader类,会对整个FST反向读取。以下图为例,假设aaz,abyz,acdyz,afgyz,be构建一个FST:

1.5 Arc反向存储的原因
上图的FST的Node的存储顺序是[()1, (z)2, (y)3, (d)4, (g)5, (f,c,b)6, ()7, (e)8, (b,a)9],小括号中的内容代表Node的Arc的label,每个Node的所有Arc的label都被翻转了,是反向字典序,最后再整体通过ReverseBytesReader对象翻转读取整个FST得到的是[9(a,b), 8(e), 7(), 6(b,c,f), 5(g), 4(d), 3(y), 2(z), 1()],这样每个Node的所有Arc信息都是正向字典序的,根节点也相当于移动到了字节数组的最前面。
当FST的所有Node都写入之后,还需要调用finish函数完成构建过程,finish函数中的cacheRootArcs和构建完成之后的pack过程放到文章末尾再讲。
1.6 遍历FST
除了上述的内容,还可以看到FST中有一些查询函数,要查询FST就需要初始化bytes对象,在FST的构造函数中,会用一个DataInput来初始化bytes。遍历FST的函数中,getFirstArc的主要作用是拿到FST的根节点的ID,拿到根节点的ID以后,就可以用readFirstTargetArc函数或者readFirstRealTargetArc函数来读取从Node出发的第一个Arc的信息,readNextArc则可以读取Arc紧邻的下一个Arc,这些都内容都比较简单。只是需要注意的是,Arc的isLast()为true的时候,说明已经遍历到了从Node出发的最后一个Arc,isFinal()为true的时候,说明这个Arc的target是终止节点。
还需要关注下的是findTargetArc函数的后半段二分查询的代码:
private Arc<T> findTargetArc(int labelToMatch, Arc<T> follow, Arc<T> arc, BytesReader in, boolean useRootArcCache) throws IOException {
// ... 省略一部分无关紧要的逻辑
// 如果ARCS存储的时候是FIXED_ARRAY,那么就二分查找label的值为labelToMatch的Arc
if (in.readByte() == ARCS_AS_FIXED_ARRAY) {
arc.numArcs = in.readVInt();
if (packed || version >= VERSION_VINT_TARGET) {
arc.bytesPerArc = in.readVInt();
} else {
arc.bytesPerArc = in.readInt();
}
arc.posArcsStart = in.getPosition();
int low = 0;
int high = arc.numArcs-1;
// 二分查找
while (low <= high) {
int mid = (low + high) >>> 1;
in.setPosition(arc.posArcsStart);
in.skipBytes(arc.bytesPerArc*mid + 1);
int midLabel = readLabel(in);
final int cmp = midLabel - labelToMatch;
if (cmp < 0) {
low = mid + 1;
} else if (cmp > 0) {
high = mid - 1;
} else {
arc.arcIdx = mid-1;
return readNextRealArc(arc, in);
}
}
return null;
}
// 如果不是FIXED_ARRAY, 那就顺序遍历直到找到label的值为labelToMatch的Arc
readFirstRealTargetArc(follow.target, arc, in);
while(true) {
if (arc.label == labelToMatch) {
return arc;
} else if (arc.label > labelToMatch) {
return null;
} else if (arc.isLast()) {
return null;
} else {
readNextRealArc(arc, in); //循环遍历follow.target的所有Arc
}
}
这里可以看到Arc的FIXED_ARRAY存储方式在查询Node中是否存在Arc的label匹配某个值的时候,用的二分查找。
剩余的一些遍历FST的函数都比较简单了,这里节省篇幅,不再列出来。
1.7 遍历FST的优化: 缓存根节点的Arc
在了解了getFirstArc等遍历的函数之后可以看下cacheRootArcs,可以缓存从根节点出发的128个Arc,并且Arc的label值不能超过128,这样的优化可以在findTargetArc查询函数中提升一点效率,代码如下:
private void cacheRootArcs() throws IOException {
final Arc<T> arc = new Arc<>();
getFirstArc(arc); //获取根节点编号,存到arc变量中
if (targetHasArcs(arc)) { //判断从根节点出发是否还有Arc
final BytesReader in = getBytesReader();
Arc<T>[] arcs = (Arc<T>[]) new Arc[0x80]; //最多cache的Arc数量
readFirstRealTargetArc(arc.target, arc, in); //读取从根节点出发的第一个Arc
int count = 0;
while(true) {
if (arc.label < arcs.length) { //如果Arc的label的值小于128,就把这个Arc加入cache数组
arcs[arc.label] = new Arc<T>().copyFrom(arc);
} else { //如果Arc的值大于128,就终止循环,因为Arc存储是按照label排序的,接下来的Arc的label值肯定也大于128
break;
}
if (arc.isLast()) { //是否已经到了最后一个Arc
break;
}
readNextRealArc(arc, in); //读取下一个Arc
count++;
}
int cacheRAM = (int) ramBytesUsed(arcs);
// 当cache的Arc数量大于FIXED_ARRAY_NUM_ARCS_SHALLOW并且这些Arc的内存小于整个FST内存的20%的时候才cache它们
if (count >= FIXED_ARRAY_NUM_ARCS_SHALLOW && cacheRAM < ramBytesUsed()/5) {
cachedRootArcs = arcs;
cachedArcsBytesUsed = cacheRAM;
}
}
}
1.8 怎样压缩FST
最后就是pack的代码,pack的目的是压缩之前存的Arc的内容,根据上面addNode代码,可以看到,每个Arc存的内容是一个字节的flag,Arc的output,Arc的nextFinalOutput,VLong格式的target.node,前面三项基本上没什么压缩的空间,只剩下target.node(后续用target表示)还有点优化的余地。下面两个FST是等价的:

这里只是对Node的ID重新编号了,改变Arca和Arcb的target的值,并不会改变FST的结构。target是用VLong存的,如果值小于128,只需要一个字节存,如果target的值大于128,那至少要2个字节。下面给个例子:

上图中,左右两个FST是等价的,但是左边FST占用的内存明显比右边大,因为Node129和Node130的target占了两个字节,而右边的只占了一个字节。
所以如果能把那些inCounts比较大,而且ID也比较大的Node重新编号,把ID变小,那么就会有很多Arc的target的值变小了,就达到了减少了内存占用。
pack函数的主要目的是替换Arc的target,其基本逻辑是,先对所有的Node按照inCounts从大到小排序,得到一个新数组,每个Node的ID变成了在新数组中的位置,也就是数组的下标,这就是对Node重新编号。然后把bytes的内容复制到一个新的bytes中。原来的FST是反向存放的,pack后的FST是正向存放的,在每个Arc复制到新的bytes的时候,会用编号之后的ID替换掉原来Arc的target。
pack函数的参数maxDerefNodes表示重新编码的Node的数量,被编码的Node会存到topNodeMap,表示旧ID->新ID的映射,而没被编码的Node,只能那么Arc的target就只能存这些Node的Address了。minInCountDeref表示重新编码的Node必须满足Node的inCount数量超过minInCountDeref。
具体的代码中有4个循环,第二层循环for是将所有的Node写到新的bytes中,注意这里是Node ID比较大的先写入新的bytes,这就是正向存储,第四层循环是将Node的所有Arc都写入bytes,这两个点比较好理解。第三层循环是针对FIXED_ARRAY存储模式的,当Node使用FIXED_ARRAY存储的时候,一次循环只能确定Arc的最大长度的值,第二次循环才能将每个Arc都用最大长度去存储。
重点就是第一层循环,由于pack后的FST是正向存储,以图 1为例,先写入了跟节点的Arca,但是能计算出Arca的target,Node 6的Address吗?显然,不一定能计算出来,因为Node 9和Node 6之间还要存Node 7,Node 8,这两个Node如果没有压缩,那Node 6的Address就能算出,如果这两个Node压缩了,那Arca的target存的Address就不正确,所以这里只有当所有的Node的Address不再变化的时候,也就是所有Node都压缩了一遍之后,循环才能终止,所以最外层是需要循环的。具体代码如下:
FST<T> pack(Builder<T> builder, int minInCountDeref, int maxDerefNodes, float acceptableOverheadRatio) throws IOException{
if (nodeAddress == null) {
throw new IllegalArgumentException("this FST was not built with willPackFST=true");
}
T NO_OUTPUT = outputs.getNoOutput();
Arc<T> arc = new Arc<>();
final BytesReader r = getBytesReader();
// maxDerefNodes 表示最多允许重新编码的Node数量
final int topN = Math.min(maxDerefNodes, inCounts.size());
// 大小为topN的优先队列
NodeQueue q = new NodeQueue(topN);
NodeAndInCount bottom = null;
// 所有的Node写入优先队列中,得到的是inCounts最大的topN个Node
for(int node=0; node<inCounts.size(); node++) {
if (inCounts.get(node) >= minInCountDeref) {
if (bottom == null) {
q.add(new NodeAndInCount(node, (int) inCounts.get(node)));
if (q.size() == topN) {
bottom = q.top();
}
} else if (inCounts.get(node) > bottom.count) {
q.insertWithOverflow(new NodeAndInCount(node, (int) inCounts.get(node)));
}
}
}
inCounts = null;
// 将这topN个Node的ID重新编码
final Map<Integer,Integer> topNodeMap = new HashMap<>();
for(int downTo=q.size()-1;downTo>=0;downTo--) {
NodeAndInCount n = q.pop();
topNodeMap.put(n.node, downTo);
}
// 这里第二个参数表示Node的数量, +1 是因为 bytes 会有个 bytes.writeByte((byte) 0);
final GrowableWriter newNodeAddress = new GrowableWriter(
PackedInts.bitsRequired(builder.bytes.getPosition()), (int) (1 + builder.nodeCount), acceptableOverheadRatio);
// 记录Node的Address
for(int node=1;node<=builder.nodeCount;node++) {
newNodeAddress.set(node, 1 + builder.bytes.getPosition() - nodeAddress.get(node));
}
int absCount;
int deltaCount;
int topCount;
int nextCount;
FST<T> fst;
// 这里循环是因为,一次遍可能无法正确计算出所有Arc的target
// 如果maxDerefNodes = inCounts.size(),在这种情况一次遍历就能计算出来
while(true) {
boolean changed = false;
boolean negDelta = false;
fst = new FST<>(inputType, outputs, builder.bytes.getBlockBits());
final BytesStore writer = fst.bytes;
writer.writeByte((byte) 0);
absCount = deltaCount = topCount = nextCount = 0;
int changedCount = 0;
long addressError = 0;
// 从Node ID 大的Node开始,往新的bytes里面写,从上图1可以看到,Node ID最大的Node是根节点
// 这里的目的就是为了正向存储
for(int node=(int) builder.nodeCount;node>=1;node--) {
final long address = writer.getPosition();
// 计算本次写入的Node的Address与上次写入时候的差值,并且更新Node的Address
if (address != newNodeAddress.get(node)) {
// 这个值只有当Arc没有压缩的时候才是正确的
addressError = address - newNodeAddress.get(node);
changed = true;
newNodeAddress.set(node, address);
changedCount++;
}
int nodeArcCount = 0;
int bytesPerArc = 0;
boolean retry = false;
// 没太多意义的变量
boolean anyNegDelta = false;
//这里的循环主要是 FIXED_ARRAY 存储模式下,如果Node的最长的Arc的长度变化了,那么需要更新最长Arc的长度,并且重新把Node的所有Arc按照新的长度再写入一次
writeNode:
while(true) {
readFirstRealTargetArc(node, arc, r);
final boolean useArcArray = arc.bytesPerArc != 0;
if (useArcArray) {
// Write false first arc:
if (bytesPerArc == 0) {
bytesPerArc = arc.bytesPerArc;
}
writer.writeByte(ARCS_AS_FIXED_ARRAY);
writer.writeVInt(arc.numArcs);
writer.writeVInt(bytesPerArc);
}
int maxBytesPerArc = 0;
// 循环写入Node的所有Arc
while(true) {
final long arcStartPos = writer.getPosition();
nodeArcCount++;
//处理 Arc 的 flag 逻辑
byte flags = 0;
if (arc.isLast()) {
flags += BIT_LAST_ARC;
}
if (!useArcArray && node != 1 && arc.target == node-1) {
flags += BIT_TARGET_NEXT;
if (!retry) { //没实际意义
nextCount++;
}
}
if (arc.isFinal()) {
flags += BIT_FINAL_ARC;
if (arc.nextFinalOutput != NO_OUTPUT) {
flags += BIT_ARC_HAS_FINAL_OUTPUT;
}
} else {
assert arc.nextFinalOutput == NO_OUTPUT;
}
if (!targetHasArcs(arc)) {
flags += BIT_STOP_NODE;
}
if (arc.output != NO_OUTPUT) {
flags += BIT_ARC_HAS_OUTPUT;
}
final long absPtr;
final boolean doWriteTarget = targetHasArcs(arc) && (flags & BIT_TARGET_NEXT) == 0;
if (doWriteTarget) {
final Integer ptr = topNodeMap.get(arc.target);
if (ptr != null) {
absPtr = ptr;
} else {
// 如果Node不在重编号Map中,那么absPtr就用Node的Address
absPtr = topNodeMap.size() + newNodeAddress.get((int) arc.target) + addressError;
}
// 计算target Node和 当前Node起始的Address之间的差值,这里的-2我不是很懂,如果看懂了请联系我,谢谢
long delta = newNodeAddress.get((int) arc.target) + addressError - writer.getPosition() - 2;
if (delta < 0) {
anyNegDelta = true;
delta = 0;
}
// 两个值哪个小存哪个
if (delta < absPtr) {
flags |= BIT_TARGET_DELTA;
}
} else {
absPtr = 0;
}
// 写入Arc 的信息
writer.writeByte(flags);
fst.writeLabel(writer, arc.label);
if (arc.output != NO_OUTPUT) {
outputs.write(arc.output, writer);
}
if (arc.nextFinalOutput != NO_OUTPUT) {
outputs.writeFinalOutput(arc.nextFinalOutput, writer);
}
if (doWriteTarget) {
// 计算target Node和当前Arc的Address之间的差值
long delta = newNodeAddress.get((int) arc.target) + addressError - writer.getPosition();
if (delta < 0) {
anyNegDelta = true;
delta = 0;
}
// delta 和 absPtr 只有一个值会被写入
if (flag(flags, BIT_TARGET_DELTA)) {
writer.writeVLong(delta);
if (!retry) {
deltaCount++;
}
} else {
writer.writeVLong(absPtr);
if (!retry) {
if (absPtr >= topNodeMap.size()) {
absCount++;
} else {
topCount++;
}
}
}
}
if (useArcArray) {
// 这里是FIXED_ARRAY存储的时候,每个Arc都要跳过一段内存空隙
writer.skipBytes((int) (arcStartPos + bytesPerArc - writer.getPosition()));
}
if (arc.isLast()) {
break;
}
readNextRealArc(arc, r); //遍历与当前Arc相邻的下一个Arc
}
if (useArcArray) {
// 如果是 FIXED_ARRAY 存储模式,那么只有当Node两次写入的maxBytesPerArc 的值不再变化时,才能终止循环
// 实际上每个Node最多写两次
if (maxBytesPerArc == bytesPerArc || (retry && maxBytesPerArc <= bytesPerArc)) {
break;
}
} else {
break;
}
// 更新 FIXED_ARRAY 存储模式时的信息
bytesPerArc = maxBytesPerArc;
writer.truncate(address);
nodeArcCount = 0;
retry = true;
anyNegDelta = false;
}
negDelta |= anyNegDelta;
}
if (!changed) {
break;
}
}
long maxAddress = 0;
for (long key : topNodeMap.keySet()) {
maxAddress = Math.max(maxAddress, newNodeAddress.get((int) key));
}
PackedInts.Mutable nodeRefToAddressIn = PackedInts.getMutable(topNodeMap.size(),
PackedInts.bitsRequired(maxAddress), acceptableOverheadRatio);
for(Map.Entry<Integer,Integer> ent : topNodeMap.entrySet()) {
nodeRefToAddressIn.set(ent.getValue(), newNodeAddress.get(ent.getKey()));
}
fst.nodeRefToAddress = nodeRefToAddressIn;
fst.startNode = newNodeAddress.get((int) startNode);
if (emptyOutput != null) {
fst.setEmptyOutput(emptyOutput);
}
fst.bytes.finish();
fst.cacheRootArcs();
return fst;
}
以上就是FST的所有源码及我的解读,如果有错误的地方,请联系我修改,谢谢!(企鹅号308384127)
本文详细分析了Lucene的FST(Finite State Transducer)实现,从源码层面探讨了FIXED ARRAY存储方式、Arc的反向存储原因、FST遍历优化及压缩策略等内容,旨在帮助读者深入了解Lucene中FST的数据结构和操作原理。

502





