15分钟走进 Elastcisearch

Elasticsearch 是什么

(官方版)

ElasticSearch 是一个基于 Lucene 的搜索服务器。它提供了一个基于 RESTful web 接口的分布式多用户能力的全文搜索引擎。

(人话版)

Elasticsearch = 搜索引擎 + NoSQL数据库 + 服务器

Elasticsearch 为什么

知道什么叫 MySQL 吗?相信看到本文的你多少都会有一定的了解,想想 MySQL 有什么用:存储数据?查询数据?其实简单来说就是增删查改(CURD)。Elasticsearch (以下使用ES代指Elasticsearch) 也一样,你可以通过它提供的接口进行同样的数据管理操作,那么问题来了,凭什么我 MySQL 用的好好的,要来用你 ES 呢?想想你做过的项目,你需要存储的数据,通常不可能只是简单的键和值的列表,而是相当复杂的数据结构,可能包括日期、地理信息、其他对象或者数组等等…当你使用关系型数据库的行和列进行存储时,相当于把结构相当复杂的对象挤压到一个非常大的表格中,而且你还必须把这个对象扁平化来适应表结构,也就是一个字段对应一列,最后需要得到查询结果时,还不得不在每次查询后重新构造需要返回的对象,我只想问一句,累不累?看到这里,有人可能会想到 MongoDB 这种非关系型数据库,没错,ES 正是使用 JSON 格式进行数据存储,相信不少 NoSQL 数据库的玩家会感到欣慰不少吧。

那么为什么用 ES 呢?因为 MySQL 能做到的,ES也能做到,甚至做的更好,MySQL 做不到的,ES 就当仁不让的全盘接下,想知道 ES 到底能做到多好?继续往下看吧,在这 15 分钟里,足够让你初步认识这位大数据时代的搜索大佬了。

希望在接下来的阅读中,对于每一个例子中的查询需求,各位先在脑海中想想这两个问题:

  1. 用 MySQL(MongoDB) 能实现吗?
  2. 用 MySQL(MongoDB) 怎么实现,对应的查询语句怎么写?

让我们带着不屑和审视的目光开始认识 ES。

Elasticsearch 从入门到入门

为什么是从入门到入门?在我和 ES 朝夕相伴嬉笑打闹的这段日子里,无数次感觉自己入门了,又无数次看到新的大门矗立在眼前,所以至今还没有真正的进入殿堂,就让我们一起加油吧🤣

ES 初体验

光说不练假把式,就用官方提供的一个模拟需求来开始这一章吧,为了符合我大天朝人民的胃口,特意加了点佐料,希望大家玩的开心,看的愉快

关于 ES 的安装这里就不再赘述了,建议大家有条件的最好在 Unix/Linux 环境下安装,Windows 环境下也有对应的安装方法,但会存在很多Bug,大家按照情况进行选择即可

假设:我和各位成立了一家创业公司,名叫“FuckGTW”,需要招聘一批有志之士加入我们的团队,参与我们的最新项目“JustFuckGTW”,我们的任务是先为这批人才创建一个员工目录,希望能培养雇员认同感并且支持实时、高效、动态协作,因此有一些业务需求:

  • 支持包含多值标签、数值、以及全文本的数据
  • 检索任一雇员的完整信息
  • 允许结构化搜索,比如查询 30 岁以上的员工
  • 允许简单的全文搜索以及较复杂的短语搜索
  • 支持在匹配文档内容中高亮显示搜索片段
  • 支持基于数据创建和管理分析仪表盘

首先,我们要存储雇员数据,这将会以雇员文档的形式存储:一个文档代表一个雇员。存储数据到 ES 的行为叫做索引(动词) ,但在索引(动词)一个文档之前,需要确定将文档存储在哪里。

这里引用官网对于索引的解释

索引(名词):

如前所述,一个索引类似于传统关系数据库中的一个数据库,是一个存储关系型文档的地方。索引(index)的复数词为 indices 或 indexes。

