lucene4.5源码分析系列:索引的创建过程

在开始之前,先对IndexWriter的所需要考虑的问题有个大致的了解。IndexWriter主要负责索引数据写入,所以,增删改是主要需要考虑的问题;其次,由于写入的过程涉及成功失败的问题,所以,事务以及二阶段事务也是应该考虑到的;再来就是需要写入过程有最大的吞吐量,所以,还存在缓存以及刷新的问题;另外,如何让多线程高效并发访问索引也是要思考的;还有,通常查找内容的数量都非常庞大,如何管理索引过程中的内存以及磁盘消耗也是值得考虑的事情。接下来的文章,会对这些问题分别分析。

 

  索引的增删改

  增加,下面的api主要是加入一个或多个文档,以及对这篇文档使用的analyzer。实际上,增加文档在背后是调用了更新这一操作。它所提供的接口无非是单文档和多文档,有分析器和无分析器的区别。

 

[java]  view plain copy
 
  1. public void addDocument(IndexDocument doc) throws IOException  
  2. public void addDocument(IndexDocument doc, Analyzer analyzer) throws IOException  
  3. public void addDocuments(Iterable<? extends IndexDocument> docs) throws IOException  
  4. public void addDocuments(Iterable<? extends IndexDocument> docs, Analyzer analyzer) throws IOException  

  更新,需要明白的是,更新并非像数据库一样,而是一个先删除后增加的过程

 

[java]  view plain copy
 
  1. public void updateDocuments(Term delTerm, Iterable<? extends IndexDocument> docs) throws IOException  
  2. public void updateDocuments(Term delTerm, Iterable<? extends IndexDocument> docs, Analyzer analyzer) throws IOException  
  3. public void updateDocument(Term term, IndexDocument doc) throws IOException  
  4. public void updateDocument(Term term, IndexDocument doc, Analyzer analyzer)  
  5.       throws IOException  

  删除,第一种方式是删除所有包含这个词的文档,第二种方式是删除所有包含这几个词的文档,第三种方式是删除所有符合该查询的文档,第四种方式是删除所有符合这组查询的文档,第五种方式是直接删除所有文档

[java]  view plain copy
 
  1. public void deleteDocuments(Term term) throws IOException  
  2. public void deleteDocuments(Term... terms) throws IOException  
  3. public void deleteDocuments(Query query) throws IOException  
  4. public void deleteDocuments(Query... queries) throws IOException  
  5. public void deleteAll() throws IOException  

  在更深入的讨论前先理下索引的链式结构,因为这个神奇的结构经常把人搞得云里雾里。默认的索引链在DocumentsWriterPerThread中有初始化。代码如下:

 

[java]  view plain copy
 
  1. static final IndexingChain defaultIndexingChain = new IndexingChain() {  
  2.   
  3.     @Override  
  4.     DocConsumer getChain(DocumentsWriterPerThread documentsWriterPerThread) {  
  5.       /* 
  6.       This is the current indexing chain: 
  7.  
  8.       DocConsumer / DocConsumerPerThread 
  9.         --> code: DocFieldProcessor 
  10.           --> DocFieldConsumer / DocFieldConsumerPerField 
  11.             --> code: DocFieldConsumers / DocFieldConsumersPerField 
  12.               --> code: DocInverter / DocInverterPerField 
  13.                 --> InvertedDocConsumer / InvertedDocConsumerPerField 
  14.                   --> code: TermsHash / TermsHashPerField 
  15.                     --> TermsHashConsumer / TermsHashConsumerPerField 
  16.                       --> code: FreqProxTermsWriter / FreqProxTermsWriterPerField 
  17.                       --> code: TermVectorsTermsWriter / TermVectorsTermsWriterPerField 
  18.                 --> InvertedDocEndConsumer / InvertedDocConsumerPerField 
  19.                   --> code: NormsConsumer / NormsConsumerPerField 
  20.           --> StoredFieldsConsumer 
  21.             --> TwoStoredFieldConsumers 
  22.               -> code: StoredFieldsProcessor 
  23.               -> code: DocValuesProcessor 
  24.     */  
  25.   
  26.     // Build up indexing chain:  
  27.   
  28.       final TermsHashConsumer termVectorsWriter = new TermVectorsConsumer(documentsWriterPerThread);  
  29.       final TermsHashConsumer freqProxWriter = new FreqProxTermsWriter();  
  30.   
  31.       final InvertedDocConsumer termsHash = new TermsHash(documentsWriterPerThread, freqProxWriter, true,  
  32.                                                           new TermsHash(documentsWriterPerThread, termVectorsWriter, falsenull));  
  33.       final NormsConsumer normsWriter = new NormsConsumer();  
  34.       final DocInverter docInverter = new DocInverter(documentsWriterPerThread.docState, termsHash, normsWriter);  
  35.       final StoredFieldsConsumer storedFields = new TwoStoredFieldsConsumers(  
  36.                                                       new StoredFieldsProcessor(documentsWriterPerThread),  
  37.                                                       new DocValuesProcessor(documentsWriterPerThread.bytesUsed));  
  38.       return new DocFieldProcessor(documentsWriterPerThread, docInverter, storedFields);  
  39.     }  
  40.   };  

 

  之所以说它是一个索引链,因为它把整个请求层层向下链状传递(注意这不是真正的责任链模式,虽然非常像,因为它们并不是一个统一的接口)。最上层的领导发话要做什么,都经过层层传递,每层分解任务做一小部分,然后把剩余的向下传递,直到最下层最终执行完任务。而且这个责任链比较整齐的一点是,从触发点DocConsumer开始,都hold住两个对象(他们都是consumer),并且先后被调用。最下层的TeamVectorsWriter实际上已经是解码器的范畴了。lucene4比起lucene3做了一些改进,取缔了*PerThread的一堆类和结构,我猜测lucene3是一个更细粒度的线程同步,对下面的每个小组件都做了同步的选择(即PerThread),但是实际上发现,最后一般都使用粗粒度的同步,就是DocumentsWriterPerThread,从这里向下的方法都是同步的结果。

 

  下图是PerField的索引链,add和update实际上就是通过这条链向下传递的。

