0%

本文用的 MySQL 是阿里云的 RDS,一主一从,主 1 核,从 2 核。配置很低,这不是本文讨论内容,不多说为什么。

背景

在很长一段时间的上线流程中,我们都要跑一个脚本(Laravel artisan 命令),用来对所有用户的权限做一些可能的更新操作。这个过程需要很长的时间,30w 左右的客户,19 个进程跑这些脚本短则 12 小时左右,多则一天以上。

影响

运行这个脚本的过程中,整个网站响应速度都慢了很多。查看服务器 CPU、内存 负载,没有什么压力,最后发现是 MySQL 从库 CPU 负载一直 100%(2 核的配置)。

分析

  1. 大家会说,很明显啊,升级一下 MySQL 从库,让它读得快一点就好了啊。我开始也是这么想的,从 2 核升到 8 核后,发现虽然快了,但是没有快 4 倍,后来去看主库发现,这个时候主库的 CPU 100% 了(原来平稳 30% 左右)。

  2. 现在发现了主库满载,我也想到了可以考虑升级一下主库,但是主库是包年包月的,一升就要按之前付费的时间来补足这些费用,但这些我决定不了。所以暂时放弃这个想法了。

本地测试

上一次上线的时候,也是发现了这个问题,上一次个人是想从代码层面找到优化的地方的(比如,会不会有嵌套的循环等,这个可以用 xhprof),后来把这个给忘了。

发现升级 MySQL 服务器的想法失败了,想起了自己之前写过的一个记录 SQL 语句的工具,便想着可以看看是不是有什么 SQL 执行太久了。

  1. 本地测试发现,一个脚本执行下来,有 200 个 SQL 语句,其中有很多是可以修改为批量操作的。因为 Laravel 模型的 createMany 是将关联数据一条一条插入的,所以将 createMany 修改为 insert 同时插入多条记录。

  2. 我们模型里面还用了 spatie/laravel-activitylog 这个包,这就导致,上面的批量语句有多少条,最终产生的 SQL 语句是这个数量的两倍。考虑到这个包记录的那些数据从来就没有用过,就把相关模型记录变更的操作禁用掉了

更新后的效果

把这两百个 SQL 缩减为 17 个之后,信心满满地将代码更新上去,失望地发现,感觉一点都没有快(Laravel horizon 面板 job 数量减少还是很缓慢)。然后接手这个功能的人告诉我,其实真实场景是写的情况是很少的,大部分是读语句。

本地测试的时候,打印的语句好像并没有非常慢的,但是有几个 80ms 左右的查询,个人觉得这是一个比较正常的。但事实证做优化的时候不能太感性,优化应该是有某些指标、方向指导的。

记录生成的 SQL 语句

既然去掉了 90% 的 SQL 语句都没有效,那就可能是一些语句在测试环境和生产的执行效率差别太大(生产数据多一些)。所以就写了下面的代码:

1
2
3
4
5
6
7
8
9
10
11
$cache = \Cache::get('permission');
if (!$cache) {
enable_mySQL_log();
enable_mongo_log();
\Cache::put('permission', 1, 120);
}
// ... 这里省略业务代码 ...
if (!$cache) {
disable_mySQL_log();
disable_mongo_log();
}

这里的 enable_mySQL_log 几个函数是自定义用来记录 SQL 语句的,使用 Cache 的目的是为了只记录一次(一次就够)。

从记录的语句中,发现了有两条语句都是用了 300ms 以上,而其他语句平均时间是 3ms。但这两个语句所在的表数据不是很多(20w左右),而且只是一个简单的 join 加一些条件筛选。

到这里原因其实已经很明显了,把这两条 SQL 语句拿去 explain 一下,答案没有让我失望,type: All

接下来的事情就很简单了,给涉及的那张表加了两个字段的独立索引,再去看 SQL 语句,300ms 降到了平均水平 3ms。

原因

MySQL join 表的时候,关联表的关联字段如果没有索引的话,会导致全表扫描。

回到测试环境加上索引后测试发现,原来 80ms 的语句,现在只需要 10ms 左右。

其他可以优化的地方

因为我们的脚本是扫描全表做处理的,所以可以考虑将里面的大部分查询转换为批量查询,查询到结果之后再进行业务逻辑处理。

总而言之,可以批量就不要分开操作。

反思总结

  1. 优化的时候如果找不到方向,不妨可以看看产生的 SQL 语句,可能只是某个表忘记加索引了。

  2. 关于 spatie/laravel-activitylog,虽然我们从来没有用过,但是它一直在记录,感觉如果从来不需要去看模型变更记录的话,还不如不要。

  3. 关于定位系统性能问题,上一次上线的时候,虽然想从代码着手,但是并没有发现有什么特别影响性能的地方。经过这次经历发现,发现系统有性能的时候,靠谱的做法是先定位到哪里产生了性能的问题。因为这个系统从开始到现在一直很多地方都有一些性能问题,另外一方面觉得那一部分业务负责,所以觉得慢是正常的。这些都是非常感性的想法,非常有害的,这会导致我们做不出正确的判断。

