索引过程是由Lucene所提供的核心功能之一,而IndexWriter 是索引过程中最重要的和核心组件。
目录
1. 索引基本流程
索引文件的写入顺序: fdt--->fdx --->frq ----->prx --->tis--->tii -----> nrm ----->fnm -------> segement,参考之前分享的索引结构
锁文件
默认情况下,存储在索引目录中的锁文件名为 write.lock
。如果锁目录与索引目录不同,则锁文件将命名为“XXXX-write.lock”,其中XXXX是从索引目录的完整路径导出的唯一前缀。此锁文件确保每次只有一个写入程序在修改索引。
2. IndexWriter
public class IndexWriter implements Closeable, TwoPhaseCommit, Accountable {
2.1. 关于线程安全
IndexWriter提供的核心接口都是线程安全的,并且内部做了特殊的并发优化来优化多线程写入的性能。IndexWriter内部为每个线程都会单独开辟一个空间来写入,这块空间由DocumentsWriterPerThread来控制。
- 多线程并发调用IndexWriter的写接口,实际上是由DocumentsWriter来执行。DocumentsWriter内部在处理请求之前,会先根据当前执行操作的Thread来分配DocumentsWriterPerThread。
- 每个线程在其独立的DocumentsWriterPerThread空间内部进行数据处理,包括分词、相关性计算、索引构建等。
- 数据处理完毕后,在DocumentsWriter层面执行一些后续动作,例如触发FlushPolicy的判定等。
引入了DocumentsWriterPerThread(社区都叫DWPT),Lucene内部在处理数据时,整个处理步骤只需要对以上第一步和第三步进行加锁(非常轻量级的),第二步完全不用加锁(对计算和内存资源消耗最大的),每个线程都在自己独立的空间内处理数据。所以这样做之后,对多线程并发写入性能有很大的提升(能够将加锁的时间大大缩短,提高并发的效率)。每个DWPT内单独包含一个In-memory buffer,这个buffer最终会flush成不同的独立的segment文件。这样做的好处是:特别是针对纯新增文档的场景,所有数据写入都不会有冲突,所以非常适合这种空间隔离式的数据写入方式。但对于删除文档的场景,一次删除动作可能会涉及删除不同线程空间内的数据,这里Lucene也采取了一种特殊的交互方式来降低锁的开销。
在搜索场景中,全量构建索引的阶段,基本是纯新增文档式的写入,而在后续增量索引阶段(特别是数据源是数据库时),会涉及大量的update和delete操作。从原理上来分析,一个最佳实践是包含相同唯一主键Term的文档分配相同的线程来处理,使数据更新发生在一个独立线程空间内,避免跨线程。
2.2. IndexWriterConfig
Directory directory = FSDirectory.open(Paths.get(directoryPath));
IndexWriterConfig config = new IndexWriterConfig();
IndexWriter writer = new IndexWriter(directory, config);//1.创建IndexWriter
System.out.println(writer.getConfig());//默认配置
输入:
analyzer=org.apache.lucene.analysis.standard.StandardAnalyzer
ramBufferSizeMB=16.0
maxBufferedDocs=-1
mergedSegmentWarmer=null
delPolicy=org.apache.lucene.index.KeepOnlyLastCommitDeletionPolicy
commit=null
openMode=CREATE_OR_APPEND
similarity=org.apache.lucene.search.similarities.BM25Similarity
mergeScheduler=ConcurrentMergeScheduler: maxThreadCount=-1, maxMergeCount=-1, ioThrottle=true
codec=Lucene70
infoStream=org.apache.lucene.util.InfoStream$NoOutput
mergePolicy=[TieredMergePolicy: maxMergeAtOnce=10, maxMergeAtOnceExplicit=30, maxMergedSegmentMB=5120.0, floorSegmentMB=2.0, forceMergeDeletesPctAllowed=10.0, segmentsPerTier=10.0, maxCFSSegmentSizeMB=8.796093022207999E12, noCFSRatio=0.1
indexerThreadPool=org.apache.lucene.index.DocumentsWriterPerThreadPool@31f924f5
readerPooling=true
perThreadHardLimitMB=1945
useCompoundFile=true
commitOnClose=true
indexSort=null
writer=org.apache.lucene.index.IndexWriter@5579bb86
- Analyzer:即分词器,这个通常是定制化最多的,特别是针对不同的语言。
- RAMBufferSizeMB:Lucene提供的默认FlushPolicy的实现FlushByRamOrCountsPolicy中允许DocumentsWriterPerThread使用的最大内存上限,超过则触发flush。
- IndexDeletionPolicy:Lucene开放对commit point的管理,通过对commit point的管理可以实现例如snapshot等功能。Lucene默认配置的DeletionPolicy,只会保留最新的一个commit point。
- Similarity:搜索的核心是相关性,Similarity是相关性算法的抽象接口,Lucene默认实现了TF-IDF和BM25算法。相关性计算在数据写入和搜索时都会发生,数据写入时的相关性计算称为Index-time boosting,计算Normalizaiton并写入索引,搜索时的相关性计算称为query-time boosting。
- MergePolicy:Lucene内部数据写入会产生很多Segment,查询时会对多个Segment查询并合并结果。所以Segment的数量一定程度上会影响查询的效率,所以需要对Segment进行合并,合并的过程就称为Merge,而何时触发Merge由MergePolicy决定。
- MergeScheduler:当MergePolicy触发Merge后,执行Merge会由MergeScheduler来管理。Merge通常是比较耗CPU和IO的过程,MergeScheduler提供了对Merge过程定制管理的能力。
- Codec:Codec可以说是Lucene中最核心的部分,定义了Lucene内部所有类型索引的Encoder和Decoder。Lucene在Config这一层将Codec配置化,主要目的是提供对不同版本数据的处理能力。对于Lucene用户来说,这一层的定制需求通常较少,能玩Codec的通常都是顶级玩家了。
- IndexerThreadPool:管理IndexWriter内部索引线程(DocumentsWriterPerThread)池,这也是Lucene内部定制资源管理的一部分。
- FlushPolicy:FlushPolicy决定了In-memory buffer何时被flush,默认的实现会根据RAM大小和文档个数来判断Flush的时机,FlushPolicy会在每次文档add/update/delete时调用判定。
- MaxBufferedDoc:Lucene提供的默认FlushPolicy的实现FlushByRamOrCountsPolicy中允许DocumentsWriterPerThread使用的最大文档数上限,超过则触发Flush。
2.3 核心API
- addDocument:比较纯粹的一个API,就是向Lucene内新增一个文档。Lucene内部没有主键索引,所有新增文档都会被认为一个新的文档,分配一个独立的docId。add操作会直接将文档写入DWPT的In-memory buffer。相关源码分析ref
- updateDocuments:更新文档,但是和数据库的更新不太一样。数据库的更新是查询后更新(流程是先delete by term,后add document),也就是说不支持更新文档中部分列。但是这个流程又和直接先调用delete后调用add效果不一样,只有update能够保证在Thread内部删除和新增保证原子性。IndexWriter提供的add和update接口,都会映射到DocumentsWriter的udpate接口。
- deleteDocument:删除文档,支持两种类型删除,by term和by query。而update和delete虽然内部都会执行数据删除,但这两者又是不同的数据路径。文档删除不会直接影响In-memory buffer内的数据,而是会有另外的方式来达到删除的目的。
在Delete路径上关键的数据结构就是Deletion queue,在IndexWriter内部会有一个全局的Deletion Queue,称为Global Deletion Queue,而在每个DWPT内部,还会有一个独立的Deletion Queue,称为Pending Updates。DWPT Pending Updates会与Global Deletion Queue进行双向同步,因为文档删除是全局范围的,不应该只发生在DWPT范围内。Pending Updates内部会按发生顺序记录每个删除动作,并且标记该删除影响的文档范围,文档影响范围通过记录当前已写入的最大DocId(DocId Upto)来标记,即代表这个删除动作只删除小于等于该DocId的文档。
update接口和delete接口都可以进行文档删除,但是有一些差异:
1.update只能进行by term的文档删除,而delete除了by term,还支持by query。
2.update的删除会先作用于DWPT内部,后作用于Global,再由Global同步到其他DWPT。
3.delete的删除会作用在Global级别,后异步同步到DWPT级别。
update和delete流程上的差异也决定了他们行为上的一些差异,update的删除操作会先发生在DWPT内部,并且是和add同时发生,所以能够保证该DWPT内部的delete和add的原子性,即保证在add之前的所有符合条件的文档一定被删除。
DWPT Pending Updates里的删除操作什么时候会真正作用于数据呢?在Lucene Segment内部,数据实际上并不会被真正删除。Segment中有一个特殊的文件叫live docs,内部是一个位图的数据结构,记录了这个Segment内部哪些DocId是存活的,哪些DocId是被删除的。所以删除的过程就是构建live docs标记位图的过程,数据实际上不会被真正删除,只是在live docs里会被标记删除。Term删除和Query删除会在不同阶段构建live docs,Term删除要求先根据Term查询出它关联的所有doc,所以很明显这个会发生在倒排索引构建时。而Query删除要求执行一次完整的查询后才能拿到其对应的docId,所以会发生在segment被flush完成后,基于flush后的索引文件构建IndexReader后执行搜索才能完成。
还有一点要注意的是,live docs只影响倒排,所以在live docs里被标记删除的文档没有办法通过倒排索引检索出,但是还能够通过doc id查询到store fields。当然文档数据最终是会被真正物理删除,这个过程会发生在merge时。
- flush:触发强制flush,flush是将DWPT内In-memory buffer里的数据持久化到文件的过程,flush会在每次新增文档后由FlushPolicy判定自动触发,也可以通过IndexWriter的flush接口手动触发。每个DWPT会flush成一个segment文件,flush完成后这个segment文件是不可被搜索的,只有在commit之后,所有commit之前flush的文件才可被搜索。
- prepareCommit/commit/rollback:commit后数据才可被搜索,commit是一个二阶段操作,prepareCommit是二阶段操作的第一个阶段,也可以通过调用commit一步完成,rollback提供了回滚到last commit的操作。commit时会触发数据的一次强制flush,commit动作会触发生成一个commit point,commit point是一个文件。Commit point会由IndexDeletionPolicy管理,lucene默认配置的策略只会保留last commit point,当然lucene提供其他多种不同的策略供选择。
- merge/maybeMerge/forceMerge:maybeMerge触发一次MergePolicy的判定,而forceMerge则触发一次强制merge。merge是对segment文件合并的动作,合并的好处是能够提高查询的效率以及回收一些被删除的文档。Merge会在segment文件flush时触发MergePolicy来判定自动触发,也可通过IndexWriter进行一次force merge。
注:IndexWriter这个对象很消耗资源,所以一般会使用单例,或者不关闭而使用一个池,对应于数据库的会话一样。
2.4 演示实例
public class Lucene_test {
private static String directoryPath = "target";
private static Directory directory;
private static IndexWriter writer;
private static IndexReader reader;
private static IndexSearcher searcher;
private static IndexWriter getWriter() throws IOException {
IndexWriterConfig config = new IndexWriterConfig();
IndexWriter writer = new IndexWriter(directory, config);//1.创建IndexWriter
//fixed:org.apache.lucene.index.IndexNotFoundException: no segments* file found in MMapDirectory
writer.commit();
return writer;
}
@BeforeClass
public static void init() throws IOException {
directory = FSDirectory.open(Paths.get(directoryPath));
// System.out.println("创建完成, 索引的存放位置在:" + directory);
writer = getWriter();
// System.out.println(writer.getConfig());
reader = DirectoryReader.open(directory);
searcher = new IndexSearcher(reader); //打开索引
}
@AfterClass
public static void disabled() throws IOException {
reader.close();
writer.close();
directory.close();
}
private Document createDocument(List<Triplet<String, String, Boolean>> cols) {
Document doc = new Document();
//Field filePathField = new Field("id", "2", Field.Store.YES, Field.Index.NOT_ANALYZED);
cols.forEach(x -> {
TextField field = new TextField(x.getValue0(), x.getValue1(), x.getValue2() ? Field.Store.YES : Field.Store.NO);
doc.add(field);
});
return doc;
}
@Test
public void go_simple_create() throws IOException {
//支持分词索引,存储
List<Triplet<String, String, Boolean>> feilds = Lists.newArrayList();
feilds.add(new Triplet("id", "5", true));
feilds.add(new Triplet("title", "Lucene - is greater search", false));
feilds.add(new Triplet("content", "The Apache Lucene TM project develops open-source search software, including", true));
Document doc = createDocument(feilds);
writer.addDocument(doc);
writer.commit();
}
@Test
public void go_delete() throws IOException {
writer.deleteDocuments(new Term("id", "3"));
writer.commit();
}
}
3. 数据存储格式
Lucene内部索引构建最关键的概念是IndexingChain,顾名思义,链式的索引构建。为啥是链式的?这个和Lucene的整个索引体系结构有关系,Lucene提供了各种不同类型的索引类型,例如倒排、正排(列存)、StoreField、DocValues等。每个不同的索引类型对应不同的索引算法、数据结构以及文件存储,有些是列级别的,有些是文档级别的。所以一个文档写入后,需要被这么多种不同索引处理,有些索引会共享memory-buffer,有些则是完全独立的。
indexing chain上索引构建顺序是invert index、store fields、doc values和point values。有些索引类型处理文档后会将索引内容直接写入文件(主要是store field和term vector),而有些索引类型会先将文档内容写入memory buffer,最后在flush的时候再写入文件。能直接写入文件的索引,通常是文档级的索引,索引构建可以文档级的增量构建。而不能写入文件的索引,例如倒排,则必须等Segment内所有文档全部写入完毕后,会先对Term进行一个全排序,之后才能构建索引,所以必须要有一个memory-buffer先缓存所有文档。
在Lucene 7.2.1版本中,主要有这么几种Codec:
- BlockTreeTermsWriter:倒排索引对应的Codec,其中倒排表部分使用Lucene50PostingsWriter(Block方式写入倒排链)和Lucene50SkipWriter(对Block的SkipList索引),词典部分则是使用FST(针对倒排表Block级的词典索引)。
- CompressingTermVectorsWriter:对应Term vector索引的Writer,底层是压缩Block格式。
- CompressingStoredFieldsWriter:对应Store fields索引的Writer,底层是压缩Block格式。
- Lucene70DocValuesConsumer:对应Doc values索引的Writer。
- Lucene60PointsWriter:对应Point values索引的Writer。
4. 总结
探索IndexWriter的奥秘,需要了解memory-buffer的实现、索引算法以及数据存储格式。