索引(动词):

索引一个文档,就是存储一个文档到一个索引(名词)中以便它可以被检索和查询到。这非常类似于SQL语句中的 INSERT 关键词,除了文档已存在时新文档会替换旧文档情况之外。

倒排索引

关系型数据库通过增加一个索引比如一个B树(B-tree)索引到指定的列上,以便提升数据检索速度。ElasticsearchLucene 使用了一个叫做倒排索 的结构来达到相同的目的。

索引文档

接下来,我们需要进行这些操作

  • 为每个雇员索引一个文档,包含该雇员的所有信息。
  • 每个文档都将是 employee 类型 。
  • 该类型位于索引 FuckGTW 内。
  • 该索引保存在我们的 ES 集群中。

看起来挺复杂,还有集群是什么鬼(这部分会在后面解释,现在可以简单的把 ES 集群就理解为一个 ES 数据库),其实通过一条命令就能完成:

1
2
3
4
5
6
7
8
PUT /fuckgtw/employee/1
{
"first_name" : "张",
"last_name" : "三",
"age" : 25,
"about" : "喜欢爬树、LOL、hate cats、love dog",
"interests": [ "sports", "music" ]
}

路径 /fuckgtm/employee/1 包含了三部分信息:

  • fuckgtm 索引名称
  • employee 类型名称
  • 1 特定雇员的ID

请求体(JSON 文档):包含了这位员工的所有详细信息,他的名字叫张三,今年25岁,喜欢爬树、LOL等…,对运动和音乐感兴趣。

很简单!无需进行执行管理任务,如创建一个索引或指定每个属性的数据类型之类的,可以直接只索引一个文档。Elasticsearch 默认地完成其他一切,因此所有必需的管理任务都在后台使用默认设置完成。

接下来用同样的方法增加更多数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PUT /fuckgtw/employee/2
{
"first_name" : "张",
"last_name" : "四",
"age" : 24,
"about" : "love girls",
"interests": [ "sports" ]
}
PUT /fuckgtw/employee/3
{
"first_name" : "王",
"last_name" : "五",
"age" : 30,
"about" : "喜欢树、love cats",
"interests": [ "music", "books" ]
}

检索文档

目前我们已经在 ES 中存储了一些数据,接下来就能专注于实现业务需求了。第一个需求是检索单个雇员的数据,这里需要注意的是搜索的方法,和 MySQL 不同,在 ES 中进行搜索很简单,基本上都是直接发起 HTTP 请求,因为前面说了 ES 不光是一个数据库,还是一个服务器,看下面的例子:

1
GET /fuckgtw/employee/1

这就是一条查询语句,执行了一个 HTTP GET 请求并指定文档的地址——索引库、类型和ID,表示查询 megacorp 索引(数据库)中 employee 类型(表) 中 id 为 1的数据。返回结果则是原始的 JSON 文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index" : "fuckgtw",
"_type" : "employee",
"_id" : "1",
"_version" : 1,
"found" : true,
"_source" : {
"first_name" : "张",
"last_name" : "三",
"age" : 25,
"about" : "喜欢爬树、LOL、hate cats、love dog",
"interests": [ "sports", "music" ]
}
}

注:HTTP 请求 GET 可以用来检索文档,同样的,可以使用 DELETE 命令来删除文档,以及使用 HEAD 指令来检查文档是否存在。如果想更新已存在的文档,只需再次 PUT 。

简单搜索

一个 GET 请求是相当简单的,接下来尝试点高级用法,比如进行简单搜索,查出所有的雇员

1
GET /fuckgtw/employee/_search