正确的做法是,从各方面去定位原因,比如应用服务器负载、数据库服务器负载,如果有某一个方面到达了瓶颈的话,马上升级并不是一个明智的做法,我们需要明确的知道是不是服务器不够用,还是代码写得有问题,如果最终确定的确是服务器支撑不住的话,再考虑升级。因为有些严重的性能问题,带来的性能下降是指数级的,单靠升级服务器要付出很大的成本。

如果我们遇到问题就凭感觉来断定的话,往往导致走很多弯路(走就算了,更坏的结果是到最后问题也还是没有解决)。

  1. 那些你觉得理所当然的未必就是理所当然的。这个问题从出现到解决经历的周期说实话非常长了,接手它的人和大家其实一致觉得,是因为里面业务逻辑复杂,可能需要的查询很多,处理很多东西。但事实证明,事情不像表明看到的那样。

nested 类型是一种特殊的对象 object 数据类型,允许对象数组彼此独立地进行索引和查询。

对象数组如何扁平化

内部对象 object 字段的数组不能像我们期望的那样工作。Lucene 没有内部对象的概念,所以 Elasticsearch 将对象层次结构扁平化为一个字段名称和值简单列表。例如,以下文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
curl -XPUT 'localhost:9200/my_index/my_type/1?pretty' -H 'Content-type: application/json' -d '
{
"group": "fans",
"user": [
{
"first": "John",
"last": "Smith"
},
{
"first": "Alice",
"last": "White"
}
]
}
'

说明

user 字段被动态的添加为 object 类型的字段。

在内部其转换成一个看起来像下面这样的文档:

1
2
3
4
5
{
"group": "fans",
"user.first": ["alice", "john"],
"user.last": ["smith", "white"]
}

user.firstuser.last 字段被扁平化为多值字段,并且 alice 和 white 之间的关联已经丢失。本文档将错误地匹配 user.first 为 alice 和 user.last 为 smith 的查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
curl -XGET 'localhost:9200/my_index/_search?pretty' -H 'Content-Type: application/json' -d '
{
"query": {
"bool": {
"must": [
{
"match": {
"user.first": "Alice"
}
},
{
"match": {
"user.last": "Smith"
}
}
]
}
}
}
'

对对象数组使用嵌套字段

如果需要索引对象数组并维护数组中每个对象的独立性,则应使用 nested 数据类型而不是 object 数据类型。在内部,嵌套对象将数组中的每个对象作为单独的隐藏文档进行索引,这意味着每个嵌套对象都可以使用嵌套查询 nested query 独立于其他对象进行查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
curl -XPUT 'localhost:9200/my_index?pretty' -H 'Content-Type: application/json' -d '
{
"mappings": {
"my_type": {
"properties": {
"user": {
"type": "nested"
}
}
}
}
}
'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
curl -XPUT 'localhost:9200/my_index/my_type/1?pretty' -H 'Content-Type: application/json' -d '
{
"group": "fans",
"user": [
{
"first": "John",
"last": "Smith"
},
{
"first": "Alice",
"last": "White"
}
]
}
'

说明:

user 字段映射为 nested 类型,而不是默认的 object 类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
curl -XGET 'localhost:9200/my_index/_search?pretty' -H 'Content-Type: application/json' -d '
{
"query": {
"nested": {
"path": "user",
"query": {
"bool": {
"must": [
{
"match": { "user.first": "Alice"}
},
{
"match": { "user.last": "Smith" }
}
]
}
}
}
}
}
'

说明:

此查询得不到匹配,是因为 Alice 和 Smith 不在同一个嵌套对象中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
curl -XGET 'localhost:9200/my_index/_search?pretty' -H 'Content-Type: application/json' -d '
{
"query": {
"nested": {
"path": "user",
"query": {
"bool": {
"must": [
{ "match": { "user.first": "Alice" } },
{ "match": { "user.last": "White" } }
]
}
},
"inner_hits": {
"highlight": {
"fields": {
"user.first": {}
}
}
}
}
}
}
'

说明:

此查询得到匹配,是因为 Alice 和 White 位于同一个嵌套对象中。

inner_hits 允许我们突出显示匹配的嵌套文档。

嵌套文档可以:

  • 使用 nested 查询进行查询
  • 使用 nested 和 reverse_nested 聚合进行分析
  • 使用 nested 排序进行排序
  • 使用 nested_inner_hits 进行检索与突出显示

嵌套字段参数

嵌套字段接受以下参数:

  • dynamic:是否将新属性动态添加到现有的嵌套对象。共有 true(默认),false 和 strict 三种参数。
  • include_in_all:(_all 字段已经废弃了)
  • properties:嵌套对象中的字段,可以是任何数据类型,包括嵌套。新的属性可能会添加到现有的嵌套对象。

备注:

类型映射(type mapping)、对象字段和嵌套字段包含的子字段,称之为属性 properties。这些属性可以为任意数据类型,包括 object 和 nested。属性可以通过以下方式加入:

  • 当在创建索引时显式定义它们
  • 当使用 PUT mapping API 添加或更新映射类型时显式地定义它们
  • 当索引包含新字段的文档时动态的加入

重要:

由于嵌套文档作为单独的文档进行索引,因此只能在 nested 查询,nested/reverse_nested 聚合或者 nested_inner_hits 的范围内进行访问。

限制嵌套字段的个数