lucene4.5源码分析系列:索引的创建过程_第1张图片  

  这里用我的文章作为例子来讲解lucene是如何将这样的文档变为倒排索引结构的,假设有如下1篇文章,简化考虑,他们只有3个field:title和content和author

  

  主要的执行过程在DocFieldProcessor的processDocument中,有这么几个主要步骤(只是为了说明主体,实际上还有很多细节):

  lucene4.5源码分析系列:索引的创建过程_第2张图片

  可以看到这几个主要的组件的组织非常有逻辑,基本上保持着朴素的分治思想,他们的主要作用如下:

  1. DocFieldProcessor: 主要是处理遇到的单个文档,也就是说每碰到一个文档就会有一个DocFieldProcessor来进行相应的处理。

  2. DocFieldProcessorPerField: 主要是将文档按field来归类分好,以便将任务分解,经过DocFieldProcessorPerField,可以发现整个文档已经被归类为了许多fields,接下来可以分别对每个field的进行处理,如下图:

  3. DocInverterPerField: 主要是对每个field中的fieldsData做语汇单元的分析处理,经过这步以后,每次处理的就只是一个term

  4. TermHashPerField: 记录将上一步的term映射到缓冲区中后的各种信息,它会生成部分的postingsArray以及填充term的文本缓冲区。TermHashPerField中最重要的结构就是postingsArray,可以把它理解为二维数组,如图:termID其实就是获取每条记录的key,每条记录其实就是记录每个词的一些元信息。其中最重要的是textStarts,byteStarts,intStarts以及termFreqs,termFreqs很好理解,其实就是记录了词频,比如“的”字出现了3次,因此termFreqs为3.

lucene4.5源码分析系列:索引的创建过程_第3张图片

  那么textStarts,byteStarts,intStarts分别是什么呢?

  在lucene3中,有三个重要的结构作为索引在内存的缓冲,它们分别是IntBlockPool, ByteBlockPool和TextBlockPool,其中IntBlockPool存在的目的是指示docid(文档号),freq(词频)和prox(位置)在ByteBlockPool中的位置,而byteBlockPool专门存放docId,freq,prox,因为存放的字节可能不等,所以需要IntBlockPool的位置指示;而TextBlockPool则存放真正的term文本。而到了lucene4,这个结构有了稍微的改变,TextBlockPool合并到了ByteBlockPool。下面,我们对这两个重要的缓冲区和postingArray的关系展开说明,事实上,只需要了解这两个缓冲区的数据结构,便能清楚的知道这个整体的结构。

lucene4.5源码分析系列:索引的创建过程_第4张图片

  从之前的postingsArray来看,我们有textStarts,它主要作用是指示term文本内容的位置,这样,每个term都能根据textStarts迅速定位到term文本;从图中可以看出来,对应“的”的textStarts指示的位置就能够定位到ByteBlockPool中第116个字节,这里首先指示了接下来三个字节是我们的文本,于是,从117到119就可以确定是文本了。接下来byteStarts指示的是docid,freq和prox开始的位置,即ByteBlockPool的第120个位置;IntBlockPool紧邻的两个数值分别指示docid,freq与prox,intStarts指示13,于是位于IntBlockPool的第13个字节120就是docid,freq在ByteBlockPool中的位置,位于128的就是prox的下一个待写入位置。如此一来,我们就完成了从postingsArray到Pool的映射。

  当然,这里远远没有了解所有问题。比如docid,freq和prox为什么都以16结尾,他们每块为什么都是5个字节?如果同一个term重复了多次,docid和prox会有多份,应当如何记录?为什么在这个例子中ByteBlockPool第一个term只有docid,freq而没有prox?

  实际上,这部分问题是比较复杂的。这里尝试用一个简单的例子讲解。这次,我们向同一个文档中加了6个同样的词,如下

  整个结构会变为