可以看到,简单搜索与之前检索使用的参数不同,检索是指定一个文档的 ID,搜索使用 _search,返回结果包含了索引的三个文档,并返回在数组 hits 中,表示搜索的命中数,一个搜索默认返回十条结果。

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
{
"took": 6,
"timed_out": false,
"_shards": { ... },
"hits": {
"total": 3,
"max_score": 1,
"hits": [
{
"_index": "fuckgtw",
"_type": "employee",
"_id": "3",
"_score": 1,
"_source": {
"first_name" : "张",
"last_name" : "三",
"age" : 25,
"about" : "喜欢爬树、LOL、hate cats、love dog",
"interests": [ "sports", "music" ]
}
},
{
"_index": "fuckgtw",
"_type": "employee",
"_id": "1",
"_score": 1,
"_source": {
"first_name" : "张",
"last_name" : "四",
"age" : 24,
"about" : "love girls",
"interests": [ "sports" ]
}
},
{
"_index": "fuckgtw",
"_type": "employee",
"_id": "2",
"_score": 1,
"_source": {
"first_name" : "王",
"last_name" : "五",
"age" : 30,
"about" : "喜欢树、love cats",
"interests": [ "music", "books" ]
}
}
]
}
}

注意:返回结果不仅显示匹配了哪些文档,还包含了整个文档本身:显示搜索结果给最终用户所需的全部信息。

接下来,我们尝试下搜索姓氏为 的雇员。为此,我们将使用一个高亮搜索,很容易通过命令行完成。这个方法一般涉及到一个查询字符串 (query-string) 搜索,因此我们通过一个URL参数来传递查询信息给搜索接口:

1
GET /fuckgtw/employee/_search?q=first_name:张

我们在请求路径中使用 _search 参数,并将查询本身赋值给参数 q= 。返回结果给出了所有姓张的员工:

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
{
...
"hits": {
"total": 2,
"max_score": 0.30685282,
"hits": [
{
...
"_source": {
"first_name" : "张",
"last_name" : "三",
"age" : 25,
"about" : "喜欢爬树、LOL、hate cats、love dog",
"interests": [ "sports", "music" ]
}
},
{
...
"_source": {
"first_name" : "张",
"last_name" : "四",
"age" : 24,
"about" : "love girls",
"interests": [ "sports" ]
}
}
]
}
}

上面提到了查询字符串 (_query-string_),query-string搜索可以通过命令非常方便地进行临时性的搜索 ,但它有自身的局限性。ES 提供了一个丰富灵活的查询语言叫做查询表达式, 它支持构建更加复杂和健壮的查询。使用查询表达式就要用到领域特定语言(DSL),不用担心,这很简单,它指定了使用一个 JSON 请求,将需要查询的条件从原来的 url 中改写到请求的 body 请求体内,下面我们用查询表达式改写上面的查询

1
2
3
4
5
6
7
8
GET /fuckgtw/employee/_search
{
"query" : {
"match" : {
"first_name" : "张"
}
}
}

返回结果与之前的查询一样,但还是可以看到有一些变化。其中之一是,不再使用 query-string 参数,而是一个请求体替代。这个请求使用 JSON 构造,并使用了一个 match 查询(属于查询类型之一,后续将会了解)。

可以看到,显然使用查询表达式可以应对更加复杂的查询条件,防止查询 url 过长,并且可以更加有逻辑性,接下来,我们尝试下更复杂的搜索,同时大家也可以思考下使用 SQL 进行查询时的查询语句,并自己做下对比,这样更加能够体会到 ES 的强大

复杂查询

现在,我们开始查询姓王、年龄大于25岁的员工,相应的查询就需要稍作调整,并使用过滤器 filter, 它支持高效地执行一个结构化查询。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /fuckgtw/employee/_search
{
"query" : {
"bool": {
"must": {
"match" : {
"first_name" : "王"
}
},
"filter": {
"range" : {
"age" : { "gt" : 25 }
}
}
}
}
}

filter 部分是一个 range 过滤器,它能找到 age 大于 25 的文档,其中 gt 表示大于(great than)