索引一个拥有 100 个嵌套字段的文档,相当于索引了 101 个文档,因为每一个嵌套文档都被索引为一个独立的文档。为了防止不明确的映射,每个索引可以定义的嵌套字段的数量已被限制为 50 个。

多嵌套查询方式:

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
{
"query": {
"bool": {
"must": [
{
"nested": {
"path": [
"ajxx"
],
"query": {
"bool": {
"must": [
{
"match": {
"ajxx.ajzt": "破案"
}
},
{
"range": {
"ajxx.sasj": {
"gte": "2017-01-01 12:10:10",
"lte": "2017-01-02 12:10-40"
}
}
}
],
"should": [
{
"query_string": {
"query": "20170316盗窃案"
}
}
]
}
}
}
}
]
}
}
}

查询字段名称的模糊匹配编辑 字段名称可以用模糊匹配的方式给出:任何与模糊式正则匹配的字段都会被包括在搜索条件中。

1
2
3
4
5
6
{
"multi_match": {
"query": "结果",
"fields": ["*", "*_title"]
}
}

当这些子字段出现数值类型的时候,就会报异常了,解决方法是加入 lenient 字段。

1
2
3
4
5
6
7
8
9
10
11
12
{
"type": "parse_exception",
"reason": "failed to parse date field [XXX] with format [yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis]"
}

{
"multi_match": {
"query": "结果",
"lenient": "true",
"fields": ["*"]
}
}

利用 multi_match 嵌套全文检索

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
106
107
108
"include_in_parent": true,
"include_in_root": true,
{
"query": {
"bool": {
"must": [
{
"match": {
"ajztmc": "立案"
}
},
{
"match": {
"zjasl": "刑事"
}
},
{
"range": {
"lasj": {
"gte": "2015-01-01 12:10:10",
"lte": "2016-01-01 12:10:40"
}
}
},
{
"nested": {
"path": [
"rqxxx"
],
"query": {
"bool": {
"must": [
{
"match": {
"rqxx.baqmc": "办案区名称"
}
}
]
}
}
}
},
{
"nested": {
"path": [
"saryxx"
],
"query": {
"bool": {
"must": [
{
"match": {
"abc": "嫌疑人"
}
},
{
"match": {
"def": "女"
}
}
]
}
}
}
},
{
"nested": {
"path": [
"wp"
],
"query": {
"bool": {
"must": [
{
"match": {
"wp.wpzlnc": "赃物"
}
},
{
"match": {
"wp.wpztmc": "物品入库"
}
}
]
}
}
}
},
{
"multi_match": {
"query": "男",
"lenient",
"fields": [
"*"
]
}
}
]
}
},
"from": 0,
"size": 100,
"sort": {
"zxxgsj": {
"order": "desc"
}
}
}

使用查询解析器来解析其内容的查询。下面是一个例子:

1
2
3
4
5
6
7
8
9
GET /_search
{
"query": {
"query_string": {
"default_field": "content",
"query": "this AND that OR thus"
}
}
}

query_string 查询解析输入并在运算符周围分割文本。每个文本部分彼此独立地分析。例如以下查询:

1
2
3
4
5
6
7
8
9
GET /_search
{
"query": {
"query_string": {
"default_field": "content",
"query": "(new york city) OR (big apple)"
}
}
}

将分成 new york citybig apple,然后通过为该字段配置的分析器独立地分析每个部分。

空格不被视为运算符,这意味着 new york city 将 “按原样” 传递给为该字段配置的分析器。如果该字段是关键字字段,则分析器将创建单个术语 new york city,并且查询构建器将在查询中使用此术语。如果要分别查询每个术语,则需要在术语周围添加显式运算符(例如 new AND york AND city)。

当提供多个字段时,也可以修改如何使用类型参数在每个文本部分内组合不同字段查询。这里描述了可能的模式,默认是 Bestfield。query_string 顶级参数包括:

  • query: 要解析的实际查询。参见查询字符串语法
  • default_field: 如果未指定前缀字段,则查询字词的默认字段。默认为 index.query.default_field 索引设置,而索引设置默认为 *.* 提取映射中符合术语查询条件的所有字段,并过滤元数据字段。然后组合所有提取的字段以在没有提供前缀字段时构建查询。
  • default_operator: 如果未显式指定运算符,则使用默认运算符。例如,使用默认运算符 OR,查询 capital of Hungary 将转换为 capital OR of OR Hungary,并且使用默认运算符 AND,将相同的查询转换为 capital AND of AND Hungary。默认值为 OR。
  • analyzer:用于分析查询字符串的分析器名称。
  • quote_analyzer:分析器的名称,用于分析查询字符串中的引用短语。对于这些部件,它将覆盖使用 analyzer 参数或 search_quote_analyzer 设置设置的其他分析器。
  • allow_leading_wildcard: 设置时,* 或 ? 允许作为第一个字符。默认为 true。
  • enable_position_increments:设置为 true 在结果查询中启用位置增量。默认为 true。
  • fuzzy_max_expansions:控制模糊查询将扩展到的术语数。默认为 50
  • fuzziness:设置模糊查询的模糊性。默认为 AUTO。
  • fuzzy_prefix_length:设置模糊查询的前缀长度。默认是 0.
  • fuzzy_transpositions:设置为 false 禁用模糊转置(ab->ba)。默认是 true。
  • phrase_slop: 设置短语的默认斜率。如果为 0,则需要精确的短语匹配。默认值是 0。
  • boost:设置查询的提升值。默认为 1.0。
  • auto_generate_phrase_queries:默认为 false。
  • analyze_wildcard: 默认情况下,不分析查询字符串中的通配符。通过将此值设置为 true,将尽最大努力分析这些值。
  • max_determinized_states: 限制允许创建的 regexp 查询的自动机状态数。这可以防止太难(例如指数级)的 regexp。默认为 10000。
  • minimum_should_match: 一个值,用于控制生成的布尔查询中应该匹配的 "should" 子句的数量。它可以是绝对值(2),百分比(30%)或两者的组合。
  • lenient:如果设置为 true 将导致基于格式的失败(如向数字字段提供文本)将被忽略。
  • time_zone: 时区应用于与日期相关的任何范围查询。
  • quote_field_suffix: 附加到查询字符串的引用部分的字段的后缀。这允许使用具有不同分析链的字段进行精确匹配。
  • auto_generate_synonyms_phrase_query: 是否应为多项同义词自动生成短语查询。默认为 true。
  • all_fields:执行上可以查询映射检测到的所有字段的查询。

