文章

Elasticsearch:排序和聚合

上一篇讲了搜索,这一篇说说排序和聚合。

  1. sort
    1. 显式排序
    2. 按照得分排序
  2. aggregation
  3. doc_values - doc的所有value
    1. index vs. doc_values
    2. 性能
      1. 列式存储的压缩
      2. jvm内存不要开太大 - 留点儿page cache给doc_values
  4. fielddata - “内存版doc_values”
    1. 性能
    2. fielddata的优化
      1. 预加载fielddata - 类似bean的预加载
      2. 全局序号Global Ordinals - 使用映射值

sort

显式排序

按照某个字段的值排序,和mysql没什么区别,支持升序降序等。但是field必须开启doc_values属性才能排序

  • https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html

按照得分排序

es的查询结果,如果没有显式排序,默认按照文档的搜索得分排序。分数越高的文档和搜索词的相关性越强。从5.0开始,默认的搜索算法从TF/IDF变成了BM25

On the search side, the default relevance calculation has been changed from TF/IDF to the more modern BM25.

虽然BM25和TF/IDF(term frequency–inverse document frequency)不一样,但按照TF/IDF来理解也差不多:

  1. TF:词语在该文档出现频率高;
  2. IDF:且并不是在所有文档出现频率都这么高;

如果一个词在很多文档中都经常出现,那它可能和每个文档都不太相关。(仅仅是一个表达的常用词而已,并不和文章主题有显著关联)

通俗地讲,就是该词不仅在你文档里出现的频率高,而且比在其他文档里出现的平均频率高,那说明这个词在你的文档里很重要。

  • 什么是相关性:https://www.elastic.co/guide/cn/elasticsearch/guide/current/relevance-intro.html
  • 相关度背后的理论:https://www.elastic.co/guide/cn/elasticsearch/guide/current/scoring-theory.html

但是es默认不会使用整个索引的数据计算出来的IDF,只使用本分片的IDF近似整个索引的IDF(假设数据量足够大,二者的区别其实就不大了):

  • 被破坏的相关度:https://www.elastic.co/guide/cn/elasticsearch/guide/current/relevance-is-broken.html

如果非要使用精确的IDF,考虑设置search_type=dfs_query_then_fetch

  • search type:https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html

此时,本次请求要向其他分片发出额外内部请求,获取他们的该检索词的IDF,以算出总的IDF,所以会消耗额外的时间在请求上。

aggregation

和mysql的group by类似。聚合的好处是:有时候并不知道该搜啥,不如先聚合一波看看结果。

但是es是分布式系统,聚合起来比单体系统难多了…… 参考Elasticsearch:aggregation

doc_values - doc的所有value

排序和聚合之所以要一起说,是因为他们都用到了文档的所有token

  1. 排序使用TFIDF给文档打分,需要知道这个文档所有的token有哪些;
  2. 聚合也一样。比如对keyword类型的字符串做聚合,要知道这个完整的字符串的内容;

这就需要用到正排索引,它和倒排索引是完全相反的概念:

  • 倒排索引是根据token找所有包含这个token的文档;
  • 正排索引是根据文档找它所有的token;

两个文档:

  1. The quick brown fox jumped over the lazy dog
  2. Quick brown foxes leap over lazy dogs in summer

倒排索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Term      Doc_1  Doc_2
-------------------------
Quick   |       |  X
The     |   X   |
brown   |   X   |  X
dog     |   X   |
dogs    |       |  X
fox     |   X   |
foxes   |       |  X
in      |       |  X
jumped  |   X   |
lazy    |   X   |  X
leap    |       |  X
over    |   X   |  X
quick   |   X   |
summer  |       |  X
the     |   X   |
------------------------

正排索引:

1
2
3
4
5
Doc      Terms
-----------------------------------------------------------------
Doc_1 | brown, dog, fox, jumped, lazy, over, quick, the
Doc_2 | brown, dogs, foxes, in, lazy, leap, over, quick, summer
-----------------------------------------------------------------

所以正排索引其实很简单:把这个字符串所有的token存下来就行了。看起来似乎毫无难度可言。这也是给一个field建正排索引的属性名称叫doc_values的原因,doc’s all the values,非常朴素的一个名字

  • https://www.elastic.co/guide/cn/elasticsearch/guide/current/docvalues.html

