背景
最近有用户让帮忙看一下一个诡异的问题,同样的一个查询语句,执行多次查询结果竟然不一致,查询结果中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的值不同,最终得到了不同的得分。
下面通过示例复现上述过程,更加直观的了解问题出现的原因:
-
index doc
批量插入文档,文档数量越多越好
POST cc/c/1
{
"x":"ab abc abc"
}
- 随机delete或者update doc
PUT cc/c/1
{
"x":"abc abc abc abc"
}
DELETE cc/c/5
- 执行forcemerge
POST cc/_forcemerge?only_expunge_deletes=true
- 查看segment
GET _cat/segments/cc
image
上图中,经过第3步的forcemerge, 分片1的主分片进行了merge,但是副本分片并没有进行merge,副本分片的segments_a中包含了一个标记为删除的文档,主分片因为进行了merge,没有包含标记未删除的文档。
- 执行查询
指定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或者其它自定义的值,保证同样的查询语句会请求到相同的分片。
网友评论