Default Field

如果未在查询字符串语法中明确指定要搜索的字段,index.query.default_field 则将使用该字段来派生要搜索的字段。如果 index.query.default_field 未指定,query_string 则将自动尝试确定索引映射中可查询的现有字段,并对这些字段执行搜索。请注意,这不包括嵌套文档,使用嵌套查询来搜索这些文档。

Multi Field

该 query_string 查询还可以指定查询的字段。可以通过 "fields" 参数提供字段:

1
field1: query_term OR field2: query_term | ...

query_string 针对多个字段运行查询的想法是将每个查询字词扩展为 OR 子句,如下所示:

例如,以下查询:

1
2
3
4
5
6
7
8
9
GET /_search
{
"query": {
"query_string": {
"fields": ["content", "name"],
"query": "this AND that"
}
}
}

匹配相同的单词

1
2
3
4
5
6
GET /_search
{
"query": {
"query_string": "(content:this OR name:this) AND (content:that OR name:that)"
}
}

由于从单个搜索项生成了多个查询,因此使用 dis_max 带有 tie_breaker 的查询自动组合它们。例如(name 使用 ^5 符号表示增强 5):

1
2
3
4
5
6
7
8
9
10
GET /_search
{
"query": {
"query_string": {
"fields": ["content", "name^5"],
"query": "this AND that OR thus",
"tie_breaker": 0
}
}
}

简单通配符也可以用于搜索文档的特定内部元素。例如,如果我们有一个 city 包含多个字段(或带有字段的内部对象)的对象,我们可以自动搜索所有 “城市” 字段:

1
2
3
4
5
6
7
8
9
GET /_search
{
"query": {
"query_string": {
"fields": ["city.*"],
"query": "this AND that OR thus"
}
}
}

另一种选择是在查询字符串本身中提供通配符字段搜索(正确转义*符号),例如 city.\*:something

1
2
3
4
5
6
7
8
GET /_search
{
"query": {
"query_string": {
"query": "city.\\*:(this AND that OR thus)"
}
}
}

由于 (反斜杠)是json字符串中的特殊字符,因此需要对其进行转义,因此上面的两个反斜杠 query_string。

query_string 对多个字段运行查询时,允许使用以下附加参数:

  • type: 应如何组合字段以构建文本查询。

fields 参数还可以包括基于通配符字段名称,允许自动扩展到相关字段(包括动态引入的字段)。例如:

1
2
3
4
5
6
7
8
9
GET /_search
{
"query": {
"query_string": {
"fields": ["content", "name.*^5"],
"query": "this AND that OR thus"
}
}
}

Query stirng 语法

查询字符串被解析为一些列术语和运算符。术语可以是单个单词 - quick 或 brown - 或短语,由双引号括起来 - "quick brown" 以相同的顺序搜索短语中的所有单词。

operator 允许你自定义搜索 - 可用选项如下:

Field names

如 query_string 查询中所述 default_field,搜索搜索词,但可以在查询语法中指定其他字段:

  • 其中 status 字段包含 active:
    • status:active
  • 其中 title 字段包含 quick 或 brown。如果省略 OR 运算符,将使用默认运算符:
    • title:(quick OR brown)
    • title:(quick brown)
  • 其中 author 字段包含精确短语 "john smith"
    • author:"john smith"
  • book 中任何字段包含 quick 或 brown(注意我们需要对 * 使用反斜杠转义)
    • book.\*:(quick brown)
  • 该字段 title 具有任何非 null 值:
    • _exists_:title

Wildcards 通配符

通配符可以在单个术语上运行,使用 ? 替换单个字符,使用 * 替换零个或多个字符:

qu?ck bro *

  • 请注意,通配符查询可能使用大量的内存,并且执行得很糟糕,只要想想需要查询多少项来匹配查询字符串 a*b*c*
  • 纯通配符 * 被重写为 exists。因此,通配符 "field:*" 将匹配具有空值的文档,如下所示:{"field": ""},如果字段丢失或使用显式空值设置则不匹配:{"field": null}
  • 允许在单词的开头(例如 "*ing")使用通配符特别重,因为需要检查索引中的所有术语,以防它们匹配。可以通过设置 allow_leading_wildcard 为禁用前导通配符 false。

Regular expressions 正则表达式

正则表达式模式可以通过将它们包装在 forward-slashes("/") 中嵌入查询字符串中:

name:/jo?n(ath[oa]n)/

正则表达式语法中解释了受支持的正则表达式语法。

allow_leading_wildcard 参数对正则表达式没有任何控制权。如下所示的查询字符串将强制 Elasticsearch 访问索引中的每个术语:/.*n/ 谨慎使用!

Fuzziness 模糊

我们可以使用 "fuzzy" 运算符搜索与我们的搜索字词类似但不完全相同的字词:

1
quikc~ brwn~ foks~

Ranges 范围

可以为日期,数字或字符串字段指定范围。包含范围用方括号指定,[min TO max] 排他范围用大括号指定 {min TO max}

  • All days in 2012:
    • date:[2012-01-01 TO 2012-12-31]
  • Numbers 1..5
    • count:[1 TO 5]
  • Tags between alpha and omega,excluding alpha and omega:
    • tag:{alpha TO omega}
  • Numbers from 10 upwards
    • count:[10 TO *]
  • Dates before 2012
    • date:{* TO 2012-01-01}

可以组合使用大括号和方括号:

  • 数字从 1 到 5 但不包括 5:
    • count:[1 TO 5}
  • 一边无范围的范围可以使用如下语法:
    • age:>10
    • age:>=10
    • age:<10
    • age:<=10

要将上限和下限与简化语法结合使用,你需要将两个子句与 AND 运算符连接:

1
2
age:(>=10 AND <20)
age:(+>=10 +<20)

使用 Boost 提升权重

使用 boost 运算符 ^ 使一个术语比另一个术语更相关。例如,如果我们想要找到关于 foxes 的所有文档,但我们对 quick foxes 特别感兴趣:

1
quick^2 fox

默认 boost 值为 1,但可以是任何正浮点数。0 到 1 之间的提升会降低相关性。

提升也可以应用于短语或群组:

1
"john smith"^2 (foo bar)^4

Boolean operators 布尔运算符

默认情况下,只要一个术语匹配,所有术语都是可选的。搜索 foo bar baz 将查找包含一个或多个 foo 或 bar 或 baz 的任何文档。我们已经讨论了 default_operator 上面的内容,它允许你强制要求所有的术语,但也有一些布尔运算符可以在查询字符串本身中使用,以提供更多的控制。

首选运算符+(此术语必须存在)和-(此术语不得出现)。所有其他条款都是可选的。例如,这个查询:

1
quick brown +fox -news

说明:

  • fox 必须存在
  • news 一定不能存在
  • quick 和 brown 是可选的 - 它们的存在增加了相关性

熟悉的布尔运算符 AND,OR 以及 NOT (也写作 &&, || 和 !)也支持,但要小心,他们不遵守通常的优先级规则,所以每当多个运算符一起使用时,应使用括号。例如,以前的查询可以重写为:

1
((quick AND fox) OR (brown AND fox) OR fox) AND NOT news

此表单现在可以正确复制原始查询中的逻辑,但相关性评分与原始查询几乎没有相似之处。

相反,使用查询 bool 查询重写相同 match 查询将如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"bool": {
"must": {
"match": "fox"
},
"should": {
"match": "quick brown"
},
"must_not": {
"match": "news"
}
}
}

Grouping 分组

可以将多个术语或子句与括号组合在一起,以形成子查询:

1
(quick OR brown) AND fox

组可用于定位特定字段,或用于提升子查询的结果:

1
status:(active OR pending) title:(full text search)^2

Reserved characters 保留字符

如果你需要在查询本身中使用任何作为运算符的字符(而不是运算符),那么你应该使用前导反斜杠来转义它们。例如,要搜索 (1+1)=2,你需要将查询编写为 \(1\+1\)\=2

保留的字符是:+ - = && || > < ! ( ) { } [ ] ^ " ~ * ? : \ /

无法正确转义这些特殊字符可能会导致语法错误,从而阻止你的查询运行。

<and> 根本无法转义。阻止它们尝试创建范围查询的唯一方法是安全从查询字符串中删除它们。

Empty Query 空查询

如果查询字符串为空或仅包含空格,则查询将生成空结果集。

elasticsearch 中的查询请求有两种方式,一种是简易版的查询,另外一种是使用 JSON 完整的请求体,叫做结构化查询(DSL)。由于 DSL 查询更为直观也更为简易,所以大都使用这种方式。DSL 查询是 POST 过去一个 json,由于 post 的请求是 json 格式的,所以存在很多灵活性,也有很多形式。这里有一个地方注意的是官方文档里面给的例子的 json 结构只是一部分,并不是可以直接复制粘贴进去使用的。一般要在外面加个 query 为 key 的结构。

match

最简单的一个 match 例子:

查询和 “我的宝马多少马力” 这个查询语句匹配的文档。

1
2
3
4
5
6
7
8
9
{
"query": {
"match": {
"content": {
"query": "我的宝马多少马力"
}
}
}
}

上面的查询匹配就会进行分词,比如 “宝马多少马力” 会被分词为 “宝马 多少 马力”,所有有关 “宝马 多少 马力”,那么所有包含这三个词中的一个或多个的文档就会被搜索出来。并且根据 lucene 的评分机制(TF/IDF)来进行评分。

match_phrase

比如上面一个例子,一个文档 “我的保时捷马力也不错” 也会被搜索出来,那么想要精确匹配所有同时包含 “宝马 多少 马力” 的文档怎么做?就要使用 match_phrase 了。

1
2
3
4
5
6
7
8
9
{
"query": {
"match_phrase": {
"content": {
"query": "我的宝马多少马力"
}
}
}
}

完全匹配可能比较严,我们会希望有个可调节因子,少匹配一个也满足,那就需要使用到 slop。

1
2
3
4
5
6
7
8
9
10
{
"query": {
"match_phrase": {
"content": {
"query": "我的宝马多少马力",
"slop": 1
}
}
}
}

multi_match

如果我们希望两个字段进行匹配,其中一个字段有这个文档就满足的话,使用 multi_match

1
2
3
4
5
6
7
8
{
"query": {
"multi_match": {
"query": "我的宝马多少马力",
"fields": ["title", "content"]
}
}
}

但是 multi_match 就涉及到匹配评分的问题了。

我们希望完全匹配的文档占的评分比较高,则需要使用 best_fields

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"query": {
"multi_match": {
"query": "我的宝马发动机多少",
"type": "best_fields",
"fields": [
"tag",
"content"
],
"tie_breaker": 0.3
}
}
}

意思就是完全匹配 “宝马 发动机” 的文档评分会比较靠前,如果只匹配宝马的文档评分乘以 0.3 的系数。

我们希望越多字段匹配的文档评分越高,就要使用 most_fields

1
2
3
4
5
6
7
8
9
10
11
12
{
"query": {
"multi_match": {
"query": "我的宝马发动机多少",
"type": "most_fields",
"fields": [
"tag",
"content"
]
}
}
}

我们希望这个词条的分词词汇是分配到不同字段中的,那么就使用 cross_fields

1
2
3
4
5
6
7
8
9
10
11
12
{
"query": {
"multi_match": {
"query": "我的宝马发动机多少",
"type": "cross_fields",
"fields": [
"tag",
"content"
]
}
}
}

term

term 是代表完全匹配,即不进行分词器分析,文档中必须包含整个搜索的词汇

1
2
3
4
5
6
7
{
"query": {
"term": {
"content": "汽车保养"
}
}
}

查询出的所有文档都包含 “汽车保养” 这个词组的词汇。

使用 term 要确定的是这个字段是否 “被分析(analyzed)”,默认的字符串是被分析的。

拿官网上的例子举例:

mapping 是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"full_text": {
"type": "string"
},
"exact_value": {
"type": "string",
"index": "not_analyzed"
}
}
}
}
}

PUT my_index/my_type/1
{
"full_text": "Quick Foxes!",
"exact_value": "Quick Foxes!"
}

其中的 full_text 是被分析过的,所以 full_text 的索引中存的就是 [quick, foxes],而 extra_value 中存的是 [Quick Foxes!]。

那下面的几个请求:

1
2
3
4
5
6
7
8
GET my_index/my_type/_search
{
"query": {
"term": {
"exact_value": "Quick Foxes!"
}
}
}

能请求得的数据,因为完全匹配。

1
2
3
4
5
6
7
8
GET my_index/my_type/_search
{
"query": {
"term": {
"full_text": "Quick Foxes!"
}
}
}

请求不出数据,因为 full_text 分词后的结果中没有 [Quick Foxes!] 这个分词。

bool 联合查询:must,should,must_not

如果我们想要请求 “content 中带宝马,但是 tag 中不带宝马” 类似这样的需求,就需要用到 bool 联合查询。联合查询就会使用到 must,should,must_not 三种关键词。

这三个可以这么理解:

  • must:文档必须完全匹配条件
  • should:should 下面会带有一个以上的条件,至少满足一个条件,这个文档就符合 should
  • must_not:文档必须不匹配条件

比如上面那个需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"query": {
"bool": {
"must": {
"term": {
"content": "宝马"
}
},
"must_not": {
"term": {
"tags": "宝马"
}
}
}
}
}

运行搜索

你可以使用搜索API搜索 Elasticsearch 数据流或索引中存储的数据。

该 API 可以运行两种类型的搜索,具体取决于你如何提供查询

  • URI 搜索
    • 通过查询参数提供查询。URI 搜索往往更简单,最适合测试。
  • 请求体搜索
    • 通过 API 请求的 JSON body 提供查询。这些查询是用 查询DSL 编写的。我们建议在大多数生产用例中使用请求体搜索。

如果同时在 URI 和请求正文中指定查询,则搜索 API 请求仅运行 URI 查询。

运行 URI 搜索

你可以使用搜索 API 的 q查询字符串参数 在请求的 URI 中运行搜索。该 q 参数仅接受以 Lucene 的查询字符串语法编写的查询。

例子:

首先,向 Elasticsearch 索引添加一些数据。

以下 bulk API 将一些示例用户日志数据添加到 user_logs_00001 索引。

示例

$response = $client->bulk([ 'body' => [ [ 'index' => [ '_index' => 'user_logs_00001', '_id' => 1, ], ], [ '@timestamp' => '2020-12-06T11:04:05.000Z', 'user' => [ 'id' => 'vlb44hny', ], 'message' => 'Login attempt failed', ],

    [
            'index' => [
                '_index' => 'user_logs_00001',
                '_id' => 2,
            ],
        ],
        [
            '@timestamp' => '2020-12-07T11:06:07.000Z',
            'user' => [
                'id' => '8a4f500d',
            ],
            'message' => 'Login successful',
        ],

        [
            'index' => [
                '_index' => 'user_logs_00001',
                '_id' => 3,
            ],
        ],
        [
            '@timestamp' => '2020-12-07T11:07:08.000Z',
            'user' => [
                'id' => 'l7gk7f82',
            ],
            'message' => 'Logout successful',
        ],
    ],
]);

echo json_encode($response, JSON_PRETTY_PRINT);

现在你可以使用 URI 搜索来匹配一个 user.idl7gk7f82 的文档,请注意查询通过 q 查询参数来指定。

1
2
3
4
5
6
$response = $client->search([
'index' => 'user_logs_00001',
'q' => 'user.id:8a4f500d',
]);

echo json_encode($response, JSON_PRETTY_PRINT);

响应结果的 hits.hits 属性包含了匹配到的文档。

运行一个请求体查询

你也可以通过 查询DSL 语法来传递一个 query 请求体参数进行查询。

示例

$response = $client->search([ 'index' => 'user_logs_00001', 'body' => [ 'query' => [ 'match' => [ 'message' => '登录成功', ], ], ] ]);

echo json_encode($response, JSON_PRETTY_PRINT);

API 返回以下响应:

hits.hits 属性包含匹配的文档。默认情况下,响应按这些匹配的文档 _score 的相关性得分排序,该得分衡量每个文档与查询的匹配程度。

响应 { "took": 1, "timed_out": false, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 3, "relation": "eq" }, "max_score": 0.9983525, "hits": [ { "_index": "user_logs_00001", "_type": "_doc", "_id": "2", "_score": 0.9983525, "_source": { "@timestamp": "2020-12-07T11:06:07.000Z", "user": { "id": "8a4f500d" }, "message": "Login successful" } }, { "_index": "user_logs_00001", "_type": "_doc", "_id": "3", "_score": 0.49917626, "_source": { "@timestamp": "2020-12-07T11:07:08.000Z", "user": { "id": "l7gk7f82" }, "message": "Logout successful" } }, { "_index": "user_logs_00001", "_type": "_doc", "_id": "1", "_score": 0.42081726, "_source": { "@timestamp": "2020-12-06T11:04:05.000Z", "user": { "id": "vlb44hny" }, "message": "Login attempt failed" } } ] } }

搜索多个数据流和索引

要搜索多个数据流和索引,请将它们作为逗号分隔的值添加到搜索 API 请求路径中。

示例

以下请求搜索 user_logs_00001user_logs_00002 索引。

$response = $client->search([ 'index' => 'user_logs_00001,user_logs_00002', 'body' => [ 'query' => [ 'match' => [ 'message' => 'Login Successful', ], ], ] ]);

echo json_encode($response, JSON_PRETTY_PRINT);

你也可以使用通配符 * 模式搜索多个数据流和索引。

示例

$response = $client->search([ 'index' => 'user_logs*', 'body' => [ 'query' => [ 'match' => [ 'message' => 'Login Successful', ], ], ] ]);

echo json_encode($response, JSON_PRETTY_PRINT);

要搜索集群中的所有数据流和索引,请从请求路径中省略目标。或者,你可以使用 _all*

示例

