如何优化Elasticsearch大文档查询?

B端商城业务场景下,接口毛刺严重,耗时主要集中在ES查询。本文记录一次复杂的DSL优化过程,从问题定位到优化方案落地。

背景

B端商城业务有一个场景就是客户可见的产品列表是需要N多闸口及各种其它逻辑组合过滤的,各种闸口数据及产品数据都是存储在ES的(有的是独立索引,有的是作为产品属性存储在产品文档上)。

在实际使用的过程中,发现接口的毛刺比较严重,而这部分毛刺请求的耗时基本都是花费在从ES中查询产品索引的时候。

开启了一下ES慢DSL的日志

1
2
3
4
5
6
7
PUT /jiankunking_product_prod/_settings
{
"index.search.slowlog.threshold.query.warn": "10s",
"index.search.slowlog.threshold.query.info": "5s",
"index.search.slowlog.threshold.fetch.warn": "2s",
"index.indexing.slowlog.source": true
}

经过分析慢DSL日志发现耗时长的部分都是在fetch阶段。

这里有个地方需要注意

1
2
3
4
5
6
7
8
9
10
11
[root@jiankunking-search-01: /data/es/logs]# ls -lrth |grep -v .gz
total 2.2G
-rw-r--r-- 1 es es 0 Sep 30 2019 jiankunking_audit.json
-rw-r--r-- 1 es es 0 Sep 30 2019 jiankunking_index_indexing_slowlog.log
-rw-r--r-- 1 es es 0 Sep 30 2019 jiankunking_index_indexing_slowlog.json
-rw-r--r-- 1 es es 53M Dec 31 2023 jiankunking_deprecation.log
-rw-r--r-- 1 es es 108M Dec 31 2023 jiankunking_deprecation.json
-rw-r--r-- 1 es es 55K Jul 30 10:43 jiankunking_server.json
-rw-r--r-- 1 es es 52K Jul 30 10:43 jiankunking.log
-rw-r--r-- 1 es es 63M Jul 30 11:32 jiankunking_index_search_slowlog.log //这里是完整的DSL
-rw-r--r-- 1 es es 8.9M Jul 30 11:32 jiankunking_index_search_slowlog.json //这里的DSL会被截断

分析

已知问题点

nested这两个属性要单独说下:nested子文档在Lucene里是独立的文档,一条产品挂几万个nested子文档的话,落到segment里就是几万个文档,文档数和merge的开销都会跟着放大,_source变得巨大跟这个也有关系。

还有个点容易搞混:_source里的includes/excludes只是在返回结果的时候裁剪字段,ES该读、该解压、该解析整个_source的步骤一步都没省。fetch阶段的快慢,看的是文档本身的大小,而不是你最终返回了几个字段。

Query 与 Fetch 分别做什么?

按照原文介绍的 query-then-fetch 流程,一次搜索分为两个阶段:Query 负责查找匹配文档并排序,Fetch 负责取回本次需要返回的文档内容。

第一阶段:Query(查询)

根据 Query 阶段原文,流程如下:

  1. 客户端将搜索请求发送给协调节点。
  2. 协调节点将请求转发给各个相关分片的一份副本(主分片或副本分片)。每个分片在本地执行查询,保留排序最靠前的 from + size 条匹配结果。
  3. 各分片将这些结果的文档 ID 和排序值(例如相关性得分 _score)返回给协调节点,协调节点合并为全局有序的结果列表。此时返回的是用于确定命中结果和顺序的信息,还没有取回文档正文。

第二阶段:Fetch(取回)

有了 Query 阶段的全局排序结果,协调节点就可以确定本次需要取回哪些文档。例如,from: 90, size: 10 表示跳过前 90 条,只取回接下来的 10 条文档内容。

下图只展示 Fetch 阶段,①②③ 都是 Fetch 内部的步骤。

ES Fetch 阶段:请求文档、返回文档、响应客户端

图中三个编号的说明译自原文:

  • ① 请求文档:协调节点确定需要取回哪些文档,并向相关分片发出 multi GET 请求。
  • ② 返回文档:每个分片加载文档,并在需要时为文档补充信息,然后将文档返回给协调节点。
  • ③ 响应客户端:所有文档都取回后,协调节点将结果返回给客户端。

原文进一步说明:Fetch 请求发往处理 Query 阶段的同一份分片副本;分片加载文档正文(_source),并根据请求补充元数据、高亮片段等信息。

因此,知道命中文档的 ID,不等于已经拿到了文档内容。下面直接按 _id 查询,是为了简化 Query 阶段的查询逻辑;请求仍然需要返回文档内容,因此还会经过 Fetch 阶段。

下面简化一下请求的DSL,看下移除所有复杂的查询逻辑后,直接按照_id来terms查询效果如何?

DSL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GET /jiankunking_product_prod/_search
{
"size": 10000,
"_source": {
"includes": [
"code",
"group",
"groupBrand"
],
"excludes": []
},
"query": {
"terms": {
"_id": [
"具体文档_id"
]
}
}
}