目前不用过多的担心语法问题,相信这些简单的例子也难不倒大家,相关的语法会在后面更详细的介绍。这里只需要明确一点:我们添加了一个过滤器用于执行一个范围查询,并复用之前的 match 查询。结果只返回了一个雇员,叫 王五,30 岁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
...
"hits": {
"total": 1,
"max_score": 0.30685282,
"hits": [
{
...
"_source": {
"first_name" : "王",
"last_name" : "五",
"age" : 30,
"about" : "喜欢树、love cats",
"interests": [ "music", "books" ]
}
}
]
}
}

全文搜索

截止目前的搜索都相对很简单:单个姓名、通过年龄过滤。现在尝试下稍微高级点儿的全文搜索,这是一项 传统数据库确实很难搞定的任务,大家可以自行尝试使用 SQL 等传统查询语句进行下面的查询

查询所有喜欢树的雇员:

1
2
3
4
5
6
7
8
GET /fuckgtw/employee/_search
{
"query" : {
"match" : {
"about" : "喜欢树"
}
}
}

我们依旧使用之前的 match 查询,并在 about 属性上搜索 “喜欢树” 。得到两个匹配的文档:

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
{
...
"hits": {
"total": 2,
"max_score": 0.16273327,
"hits": [
{
...
"_score": 0.16273327,
"_source": {
"first_name" : "王",
"last_name" : "五",
"age" : 30,
"about" : "喜欢树、love cats",
"interests": [ "music", "books" ]
}
},
{
...
"_score": 0.016878016,
"_source": {
"first_name" : "张",
"last_name" : "三",
"age" : 25,
"about" : "喜欢爬树、LOL、hate cats、love dog",
"interests": [ "sports", "music" ]
}
}
]
}
}

也许大家注意到了 _score 这个字段,在 ES 中,这代表相关性得分。ES 默认按照相关性得分排序,即每个文档跟查询的匹配程度。第一个最高得分的结果很明显:王五 的 about 属性清楚地写着 “喜欢树” ,但为什么张三也作为结果返回了呢?原因是他的 about 属性里提到了 “喜欢爬树” 。虽然他不是准确的表示”喜欢树”,但也包含了”喜欢”和”树”这两个关键词,所以他的相关性得分低于王五的。

这是一个很好的案例,阐明了 ES 如何在全文属性上搜索并返回相关性最强的结果。ES 中的相关性概念非常重要,也是完全区别于传统关系型数据库的一个概念,传统数据库中的一条记录要么匹配要么不匹配。

找出一个属性中的独立单词是没有问题的,但有时候想要精确匹配一系列单词或者短语 。 比如, 我们想执行这样一个查询,仅匹配同时包含 “喜欢树” ,并且以短语 “喜欢树” 的形式紧挨着的雇员记录,为此对 match 查询稍作调整,使用一个叫做 match_phrase 的查询:

1
2
3
4
5
6
7
8
GET /fuckgtw/employee/_search
{
"query" : {
"match_phrase" : {
"about" : "喜欢树"
}
}
}

毫无悬念,返回结果仅有 王五 的文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
...
"hits": {
"total": 1,
"max_score": 0.23013961,
"hits": [
{
...
"_score": 0.23013961,
"_source": {
"first_name" : "王",
"last_name" : "五",
"age" : 30,
"about" : "喜欢树、love cats",
"interests": [ "music", "books" ]
}
}
]
}
}

高亮搜索

许多需求都倾向于在每个搜索结果中高亮部分文本片段,以便让用户知道为何该文档符合查询条件。在 ES 中检索出高亮片段也很容易。

再次执行前面的查询,并增加一个新的 highlight 参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /fuckgtw/employee/_search
{
"query" : {
"match_phrase" : {
"about" : "喜欢树"
}
},
"highlight": {
"fields" : {
"about" : {}
}
}
}