关于doc_values,参考:Elasticsearch:_source store doc_values

index vs. doc_values

  • index:倒排索引,绝大多数field类型默认都开启,开启之后即可搜索。不开启一般不能搜索,但不绝对
  • doc_values:倒排索引,除了textannotated_text,其他field类型都默认开启了;

Numeric types, date types, the boolean type, ip type, geo_point type and the keyword type can also be queried when they are not indexed but only have doc values enabled. 大概是因为排好序了,所以就可搜索了。类似innodb

text不开启正排索引,估计是开启后聚合出来的都是token,意义不大。一般聚合都是在聚合不同的文档,查看分组数据。如果对text做sort或agg,会报错:

“type” : “illegal_argument_exception”,

“reason” : “Text fields are not optimised for operations that require per-document field data like aggregations and sorting, so these operations are disabled by default. Please use a keyword field instead. Alternatively, set fielddata=true on [name] in order to load field data by uninverting the inverted index. Note that this can use significant memory.

关于fielddata,见后文。

  • https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-index.html
  • https://www.elastic.co/guide/en/elasticsearch/reference/current/doc-values.html

性能

doc_values是用磁盘的,但是如果jvm内存足够,会被放在内存里,加快访问速度。如果jvm内存不够大,虽然放在磁盘上,但是也可以充分利用操作系统的page cache来优化访问磁盘的速度

page cache可参考:Innodb - Buffer Pool

  • https://www.elastic.co/guide/cn/elasticsearch/guide/current/docvalues-intro.html

不需要排序或聚合的字段可以关闭doc_values,可以节省不少磁盘空间,但是一定要想清楚,如果哪天需要排序了,就要重建索引了!

列式存储的压缩

其实就一句话:不存原始值!

  • https://www.elastic.co/guide/cn/elasticsearch/guide/current/_deep_dive_on_doc_values.html

jvm内存不要开太大 - 留点儿page cache给doc_values

一般意义上的理解是,内存当然是越大越好,越大越不容易oom啊!的确没错,但太大的内存也更难回收垃圾。而且如果不是程序写的太烂,一般不需要特别大的jvm内存。

而且现在还要考虑一点:doc_values使用利用os的page cache加快查询速度的。如果jvm内存太大,把os内存都占了,os就没有太多的内存用来做page cache了,那doc values的速度就很受影响

因为 Doc Values 不是由 JVM 来管理,所以 Elasticsearch 实例可以配置一个很小的 JVM Heap,这样给系统留出来更多的内存。同时更小的 Heap 可以让 JVM 更加快速和高效的回收。

之前,我们会建议分配机器内存的 50% 来给 JVM Heap。但是对于 Doc Values,这样可能不是最合适的方案了。 以 64gb 内存的机器为例,可能给 Heap 分配 4-16gb 的内存更合适,而不是 32gb。

  • https://www.elastic.co/guide/cn/elasticsearch/guide/current/heap-sizing.html

fielddata - “内存版doc_values”

上面对text排序、聚合的报错里提到,如果一定要用,请使用fielddata。

先演示一下fielddata:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
PUT users
{
    "mappings" : {
      "properties" : {
        "name" : {
          "type" : "text",
          "fields": {
            "kw": {
              "type": "keyword"
            },
            "fd": {
              "type": "text",
              "fielddata": true
            }
          }
        },
        "mobile" : {
          "type" : "keyword"
        },
        "age" : {
          "type" : "integer"
        }
      }
    }
}

PUT users/_doc/1
{
  "name":"tom cat",
  "mobile": "15978866921",
  "age": 30
}

PUT users/_doc/2
{
  "name":"jerry mouse",
  "mobile": "15978866920",
  "age": 35
}

PUT users/_doc/3
{
  "name":"jack rose",
  "mobile": "15978866922",
  "age": 20
}

PUT users/_doc/4
{
  "name":"rose jack",
  "mobile": "110",
  "age": 20
}

GET users/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "name.kw": {
        "order": "asc"
      }
    }
  ]
}

GET users/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "name.fd": {
        "order": "asc"
      }
    }
  ]
}

GET users/_search
{
  "aggs": {
    "by_word": {
      "terms": {
        "field": "name.kw"
      }
    }
  },
  "size": 0
}