lucene4.5源码分析系列:索引的创建过程_第5张图片

  首先解释为什么每块5个字节,结束位置是16. 请看ByteBlockPool代码,每次空间不够时,会按照固定好的数组来分配空间。每次分配时按照层级,一共有9层,NEXT_LEVEL_ARRAY规定了这点,每层的byte数量在LEVEL_SIZE_ARRAY中规定,比如刚开始是第一层,会分配好5个bytes,(byte) (16|newLevel)便定义了第1层的结束标志为16,第2层(实际上newLevel为1)为17,以此类推……而这个结束标志16不仅表明该块结束,而且还能通过该数字反推出目前的块到底是第几层。

 

[java]  view plain copy
 
  1. public final static int[] NEXT_LEVEL_ARRAY = {1234567899};  
  2. public final static int[] LEVEL_SIZE_ARRAY = {514203040408080120200};  
  3. public int allocSlice(final byte[] slice, final int upto) {  
  4.   
  5.     final int level = slice[upto] & 15;  
  6.     final int newLevel = NEXT_LEVEL_ARRAY[level];  
  7.     final int newSize = LEVEL_SIZE_ARRAY[newLevel];  
  8.   
  9.     // Maybe allocate another block  
  10.     if (byteUpto > BYTE_BLOCK_SIZE-newSize) {  
  11.       nextBuffer();  
  12.     }  
  13.   
  14.     final int newUpto = byteUpto;  
  15.     final int offset = newUpto + byteOffset;  
  16.     byteUpto += newSize;  
  17.   
  18.     // Copy forward the past 3 bytes (which we are about  
  19.     // to overwrite with the forwarding address):  
  20.     buffer[newUpto] = slice[upto-3];  
  21.     buffer[newUpto+1] = slice[upto-2];  
  22.     buffer[newUpto+2] = slice[upto-1];  
  23.   
  24.     // Write forwarding address at end of last slice:  
  25.     slice[upto-3] = (byte) (offset >>> 24);  
  26.     slice[upto-2] = (byte) (offset >>> 16);  
  27.     slice[upto-1] = (byte) (offset >>> 8);  
  28.     slice[upto] = (byte) offset;  
  29.           
  30.     // Write new level:  
  31.     buffer[byteUpto-1] = (byte) (16|newLevel);  
  32.   
  33.     return newUpto+3;  
  34.   }  

  其次应当注意两个原则:差值原则和变长原则,请参见索引的文件格式一文。

 

  接下来,基本上与上面一样,不同的是prox需要记录很多词的位置,是如何做呢,第一个term“索引”到来会在12处记录位置为0,第二个“索引”会在13处记录位置为4(增量),第三个会在14处记录4,第四个会在15处记录4,而第5个会发现已经没有空间了(16不能被占用),于是分配第二层块,插入到ByteBlockPool后面。查LEVEL_SIZE_ARRAY得知,块大小为14,块的结束符为17(例子中17-30就是第二层块),于是将16前面的3个字节向后移动到第二层块的起始位置,连同16共4个字节用于指向下一层的指针,指示下一个块的地址,在本例中将13-15中的4移动到17-19,将16指向了17,再将第5个4放到20,第6个4放到21。这个动态的过程如下,左边序号代表到来的term“索引”:

  为什么需要4个字节的指针?本例中不是一个字节就搞定了吗?当然这是为了支持更大的Pool。那又为什么需要指针呢,从上面来看16就指向了17,完全没有必要,但是这只是一个特例,lucene采用的方法是非常高效的,为了避免在待扩展块后面直接插入扩展块(即在有序数组的中间来扩展),lucene会将扩展块放到ByteBlockPool的最后,这样一来,省去了每次扩展时对数组的一个大规模移动,只需要用指针来换取这种移动。

  关于doc和freq也是类似的,这里就不再赘述。

  最后,为什么在我们最初的例子中只有docid而没有prox,事实上,这与选择的Field有关系,对于author这个field,我选择了StringField,它的IndexOptions为DOCS_ONLY,意味着我们将只会记录docid,忽略了freq和prox

  5. FreqProxTermsWriterPerField: 将最终分析的prox, offset以及lastDocIDs, lastDocCodes等信息记录下来。其实这部所做的事情已经在上面分析过了。

  接下来的问题是,当添加或者更新完毕之后,这些数据会立即写入磁盘吗?答案是不会。lucene会在flush的时候将缓冲区的数据写入磁盘。FreqProxTermWriterPerField中的flush方法就负责这件事。关于缓存及刷新,请参考后续文章

 

  参考文档:Lucene索引过程分析(2)  http://www.cnblogs.com/forfuture1978/archive/2010/02/02/1661440.html

                      Lucene索引过程分析(3)  http://www.cnblogs.com/forfuture1978/archive/2010/02/02/1661441.html

                      Annotated lucene

http://blog.csdn.net/liweisnake/article/details/11364597

你可能感兴趣的:(Lucene)