上接《索引创建(3):DocumentWriter 处理流程二 》
1.3.3 第三车间—— TermsHashPerField & FreqProxTermsWriterPerField
TermsHashPerField和FreqProxTermsWriterPerField负责将token信息(字符串内容termTest,所在文档编号docID,所在文档中的位置position,所在文档中的词频frequence)添加到索引的Hash表结构(postingsHash)中。事实上,这些信息并不是直接存放在Hash表中的,而是存放在三个很重要的数据缓存池中(CharBlockPool、IntBlockPool、ByteBlockPool) 。而postingsHash中存放的只是数据在三个数据池中的地址偏移。
★ TermsHashPerField
这里首先简单介绍一下这三个数据池,想要深刻了解这三个数据池, 可见《索引数据池及内存数据细节 》。
1) CharBlockPool: 存储token的字面信息
2) ByteBlockPool: 存储token所在文档编号,位置和词频信息
3) IntBlockPool: 存储对应的ByteBlockPool中slice的位置
TermsHashPerField类的主要职责就是建立和维护一张postingsHash的倒排索引表。这张表是一张以token字符串作为关键字的Hash表。其结构如下:
final class TermsHashPerField extends InvertedDocConsumerPerField { private int postingsHashSize = 4; //倒排索引表的Hash数组结构,初始大小为4 private RawPostingList[] postingsHash = new RawPostingList[postingsHashSize]; }
其中postingHash[]的每个元素都是RawPostingList类型的。该类在内存中表示一个由token唯一标示的posting list(倒排索引结构:token-> posting list)。
//该类在内存中表示一个由token唯一标示的posting list(倒排索引结构: token -> posting list)。 abstract class RawPostingList { int textStart; //存储token信息在CharBlockPool中的初始位置 int intStart; //存储token信息在IntBlockPool中的初始位置 int byteStart; //存储token信息在ByteBlockPool中的初始位置 }
实际上postingHash[]的每个元素的实际类型是PostingList。这个类型包含的数据域如下:
static final class PostingList extends RawPostingList { int docFreq; //此词在当前文档中出现的次数 int lastDocID; // 上次处理完的包含此词的文档号。 int lastDocCode; // 文档号和词频按照或然跟随原则形成的编码 int lastPosition; // 上次处理完的此词的位置 }
TermsHashPerField.add()方法是创建待排索引的核心方法,我们分功能段来解析它的源代码:
// 将token加入到倒排索引的Hash链表结构中:token -> posting list @Override void add() throws IOException {....}
1、首先,计算token的hashcode值。很显然,下面的代码表明处理token中的字符采用的是UTF-16编码方式(Java对字符串都是Unicode编码的)。想要了解UTF-16编码方式,可参见《解析Unicode编码和Java char 》。另外,token的hashcode计算公式是code=(code*31)+ch,其中ch是token从末尾开始向前遍历的字符。
//得到当前token的内容 final char[] tokenText = termAtt.termBuffer(); //得到当前token的长度 final int tokenTextLen = termAtt.termLength(); int downto = tokenTextLen; int code = 0; //计算token的hashcode(其中每个字符按照的UTF-16编码方式进行编码) while (downto > 0) { //从后往前取出token中的每一个字符 char ch = tokenText[--downto]; //判断ch是不是Unicode编码中的替代区域(surrogate area),这个区域不表示任何字符 if (ch >= UnicodeUtil.UNI_SUR_LOW_START && ch <= UnicodeUtil.UNI_SUR_LOW_END) { if (0 == downto) { ch = tokenText[downto] = UnicodeUtil.UNI_REPLACEMENT_CHAR; } else { //取出连续两个字符进行hashcode计算(UTF-16编码方式) final char ch2 = tokenText[downto-1]; if (ch2 >= UnicodeUtil.UNI_SUR_HIGH_START && ch2 <= UnicodeUtil.UNI_SUR_HIGH_END) { code = ((code*31) + ch)*31+ch2; downto--; continue; } else { ch = tokenText[downto] = UnicodeUtil.UNI_REPLACEMENT_CHAR; } } } else if (ch >= UnicodeUtil.UNI_SUR_HIGH_START && (ch <= UnicodeUtil.UNI_SUR_HIGH_END || ch == 0xffff)) { ch = tokenText[downto] = UnicodeUtil.UNI_REPLACEMENT_CHAR; } //如果ch是正常字符,则开始计算token的hashcode code = (code*31) + ch; }//end while
2、 其次,根据token的hashcode定位到postingHash[]上的位置。 我们通过code & postingHashMask来找到当前token在postingHash[]上的位置(其中postingsHashMask = postingsHashSize-1)。当产生Hash冲突的时候,其解决办法就是不断计算新的位置直到不产生冲突为止。
这里顺便提一下:code & postingHashMask 和code % postingHashMask是完全等价的,而且位运算效率更高,Lucene经常使用位运算。
//计算当前token将要插入在Hash表中的位置,code & postingsHashMask等价于code % postingHashMask //postingsHashMask=postingsHashSize-1 int hashPos = code & postingsHashMask; //取出原hash表中hashPos位置上的数据 p = postingsHash[hashPos]; //如果原Hash表中的该位置上有数据并且两个token的字符串内容不等,则产生Hash冲突 if (p != null && !postingEquals(tokenText, tokenTextLen)) { // 冲突解决方法:不断计算新的hashcode,直到避开冲突位置 final int inc = ((code>>8)+code)|1; do { code += inc; hashPos = code & postingsHashMask; p = postingsHash[hashPos]; } while (p != null && !postingEquals(tokenText, tokenTextLen)); }
3、最后,将token的各种信息写入数据池,并在PostingList中存储数据池的地址偏移。整个 过程有两种可能:
(1) 如果当前token在前面从未遇到过,也就是已经加入索引结构的所有词都没有这个词语。那么首先需要在postingHash中开辟一个新的PostingList准备存放当前token所对应信息(code line :18,33)。接着在CharBlackPool中创建一个新的区域来存储当前token的字符串信息(line: 27),并且将地址偏移记录在新创建的PostingList.textStart中(line: 25)。然后就是在ByteBlockPool中开辟两个slice (line:57,58)。并且在IntBlockPool中开辟两个空间存储ByteBlockPool中的新开辟的slice地址偏移(line:57,60)。最后调用FreqProxTermsWriterPerField.newTerm() 将token的docID+freq和position信息存储进去(line: 67)。
(2) 如果当前token在前面已经遇到过了,此时就不需要在三大数据池中分配新的空间存放了。直接调用FreqProxTermsWriterPerField.addTerm()将信息存储进去(line:72)。
//说明当前token以前的文本中从未出现过 if (p == null) { final int textLen1 = 1+tokenTextLen; //当CharBlockPool当前的buffer空间不足时,则重新分配一个新的buffer if (textLen1 + charPool.charUpto > DocumentsWriter.CHAR_BLOCK_SIZE) { if (textLen1 > DocumentsWriter.CHAR_BLOCK_SIZE) { if (docState.maxTermPrefix == null) docState.maxTermPrefix = new String(tokenText, 0, 30); consumer.skippingLongTerm(); return; } charPool.nextBuffer(); } if (0 == perThread.freePostingsCount) perThread.morePostings(); //从空闲的posting中分配新的posting p = perThread.freePostings[--perThread.freePostingsCount]; assert p != null; //将当前token的内容写入CharBlockPool中,此时text和CharBlockPool中的当前buffer都指向同一块内存区域 final char[] text = charPool.buffer; final int textUpto = charPool.charUpto; //PostingList类中的textStart保存的是当前token首字母在CharBlockPool中的位置 p.textStart = textUpto + charPool.charOffset; charPool.charUpto += textLen1; System.arraycopy(tokenText, 0, text, textUpto, tokenTextLen); //每个词当中存放一个分隔符'#66535'(66535号字符,我们用“#66535”表示) text[textUpto+tokenTextLen] = 0xffff; assert postingsHash[hashPos] == null; //将postingHash[hashPos]指向刚开辟的空闲RawPostList链表 postingsHash[hashPos] = p; //记录链表数量 numPostings++; //如果postingsHash的加载因子达到了50%,则扩大2倍的postingsHash容量 if (numPostings == postingsHashHalfSize) rehashPostings(2*postingsHashSize); //当IntBlockPool中buffer容量不足时,分配一个新buffer if (numPostingInt + intPool.intUpto > DocumentsWriter.INT_BLOCK_SIZE) intPool.nextBuffer(); //当ByteBlockPool中buffer容量不足时,分配一个新buffer if (DocumentsWriter.BYTE_BLOCK_SIZE - bytePool.byteUpto < numPostingInt*ByteBlockPool.FIRST_LEVEL_SIZE) bytePool.nextBuffer(); intUptos = intPool.buffer; intUptoStart = intPool.intUpto; //streamCount=2在ByteBlockPool对一个token同时开辟两个大小一样的slice,一个存放docID+frequence,另一个存放positive. //并且在IntBlockPool中也同时开辟两个空间,用于分别存放一个token对应在ByteBlockPool中两个slice的地址偏移 intPool.intUpto += streamCount; //PostingList类中的intStart保存的是当前token在IntBlockPool中的两个存储空间的第一个地址 p.intStart = intUptoStart + intPool.intOffset; //每一次记录一个token,都需要在ByteBlckPool中开辟两个块来记录: docID+freq(文档ID+词频) 和 prox(位置) for(int i=0;i<streamCount;i++) { final int upto = bytePool.newSlice(ByteBlockPool.FIRST_LEVEL_SIZE); //IntBlockPool用来存储ByteBlockPool每次开辟的块的初始位置 intUptos[intUptoStart+i] = upto + bytePool.byteOffset; } //PostingList类中的byteStart用于存储当前token使用ByteBlckPool开辟的空间的初始位置 //也就是刚开辟的两个块中第一个块的初始位置 p.byteStart = intUptos[intUptoStart]; //token原来没有出现过的时候,FreqProxTermsWriterPerField调用newTerm()记录docID,freq和position consumer.newTerm(p); } else { //如果此Token之前曾经出现过,FreqProxTermsWriterPerField调用addTerm()记录docID,freq和position intUptos = intPool.buffers[p.intStart >> DocumentsWriter.INT_BLOCK_SHIFT]; intUptoStart = p.intStart & DocumentsWriter.INT_BLOCK_MASK; consumer.addTerm(p); }
我们在《 全文检索基本理论 》中讲到的倒排索引结构是 token -> posting list的形式,而文档结合的所有token组成了一个Dictionary。 TermsHashPerField类的作用就是建立这样一个倒排索引结构——postingHash。其中 postingHash[](哈希数组)是以token的字面值作为关键字的,相当于Dictionary。而 postingHash的每一个元素都指向了PostingList对象,这个对象就是用来存储指定token所对应的posting list信息(包括docID,freq,position)。实际上,真正的信息是存储在三大数据池中的,但 PostingList对象只存储三大数据池中的地址偏移。我们通过上面的代码可以发现: TermsHashPerField已经把token的字面值存储在CharBlockPool中了,并且在ByteBlockPool中分配好的存储空间,并将地址偏移记录到了IntBlockPool中了。接下来要做的就是把 token所对应的docID,freq,position的信息通过 FreqProxTermsWriterPerField 的方法写入ByteBlockPool。
★ FreqProxTermsWriterPerField
(1)当出现一个索引结构中没有的token时,我们需要调用newTerm()方法将token所对应的 docID,freq,position信息存储到ByteBlockPool中。
final void newTerm(RawPostingList p0) { assert docState.testPoint("FreqProxTermsWriterPerField.newTerm start"); FreqProxTermsWriter.PostingList p = (FreqProxTermsWriter.PostingList) p0; //当一个新的term出现的时候,包含此Term的就只有本篇文档,记录其ID p.lastDocID = docState.docID; if (omitTermFreqAndPositions) { p.lastDocCode = docState.docID; } else { //docCode是文档ID左移一位,为什么左移,这就需要Lucene的索引文件结构来解释了。 p.lastDocCode = docState.docID << 1; p.docFreq = 1; //写入position信息到bytePool中,此时freq信息还不能写入,因为当前的文档还没有处理完,尚不知道此文档包含此token的总数。 writeProx(p, fieldState.position); } }
(2) 当出现的token在索引结构中已经存在,我们需要调用addTerm()方法将token所对应的 docID,freq,position信息存储到 ByteBlockPool中。
final void addTerm(RawPostingList p0) { assert docState.testPoint("FreqProxTermsWriterPerField.addTerm start"); //取出postingHash已经存在的PostingList FreqProxTermsWriter.PostingList p = (FreqProxTermsWriter.PostingList) p0; assert omitTermFreqAndPositions || p.docFreq > 0; if (omitTermFreqAndPositions) { //p.lastDocID记录了上一次token写入索引结构的docID //docState.docID记录的是token将要写入索引结构的当前docID if (docState.docID != p.lastDocID) { assert docState.docID > p.lastDocID; termsHashPerField.writeVInt(0, p.lastDocCode); p.lastDocCode = docState.docID - p.lastDocID; p.lastDocID = docState.docID; } } else { if (docState.docID != p.lastDocID) { assert docState.docID > p.lastDocID; //如果当前的docID与上一次相同的token写入的docID不同 //则表明上一篇文本中该token已经处理完毕,则将freq信息ByteBlockPool中 if (1 == p.docFreq) termsHashPerField.writeVInt(0, p.lastDocCode|1); else { termsHashPerField.writeVInt(0, p.lastDocCode); termsHashPerField.writeVInt(0, p.docFreq); } p.docFreq = 1;//对于新的文档,freq还是为1. p.lastDocCode = (docState.docID - p.lastDocID) << 1;//文档号存储差值 p.lastDocID = docState.docID; writeProx(p, fieldState.position); } else { //当文档ID不变的时候,说明此文档中这个词又出现了一次,从而freq加1,写入再次出现的位置信息,用差值存储。这里不写入freq,因为该文档还没有结束 p.docFreq++; //将position信息写入ByteBlockPool中 writeProx(p, fieldState.position-p.lastPosition); } } }
上面newTerm()和addTerm()方法都需要调用writeProx()方法将position信息写入ByteBlockPool中
//将position信息写入ByteBlockPool中 final void writeProx(FreqProxTermsWriter.PostingList p, int proxCode) { final Payload payload; //payloadAttribute是token的元数据信息 if (payloadAttribute == null) { payload = null; } else { payload = payloadAttribute.getPayload(); } //将position信息写入ByteBlockPool中 //其中writeVInt()第一个参数1表示将position写入开辟在ByteBlockPool中两个slice的第2个中 //第一个slice存放docID+freq,第二个slice存放position if (payload != null && payload.length > 0) { termsHashPerField.writeVInt(1, (proxCode<<1)|1); termsHashPerField.writeVInt(1, payload.length); termsHashPerField.writeBytes(1, payload.data, payload.offset, payload.length); hasPayloads = true; } else termsHashPerField.writeVInt(1, proxCode<<1); p.lastPosition = fieldState.position; }
实际上,写入ByteBlockPool中的数据到底是什么样子的呢?还有在CharBlockPool中是如何存储token的字面值的呢?IntBlockPool又是怎么样记录ByteBlockPool中的地址偏移的呢?这些问题我们将在《索引数据池及其存储细节 》详细阐明.
★ 总结:
我们以前面所举的例子为例,将《索引数据池及内存数据细节 》中的content field的第一个token="lucene"(docID=0,position=1)创建索引结构,其图示如下: