写点什么

从根上理解 elasticsearch(lucene) 查询原理 (1)-lucece 查询逻辑介绍

作者:EquatorCoco
  • 2023-12-12
    福建
  • 本文字数:4200 字

    阅读完需:约 14 分钟

大家好,最近在做一些 elasticsearch 慢查询优化的事情,通常用分析 elasticsearch 慢查询的时候可以通过 profile api 去分析,分析结果显示的底层 lucene 在搜索过程中使用到的函数调用。所以要想彻底弄懂 elasticsearch 慢查询的原因,还必须将 lucene 的查询原理搞懂,今天我们就先来介绍下 lucene 的查询逻辑的各个阶段。


lucene 查询过程分析


先放上一张查询过程的流程图,下面的分析其实都是对这张图的更详细的介绍。



lucene 的查询可以大致分为 4 个阶段,重写查询,创建查询 weight 对象,创建 scorer 对象准备计分,进行统计计分。


简单解释下这 4 个阶段;


1, 重写查询语句( rewrite query )


lucene 提供了比较丰富的外部查询类型,像 wildcardQuery,MatchQuery 等等,但它们最后都会替换为比较底层的查询类型,例如 wildcardQuery 会被重写为 MultiTermsQuery。


2, 创建查询 weight 对象( createWeight )


Query 对象创建的权重对象, lucece 的每个查询都会计算一个该查询占用的权重值,如果是不需要计分的,则权重值是一个固定常量,得到的文档结果是根据多个查询的权重值计算其得分的。下面是 Weight 对象涉及的方法,



其中,scorer(LeafReaderContext context) 方法是个抽象方法,需要子类去实现的。


public abstract Scorer scorer(LeafReaderContext context) throws IOException;
复制代码


方法返回的 scorer 对象拥有遍历倒排列表和统计文档得分的功能,下面会讲到实际上 weight 对象是创建 BulkScore 进行计分的,但 BulkScore 内部还是通过 score 对象进行计分。



再详细解释下 Scorer 对象中比较重要的方法;


  • iterator() 方法返回的 DocIdSetIterator 对象提供了遍历倒排列表的能力。如下是 DocIdSetIterator 涉及的方法,其中 docID()是为了返回当前遍历到的倒排列表的文档 id,nextDoc()则是将遍历指针移动到下一个文档,并且返回文档 id,advance 用于移动遍历指针。



  • twoPhaseIterator 方法提供对文档二次精准匹配的能力,比如在 matchPhrase 查询中,不但要查出某个词,还要求查出的词之间相对顺序不变,那么这个相对顺序则是通过 twoPhaseIterator 的 matches 方法去进行判断。



3, 创建 bulkScorer 对象( weight.bulkScore)


weight 对象会调用 BulkScore 方法创建 BulkScorer 对象,bulkScorer 内部首先调用的是 scorer 抽象方法(需要由 weight 子类去实现的方法),得到的 scorer 对象再拿去构建 DefaultBulkScorer 对象,所以说,实际上最后计分的还是通过 scorer 对象进行计分的。


