代码:
writer.addDocument(doc); -->IndexWriter.addDocument(Document doc, Analyzer analyzer) -->doFlush = docWriter.addDocument(doc, analyzer); --> DocumentsWriter.updateDocument(Document, Analyzer, Term) 注:--> 代表一级函数调用 |
IndexWriter继而调用DocumentsWriter.addDocument,其又调用DocumentsWriter.updateDocument。
代码:
DocumentsWriter.updateDocument(Document doc, Analyzer analyzer, Term delTerm) -->(1) DocumentsWriterThreadState state = getThreadState(doc, delTerm); -->(2) DocWriter perDoc = state.consumer.processDocument(); -->(3) finishDocument(state, perDoc); |
DocumentsWriter对象主要包含以下几部分:
IndexWriter.doFlushInternal() --> String segment = docWriter.getSegment();//return segment --> newSegment = new SegmentInfo(segment,……); --> docWriter.createCompoundFile(segment);//根据segment创建cfs文件。 |
基本索引链: 对于一篇文档的索引过程,不是由一个对象来完成的,而是用对象组合的方式形成的一个处理链,链上的每个对象仅仅处理索引过程的一部分,称为索引链,由于后面还有其他的索引链,所以此处的索引链我称为基本索引链。 DocConsumer consumer 类型为DocFieldProcessor,是整个索引链的源头,包含如下部分:
|
类BufferedDeletes包含了一下的成员变量:
由此可见,文档的删除主要有三种方式:
删除文档既可以用reader进行删除,也可以用writer进行删除,不同的是,reader进行删除后,此reader马上能够生效,而用writer删除后,会被缓存在deletesInRAM及deletesFlushed中,只有写入到索引文件中,当reader再次打开的时候,才能够看到。 那deletesInRAM和deletesFlushed各有什么用处呢? 此版本的Lucene对文档的删除是支持多线程的,当用IndexWriter删除文档的时候,都是缓存在deletesInRAM中的,直到flush,才将删除的文档写入到索引文件中去,我们知道flush是需要一段时间的,那么在flush的过程中,另一个线程又有文档删除怎么办呢? 一般过程是这个样子的,当flush的时候,首先在同步(synchornized)的方法pushDeletes中,将deletesInRAM全部加到deletesFlushed中,然后将deletesInRAM清空,退出同步方法,于是flush的线程程就向索引文件写deletesFlushed中的删除文档的过程,而与此同时其他线程新删除的文档则添加到新的deletesInRAM中去,直到下次flush才写入索引文件。 |
缓存用量之间的关系如下: DocumentsWriter.setRAMBufferSizeMB(double mb){ ramBufferSize = (long) (mb*1024*1024);//用户设定的内存用量,当使用内存大于此时,开始写入磁盘 DocumentsWriter.balanceRAM(){ if (numBytesAlloc+deletesRAMUsed > freeTrigger) { //当分配的内存加删除文档所占用的内存大于105%的时候,开始释放内存 while(numBytesAlloc+deletesRAMUsed > freeLevel) { //一直进行释放,直到95% //释放free blocks byteBlockAllocator.freeByteBlocks.remove(byteBlockAllocator.freeByteBlocks.size()-1); freeCharBlocks.remove(freeCharBlocks.size()-1); freeIntBlocks.remove(freeIntBlocks.size()-1); if (numBytesUsed+deletesRAMUsed > ramBufferSize){ //当使用的内存加删除文档占有的内存大于用户指定的内存时,可以写入磁盘 bufferIsFull = true; } } 当判断是否应该写入磁盘时:
DocumentsWriter.timeToFlushDeletes(){ return (bufferIsFull || deletesFull()) && setFlushPending(); } DocumentsWriter.deletesFull(){ return (ramBufferSize != IndexWriter.DISABLE_AUTO_FLUSH && } |
在Lucene中,文档是按添加的顺序编号的,DocumentsWriter中的nextDocID就是记录下一个添加的文档id。 当Lucene支持多线程的时候,就必须要有一个synchornized方法来付给文档id并且将nextDocID加一,这些是在DocumentsWriter.getThreadState这个函数里面做的。 虽然给文档付ID没有问题了。但是由Lucene索引文件格式我们知道,文档是要按照ID的顺序从小到大写到索引文件中去的,然而不同的文档处理速度不同,当一个先来的线程一处理一篇需要很长时间的大文档时,另一个后来的线程二可能已经处理了很多小的文档了,但是这些后来小文档的ID号都大于第一个线程所处理的大文档,因而不能马上写到索引文件中去,而是放到waitQueue中,仅仅当大文档处理完了之后才写入索引文件。 waitQueue中有一个变量nextWriteDocID表示下一个可以写入文件的ID,当付给大文档ID=4时,则nextWriteDocID也设为4,虽然后来的小文档5,6,7,8等都已处理结束,但是如下代码, WaitQueue.add(){ if (doc.docID == nextWriteDocID){ doPause() } 则把5, 6, 7, 8放入waiting队列,并且记录当前等待的文档所占用的内存大小waitingBytes。 当大文档4处理完毕后,不但写入文档4,把原来等待的文档5, 6, 7, 8也一起写入。 WaitQueue.add(){ if (doc.docID == nextWriteDocID) { writeDocument(doc); while(true) { doc = waiting[nextWriteLoc]; writeDocument(doc); } } else { ………… } doPause() } 但是这存在一个问题:当大文档很大很大,处理的很慢很慢的时候,后来的线程二可能已经处理了很多的小文档了,这些文档都是在waitQueue中,则占有了越来越多的内存,长此以往,有内存不够的危险。 因而在finishDocuments里面,在WaitQueue.add最后调用了doPause()函数 DocumentsWriter.finishDocument(){ doPause = waitQueue.add(docWriter); if (doPause) notifyAll(); } WaitQueue.doPause() { 当waitingBytes足够大的时候(为用户指定的内存使用量的10%),doPause返回true,于是后来的线程二会进入wait状态,不再处理另外的文档,而是等待线程一处理大文档结束。 当线程一处理大文档结束的时候,调用notifyAll唤醒等待他的线程。 DocumentsWriter.waitForWaitQueue() { WaitQueue.doResume() { 当waitingBytes足够小的时候,doResume返回true, 则线程二不用再wait了,可以继续处理另外的文档。 |
此过程又包含如下三个子过程:
代码为:
DocumentsWriterThreadState state = getThreadState(doc, delTerm); |
在Lucene中,对于同一个索引文件夹,只能够有一个IndexWriter打开它,在打开后,在文件夹中,生成文件write.lock,当其他IndexWriter再试图打开此索引文件夹的时候,则会报org.apache.lucene.store.LockObtainFailedException错误。
这样就出现了这样一个问题,在同一个进程中,对同一个索引文件夹,只能有一个IndexWriter打开它,因而如果想多线程向此索引文件夹中添加文档,则必须共享一个IndexWriter,而且在以往的实现中,addDocument函数是同步的(synchronized),也即多线程的索引并不能起到提高性能的效果。
于是为了支持多线程索引,不使IndexWriter成为瓶颈,对于每一个线程都有一个相应的文档集处理对象(DocumentsWriterThreadState),这样对文档的索引过程可以多线程并行进行,从而增加索引的速度。
getThreadState函数是同步的(synchronized),DocumentsWriter有一个成员变量threadBindings,它是一个HashMap,键为线程对象(Thread.currentThread()),值为此线程对应的DocumentsWriterThreadState对象。
DocumentsWriterThreadState DocumentsWriter.getThreadState(Document doc, Term delTerm)包含如下几个过程:
DocumentsWriterThreadState state = (DocumentsWriterThreadState) threadBindings.get(Thread.currentThread()); if (state == null) { …… state = new DocumentsWriterThreadState(this); …… threadBindings.put(Thread.currentThread(), state); } |
DocumentsWriter.getThreadState() { waitReady(state); state.isIdle = false; } waitReady(state) { while (!state.isIdle) {wait();} } 显然如果state.isIdle为false,则此线程等待。 在一篇文档处理之前,state.isIdle = false会被设定,而在一篇文档处理完毕之后,DocumentsWriter.finishDocument(DocumentsWriterThreadState perThread, DocWriter docWriter)中,会首先设定perThread.isIdle = true; 然后notifyAll()来唤醒等待此文档完成的线程,从而处理下一篇文档。 |
initSegmentName(false); --> if (segment == null) segment = writer.newSegmentName(); |
代码为:
DocWriter perDoc = state.consumer.processDocument(); |
每一个文档集处理对象DocumentsWriterThreadState都有一个文档及域处理对象DocFieldProcessorPerThread,它的成员函数processDocument()被调用来对文档及域进行处理。
线程索引链(XXXPerThread): 由于要多线程进行索引,因而每个线程都要有自己的索引链,称为线程索引链。 线程索引链同基本索引链有相似的树形结构,由基本索引链中每个层次的对象调用addThreads进行创建的,负责每个线程的对文档的处理。 DocFieldProcessorPerThread是线程索引链的源头,由DocFieldProcessor.addThreads(…)创建 DocFieldProcessorPerThread对象结构如下:
|
DocumentsWriter.DocWriter DocFieldProcessorPerThread.processDocument()包含以下几个过程:
4.2.1、开始处理当前文档
consumer(DocInverterPerThread).startDocument(); |
在此版的Lucene中,几乎所有的XXXPerThread的类,都有startDocument和finishDocument两个函数,因为对同一个线程,这些对象都是复用的,而非对每一篇新来的文档都创建一套,这样也提高了效率,也牵扯到数据的清理问题。一般在startDocument函数中,清理处理上篇文档遗留的数据,在finishDocument中,收集本次处理的结果数据,并返回,一直返回到DocumentsWriter.updateDocument(Document, Analyzer, Term) 然后根据条件判断是否将数据刷新到硬盘上。
4.2.2、逐个处理文档的每一个域
由于一个线程可以连续处理多个文档,而在普通的应用中,几乎每篇文档的域都是大致相同的,为每篇文档的每个域都创建一个处理对象非常低效,因而考虑到复用域处理对象DocFieldProcessorPerField,对于每一个域都有一个此对象。
那当来到一个新的域的时候,如何更快的找到此域的处理对象呢?Lucene创建了一个DocFieldProcessorPerField[] fieldHash哈希表来方便更快查找域对应的处理对象。
当处理各个域的时候,按什么顺序呢?其实是按照域名的字典顺序。因而Lucene创建了DocFieldProcessorPerField[] fields的数组来方便按顺序处理域。
因而一个域的处理对象被放在了两个地方。
对于域的处理过程如下:
4.2.2.1、首先:对于每一个域,按照域名,在fieldHash中查找域处理对象DocFieldProcessorPerField,代码如下:
final int hashPos = fieldName.hashCode() & hashMask;//计算哈希值 |
如果能够找到,则更新DocFieldProcessorPerField中的域信息fp.fieldInfo.update(field.isIndexed()…)
如果没有找到,则添加域到DocFieldProcessorPerThread.fieldInfos中,并创建新的DocFieldProcessorPerField,且将其加入哈希表。代码如下:
fp = new DocFieldProcessorPerField(this, fi); |
如果是一个新的field,则将其加入fields数组fields[fieldCount++] = fp;
并且如果是存储域的话,用StoredFieldsWriterPerThread将其写到索引中:
if (field.isStored()) { |
4.2.2.1.1、处理存储域的过程如下:
StoredFieldsWriterPerThread.addField(Fieldable field, FieldInfo fieldInfo) --> localFieldsWriter.writeField(fieldInfo, field); |
FieldsWriter.writeField(FieldInfo fi, Fieldable field)代码如下:
请参照fdt文件的格式,则一目了然: fieldsStream.writeVInt(fi.number);//文档号 fieldsStream.writeByte(bits); //域的属性位 if (field.isCompressed()) {//对于压缩域 fieldsStream.writeVInt(len);//写长度 |
4.2.2.2、然后:对fields数组进行排序,是域按照名称排序。quickSort(fields, 0, fieldCount-1);
4.2.2.3、最后:按照排序号的顺序,对域逐个处理,此处处理的仅仅是索引域,代码如下:
for(int i=0;i<fieldCount;i++) |
域处理对象(DocFieldProcessorPerField)结构如下:
域索引链: 每个域也有自己的索引链,称为域索引链,每个域的索引链也有同线程索引链有相似的树形结构,由线程索引链中每个层次的每个层次的对象调用addField进行创建,负责对此域的处理。 和基本索引链及线程索引链不同的是,域索引链仅仅负责处理索引域,而不负责存储域的处理。 DocFieldProcessorPerField是域索引链的源头,对象结构如下:
|
4.2.2.3.1、处理索引域的过程如下:
DocInverterPerField.processFields(Fieldable[], int) 过程如下:
boolean doInvert = consumer.start(fields, count); --> TermsHashPerField.start(Fieldable[], int) --> for(int i=0;i<count;i++) if (fields[i].isIndexed()) return true; return false; |
读到这里,大家可能会发生困惑,既然XXXPerField是对于每一个域有一个处理对象的,那为什么参数传进来的是Fieldable[]数组, 并且还有域的数目count呢?
其实这不经常用到,但必须得提一下,由上面的fieldHash的实现我们可以看到,是根据域名进行哈希的,所以准确的讲,XXXPerField并非对于每一个域有一个处理对象,而是对每一组相同名字的域有相同的处理对象。
对于同一篇文档,相同名称的域可以添加多个,代码如下:
doc.add(new Field("contents", "the content of the file.", Field.Store.NO, Field.Index.NOT_ANALYZED)); |
则传进来的名为"contents"的域如下:
fields Fieldable[2] (id=52) |
for(int i=0;i<count;i++){ final Fieldable field = fields[i]; if (field.isIndexed() && doInvert) { //仅仅对索引域进行处理 if (!field.isTokenized()) { //如果此域不分词,见(1)对不分词的域的处理 } else { //如果此域分词,见(2)对分词的域的处理 } } } |
(1) 对不分词的域的处理
(1-1) 得到域的内容,并构建单个Token形成的SingleTokenAttributeSource。因为不进行分词,因而整个域的内容算做一个Token.
String stringValue = field.stringValue(); //stringValue "200910240957"
final int valueLength = stringValue.length();
perThread.singleToken.reinit(stringValue, 0, valueLength);
对于此域唯一的一个Token有以下的属性:
在SingleTokenAttributeSource里面,有一个HashMap来保存可能用于保存属性的类名(Key,准确的讲是接口)以及保存属性信息的对象(Value):
singleToken DocInverterPerThread$SingleTokenAttributeSource (id=150) |
(1-2) 得到Token的各种属性信息,为索引做准备。
consumer.start(field)做的主要事情就是根据各种属性的类型来构造保存属性的对象(HashMap中有则取出,无则构造),为索引做准备。
consumer(TermsHashPerField).start(…) --> termAtt = fieldState.attributeSource.addAttribute(TermAttribute.class);得到的就是上述HashMap中的TermAttributeImpl --> consumer(FreqProxTermsWriterPerField).start(f); --> if (fieldState.attributeSource.hasAttribute(PayloadAttribute.class)) { payloadAttribute = fieldState.attributeSource.getAttribute(PayloadAttribute.class); --> nextPerField(TermsHashPerField).start(f); --> termAtt = fieldState.attributeSource.addAttribute(TermAttribute.class);得到的还是上述HashMap中的TermAttributeImpl --> consumer(TermVectorsTermsWriterPerField).start(f); --> if (doVectorOffsets) { offsetAttribute = fieldState.attributeSource.addAttribute(OffsetAttribute.class); |
(1-3) 将Token加入倒排表
consumer(TermsHashPerField).add();
加入倒排表的过程,无论对于分词的域和不分词的域,过程是一样的,因而放到对分词的域的解析中一起说明。
(2) 对分词的域的处理
(2-1) 构建域的TokenStream
final TokenStream streamValue = field.tokenStreamValue(); //用户可以在添加域的时候,应用构造函数public Field(String name, TokenStream tokenStream) 直接传进一个TokenStream过来,这样就不用另外构建一个TokenStream了。 if (streamValue != null) …… stream = docState.analyzer.reusableTokenStream(fieldInfo.name, reader); } |
此时TokenStream的各项属性值还都是空的,等待一个一个被分词后得到,此时的TokenStream对象如下:
stream StopFilter (id=112) |
(2-2) 得到第一个Token,并初始化此Token的各项属性信息,并为索引做准备(start)。
boolean hasMoreTokens = stream.incrementToken();//得到第一个Token
OffsetAttribute offsetAttribute = fieldState.attributeSource.addAttribute(OffsetAttribute.class);//得到偏移量属性
offsetAttribute OffsetAttributeImpl (id=164) |
PositionIncrementAttribute posIncrAttribute = fieldState.attributeSource.addAttribute(PositionIncrementAttribute.class);//得到位置属性
posIncrAttribute PositionIncrementAttributeImpl (id=129) |
consumer.start(field);//其中得到了TermAttribute属性,如果存储payload则得到PayloadAttribute属性,如果存储词向量则得到OffsetAttribute属性。
(2-3) 进行循环,不断的取下一个Token,并添加到倒排表
for(;;) { if (!hasMoreTokens) break; …… …… |
(2-4) 添加Token到倒排表的过程consumer(TermsHashPerField).add()
TermsHashPerField对象主要包括以下部分:
形成倒排表的过程如下:
//得到token的文本及文本长度 final char[] tokenText = termAtt.termBuffer();//[s, t, u, d, e, n, t, s] final int tokenTextLen = termAtt.termLength();//tokenTextLen 8 //按照token的文本计算哈希值,以便在postingsHash中找到此token对应的倒排表 int downto = tokenTextLen; int hashPos = code & postingsHashMask; //在倒排表哈希表中查找此Token,如果找到相应的位置,但是不是此Token,说明此位置存在哈希冲突,采取重新哈希rehash的方法。 p = postingsHash[hashPos]; if (p != null && !postingEquals(tokenText, tokenTextLen)) { //如果此Token之前从未出现过 if (p == null) { if (textLen1 + charPool.charUpto > DocumentsWriter.CHAR_BLOCK_SIZE) { //当charPool不足的时候,在freeCharBlocks中分配新的buffer charPool.nextBuffer(); } //从空闲的倒排表中分配新的倒排表 p = perThread.freePostings[--perThread.freePostingsCount]; //将文本复制到charPool中 final char[] text = charPool.buffer; //将倒排表放入哈希表中 postingsHash[hashPos] = p; if (numPostingInt + intPool.intUpto > DocumentsWriter.INT_BLOCK_SIZE) intPool.nextBuffer(); //当intPool不足的时候,在freeIntBlocks中分配新的buffer。 if (DocumentsWriter.BYTE_BLOCK_SIZE - bytePool.byteUpto < numPostingInt*ByteBlockPool.FIRST_LEVEL_SIZE) bytePool.nextBuffer(); //当bytePool不足的时候,在freeByteBlocks中分配新的buffer。 //此处streamCount为2,表明在intPool中,每两项表示一个词,一个是指向bytePool中freq信息偏移量的,一个是指向bytePool中prox信息偏移量的。 intUptos = intPool.buffer; p.intStart = intUptoStart + intPool.intOffset; //在bytePool中分配两个空间,一个放freq信息,一个放prox信息的。 final int upto = bytePool.newSlice(ByteBlockPool.FIRST_LEVEL_SIZE); //当Term原来没有出现过的时候,调用newTerm consumer(FreqProxTermsWriterPerField).newTerm(p); } //如果此Token之前曾经出现过,则调用addTerm。 else { intUptos = intPool.buffers[p.intStart >> DocumentsWriter.INT_BLOCK_SHIFT]; } |
(2-5) 添加新Term的过程,consumer(FreqProxTermsWriterPerField).newTerm
final void newTerm(RawPostingList p0) { writeProx(FreqProxTermsWriter.PostingList p, int proxCode) { termsHashPerField.writeVInt(1, proxCode<<1);//第一个参数所谓1,也就是写入此文档在intPool中的第1项——prox信息。为什么左移一位呢?是因为后面可能跟着payload信息,参照索引文件格式(1)中或然跟随规则。 } |
(2-6) 添加已有Term的过程
final void addTerm(RawPostingList p0) { FreqProxTermsWriter.PostingList p = (FreqProxTermsWriter.PostingList) p0; if (docState.docID != p.lastDocID) { //当文档ID变了的时候,说明上一篇文档已经处理完毕,可以写入freq信息了。 //第一个参数所谓0,也就是写入上一篇文档在intPool中的第0项——freq信息。至于信息为何这样写,参照索引文件格式(1)中的或然跟随规则,及tis文件格式。 if (1 == p.docFreq) //当文档ID不变的时候,说明此文档中这个词又出现了一次,从而freq加一,写入再次出现的位置信息,用差值。 |
(2-7) 结束处理当前域
consumer(TermsHashPerField).finish(); --> FreqProxTermsWriterPerField.finish() --> TermVectorsTermsWriterPerField.finish() endConsumer(NormsWriterPerField).finish(); --> norms[upto] = Similarity.encodeNorm(norm);//计算标准化因子的值。 --> docIDs[upto] = docState.docID; |
4.2.3、结束处理当前文档
final DocumentsWriter.DocWriter one = fieldsWriter(StoredFieldsWriterPerThread).finishDocument();
存储域返回结果:一个写成了二进制的存储域缓存。
one StoredFieldsWriter$PerDoc (id=322) |
final DocumentsWriter.DocWriter two = consumer(DocInverterPerThread).finishDocument();
--> NormsWriterPerThread.finishDocument()
--> TermsHashPerThread.finishDocument()
索引域的返回结果为null
4.3、用DocumentsWriter.finishDocument结束本次文档添加
代码:
DocumentsWriter.updateDocument(Document, Analyzer, Term) --> DocumentsWriter.finishDocument(DocumentsWriterThreadState, DocumentsWriter$DocWriter) --> doPause = waitQueue.add(docWriter);//有关waitQueue,在DocumentsWriter的缓存管理中已作解释 --> DocumentsWriter$WaitQueue.writeDocument(DocumentsWriter$DocWriter) --> StoredFieldsWriter$PerDoc.finish() --> fieldsWriter.flushDocument(perDoc.numStoredFields, perDoc.fdt);将存储域信息真正写入文件。 |