本文的代码以lucene-core 6.3.0为准,包含BlockTreeTermsWriter的
pushTerm
函数,writeBlocks
函数等整个类所有代码的解析。转载请注明出处。
0 基本信息
- BlockTreeTermsWriter类主要逻辑是存储term词典,对term建立索引。
- 假设输入字符串为
["regular", "request", "rest", "teacher", "team", "teenage", "tend"]
,假设minItemsInBlock=maxItemsInBlock=3
,词典和索引的逻辑图如下:
- 图 1中的的节点有两种,直接指向磁盘的是Term Entry,其余的是Block Entry。每个Entry分4部分,分别是prefix,isLeafNode,isFloorNode,磁盘的偏移量。
- Entry的
L
标签代表这个Entry是Leaf-Entry,F
标签代表这个Entry是Floor-Entry。Leaf-Entry中没有sub-entry,其存储格式跟non-Leaf-Entry不一样。 - Floor-Entry表示相同前缀的entry的可以存多个block,上图中前缀
te
的term有4个,而maxItemsInBlock
为3,所以存成了两个block,Floor-Entry的prefix会多存一个字符,te
的后面多了a
和n
,这是个小的优化。 - 每个non-Leaf-Entry的FST(index)是由所有sub-entry的FST联合组成的FST,Leaf-Entry的FST则是由Entry的prefix构建的FST
- prefix构建的FST的output存的是Entry的磁盘偏移量。root block的FST可以遍历到所有block的物理位置。
- 上图中root block的FST就是由
re
,te
,tea
,ten
四个FST联合而成,其对应的output就是这四个节点的磁盘偏移量。 - 了解FST的存储格式可以看下这两篇:Lucene源码分析 - FST-Builder和Lucene源码分析 - FST。
1 源码分析
write
函数将segment中的非IndexOptions.NONE的Field的构建term索引。
public void write(Fields fields) throws IOException {
String lastField = null;
for(String field : fields) {
// 遍历需要建立索引的field
lastField = field;
Terms terms = fields.terms(field);
if (terms == null) {
continue;
}
TermsEnum termsEnum = terms.iterator(); // 获取field的term迭代器
TermsWriter termsWriter = new TermsWriter(fieldInfos.fieldInfo(field)); // 构建索引的主要类 TermsWriter
while (true) {
BytesRef term = termsEnum.next();
if (term == null) {
break;
}
termsWriter.write(term, termsEnum); // 对term 构建词典和索引
}
termsWriter.finish(); // 完成field 的构建
}
}
TermsWriter
的write
函数会将term的倒排表写入磁盘,pending
是待索引列表,pushTerm
函数判断pending
是否满足构建索引的条件,并将当前term加入pending
末尾。
public void write(BytesRef text, TermsEnum termsEnum) throws IOException {
// 将term的倒排表写入磁盘,返回磁盘偏移量
BlockTermState state = postingsWriter.writeTerm(text, termsEnum, docsSeen);
if (state != null) {
pushTerm(text); // 待索引列表构建索引
PendingTerm term = new PendingTerm(text, state);
pending.add(term); //当前term加入待索引列表
sumDocFreq += state.docFreq;
sumTotalTermFreq += state.totalTermFreq;
numTerms++;
if (firstPendingTerm == null) {
firstPendingTerm = term;
}
lastPendingTerm = term;
}
}
pending
列表类似于栈,因为后续代码对pengding
的读写都是在列表尾部进行的。pending
栈中的元素可以是PendingTerm
(term)和PendingBlock
(block),后文用Entry表示。
函数pushTerm
会判断从栈顶为起始位置,至少连续minItemsInBlock
个Entry集合中,这个Entry集合的公共前缀长度大于pos,如果满足条件,那么将这个集合以block的格式写入词典和索引文件中,其中pos等于当前Entry与前一个Entry的公共前缀长度。
prefixStarts
数组存的Entry在pending
中的位置,prefixStarts[K]=Id
表示从栈的第Id个term到栈顶,这个Entry集合的公共前缀长度为K。
以输入为["teacher", "team", "tend"]
为例,先输入teacher
:
再输入team
:
再输入tend
:
这里有几个细节值得注意:
- 第一,Entry集合的数量只要超过
minItemsInBlock
就写到磁盘,却并没有限制Entry数量的上限,这是因为writeBlocks
函数会把集合分多个block写。 - 第二,上图的
prefixStarts
总长度没变化,画出来的部分是有效长度,就是栈顶Entry的长度,因为下一个term和栈顶的term的公共前缀最长也只能是栈顶Entry的长度。 - 第三,
writeBlocks
函数写的Entry集合的的公共前缀长度范围是[pos, lastTerm.length()-1]
,而不是[pos-1, lastTerm.length()-1]
,这里是因为下一个pushTerm
的前缀有可能是te
,需要等所有te
前缀的term写入pending
。 - 第四,
teacher
与team
的公共前缀有[t,te,tea]
,但是如果这两个写入block,但是需要优先把最长的前缀长度tea
的Entry写入磁盘。
具体代码逻辑如下:
private void pushTerm(BytesRef text) throws IOException {
int limit = Math.min(lastTerm.length(), text.length);
// 计算当前term与前一个term的公共前缀长度:
int pos = 0;
while (pos < limit && lastTerm.byteAt(pos) == text.bytes[text.offset+pos]) {
pos++;
}
// 对公共前缀长度超过pos,且数量大于minItemsInBlock的term集合写到一个或者多个block
// 反向遍历,让公共前缀长度比较大的term集合优先写入block
for(int i=lastTerm.length()-1;i>=pos;i--) {
// 计算与栈顶的Entry的公共前缀为 i 的Entry的数量
int prefixTopSize = pending.size() - prefixStarts[i];
if (prefixTopSize >= minItemsInBlock) {
writeBlocks(i+1