GET users/_search
{
  "aggs": {
    "by_word": {
      "terms": {
        "field": "name.fd"
      }
    }
  },
  "size": 0
}

对text+fielddata排序,效果和对keyword排序不同

  • 前者按name中的最小的那个token作为这个name的排序值
  • 后者按name的自然序,因为整个name是一个token;

对text+fielddata聚合(terms聚合),效果和keyword不同

  • 前者按name中的每个token聚合计数
  • 后者将这个name作为token进行聚合计数。

所以大致可以看出来,fielddata和doc_values起到的作用是一样的,只不过它放在内存里

而它的名字field data,和doc’s values,其实没啥区别。大概也能猜出来,二者功能上类似。

性能

fielddata放在内存里,但只有text类型的字段在做sort/aggregate/or access values from a script on a text field的时候才会用到fielddata。除此之外,fielddata已经没有立锥之地了

  • https://www.elastic.co/guide/en/elasticsearch/reference/8.1/text.html#fielddata-mapping-param

虽然关于数据放内存还是放磁盘,各有千秋:

  • 放内存:用的是jvm的内存。用得太多会oom;
  • 放磁盘:用的是os的内存,page cache。用得太多会用不上内存,变成真用磁盘了。速度会变慢。

但是因为磁盘速度越来越快,而用磁盘意味着可以搜索海量数据,所以fielddata已经快被doc_values赶尽杀绝了

从一个老文档也可以看出来,doc_values在取代fielddata,只有text在用fielddata了

  • https://www.elastic.co/guide/cn/elasticsearch/guide/current/aggregations-and-analysis.html

从历史上看,fielddata 是 所有 字段的默认设置。但是 Elasticsearch 已迁移到 doc values 以减少 OOM 的几率。分析的字符串(曾经的analyzed string,也就是现在的text类型)是仍然使用 fielddata 的最后一块阵地。 最终目标是建立一个序列化的数据结构类似于 doc values ,可以处理高维度的分析字符串,逐步淘汰 fielddata。

fielddata被取代,因为磁盘速度上来了,而把数据放在后者没有jvm oom风险

  • https://segmentfault.com/a/1190000021668629

fielddata的优化

虽然fielddata已经快没了,但曾经为了优化fielddata的内存占用,还是有一些可学习的措施的。

预加载fielddata - 类似bean的预加载

把fielddata提前加载到内存,查询的时候就可以直接用了。如果是懒加载,第一次查询将会很慢:

  • https://www.elastic.co/guide/cn/elasticsearch/guide/current/preload-fielddata.html#eager-fielddata

全局序号Global Ordinals - 使用映射值

加载fielddata,没必要非得加载原始值。比如一个字符串那么长,只存它的hash,或者只存一个它映射后的数字。如果这个字符串出现一万次,实际只是把这个数字加载到了内存里一万次。如此一来,fielddata对内存的占用得到极大的优化:

  • https://www.elastic.co/guide/cn/elasticsearch/guide/current/preload-fielddata.html#global-ordinals

但也有个问题,必须有一个全局的映射表,而且必须把映射表必须放在内存里。毕竟在查询过后,还是要把数字转回去的。

序号的构建只被应用于字符串。数值信息(integers(整数)、geopoints(地理经纬度)、dates(日期)等等)不需要使用序号映射,因为这些值自己本质上就是序号映射。因此,我们只能为字符串字段预构建其全局序号。

global oridinals同样适用于doc_values:当存储数据的时候,存储的是映射值。查询的时候,查的是全局序号表,获得符合条件的映射值,然后把所有含有映射值的文档从磁盘搜索出来。最后转回原始值,返回给用户。

同样,默认情况下,第一次搜索时才会创建全局序号表。之后只有index刷新了,且再次第一次搜索时,才会创建全局序号表

  • https://www.elastic.co/guide/en/elasticsearch/reference/current/eager-global-ordinals.html

如果想让第一次搜索变快,就需要在每次刷新过后就创建全局序号表,这相当于把“搜索数据时的压力转移到了索引数据时”。所以把refresh_interval改大点儿会比较好。

1
2
3
4
5
6
7
8
9
PUT my-index-000001/_mapping
{
  "properties": {
    "tags": {
      "type": "keyword",
      "eager_global_ordinals": true
    }
  }
}
本文由作者按照 CC BY 4.0 进行授权