段的多线程查询(一)(Lucene 9.6.0)

  前段时间有个朋友问到我:对多个段进行查询时,为什么在定义IndexSearcher时使用了Executor后,相比较单个线程轮询方式查询相同的多个段,查询速度并没有提升,有时候还下降了?本篇文章会介绍多线程查询中的一些知识点,给与大家在解决性能问题时提供一些思路。

查询流程图

图1:

  图1中的查询流程图中,如果未使用多线程,那么Lucene将依次处理每一个段,否则每个线程会负责查询某个段,并在所有线程执行结束后对结果进行合并。

Slice

  图1中的描述说到每个线程会负责一个段的查询工作,这其实只是一种特例(下文会介绍)。实际上,在初始化IndexSearcher对象阶段会所有的段进行划分,一个或者多个段根据划分规则被分配不同的Slice中,随后每一个Slice会被分配一个线程进行查询。

划分规则

例子

  如果索引文件中有以下9个段,即图3中的demo:

图2:

  图2中,段0中的文档数量超过了250000(MAX_DOCS_PER_SLICE),所以Slice 0中只有这一个段;段2中的文档数量小于250000,所以它被分配到Slice 1后,Slice 1还可以被分配其他的段,由于段1被分配到Slice 1后,Slice 1中的文档数量超过了250000,所以不再分配更多的段到Slice 1中;Slice 2中被分配段4~段8这5个段后,尽管此时Slice 2中的文档数量未超过250000,但是Slice 2中段的数量已经达到了5(MAX_DOCS_PER_SLICE),所以不再分配更多的段到Slice 2中。

  上文中说到,每个线程负责一个Slice的查询工作。因此每个线程负责的Slice实际上处理的段的数量是不一样的。

查询性能差异

  接下来介绍下一个多线程查询不及单线程的例子

图3:

  这个例子中,索引文件内有9个段,一共658000篇文档。查询条件是获取Top 1000的文档号,分别使用多线程跟单线程,查看Collector中分别处理的文档数量,如下所示:

图4:

  可见使用多线程查询需要处理的文档数量(totalHits)反而比单线程多,那性能当然是不及单线程的。

early termination机制

  Lucene中,会使用totalHits(图3中第65、69行代码)来统计Collector处理的文档数量,注意的是,由于在查询条件中定义了TopN,所以在Collector的处理逻辑中,收集完TopN篇文档后,如果能确定剩余满足查询条件的文档相比较已收集的TopN中的文档都不具备竞争力(competitive),那么就可以提前退出Collector,即early termination机制,直接返回TopN中的文档即可。

  图3中使用单线程的查询条件是MatchAllDocsQuery,那么在Collector中,文档号越小的文档越具备竞争力(基于MatchAllDocsQuery对应的ConstantScoreWeight,这里不展开介绍),所以Collector中收集完文档号区间为0~999的文档后,就可以提前结束查询,而不需要全量处理索引中的658000篇文档。

  对于使用多线程查询,根据图2的介绍,会对4个Slice进行并发查询。early termination机制只能作用在每一个Slice中,这个例子中每个Slice都分别实现了early termination,也就是每个Slice的Collector在收集完TopN后就提前退出,因此对于4个Slice,总的totalHits为4000(4 * topN)。

early termination的实现原理

  因为篇幅原因就不在本文中展开了,简单的提一下。尽管每个Collector的实现原理各不相同,但early termination的核心内容都是通过调整DocIdSetIterator来减少后续待处理的文档集合的大小,比如在TopScoreDocCollector中,当收集了TopN篇文档后并且确定剩余的文档不具备竞争力后,就会将DISI调整为空的DISI,即DocIdSetIterator.empty()

合并Slice的查询结果

  这个过程可以简单的描述为将多个Slice中的TopN根据排序规则,以及比较规则来获得最终的TopN。

结语

  基于篇幅,剩余的内容将在下一篇文章中展开介绍。

点击下载附件