不同文档大小查询耗时

当前分析的DSL原本命中的文档数就是8306
下表中的文档数是直接在terms中查询的id数

剔除大文档(只保留小于阈值的文档):

文档数 文档大小(Bytes) 文档大小(KB) 响应耗时(ms) 备注
8306 <=6,766,797 <=6,608 5908 全量(基线)
5908 <500,000 <488 2327 剔除大的
6929 <200,000 <195 1507 剔除大的
5731 <100,000 <97 599 剔除大的
4925 <50,000 <49 356 剔除大的
4236 <30,000 <29 214 剔除大的(注意这里,当文档大小比较小的时候,4000+的文档查询其实是比较快的)

剔除小文档(只保留大于阈值的文档):

文档数 文档大小(Bytes) 文档大小(KB) 响应耗时(ms) 备注
4070 >30,000 >29 6261 剔除小的
3381 >50,000 >49 6050 剔除小的
2572 >100,000 >97 5388 剔除小的
1377 >200,000 >195 4973 剔除小的
669 >500,000 >488 3984 剔除小的
381 >1,000,000 >976 3169 剔除小的
217 >2,000,000 >1,952 2391 剔除小的
88 >3,000,000 >2,928 1244 剔除小的

注:表中 KB 为按 1KB = 1024Bytes 换算所得。

从大文档开始删除

从大文档开始删除:文档数与查询耗时的关系

从小文档开始删除

从小文档开始删除:文档数与查询耗时的关系

数据分析结论

  • 文档数与文档大小查询分析
    • 剔除大文档之后,查询数据效率提升明显
    • 剔除小文档之后,查询数据效率提升缓慢

到这里我们可以发现当文档size比较小的时候,几千个文档查起来是很快的;但随着命中的大文档越来越多,耗时就上去了。

回看下我们的产品索引数据,可以发现大字段其实都是用来过滤的,并不是返回给页面需要的;那我们是不是可以:将索引拆分为两个或者ES只用来作为二级索引返回ids,然后去MySQL中查询具体的产品信息?

ES 作为二级索引只返回 id 的架构

那我们将慢DSL中查询的字段修改为只返回_id

1
2
3
4
5
6
7
8
9
10
11
POST /jiankunking_product_prod/_search
{
"size": 10000,
"_source": false,
"query": {
"terms": {
"_id": [""],
"boost": 1
}
}
}

这时候查询耗时只需要203ms,这种情况下还能不能再优化了呢?答案是可以的

索引中文档_id就是产品的code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /jiankunking_product_prod/_search
{
"size": 10000,
"_source": false,
"stored_fields": "_none_",
"docvalue_fields": [
"code"
],
"query": {
"terms": {
"code": [
""
],
"boost": 1
}
}
}

这时候查询只需要76ms。

结论

到这里这次优化基本结束了,最终的方案就是

  • 从 jiankunking_product_prod 索引中通过 列存 获取 ids
  • 到 MySQL 或者新的产品主数据索引中查询具体的产品数据

优化效果汇总:

阶段 方案 耗时 降幅
优化前 terms 查询 _id + _source includes 过滤 5908ms -
第一步 _source: false,只返回 _id 203ms 96.6%
第二步 stored_fields: "_none_" + docvalues 列存 76ms 98.7%

思考

为啥不直接从jiankunking_product_prod索引中通过列存获取前端需要的数据呢?

因为真实业务场景中需要返回的产品属性虽然每个不大,但总数有20多个,列存在返回字段数多且命中文档大小都不大的场景下,相比原逻辑直接从_source中取会略有下降。

更多原理性解释,可以看下这里:doc_values、_source与store的性能对比

ES适合的场景都有哪些?

目前我这边遇到的场景主要有:

  • 检索加速
    • 数据查询的主存储
      • 当文档大小不是太大的时候,索引检索完直接返回需要的数据
    • 二级索引
      • 针对的就是本文这种场景
  • 日志
    • 应用/容器日志
      • 这里追求的更多是高吞吐的写入
    • 业务日志

具体索引中数据大小是什么情况呢?

分位数 大小 (KB)
0.050 1.159
0.100 1.388
0.150 1.609
0.200 1.693
0.250 1.774
0.300 2.140
0.350 2.969
0.400 3.499
0.450 3.898
0.500 4.238
0.550 4.917
0.600 5.730
0.650 7.157
0.700 8.823
0.750 13.122
0.800 32.320
0.850 57.531
0.900 114.387
0.950 262.478
0.990 989.708
0.991 1,099.801
0.992 1,259.971
0.993 1,481.723
0.994 1,807.947
0.995 2,155.884
0.996 2,392.959
0.997 2,725.635
0.998 3,171.288
0.999 4,238.397

索引中文档大小的分位数分布

来自官方的点赞

这篇文章后来被我回答在了Elastic官方社区的提问里,还被标成了Found a solution:What is the appropriate document size in Elasticsearch?

Elastic 官方社区论坛将本文方案标注为 Found a solution

拓展阅读