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

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

背景

最近有用户让帮忙看一下一个诡异的问题,同样的一个查询语句,执行多次查询结果竟然不一致,查询结果中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"
}

2 随机delete或者update doc

PUT cc/c/1
{
	"x":"abc abc abc abc"
}

	DELETE cc/c/5

3 执行forcemerge

POST cc/_forcemerge?only_expunge_deletes=true

4 查看segment

GET _cat/segments/cc

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

5 执行查询

指定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:

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

翻阅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或者其它自定义的值,保证同样的查询语句会请求到相同的分片。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券