美文网首页腾讯云Elasticsearch ServiceElasticsearch实践与分析大数据
Elasticsearch:执行同样的查询语句多次结果不一致?!

Elasticsearch:执行同样的查询语句多次结果不一致?!

作者: bellengao | 来源:发表于2019-06-13 19:42 被阅读5次

    背景

    最近有用户让帮忙看一下一个诡异的问题,同样的一个查询语句,执行多次查询结果竟然不一致,查询结果中hits.total一会是30,一会为15,这是为什么呢?

    用户的查询语句如下:

    GET test/_search
    {
      "query": {
        "match": {
          "title": "中国"
        }
      },
      "min_score": 2.0
    }
    

    原因分析

    关于这个问题,官方文档中有解释:https://www.elastic.co/guide/en/elasticsearch/reference/6.4/consistent-scoring.html, 主要的原因是因为有副本(replica)的存在,主分片和副本分片可能不一致,导致最终在主分片和副本分片上计算得到的得分不同,而导致最终的查询结果不一致。用户的查询dsl中指定了min_score,限定文档最低得分为2.0,不同的查询请求落到不同的分片上,获取到的得分大于2.0的文档集就可能不一致,最终才会出现hits.total一会是30,一会为15这种情况。

    但是是如何造成主分片和副本分片不一致的情况,可能是因为用户删除了部分文档,之后主分片进行了merge, 而副本分片没有进行merge。 这种情况下主分片和副本分片上的总文档数量就会不同,打分时计算出的IDF的值不同,最终得到了不同的得分。

    下面通过示例复现上述过程,更加直观的了解问题出现的原因:

    1. index doc

      批量插入文档,文档数量越多越好

    POST cc/c/1
    {
        "x":"ab abc abc"
    }
    
    1. 随机delete或者update doc
    PUT cc/c/1
    {
        "x":"abc abc abc abc"
    }
    
        DELETE cc/c/5
    
    1. 执行forcemerge
    POST cc/_forcemerge?only_expunge_deletes=true
    
    1. 查看segment
    GET _cat/segments/cc
    
    image

    上图中,经过第3步的forcemerge, 分片1的主分片进行了merge,但是副本分片并没有进行merge,副本分片的segments_a中包含了一个标记为删除的文档,主分片因为进行了merge,没有包含标记未删除的文档。

    1. 执行查询

    指定preference只查询主分片

    GET cc/c/_search?preference=_primary
    

    查询结果为:

        {
      "took": 1,
      "timed_out": false,
      "_shards": {
        "total": 5,
        "successful": 5,
        "skipped": 0,
        "failed": 0
      },
      "hits": {
        "total": 3,
        "max_score": 4.205637,
        "hits": [
          {
            "_index": "cc",
            "_type": "c",
            "_id": "1",
            "_score": 4.205637,
            "_source": {
              "x": "abc"
            }
          },
          {
            "_index": "cc",
            "_type": "c",
            "_id": "5",
            "_score": 1.7646677,
            "_source": {
              "x": "abc a c"
            }
          },
          {
            "_index": "cc",
            "_type": "c",
            "_id": "8",
            "_score": 1.7646677,
            "_source": {
              "x": "abc ax c"
            }
          }
        ]
      }
    }
    

    指定preference只查询副本分片

        {
      "took": 0,
      "timed_out": false,
      "_shards": {
        "total": 5,
        "successful": 5,
        "skipped": 0,
        "failed": 0
      },
      "hits": {
        "total": 3,
        "max_score": 4.205637,
        "hits": [
          {
            "_index": "cc",
            "_type": "c",
            "_id": "1",
            "_score": 4.205637,
            "_source": {
              "x": "abc"
            }
          },
          {
            "_index": "cc",
            "_type": "c",
            "_id": "5",
            "_score": 1.8076806,
            "_source": {
              "x": "abc a c"
            }
          },
          {
            "_index": "cc",
            "_type": "c",
            "_id": "8",
            "_score": 1.8076806,
            "_source": {
              "x": "abc ax c"
            }
          }
        ]
      }
    }
    

    比较两个查询结果可以看到, hits中的第2条和第3条文档在两个查询结果中的得分不同,即便他们是同一个文档。
    通过在查询时增加explain参数,查看打分明细:
    当preference=_primary时计算idf时的docCount为22:


    image

    当preference=_primary时计算idf时的docCount为23,包含了标记为删除的文档:


    image

    翻阅lucene源码(7.6.0),org.apache.lucene.search.similarities.BM25Similarity类中,idf的计算部分:

        public Explanation idfExplain(CollectionStatistics collectionStats, TermStatistics termStats) {
        final long df = termStats.docFreq();
        final long docCount = collectionStats.docCount() == -1 ? collectionStats.maxDoc() : collectionStats.docCount();
        final float idf = idf(df, docCount);
        return Explanation.match(idf, "idf, computed as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)) from:",
            Explanation.match(df, "docFreq"),
            Explanation.match(docCount, "docCount"));
      }
    

    其中docCount的值,先判断collectionStats.docCount是否为-1,如果是则赋值为collectionStats.maxDoc(),否则为collectionStats.docCount(), collectionStats.maxDoc()和collectionStats.docCount()的说明如下:

        /** returns the total number of documents, regardless of 
           * whether they all contain values for this field. 
           * @see IndexReader#maxDoc() */
          public final long maxDoc() {
            return maxDoc;
          }
          
          /** returns the total number of documents that
           * have at least one term for this field. 
           * @see Terms#getDocCount() */
          public final long docCount() {
            return docCount;
          }
    

    collectionStats.maxDoc()实际上是indexReader.maxDoc(), 该值是shard级别的最大的lucene docId,实际上把已经删除的文档也统计在内了;

    /** Returns one greater than the largest possible document number.
       * This may be used to, e.g., determine how big to allocate an array which
       * will have an element for every document number in an index.
       */
      public abstract int maxDoc();
    

    而collectionStats.docCount()则是terms.getDocCount(),代码中的注释比较让人困惑,经过实测, terms.getDocCount()意思是包含要查询的field的所有文档数量,实际上也包含了已经删除的文档:

    /** Returns the number of documents that have at least one
       *  term for this field, or -1 if this measure isn't
       *  stored by the codec.  Note that, just like other term
       *  measures, this measure does not take deleted documents
       *  into account. */
      public abstract int getDocCount() throws IOException;
    

    最终取值实际上为后者也就是collectionStats.docCount()
    (8.x之后的lucene直接把docCount赋值为collectionStats.docCount(), 取消了三元表达式,因为这个三元表达式实际上是无用的),最终计算idf时的docCount值为包含要查询field字段的总文档数量,并且标记为删除的文档也统计在内。所以,本例中,在指定preference为_primay时,docCount=22;指定preference为_replica时,docCount=23,因为副本分片中包含了一个标记为删除的文档。

    实际应用中,为了保证每次查询都得到相同的结果,可以通过指定preference参数(可以自定义)让每次查询都请求到相同的分片上解决。

    但是,怎么样得到准确的docCount值呢,常规的方法是可以通过执行_forcemerge?only_expunge_deletes把标记为删除的文档物理删除,但是实际上forcemerge也不能保证主分片和副本分片同时merge, 比如在本例中,主分片进行了merge, 副本分片没有merge,所以才会造成最终查询结果不一致。至于为什么主分片和副本分片不能同时merge, 这里涉及到forcemerge的逻辑了,需要进一步查看源码研究。

    以上实战验证了如果主分片和副本分片不一致的情况下,文档的分值会不同,最终影响到查询结果。解决方式就是在查询时指定preference, 可以指定为_primary、_replica或者其它自定义的值,保证同样的查询语句会请求到相同的分片。

    相关文章

      网友评论

        本文标题:Elasticsearch:执行同样的查询语句多次结果不一致?!

        本文链接:https://www.haomeiwen.com/subject/sjsefctx.html