当执行该查询时,返回结果与之前一样,与此同时结果中还多了一个叫做 highlight 的部分。这个部分包含了 about 属性匹配的文本片段,并以 HTML 标签 <em></em> 封装:

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
{
...
"hits": {
"total": 1,
"max_score": 0.23013961,
"hits": [
{
...
"_score": 0.23013961,
"_source": {
"first_name" : "王",
"last_name" : "五",
"age" : 30,
"about" : "喜欢树、love cats",
"interests": [ "music", "books" ]
},
"highlight": {
"about": [
"<em>喜欢树</em>、love cats"
]
}
}
]
}
}

分析

最后一个业务需求:支持管理者对雇员目录做分析。ES 有一个功能叫聚合(aggregations),允许我们基于数据生成一些精细的分析结果。聚合与 SQL 中的 GROUP BY 类似但更强大。大家不妨再考虑用 SQL 语句实现下面的查询作为对比。

举个例子,挖掘出雇员中最受欢迎的兴趣爱好:

1
2
3
4
5
6
7
8
GET /fuckgtw/employee/_search
{
"aggs": {
"all_interests": {
"terms": { "field": "interests" }
}
}
}

暂时忽略掉语法,直接看看结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
...
"hits": { ... },
"aggregations": {
"all_interests": {
"buckets": [
{
"key": "sports",
"doc_count": 2
},
{
"key": "music",
"doc_count": 2
},
{
"key": "books",
"doc_count": 1
}
]
}
}
}

可以看到,两位员工对运动感兴趣,两位对音乐感兴趣,一位对书籍感兴趣。这些聚合并非预先统计,而是从匹配当前查询的文档中即时生成。如果想知道姓张的雇员中最受欢迎的兴趣爱好,可以直接添加适当的查询来组合查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET /fuckgtw/employee/_search
{
"query": {
"match": {
"first_name": "张"
}
},
"aggs": {
"all_interests": {
"terms": {
"field": "interests"
}
}
}
}

此时,all_interests 聚合已经变为只包含匹配查询的文档:

1
2
3
4
5
6
7
8
9
10
11
12
"all_interests": {
"buckets": [
{
"key": "sports",
"doc_count": 2
},
{
"key": "music",
"doc_count": 1
}
]
}

聚合还支持分级汇总 。比如,查询特定兴趣爱好员工的平均年龄:

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /fuckgtw/employee/_search
{
"aggs" : {
"all_interests" : {
"terms" : { "field" : "interests" },
"aggs" : {
"avg_age" : {
"avg" : { "field" : "age" }
}
}
}
}
}

得到的聚合结果有点儿复杂,但理解起来还是很简单的:

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
...
"all_interests": {
"buckets": [
{
"key": "sports",
"doc_count": 2,
"avg_age": {
"value": 24.5
}
},
{
"key": "music",
"doc_count": 2,
"avg_age": {
"value": 27.5
}
},
{
"key": "books",
"doc_count": 1,
"avg_age": {
"value": 30
}
}
]
}

输出基本是第一次聚合的加强版。依然有一个兴趣及数量的列表,只不过每个兴趣都有了一个附加的 avg_age 属性,代表有这个兴趣爱好的所有员工的平均年龄。

即使现在不太理解这些语法也没有关系,依然很容易了解到复杂聚合及分组通过 ES 特性实现得很完美。可提取的数据类型毫无限制,并且对比传统 SQL 查询也具有相当大的优势。

尾巴

到了这里,相信大家已经初步了解了 ES 的基本使用,当然这仅仅是 ES 的冰山一角,更多诸如 suggestions、geolocation、percolation、fuzzy 与 partial matching 等特性均被省略,但它确实突显了开始构建高级搜索功能多么容易。不需要配置,只需要添加数据就能开始搜索。

可能上面很多语法会让你在某些地方有所困惑,并且对各个方面如何微调也有一些问题,其实我们都一样,打开一扇门会发现还有另一扇门等待着我们,编程路漫漫,且行且修行…