$response = $client->search([ 'index' => '', // 搜索全部索引 // 'index' => '_all', // 搜索全部索引 // 'index' => '*', // 搜索全部索引 'body' => [ 'query' => [ 'match' => [ 'message' => 'Login Successful', ], ], ] ]);

echo json_encode($response, JSON_PRETTY_PRINT);

分页搜索结果

默认情况下,搜索 API 返回前 10 个匹配的文档。

要分页显示更多结果,可以使用搜索 API sizefrom 参数。该 size 参数是要返回匹配文档的数量。该 from 是从完整结果集开始的零索引偏移量,该偏移量指示要开始使用的文档。

示例

以下搜索 API 请求将 from 偏移量设置为 5,表示请求偏移量或跳过前五个匹配文档。 该 size 参数是 20,这意味着该请求最多可返回 20 个文档,开始偏移。

$response = $client->search([ 'index' => '*', 'body' => [ 'query' => [ 'term' => [ 'user.id' => '8a4f500d', ], ], ], 'from' => 5, 'size' => 20, ]);

echo json_encode($response, JSON_PRETTY_PRINT);

默认情况下,你不能使用 fromsize 参数分页浏览超过 10000 个文档。使用 index.max_result_window 索引设置来设置此限制。

深度分页或一次请求许多结果可能会导致搜索缓慢。结果在返回之前先进行排序。由于搜索请求通常跨越多个分片,因此每个分片必须生成自己的排序结果。然后必须对这些单独的结果进行合并和排序,以确保总体排序顺序正确。

作为深度分页的替代方法,我们建议使用 scrollsearch_after 参数。

Elasticsearch 使用 Lucene 的内部文档 ID 作为平局。这些内部文档 ID 在相同数据的副本之间可能完全不同。在进行分页时,您可能偶尔会看到排序顺序相同的文档的顺序不一致。

检索选定的字段

默认情况下,搜索响应中的每个 _source 匹配都包括 document,这是对文档建立索引时提供的整个 JSON 对象。如果在搜索响应中仅需要某些源字段,则可以使用 source-filtering 来限制返回源的哪些部分。

仅使用文档源返回字段有一些限制:

  • _source 不包含多字段或字段别名。同样,源中的字段也不包含使用 copy_to 映射参数复制的值。
  • 由于 _source 在 Lucene 中存储为单个字段,因此即使只需要少量字段,也必须加载和解析整个源对象。

为了避免这些限制,你可以:

  • 使用 docvalue_fields 参数获取选定字段的值。当返回相当少量的支持 doc 值的字段(例如关键字和日期)时,这是一个不错的选择。
  • 使用 sorted_fields 参数获取特定存储字段的值。(使用 store 映射选项的字段。)

你可以在以下各节中找到有关这些方法的更多详细信息:

  • 源过滤
  • 文件值栏位
  • 储存栏位

源过滤

你可以使用该 _source 参数选择返回源的哪些字段。这称为源过滤。

如下的搜索请求设置 _source 请求体参数为 false,这样请求结果里就不会包含 _source 字段。

示例

$response = $client->search([ 'index' => '*', 'body' => [ 'query' => [ 'term' => [ 'user.id' => '8a4f500d', ], ], ], '_source' => false, ]);

echo json_encode($response, JSON_PRETTY_PRINT);

也可以通过 * 通配符来让搜索 API 返回对应的字段,下面的请求返回的响应结果中的 _source 字段只会包含 obj 字段以及它的属性:

示例

$response = $client->search([ 'index' => '*', 'body' => [ 'query' => [ 'term' => [ 'user.id' => '8a4f500d', ], ], ], '_source' => 'obj.*', ]);

echo json_encode($response, JSON_PRETTY_PRINT);

你也可以通过数组指定多个字段名匹配的模式,如下的请求会返回 obj1obj2 字段和它们的属性:

示例

$response = $client->search([ 'index' => '*', 'body' => [ 'query' => [ 'term' => [ 'user.id' => '8a4f500d', ], ], ], '_source' => ['obj1.', 'obj2.'], ]);

echo json_encode($response, JSON_PRETTY_PRINT);

为了更好地控制,你可以指定一个对象,该对象在参数中包含 includesexcludes 模式的数组 _source

如果 includes 指定了属性,则仅返回与其模式之一匹配的源字段。你可以使用 excludes 属性从此子集中排除字段。

如果 includes 未指定该属性,则返回整个文档源,不包括与该 excludes 属性中的模式匹配的任何字段。

以下搜索 API 请求仅返回 obj1obj2 字段及属性的源,不包括任何子 description 字段。

示例

$response = $client->search([ 'index' => '*', 'body' => [ 'query' => [ 'term' => [ 'user.id' => '8a4f500d', ], ], ], '_source' => [ 'includes' => [ 'obj1.', 'obj2.', ], 'excludes' => [ '*.description', ], ], ]);

echo json_encode($response, JSON_PRETTY_PRINT);

docvalue_fields

你可以使用 docvalue_fields 参数返回搜索响应中一个或多个字段的 doc-values

Doc 值存储与 _source 相同的值,但是在磁盘上基于列的结构中进行了优化,该结构针对排序和聚合进行了优化。由于每个字段都是单独存储的,因此 Elasticsearch 仅读取所请求的字段值,并且可以避免加载整个文档。

默认情况下,将为支持的字段存储文档值。但是,texttext_annotated 字段不支持 doc 值。

以下搜索请求使用该 docvalue_fields 参数来检索以下字段的 doc 值: * 名称以 my_ip 开头的字段 * my_keyword_field * 名称以 _date_field 结尾的字段

$response = $client->search([ 'index' => '*', 'body' => [ 'query' => [ 'match_all' => (object)[ ], ], 'docvalue_fields' => [ 'my_ip*', [ 'field' => 'my_keyword_field', ], [ 'field' => '*_date_field', 'format' => 'epoch_millis', ], ], ], ]);

echo json_encode($response, JSON_PRETTY_PRINT);
  • 通配符 patten,用于匹配以字符串形式指定的字段名称
  • 通配符 patten,用于匹配指定为对象的字段名称
  • 使用对象符号,可以使用 format 参数指定字段的返回 doc 值格式。日期字段支持日期格式,数值字段支持 DecimalFormat 模式。其他字段数据类型不支持该 format 参数。

储存栏位

也可以使用 store 映射选项存储单个字段的值。你可以使用 stored_fields 参数将这些存储的值包括在搜索响应中。