public BulkScorer bulkScorer(LeafReaderContext context) throws IOException {      Scorer scorer = scorer(context);    if (scorer == null) {      // No docs match      return null;    }      // This impl always scores docs in order, so we can    // ignore scoreDocsInOrder:    return new DefaultBulkScorer(scorer);  }
复制代码


bulkScorer 类有如下方法,一个是提供对段所有文档进行计分,一个是可以在段的某个文档 id 范围内进行计分。



4, 进行统计计分


最后则是通过 collector 对象进行统计,这里提到了 collecor 对象,它其实是作为了上述 bulkScorer 的 score 方法参数传入的,在 bulkScore.score 方法内部,遍历文档时,对筛选出的文档会通过调用 collector.collect(doc)方法进行收集,在 collect 方法内部,则是调用 scorer 对象对文档进行打分。



完整的搜索流程如下


public <C extends Collector, T> T search(Query query, CollectorManager<C, T> collectorManager)      throws IOException {    final C firstCollector = collectorManager.newCollector();    // 重写查询对象  query = rewrite(query, firstCollector.scoreMode().needsScores());    // 调用indexSearch的createWeight方法,本质上还是调用的Query的createWeight方法  final Weight weight = createWeight(query, firstCollector.scoreMode(), 1);    return search(weight, collectorManager, firstCollector);  }
// 简化了代码,保留了主流程,调用scorer.score 进行计分。protected void search(List<LeafReaderContext> leaves, Weight weight, Collector collector){ // 得到每个segment段的收集器,源代码是可以在线程池中同时对几个segment进行搜索的,这里省略了。 leafCollector = collector.getLeafCollector(ctx); BulkScorer scorer = weight.bulkScorer(ctx); // 将收集器作为buklScore.score参数传入,对文档进行计分。 scorer.score(leafCollector, ctx.reader().getLiveDocs()); leafCollector.finish();}
复制代码



profile api 返回结果分析


理清楚了 lucene 的搜索逻辑,我们再来看看通过 profile api 返回的各个阶段耗时是统计的哪段逻辑。

在使用 elasticsearch 的 profile api 时,会返回如下的统计阶段



如果不了解源码可能会对这些统计指标比较疑惑,结合刚才对 lucece 源码的了解来看下几个比较常见的统计指标。


next_doc 是取倒排链表中当前遍历到的文档 id,并且把遍历的指针移动到下一个文档 id 消耗的时长。

score 是 weight.scorer 方法创建的 score 对象,进行文档计分的操作时消耗的时长。


match 是 twoPhaseIterator 进行二次匹配判断时消耗的时长。


advance 是直接将遍历的指针移动到特定文档 id 处消耗的时长。


build_score 是 weight 对象在通过 weight.scorer 方法创建 score 对象时所耗费的时长。


create_weight 是 query 对象在调用其自身 createWeight 方法创建 weight 对象时耗费的时长。


set_min_competitive_score,compute_max_score,shallow_advance 我也还没彻底弄懂它们用到的所有场景,这里暂不做分析。


这里还要注意的一点是,像布尔查询是结合了多个子查询的结果,它内部会构造特别的 scorer 对象,比如 ConjunctionScorer 交集 scorer,它的 next_doc 方法则是需要对其子查询的倒排链表求交集,所以你在用 profile api 分析时,可能会看到布尔查询的 next_doc 耗时较长,而其子查询耗时较长的逻辑则是 advance,因为倒排列表合并逻辑会有比较多的 advance 移动指针的动作。


profile api 的实现原理


最后,我再来谈谈 elasticsearch 是如何实现 profile 的,lucene 的搜索都是通过 IndexSearcher 对象来执行的,IndexSearcher 在调用 query 对象自身的 rewrite 方法重写 query 后,会调用 IndexSearcher 的 createWeight 方法来创建 weight 对象(本质上底层还是使用的 query 的 createWeight 方法)。


elasticsearch 继承了 IndexSearcher ,重写了 createWeight,在原本 weight 对象的基础上,封装了一个 profileWeight 对象。以下是关键代码。


public Weight createWeight(Query query, ScoreMode scoreMode, float boost) throws IOException {      if (profiler != null) {          // createWeight() is called for each query in the tree, so we tell the queryProfiler          // each invocation so that it can build an internal representation of the query        // tree        QueryProfileBreakdown profile = profiler.getQueryBreakdown(query);          Timer timer = profile.getNewTimer(QueryTimingType.CREATE_WEIGHT);          timer.start();          final Weight weight;          try {              weight = query.createWeight(this, scoreMode, boost);          } finally {              timer.stop();              profiler.pollLastElement();          }          return new ProfileWeight(query, weight, profile);      } else {          return super.createWeight(query, scoreMode, boost);      }  }
复制代码


基于文章开头的 lucene 查询逻辑分析,可以知道,scorer 对象最后也是通过 weight 对象的 scorer 方法得到的,所以创建出来的 profileWeight 的 scorer 方法通用也对返回的 scorer 对象封装了一层,返回的是 profileScorer 对象。


public Scorer scorer(LeafReaderContext context) throws IOException {      ScorerSupplier supplier = scorerSupplier(context);      if (supplier == null) {          return null;      }      return supplier.get(Long.MAX_VALUE);  }    @Override  public ScorerSupplier scorerSupplier(LeafReaderContext context) throws IOException {      final Timer timer = profile.getNewTimer(QueryTimingType.BUILD_SCORER);      timer.start();      final ScorerSupplier subQueryScorerSupplier;      try {          subQueryScorerSupplier = subQueryWeight.scorerSupplier(context);      } finally {          timer.stop();      }      if (subQueryScorerSupplier == null) {          return null;      }        final ProfileWeight weight = this;      return new ScorerSupplier() {            @Override          public Scorer get(long loadCost) throws IOException {              timer.start();              try {                  return new ProfileScorer(weight, subQueryScorerSupplier.get(loadCost), profile);              } finally {                  timer.stop();              }          }            @Override          public long cost() {              timer.start();              try {                  return subQueryScorerSupplier.cost();              } finally {                  timer.stop();              }          }      };  }
复制代码


剩下的就好办了,在 profileScore 对象里对 scorer 对象的原生方法前后加上时间统计即可对特定方法进行计时了。比如下面代码中 profileScore 的 advanceShallow 方法。


public int advanceShallow(int target) throws IOException {      shallowAdvanceTimer.start();      try {          return scorer.advanceShallow(target);      } finally {          shallowAdvanceTimer.stop();      }  }
复制代码


总结


通过本篇文章,应该可以对 lucene 的查询过程有了大概的了解,但其实对于 elasticsearch 的慢查询分析还远远不够,因为像布尔查询,wilcard 之类的比较复杂的查询,我们还得弄懂,它们底层是究竟如何把一个大查询分解成小查询的。才能更好的弄懂查询耗时的原因,所以在下一节,我会讲解这些比较常见的查询类型的内部重写和查询逻辑。


文章转载自:蓝胖子的编程梦

原文链接:https://www.cnblogs.com/hobbybear/p/17888698.html

用户头像

EquatorCoco

关注

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
从根上理解elasticsearch(lucene)查询原理(1)-lucece查询逻辑介绍_Elastic Stack_EquatorCoco_InfoQ写作社区