在此特别感谢黑马程序员提供的课程

初识ElasticSearch

了解ES

ElasticSearch的作用

  • ElasticSearch是一款非常强大的开源搜素引擎,具备非常强大的功能,可以帮助我们从海量数据中快速找到需要的内容
  • 例如在电商平台搜索商品,搜索4090显卡会以红色标识
    zvVHPA.png
  • 在搜索引擎搜索答案,搜索到的内容同样会以红色标识,也可以实现搜索时的自动补全功能
    zvVLxP.png

ELK技术栈

  • ElasticSearch结合kibanaLogstashBeats,也就是elastic stack(ELK)。被广泛应用在日志数据分析、实时监控等领域
  • ElasticSearchelastic stack的核心,负责存储、搜索、分析数据

ElasticSearch和Lucene

  • ElasticSearch底层是基于Lucene来实现的

  • Lucene是一个Java语言的搜索引擎类库,是Apache公司的顶级项目,由DougCutting于1999年研发,官网地址:https://lucene.apache.org/

  • Lucene的优势

    • 易扩展
    • 高性能(基于倒排索引)
  • Lucene的缺点

    • 只限于Java语言开发
    • 学习曲线陡峭
    • 不支持水平扩展
  • ElasticSearch的发展史

    • 2004年,Shay Banon基于Lucene开发了Compass
    • 2010年,Shay Banon重写了Compass,取名为ElasticSearch,官网地址:https://www.elastic.co/cnl/
  • 相比于Lucene,ElasticSearch具备以下优势

    • 支持分布式,可水平扩展
    • 提供Restful接口,可以被任意语言调用

总结

  • 什么是ElasticSearch?
    • 一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能
  • 什么是Elastic Stack(ELK)?
    • 它是以ElasticSearch为核心的技术栈,包括beats、Logstash、kibana、elasticsearch
  • 什么是Lucene?
    • 是Apache的开源搜索引擎类库,提供了搜索引擎的核心API

倒排索引

  • 倒排索引的概念是基于MySQL这样的正向索引而言的

正向索引

  • 为了搞明白什么是倒排索引,我们先来看看什么是正向索引,例如给下表中的id创建索引
id title price
1 小米手机 3499
2 华为手机 4999
3 华为小米充电器 49
4 小米手环 49
  • 如果是基于id查询,那么直接走索引,查询速度非常快。
  • 但是实际应用里,用户并不知道每一个商品的id,他们只知道title(商品名称),所以对于用户的查询方式,是基于title(商品名称)做模糊查询,只能是逐行扫描数据
1
select id, title, price from tb_goods where title like %手机%
  • 具体流程如下
    1. 用户搜索数据,搜索框输入手机,那么条件就是title符合%手机%
    2. 逐行获取数据
    3. 判断数据中的title是否符合用户搜索条件
    4. 如果符合,则放入结果集,不符合则丢弃
  • 逐行扫描,也就是全表扫描,随着数据量的增加,其查询效率也会越来越低。当数据量达到百万时,这将是一场灾难

倒排索引

  • 倒排索引中有两个非常重要的概念
    1. 文档(Document):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息
    2. 词条(Term):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我最喜欢的FPS游戏是Apex,就可以分为我、我最喜欢、FPS游戏、最喜欢的FPS、Apex这样的几个词条
  • 创建倒排索引是对正向索引的一种特殊处理,流程如下
    • 将每一个文档的数据利用算法分词,得到一个个词条
    • 创建表,每行数据包括词条、词条所在文档id、位置等信息
    • 因为词条唯一性,可以给词条创建索引,例如hash表结构索引
词条(term) 文档id
小米 1,3,4
手机 1,2
华为 2,3
充电器 3
手环 4
  • 以搜索华为手机为例
    1. 用户输入条件华为手机,进行搜索。
    2. 对用户输入的内容分词,得到词条:华为、手机。
    3. 拿着词条在倒排索引中查找,可以得到包含词条的文档id为:1、2、3。
    4. 拿着文档id到正向索引中查找具体文档
  • 虽然要先查询倒排索引,再查询正向索引,但是无论是词条还是文档id,都建立了索引,所以查询速度非常快,无需全表扫描

正向和倒排

  • 那么为什么一个叫做正向索引,一个叫做倒排索引呢?
    • 正向索引是最传统的,根据id索引的方式。但是根据词条查询是,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档查找词条的过程
    • 倒排索引则相反,是先找到用户要搜索的词条,然后根据词条得到包含词条的文档id,然后根据文档id获取文档,是根据词条查找文档的过程
  • 那么二者的优缺点各是什么呢?
    • 正向索引
      • 优点:可以给多个字段创建索引,根据索引字段搜索、排序速度非常快
      • 缺点:根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描
    • 倒排索引
      • 优点:根据词条搜索、模糊搜索时,速度非常快
      • 缺点:只能给词条创建索引,而不是字段,无法根据字段做排序

ES的一些概念

ElasticSearch中有很多独有的概念,与MySQL中略有差别,但也有相似之处

文档和字段

  • ElasticSearch是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为json格式后存储在ElasticSearch中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"id": 1,
"title": "小米手机",
"price": 3499
}

{
"id": 2,
"title": "华为手机",
"price": 4999
}

{
"id": 3,
"title": "华为小米充电器",
"price": 49
}

{
"id": 4,
"title": "小米手环",
"price ": 299
}
  • 而Json文档中往往包含很多的字段(Field),类似于数据库中的列

索引和映射

  • 索引(Index),就是相同类型的文档的集合

  • 例如

    • 所有用户文档,可以组织在一起,成为用户的索引
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    {
    "id": 101,
    "name": "张三",
    "age": 39
    }

    {
    "id": 102,
    "name": "李四",
    "age": 49
    }

    {
    "id": 103,
    "name": "王五",
    "age": 69
    }
    • 所有商品的文档,可以组织在一起,称为商品的索引
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    {
    "id": 1,
    "title": "小米手机",
    "price": 3499
    }

    {
    "id": 2,
    "title": "华为手机",
    "price": 4999
    }

    {
    "id": 3,
    "title": "苹果手机",
    "price": 6999
    }
    • 所有订单的文档,可以组织在一起,称为订单的索引
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    {
    "id": 11,
    "userId": 101,
    "goodsId": 1,
    "totalFee": 3999
    }

    {
    "id": 12,
    "userId": 102,
    "goodsId": 2,
    "totalFee": 4999
    }

    {
    "id": 13,
    "userId": 103,
    "goodsId": 3,
    "totalFee": 6999
    }
  • 因此,我们可以把索引当做是数据库中的表

  • 数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。因此,索引库就有映射(mapping),是索引中文档的字段约束信息,类似于表的结构约束

MySQL与ElasticSearch

  • 我们统一的把MySQL和ElasticSearch的概念做一下对比
MySQL Elasticsearch 说明
Table Index 索引(index),就是文档的集合,类似数据库的表(Table)
Row Document 文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式
Column Field 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column)
Schema Mapping Mapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
SQL DSL DSL是elasticsearch提供的JSON风格的请求语句,用来操作elasticsearch,实现CRUD
  • 二者各有自己擅长之处

    • MySQL:产长事务类型操作,可以保证数据的安全和一致性
    • ElasticSearch:擅长海量数据的搜索、分析、计算
  • 因此在企业中,往往是这二者结合使用

    • 对安全性要求较高的写操作,使用MySQL实现
    • 对查询性能个较高的搜索需求,使用ElasticSearch实现
    • 二者再基于某种方式,实现数据的同步,保证一致性

安装ES、Kibana

部署单点ES

  • 因为我们还需要部署Kibana容器,因此需要让es和kibana容器互联,这里先创建一个网络(使用compose部署可以一键互联,不需要这个步骤,但是将来有可能不需要kbiana,只需要es,所以先这里手动部署单点es)
1
docker network create es-net
  • 拉取镜像,这里采用的是ElasticSearch的7.12.1版本镜像
1
docker pull elasticsearch:7.12.1
  • 运行docker命令,部署单点ES
1
2
3
4
5
6
7
8
9
10
docker run -d \
--name es \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
-e "discovery.type=single-node" \
-v es-data:/usr/share/elasticsearch/data \
-v es-plugins:/usr/share/elasticsearch/plugins \
--privileged \
--network es-net \
-p 9200:9200 \
elasticsearch:7.12.1
  • 命令解释:

    • -e "ES_JAVA_OPTS=-Xms512m -Xmx512m":配置JVM的堆内存大小,默认是1G,但是最好不要低于512M
    • -e "discovery.type=single-node":单点部署
    • -v es-data:/usr/share/elasticsearch/data:数据卷挂载,绑定es的数据目录
    • -v es-plugins:/usr/share/elasticsearch/plugins:数据卷挂载,绑定es的插件目录
    • -privileged:授予逻辑卷访问权
    • --network es-net:让ES加入到这个网络当中
    • -p 9200:暴露的HTTP协议端口,供我们用户访问的
  • 成功启动之后,打开浏览器访问:http://192.168.128.130:9200/, 即可看到elasticsearch的响应结果

部署kibana

  • 同样是先拉取镜像,注意版本需要与ES保持一致
1
docker pull kibana:7.12.1
  • 运行docker命令,部署kibana
1
2
3
4
5
6
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=es-net \
-p 5601:5601 \
kibana:7.12.1
  • 命令解释
    • --network=es-net:让kibana加入es-net这个网络,与ES在同一个网络中
    • -e ELASTICSEARCH_HOSTS=http://es:9200:设置ES的地址,因为kibana和ES在同一个网络,因此可以直接用容器名访问ES
    • -p 5601:5601:端口映射配置
  • 成功启动后,打开浏览器访问:http://192.168.128.130:5601/ ,即可以看到结果

DevTools

  • kibana中提供了一个DevTools界面,在这个界面中我们可以编写DSL来操作ElasticSearch,并且有对DSL语句的自动补全功能

安装IK分词器

  • 默认的分词对中文的支持不是很好,所以这里我们需要安装IK插件
  • 在线安装IK插件
1
2
3
4
5
6
7
8
9
10
## 进入容器内部
docker exec -it elasticsearch /bin/bash

## 在线下载并安装
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip

#退出
exit
#重启容器
docker restart elasticsearch
  • IK分词器包含两种模式
    • ik_smart:最少切分
    • ik_max_word:最细切分
  • 下面我们分别测试这两种模式
1
2
3
4
5
GET /_analyze
{
"analyzer": "ik_smart",
"text": "青春猪头G7人马文不会梦到JK黑丝兔女郎铁驭艾许"
}
  • 结果
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
{
"tokens" : [
{
"token" : "青春",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "猪头",
"start_offset" : 2,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "G7",
"start_offset" : 4,
"end_offset" : 6,
"type" : "LETTER",
"position" : 2
},
{
"token" : "人",
"start_offset" : 6,
"end_offset" : 7,
"type" : "COUNT",
"position" : 3
},
{
"token" : "不会",
"start_offset" : 7,
"end_offset" : 9,
"type" : "CN_WORD",
"position" : 4
},
{
"token" : "梦到",
"start_offset" : 9,
"end_offset" : 11,
"type" : "CN_WORD",
"position" : 5
},
{
"token" : "jk",
"start_offset" : 11,
"end_offset" : 13,
"type" : "ENGLISH",
"position" : 6
},
{
"token" : "黑",
"start_offset" : 13,
"end_offset" : 14,
"type" : "CN_CHAR",
"position" : 7
},
{
"token" : "丝",
"start_offset" : 14,
"end_offset" : 15,
"type" : "CN_CHAR",
"position" : 8
},
{
"token" : "兔女郎",
"start_offset" : 15,
"end_offset" : 18,
"type" : "CN_WORD",
"position" : 9
},
{
"token" : "铁",
"start_offset" : 18,
"end_offset" : 19,
"type" : "CN_CHAR",
"position" : 10
},
{
"token" : "驭",
"start_offset" : 19,
"end_offset" : 20,
"type" : "CN_CHAR",
"position" : 11
},
{
"token" : "艾",
"start_offset" : 20,
"end_offset" : 21,
"type" : "CN_CHAR",
"position" : 12
},
{
"token" : "许",
"start_offset" : 21,
"end_offset" : 22,
"type" : "CN_CHAR",
"position" : 13
}
]
}
1
2
3
4
5
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "青春猪头G7人马文不会梦到JK黑丝兔女郎铁驭艾许"
}
  • 结果
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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
{
"tokens" : [
{
"token" : "青春",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "猪头",
"start_offset" : 2,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "G7",
"start_offset" : 4,
"end_offset" : 6,
"type" : "LETTER",
"position" : 2
},
{
"token" : "G",
"start_offset" : 4,
"end_offset" : 5,
"type" : "ENGLISH",
"position" : 3
},
{
"token" : "7",
"start_offset" : 5,
"end_offset" : 6,
"type" : "ARABIC",
"position" : 4
},
{
"token" : "人马",
"start_offset" : 6,
"end_offset" : 8,
"type" : "CN_WORD",
"position" : 5
},
{
"token" : "人",
"start_offset" : 6,
"end_offset" : 7,
"type" : "COUNT",
"position" : 6
},
{
"token" : "马文",
"start_offset" : 7,
"end_offset" : 9,
"type" : "CN_WORD",
"position" : 7
},
{
"token" : "不会",
"start_offset" : 9,
"end_offset" : 11,
"type" : "CN_WORD",
"position" : 8
},
{
"token" : "梦到",
"start_offset" : 11,
"end_offset" : 13,
"type" : "CN_WORD",
"position" : 9
},
{
"token" : "jk",
"start_offset" : 13,
"end_offset" : 15,
"type" : "ENGLISH",
"position" : 10
},
{
"token" : "黑",
"start_offset" : 15,
"end_offset" : 16,
"type" : "CN_CHAR",
"position" : 11
},
{
"token" : "丝",
"start_offset" : 16,
"end_offset" : 17,
"type" : "CN_CHAR",
"position" : 12
},
{
"token" : "兔女郎",
"start_offset" : 17,
"end_offset" : 20,
"type" : "CN_WORD",
"position" : 13
},
{
"token" : "女郎",
"start_offset" : 18,
"end_offset" : 20,
"type" : "CN_WORD",
"position" : 14
},
{
"token" : "铁",
"start_offset" : 20,
"end_offset" : 21,
"type" : "CN_CHAR",
"position" : 15
},
{
"token" : "驭",
"start_offset" : 21,
"end_offset" : 22,
"type" : "CN_CHAR",
"position" : 16
},
{
"token" : "艾",
"start_offset" : 22,
"end_offset" : 23,
"type" : "CN_CHAR",
"position" : 17
},
{
"token" : "许",
"start_offset" : 23,
"end_offset" : 24,
"type" : "CN_CHAR",
"position" : 18
}
]
}
  • 可以看到G7人马文在最少切分时,没有被分为人马,而在最细切分时,被分为了人马,而且目前现在识别不了黑丝铁驭艾许等词汇,所以我们需要自己扩展词典

随着互联网的发展,造词运动也愈发频繁。出现了许多新词汇,但是在原有的词汇表中并不存在,例如白给白嫖
所以我们的词汇也需要不断的更新,IK分词器提供了扩展词汇的功能

  1. 打开IK分词器的config目录
  2. 找到IKAnalyzer.cfg.xml文件,并添加如下内容
1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 -->
<entry key="ext_dict">ext.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典-->
<entry key="ext_stopwords">stopword.dic</entry>
</properties>
  1. 在IKAnalyzer.cfg.xml同级目录下新建ext.dic和stopword.dic,并编辑内容
1
2
3
艾许
铁驭
黑丝
1
兔女郎
  1. 重启es
1
docker restart es
  1. 测试
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
{
"tokens" : [
{
"token" : "青春",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "猪头",
"start_offset" : 2,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "g7",
"start_offset" : 4,
"end_offset" : 6,
"type" : "LETTER",
"position" : 2
},
{
"token" : "人",
"start_offset" : 6,
"end_offset" : 7,
"type" : "COUNT",
"position" : 3
},
{
"token" : "不会",
"start_offset" : 7,
"end_offset" : 9,
"type" : "CN_WORD",
"position" : 4
},
{
"token" : "梦到",
"start_offset" : 9,
"end_offset" : 11,
"type" : "CN_WORD",
"position" : 5
},
{
"token" : "jk",
"start_offset" : 11,
"end_offset" : 13,
"type" : "ENGLISH",
"position" : 6
},
{
"token" : "铁驭",
"start_offset" : 18,
"end_offset" : 20,
"type" : "CN_WORD",
"position" : 7
},
{
"token" : "艾许",
"start_offset" : 20,
"end_offset" : 22,
"type" : "CN_WORD",
"position" : 8
}
]
}
  • 分词器的作用是什么?
    • 创建倒排索引时对文档分词
    • 用户搜索时,对输入的内容分词
  • IK分词有几种模式?
    • ik_smart:智能切分,粗粒度
    • ik_max_word:最细切分,细粒度
  • IK分词器如何拓展词条?如何停用词条?
    • 利用config目录的IKAnalyzer.cfg.xml文件添加拓展词典和停用词典
    • 在词典中添加拓展词条或者停用词条

索引库操作

  • 索引库就类似于数据库表,mapping映射就类似表的结构
  • 我们要向es中存储数据,必须先创建

mapping映射属性

  • mapping是对索引库中文档的约束,常见的mapping属性包括
    • type:字段数据类型,常见的简单类型有
      1. 字符串:text(可分词文本)、keyword(精确值,例如:品牌、国家、ip地址;因为这些词,分词之后毫无意义)
      2. 数值:long、integer、short、byte、double、float
      3. 布尔:boolean
      4. 日期:date
      5. 对象:object
    • index:是否创建索引,默认为true,默认情况下会对所有字段创建倒排索引,即每个字段都可以被搜索。但是某些字段是不存在搜索的意义的,例如邮箱,图片(存储的只是图片url),搜索邮箱或图片url的片段,没有任何意义。因此我们在创建字段映射时,一定要判断一下这个字段是否参与搜索,如果不参与搜索,则将其设置为false
    • analyzer:使用哪种分词器
    • properties:该字段的子字段
  • 例如下面的json文档
1
2
3
4
5
6
7
8
9
10
11
12
{
    "age": 32,
    "weight": 48,
    "isMarried": false,
    "info": "次元游击兵--恶灵",
"email": "wraith@Apex.net",
"score": [99.1, 99.5, 98.9],
    "name": {
        "firstName": "雷尼",
        "lastName": "布莱希"
    }
}
  • 对应的每个字段映射(mapping):
字段 类型 index analyzer
age integer true null
weight float true null
isMarried boolean true null
info text true ik_smart
email keyword false null
score float true null
name object
name.firstName keyword true null
name.lastName keyword true null
  • 其中score:虽然是数组,但是我们只看其中元素的类型,类型为float;email不参与搜索,所以indexfalseinfo参与搜索,且需要分词,所以需要设置一下分词器

小结

  • mapping常见属性有哪些?
    1. type:数据类型
    2. index:是否创建索引
    3. analyzer:选择分词器
    4. properties:子字段
  • type常见的有哪些
    1. 字符串:text、keyword
    2. 数值:long、integer、short、byte、double、float
    3. 布尔:boolean
    4. 日期:date
    5. 对象:object

索引库的CRUD

  • 这里是使用的Kibana提供的DevTools编写DSL语句

创建索引库和映射

  • 基本语法
    • 请求方式:PUT
    • 请求路径:/{索引库名},可以自定义
    • 请求参数:mapping映射
  • 格式
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
PUT /{索引库名}
{
"mappings": {
"properties": {
"字段名1": {
"type": "text ",
"analyzer": "standard"
},
"字段名2": {
"type": "text",
"index": true
},
"字段名3": {
"type": "text",
"properties": {
"子字段1": {
"type": "keyword"
},
"子字段2": {
"type": "keyword"
}
}
}
}
}
}
  • 示例
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
PUT /test001
{
"mappings": {
"properties": {
"info": {
"type": "text",
"analyzer": "ik_smart"
},
"email": {
"type": "keyword",
"index": false
},
"name": {
"type": "object",
"properties": {
"firstName": {
"type": "keyword"
},
"lastName": {
"type": "keyword"
}
}
}
}
}
}

查询索引库

  • 基本语法
    • 请求方式:GET
    • 请求路径:/{索引库名}
    • 请求参数:
  • 格式:
1
GET /{索引库名}
  • 举例:
1
GET /test001

修改索引库

  • 基本语法
    • 请求方式:PUT
    • 请求路径:/{索引库名}/_mapping
    • 请求参数:mapping映射
  • 格式:
1
2
3
4
5
6
7
8
PUT /{索引库名}/_mapping
{
  "properties": {
    "新字段名":{
      "type": "integer"
    }
  }
}
  • 倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,就无法修改mapping
  • 虽然无法修改mapping中已有的字段,但是却允许添加新字段到mapping中,因为不会对倒排索引产生影响
  • 示例:
1
2
3
4
5
6
7
8
PUT /test001/_mapping
{
"properties": {
"age": {
"type": "integer"
}
}
}

  • 如果强行改,则会报错

删除索引库

  • 基本语法:
    • 请求方式:DELETE
    • 请求路径:/{索引库名}
    • 请求参数:无
  • 格式
1
DELETE /{索引库名}

总结

  • 索引库操作有哪些?
    1. 创建索引名:PUT /{索引库名}
    2. 查询索引库:GET /{索引库名}
    3. 删除索引库:DELETE /{索引库名}
    4. 添加字段:PUT /{索引库名}/_mapping

文档操作

新增文档

  • 语法
1
2
3
4
5
6
7
8
9
10
POST /{索引库名}/_doc/{文档id}
{
    "字段1": "值1",
    "字段2": "值2",
    "字段3": {
        "子属性1": "值3",
        "子属性2": "值4"
    },
// ...
}
  • 示例
1
2
3
4
5
6
7
8
9
POST /test001/_doc/1
{
"info": "次元游记兵--恶灵",
"email": "wraith@Apex.net",
"name": {
"firstName": "雷尼",
"lastName": "布莱希"
}
}
  • 响应

查询文档

  • 根据rest风格,新增是post,查询应该是get,而且一般查询都需要条件,这里我们把文档id带上
  • 语法
1
GET /{索引库名}/_doc/{id}
  • 示例
1
GET /test001/_doc/1
  • 查看结果,若未查询到结果,found为false

删除文档

  • 删除使用DELETE请求,同样,需要根据id进行删除
  • 语法
1
DELETE /{索引库名}/_doc/{id}
  • 示例:根据id删除数据, 若删除的文档不存在, 则result为not found
1
DELETE /test001/_doc/1

修改文档

  • 修改有两种方式
    1. 全量修改:直接覆盖原来的文档
    2. 增量修改:修改文档中的部分字段

全量修改

  • 全量修改是覆盖原来的文档,其本质是
    • 根据指定的id删除文档
    • 新增一个相同id的文档

注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了

  • 语法
1
2
3
4
5
6
PUT /{索引库名}/_doc/{文档id}
{
    "字段1": "值1",
    "字段2": "值2",
// ... 略
}
  • 示例
1
2
3
4
5
6
7
8
9
PUT /test001/_doc/1
{
"info": "爆破专家--暴雷",
"email": "@Apex.net",
"name": {
"firstName": "沃尔特",
"lastName": "菲茨罗伊"
}
}

增量修改

  • 增量修改只修改指定id匹配文档中的部分字段
  • 语法
1
2
3
4
5
6
7
POST /{索引库名}/_update/{文档id}
{
    "doc": {
"字段名": "新的值",
...
}
}
  • 示例
1
2
3
4
5
6
7
POST /test001/_update/1
{
"doc":{
"email":"BestApex@Apex.net",
"info":"恐怖G7人--马文"
}
}

  • 查看修改指定字段后的文档

总结

  • 文档的操作有哪些?
    1. 创建文档:POST /{索引库名}/_doc/{id}
    2. 查询文档:GET /{索引库名}/_doc/{id}
    3. 删除文档:DELETE /{索引库名}/_doc/{id}
    4. 修改文档
    • 全量修改:PUT /{索引库名}/_doc/{id}
    • 增量修改:POST /{索引库名}/_update/{id}

RestAPI

  • ES官方提供了各种不同语言的客户端用来操作ES。这些客户端的本质就是组装DSL语句,通过HTTP请求发送给ES。
  • 官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html
  • 其中JavaRestClient又包括两种
    • Java Low Level Rest Client
    • Java High Level Rest Client
  • 这里学习的是Java High Level Rest Client

导入Demo工程

  • 导入黑马提供的数据库数据和hotel-demo,其中表结构如下
字段 类型 长度 注释
id bigint 20 酒店id
name varchar 255 酒店名称
address varchar 255 酒店地址
price int 10 酒店价格
score int 2 酒店评分
brand varchar 32 酒店品牌
city varchar 32 所在城市
star_name varchar 16 酒店星级,1星到5星,1钻到5钻
business varchar 255 商圈
latitude varchar 32 纬度
longitude varchar 32 经度
pic varchar 255 酒店图片

mapping映射分析

  • 创建索引库,最关键的是mapping映射,而mapping映射要考虑的信息包括
    1. 字段名?
    2. 字段数据类型?
    3. 是否参与搜索?
    4. 是否需要分词?
      • 如果分词,分词器是什么?
  • 其中
    • 字段名、字段数据类型,可以参考数据表结构的名称和类型
    • 是否参与搜索要分析业务来判断,例如图片地址,就无需参与搜索
    • 是否分词要看内容,如果内容是一个整体就无需分词,反之则需要分词
    • 分词器,为了提高被搜索到的概率,统一使用最细切分ik_max_word
  • 下面我们来分析一下酒店数据的索引库结构
    • id:id的类型比较特殊,不是long,而是keyword,而且id后期肯定需要涉及到我们的增删改查,所以需要参与搜索
    • name:需要参与搜索,而且是text,需要参与分词,分词器选择ik_max_word
    • address:是字符串,但是个人感觉不需要分词(所以这里把它设为keyword),当然你也可以选择分词,个人感觉不需要参与搜索,所以index为false
    • price:类型:integer,需要参与搜索(做范围排序)
    • score:类型:integer,需要参与搜索(做范围排序)
    • brand:类型:keyword,但是不需要分词(品牌名称分词后毫无意义),所以为keyword,需要参与搜索
    • city:类型:keyword,分词无意义,需要参与搜索
    • star_name:类型:keyword,需要参与搜索
    • business:类型:keyword,需要参与搜索
    • latitudelongitude:地理坐标在ES中比较特殊,ES中支持两种地理坐标数据类型:
      1. geo_point:由纬度(latitude)和经度( longitude)确定的一个点。例如:“32.8752345,120.2981576”
      2. geo_shape:有多个geo_point组成的复杂几何图形。例如一条直线,“LINESTRING (-77.03653 38.897676,-77.009051 38.889939)”
      • 所以这里应该是geo_point类型
    • pic:类型:keyword,不需要参与搜索,index为false
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
PUT /hotel
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "ik_max_word"
},
"address": {
"type": "keyword",
"index": false
},
"price": {
"type": "integer"
},
"score": {
"type": "integer"
},
"brand": {
"type": "keyword"
},
"city": {
"type": "keyword"
},
"starName": {
"type": "keyword"
},
"business": {
"type": "keyword"
},
"location": {
"type": "geo_point"
},
"pic": {
"type": "keyword",
"index": false
}
}
}
}
  • 但是现在还有一个小小的问题,现在我们的name、brand、city字段都需要参与搜索,也就意味着用户在搜索的时候,会根据多个字段搜,例如:上海虹桥希尔顿五星酒店
  • 那么ES是根据多个字段搜效率高,还是根据一个字段搜效率高
    • 显然是搜索一个字段效率高
  • 那现在既想根据多个字段搜又想要效率高,怎么解决这个问题呢?
    • ES给我们提供了一种简单的解决方案

字段拷贝可以使用copy_to属性,将当前字段拷贝到指定字段,示例

1
2
3
4
5
6
7
8
"all": {
"type": "text",
"analyzer": "ik_max_word"
},
"brand": {
"type": "keyword",
"copy_to": "all"
}
  • 那现在修改我们的DSL语句
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
PUT /hotel
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "ik_max_word",
"copy_to": "all"
},
"address": {
"type": "keyword",
"index": false
},
"price": {
"type": "integer"
},
"score": {
"type": "integer"
},
"brand": {
"type": "keyword",
"copy_to": "all"
},
"city": {
"type": "keyword"
},
"starName": {
"type": "keyword"
},
"business": {
"type": "keyword"
, "copy_to": "all"
},
"location": {
"type": "geo_point"
},
"pic": {
"type": "keyword",
"index": false
},
"all":{
"type": "text",
"analyzer": "ik_max_word"
}
}
}
}

初始化RestCliet

  • ElasticSearch提供的API中,与ElasticSearch一切交互都封装在一个名为RestHighLevelClient的类中,必须先完成这个对象的初始化,建立与ES的连接
  1. 引入ES的RestHighLevelClient的依赖
1
2
3
4
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
  1. 因为SpringBoot管理的ES默认版本为7.6.2,所以我们需要覆盖默认的ES版本
1
2
3
4
<properties>
<java.version>1.8</java.version>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
  1. 初始化RestHighLevelClient
1
2
3
RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
));
  • 但是为了单元测试方便,我们创建一个测试类HotelIndexTest,在成员变量声明一个RestHighLevelClient,然后将初始化的代码编写在@BeforeEach
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
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.IOException;

@SpringBootTest
class HotelDemoApplicationTests {

private RestHighLevelClient client;

@Test
void contextLoads() {

}

@BeforeEach
public void setup() {
this.client = new RestHighLevelClient(RestClient.builder(
new HttpHost("http://192.168.128.130:9200")
));
}

@AfterEach
void tearDown() throws IOException {
this.client.close();
}
}

创建索引库

代码解读

  • 创建索引库的代码如下
1
2
3
4
5
6
@Test
void testCreateHotelIndex() throws IOException {
CreateIndexRequest request = new CreateIndexRequest("hotel");
request.source(MAPPING_TEMPLATE, XContentType.JSON);
client.indices().create(request, RequestOptions.DEFAULT);
}
  • 代码分为三部分
    1. 创建Request对象,因为是创建索引库的操作,因此Request是CreateIndexRequest,这一步对标DSL语句中的PUT /hotel
    2. 添加请求参数,其实就是DSL的JSON参数部分,因为JSON字符很长,所以这里是定义了静态常量MAPPING_TEMPLATE,让代码看起来更优雅
    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
    public static final String MAPPING_TEMPLATE = "{\n" +
    " \"mappings\": {\n" +
    " \"properties\": {\n" +
    " \"id\": {\n" +
    " \"type\": \"keyword\"\n" +
    " },\n" +
    " \"name\": {\n" +
    " \"type\": \"text\",\n" +
    " \"analyzer\": \"ik_max_word\",\n" +
    " \"copy_to\": \"all\"\n" +
    " },\n" +
    " \"address\": {\n" +
    " \"type\": \"keyword\",\n" +
    " \"index\": false\n" +
    " },\n" +
    " \"price\": {\n" +
    " \"type\": \"integer\"\n" +
    " },\n" +
    " \"score\": {\n" +
    " \"type\": \"integer\"\n" +
    " },\n" +
    " \"brand\": {\n" +
    " \"type\": \"keyword\",\n" +
    " \"copy_to\": \"all\"\n" +
    " },\n" +
    " \"city\": {\n" +
    " \"type\": \"keyword\"\n" +
    " },\n" +
    " \"starName\": {\n" +
    " \"type\": \"keyword\"\n" +
    " },\n" +
    " \"business\": {\n" +
    " \"type\": \"keyword\"\n" +
    " , \"copy_to\": \"all\"\n" +
    " },\n" +
    " \"location\": {\n" +
    " \"type\": \"geo_point\"\n" +
    " },\n" +
    " \"pic\": {\n" +
    " \"type\": \"keyword\",\n" +
    " \"index\": false\n" +
    " },\n" +
    " \"all\":{\n" +
    " \"type\": \"text\",\n" +
    " \"analyzer\": \"ik_max_word\"\n" +
    " }\n" +
    " }\n" +
    " }\n" +
    "}";
    1. 发送请求,client.indics()方法的返回值是IndicesClient类型,封装了所有与索引库有关的方法

删除索引库

  • 删除索引库的DSL语句非常简单
1
DELETE /hotel
  • 与创建索引库相比
    • 请求方式由PUT变为DELETE
    • 请求路径不变
    • 无请求参数
  • 所以代码的差异,主要体现在Request对象上,整体步骤没有太大变化
    1. 创建Request对象,这次是DeleteIndexRequest对象
    2. 准备请求参数,这次是无参
    3. 发送请求,改用delete方法
1
2
3
4
5
@Test
void testDeleteHotelIndex() throws IOException {
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
client.indices().delete(request, RequestOptions.DEFAULT);
}

判断索引库是否存在

  • 判断索引库是否存在,本质就是查询,对应的DSL是
1
GET /hotel
  • 因此与删除的Java代码流程是类似的
    1. 创建Request对象,这次是GetIndexRequest对象
    2. 准备请求参数,这里是无参
    3. 发送请求,改用exists方法
1
2
3
4
5
6
@Test
void testGetHotelIndex() throws IOException {
GetIndexRequest request = new GetIndexRequest("hotel");
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
System.out.println(exists ? "索引库已存在" : "索引库不存在");
}

总结

  • JavaRestClient对索引库操作的流程计本类似,核心就是client.indices()方法来获取索引库的操作对象
  • 索引库操作基本步骤
    1. 初始化RestHighLevelClient
    2. 创建XxxIndexRequest。Xxx是Create、Get、Delete
    3. 准备DSL(Create时需要,其它是无参)
    4. 发送请求,调用ReseHighLevelClient.indices().xxx()方法,xxx是create、exists、delete

RestClient操作文档

  • 为了与索引库操作分离,我们再添加一个测试类,做两件事
    1. 初始化RestHighLevelClient
    2. 我们的酒店数据在数据库,需要利用IHotelService去查询,所以要注入这个接口
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
import cn.blog.hotel.service.IHotelService;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.IOException;

@SpringBootTest
public class HotelDocumentTest {

@Autowired
private IHotelService hotelService;
private RestHighLevelClient client;


@BeforeEach
void setUp() {
client = new RestHighLevelClient(RestClient.builder(
new HttpHost("http://192.168.128.130:9200")
));
}

@AfterEach
void tearDown() throws IOException {
client.close();
}
}

新增文档

  • 我们要把数据库中的酒店数据查询出来,写入ES中

索引库实体类

  • 数据库查询后的结果是一个Hotel类型的对象,结构如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
@TableName("tb_hotel")
public class Hotel {
@TableId(type = IdType.INPUT)
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String longitude;
private String latitude;
private String pic;
}
  • 但是与我们的索引库结构存在差异
    • longitude和latitude需要合并为location
  • 因此我们需要定义一个新类型,与索引库结构吻合
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
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class HotelDoc {
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;

public HotelDoc(Hotel hotel) {
this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
}
}

语法说明

  • 新增文档的DSL语法如下
1
2
3
4
5
POST /{索引库名}/_doc/{id}
{
"name": "Jack",
"age": 21
}
  • 对应的Java代码如下
1
2
3
4
5
6
@Test
void testIndexDocument() throws IOException {
IndexRequest request = new IndexRequest("indexName").id("1");
request.source("{\"name\":\"Jack\",\"age\":21}");
client.index(request, RequestOptions.DEFAULT);
}
  • 可以看到与创建索引库类似,同样是三步走:
    1. 创建Request对象
    2. 准备请求参数,也就是DSL中的JSON文档
    3. 发送请求
  • 变化的地方在于,这里直接使用client.xxx()的API,不再需要client.indices()了

完整代码

  • 我们导入酒店数据,基本流程一致,但是需要考虑几点变化
    1. 酒店数据来自于数据库,我们需要先从数据库中查询,得到Hotel对象
    2. Hotel对象需要转换为HotelDoc对象
    3. HotelDoc需要序列化为json格式
  • 因此,代码整体步骤如下
    1. 根据id查询酒店数据Hotel
    2. 将Hotel封装为HotelDoc
    3. 将HotelDoc序列化为Json
    4. 创建IndexRequest,指定索引库名和id
    5. 准备请求参数,也就是Json文档
    6. 发送请求
  • 在hotel-demo的HotelDocumentTest测试类中,编写单元测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
void testAddDocument() throws IOException {
// 1. 根据id查询酒店数据
Hotel hotel = hotelService.getById(61083L);
// 2. 转换为文档类型
HotelDoc hotelDoc = new HotelDoc(hotel);
// 3. 转换为Json字符串
String jsonString = JSON.toJSONString(hotelDoc);
// 4. 准备request对象
IndexRequest request = new IndexRequest();
// 5. 准备json文档
request.source(jsonString, XContentType.JSON);
// 6. 发送请求
client.index(request, RequestOptions.DEFAULT);
}
  • 在kibana中查询我们新增的文档,发现我们的文档主要是在_source属性里,记住这点,后面要用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"_index" : "hotel",
"_type" : "_doc",
"_id" : "61083",
"_version" : 1,
"_seq_no" : 0,
"_primary_term" : 1,
"found" : true,
"_source" : {
"address" : "自由贸易试验区临港新片区南岛1号",
"brand" : "皇冠假日",
"business" : "滴水湖临港地区",
"city" : "上海",
"id" : 61083,
"location" : "30.890867, 121.937241",
"name" : "上海滴水湖皇冠假日酒店",
"pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/312e971Rnj9qFyR3pPv4bTtpj1hX_w200_h200_c1_t0.jpg",
"price" : 971,
"score" : 44,
"starName" : "五钻"
}
}

查询文档

  • 查询的DSL语句如下
1
GET /hotel/_doc/{id}
  • 由于没有请求参数,所以非常简单,代码分为以下两步
    1. 准备Request对象
    2. 发送请求
    3. 解析结果
  • 不过查询的目的是为了得到HotelDoc,因此难点是结果的解析,在刚刚查询的结果中,我们发现HotelDoc对象的主要内容在_source属性中,所以我们要获取这部分内容,然后将其转化为HotelDoc
1
2
3
4
5
6
7
8
9
10
11
@Test
void testGetDocumentById() throws IOException {
// 1. 准备request对象
GetRequest request = new GetRequest("hotel").id("61083");
// 2. 发送请求,得到结果
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3. 解析结果
String jsonStr = response.getSourceAsString();
HotelDoc hotelDoc = JSON.parseObject(jsonStr, HotelDoc.class);
System.out.println(hotelDoc);
}

修改文档

  • 修改依旧是两种方式
    1. 全量修改:本质是先根据id删除,再新增
    2. 增量修改:修改文档中的指定字段值
  • 在RestClient的API中,全量修改与新增的API完全一致,判断的依据是ID
    • 若新增时,ID已经存在,则修改(删除再新增)
    • 若新增时,ID不存在,则新增
  • 这里就主要讲增量修改,对应的DSL语句如下
1
2
3
4
5
6
7
POST /test001/_update/1
{
"doc":{
"email":"BestApex@Apex.net",
"info":"恐怖G7人--马文"
}
}
  • 与之前类似,也是分为三步
    1. 准备Request对象,这次是修改,对应的就是UpdateRequest
    2. 准备参数,也就是对应的JSON文档,里面包含要修改的字段
    3. 发送请求,更新文档
1
2
3
4
5
6
7
8
9
10
11
@Test
void testUpdateDocumentById() throws IOException {
// 1. 准备request对象
UpdateRequest request = new UpdateRequest("hotel","61083");
// 2. 准备参数
request.doc(
"city","北京",
"price",1888);
// 3. 发送请求
client.update(request,RequestOptions.DEFAULT);
}

删除文档

  • 删除的DSL语句如下
1
DELETE /hotel/_doc/{id}
  • 与查询相比,仅仅是请求方式由DELETE变为GET,不难猜想对应的Java依旧是三步走
    1. 准备Request对象,因为是删除,所以是DeleteRequest对象,要指明索引库名和id
    2. 准备参数,无参
    3. 发送请求,因为是删除,所以是client.delete()方法
1
2
3
4
5
6
7
@Test
void testDeleteDocumentById() throws IOException {
// 1. 准备request对象
DeleteRequest request = new DeleteRequest("hotel","61083");
// 2. 发送请求
client.delete(request,RequestOptions.DEFAULT);
}
  • 成功删除之后,再调用查询的测试方法,返回值为null,删除成功

批量导入文档

  • 之前我们都是一条一条的新增文档,但实际应用中,还是需要批量的将数据库数据导入索引库中

需求:批量查询酒店数据,然后批量导入索引库中
思路:

  1. 利用mybatis-plus查询酒店数据
  2. 将查询到的酒店数据(Hotel)转化为文档类型数据(HotelDoc)
  3. 利用JavaRestClient中的Bulk批处理,实现批量新增文档,示例代码如下
1
2
3
4
5
6
7
@Test
void testBulkAddDoc() throws IOException {
BulkRequest request = new BulkRequest();
request.add(new IndexRequest("hotel").id("101").source("json source1", XContentType.JSON));
request.add(new IndexRequest("hotel").id("102").source("json source2", XContentType.JSON));
client.bulk(request, RequestOptions.DEFAULT);
}
  • 实现代码如下
1
2
3
4
5
6
7
8
9
10
11
12
@Test
void testBulkAddDoc() throws IOException {
BulkRequest request = new BulkRequest();
List<Hotel> hotels = hotelService.list();
for (Hotel hotel : hotels) {
HotelDoc hotelDoc = new HotelDoc(hotel);
request.add(new IndexRequest("hotel").
id(hotelDoc.getId().toString()).
source(JSON.toJSONString(hotelDoc), XContentType.JSON));
}
client.bulk(request, RequestOptions.DEFAULT);
}
  • 使用stream流操作可以简化代码
1
2
3
4
5
6
7
8
9
@Test
void testBulkAddDoc() throws IOException {
BulkRequest request = new BulkRequest();
hotelService.list().stream().forEach(hotel ->
request.add(new IndexRequest("hotel")
.id(hotel.getId().toString())
.source(JSON.toJSONString(new HotelDoc(hotel)), XContentType.JSON)));
client.bulk(request, RequestOptions.DEFAULT);
}

小结

  • 文档初始化的基本步骤
    1. 初始化RestHighLevelClient
    2. 创建XxxRequest对象,Xxx是Index、Get、Update、Delete
    3. 准备参数(Index和Update时需要)
    4. 发送请求,调用RestHighLevelClient.xxx方法,xxx是index、get、update、delete
    5. 解析结果(Get时需要)

DSL查询文档

  • ElasticSearch的查询依然是基于JSON风格的DSL来实现的

DSL查询分类

  • ElasticSearch提供了基于DSL来定义查询。常见的查询类型包括
    • 查询所有:查询出所有数据,一般测试用。例如
      • match_all
    • 全文检索(full text):利用分词器对用户输入的内容分词,然后去倒排索引库中匹配。例如
      • match_query
      • multi_match_query
    • 精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。例如
      • ids
      • range
      • term
    • 地理查询(geo):根据经纬度查询。例如
      • geo_distance
      • geo_bounding_box
    • 复合查询(compound):复合查询可以将上述各种查询条件组合起来,合并查询条件。例如
      • bool
      • function_score
  • 查询的语法基本一致
1
2
3
4
5
6
7
8
GET /indexname/_search
{
"query": {
"查询类型": {
"查询条件": "条件值"
}
}
}
  • 这里以查询所有为例
    • 查询类型为match_all
    • 没有查询条件
    1
    2
    3
    4
    5
    6
    7
    8
    GET /indexName/_search
    {
    "query": {
    "match_all": {

    }
    }
    }
  • 其他的无非就是查询类型查询条件的变化

全文检索的查询

使用场景

  • 全文检索的查询流程基本如下
    1. 根据用户搜索的内容做分词,得到词条
    2. 根据词条去倒排索引库中匹配,得到文档id
    3. 根据文档id找到的文档,返回给用户
  • 比较常用的场景包括
    • 商城的输入框搜索
    • 百度输入框搜索
  • 因为是拿着词条去匹配,因此参与搜索的字段也必须是可分词的text类型的字段

基本语法

  • 常见的全文检索包括
    • match查询:单字段查询
    • multi_match查询:多字段查询,任意一个字段符合条件就算符合查询条件
  • match查询语法如下
1
2
3
4
5
6
7
8
GET /indexName/_search
{
"query": {
"match": {
"FIELD": "TEXT"
}
}
}
  • multi_match语法如下
1
2
3
4
5
6
7
8
GET /indexName/_search
{
"query": {
"multi_match": {
"fields": ["FIELD1", "FIELD2"]
}
}
}

示例

  • 查询上海外滩的酒店数据
    • 以match查询示例,这里的all字段是之前由namecitybusiness这三个字段拷贝得来的
    1
    2
    3
    4
    5
    6
    7
    8
    GET /hotel/_search
    {
    "query": {
    "match": {
    "all": "上海外滩"
    }
    }
    }
    • 以multi_match查询示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    GET /hotel/_search
    {
    "query": {
    "multi_match": {
    "query": "上海外滩",
    "fields": ["brand", "city", "business"]
    }
    }
    }
  • 可以看到,这两种查询的结果是一样的,为什么?
    • 因为我们将namecitybusiness的值都利用copy_to复制到了all字段中,因此根据这三个字段搜索和根据all字段搜索的结果当然一样了
    • 但是搜索的字段越多,对查询性能影响就越大,因此建议采用copy_to,然后使用单字段查询的方式

小结

  • match和multi_match的区别是什么?
    • match:根据一个字段查询
    • multi_match:根据多个字段查询,参与查询的字段越多,查询性能就越差

精确查询

  • 精确查询一般是查找keyword、数值、日期、boolean等类型字段。所以不会对搜索条件分词。常见的有
    • term:根据词条精确值查询
    • range:根据值的范围查询

term查询

  • 因为紧缺查询的字段是不分词的字段,因此查询的条件也必须是部分词的词条。查询时,用户输入的内容跟字段值完全匹配时才认为符合条件。如果用户输入的内容过多或过少,都会搜索不到数据
  • 语法说明
1
2
3
4
5
6
7
8
9
10
GET /indexName/_search
{
"query": {
"term": {
"FIELD": {
"value": "VALUE"
}
}
}
}
  • 示例:查询北京的酒店数据
1
2
3
4
5
6
7
8
9
10
GET /hotel/_search
{
"query": {
"term": {
"city": {
"value": "北京"
}
}
}
}

  • 但是当搜索的内容不是词条时,而是多个词语组成的短语时,反而搜索不到

range查询

  • 范围查询,一般应用在对数值类型做范围过滤的时候。例如做价格范围的过滤
  • 基本语法
1
2
3
4
5
6
7
8
9
10
11
GET /hotel/_search
{
"query": {
"range": {
"FIELD": {
"gte": 10, //这里的gte表示大于等于,gt表示大于
"lte": 20 //这里的let表示小于等于,lt表示小于
}
}
}
}
  • 示例:查询酒店价格在1000~3000的酒店
1
2
3
4
5
6
7
8
9
10
11
GET /hotel/_search
{
"query": {
"range": {
"price": {
"gte": 1000,
"lte": 3000
}
}
}
}

小结

  • 精确查询常见的有哪些?
    1. term查询:根据词条精确匹配,一般搜索keyword类型、数值类型、布尔类型、日期类型字段
    2. range查询:根据数值范围查询,可以使数值、日期的范围

地理坐标查询

矩形范围查询

  • 矩形范围查询,也就是geo_bounding_box查询,查询坐标落在某个矩形范围内的所有文档
  • 查询时。需指定矩形的左上、游戏啊两个点的坐标,然后画出一个矩形,落在该矩形范围内的坐标,都是符合条件的文档
  • 基本语法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /indexName/_search
{
"query": {
"geo_bounding_box": {
"FIELD": {
"top_left": { // 左上点
"lat": 31.1, // lat: latitude 纬度
"lon": 121.5 // lon: longitude 经度
},
"bottom_right": { // 右下点
"lat": 30.9, // lat: latitude 纬度
"lon": 121.7 // lon: longitude 经度
}
}
}
}
}
  • 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /hotel/_search
{
"query": {
"geo_bounding_box": {
"location": {
"top_left": {
"lat": 31.1,
"lon": 121.5
},
"bottom_right": {
"lat": 30.9,
"lon": 121.7
}
}
}
}
}

附近查询

  • 附近查询,也叫做举例查询(geo_distance):查询到指定中心点的距离小于等于某个值的所有文档
  • 换句话说,也就是以指定中心点为圆心,指定距离为半径,画一个圆,落在圆内的坐标都算符合条件
  • 语法说明
1
2
3
4
5
6
7
8
9
GET /indexName/_search
{
"query": {
"geo_distance": {
"distance": "3km", // 半径
"location": "39.9, 116.4" // 圆心
}
}
}
  • 示例:查询我附近3km内的酒店文档
1
2
3
4
5
6
7
8
9
GET /hotel/_search
{
"query": {
"geo_distance": {
"distance": "3km",
"location": "39.9, 116.4"
}
}
}

复合查询

  • 复合(compound)查询:复合查询可以将其他简单查询组合起来,实现更复杂的搜索逻辑,常见的有两种
    1. function score:算分函数查询,可以控制文档相关性算分,控制文档排名(例如搜索引擎的排名,第一大部分都是广告)
    2. bool query:布尔查询,利用逻辑关系组合多个其他的查询,实现复杂搜索

相关性算分

  • 当我们利用match查询时,文档结果会根据搜索词条的关联度打分(_score),返回结果时按照分值降序排列
  • 例如我们搜索虹桥如家,结果如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
  {
    "_score" : 17.850193,
    "_source" : {
      "name" : "虹桥如家酒店真不错",
    }
  },
  {
    "_score" : 12.259849,
    "_source" : {
      "name" : "外滩如家酒店真不错",
    }
  },
  {
    "_score" : 11.91091,
    "_source" : {
      "name" : "迪士尼如家酒店真不错",
    }
  }
]
  • 在ES中,早期使用的打分算法是TF-IDF算法,公式如下

  • 再后来的5.1版本升级中,ES将算法改进为BM25算法,公式如下

  • TF-IDF算法有一种缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。而BM25则会让单个词条的算分有一个上限,曲线更平滑

  • 小结:ES会根据词条和文档的相关度做打分,算法有两种

    1. TF-IDF算法
    2. BM25算法, ES 5.1版本后采用的算法

算分函数查询

  • 根据相关度打分是比较合理的需求,但是合理的并不一定是产品经理需要的
  • 以某搜索引擎为例,你在搜索的结果中,并不是相关度越高就越靠前,而是谁掏的钱多就让谁的排名越靠前
  • 要想控制相关性算分,就需要利用ES中的function score查询了
  • 语法说明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET /indexName/_search
{
"query": {
"function_score": {
"query": {
"match": {
"all": "外滩"
}
},
"functions": [
{
"filter": {
"term": {
"id": "1"
}
},
"weight": 10
}
],
"boost_mode": "multiply"
}
}
}

  • function score查询中包含四部分内容
    1. 原始查询条件:query部分,基于这个条件搜索文档,并且基于BM25算法给文档打分,原始算分(query score)
    2. 过滤条件:filter部分,符合该条件的文档才会被重新算分
    3. 算分函数:符合filter条件的文档要根据这个函数做运算,得到函数算分(function score),有四种函数
      • weight:函数结果是常量
      • field_value_factor:以文档中的某个字段值作为函数结果
      • random_score:以随机数作为函数结果
      • script_score:自定义算分函数算法
    4. 运算模式:算分函数的结果、原始查询的相关性算分,二者之间的运算方式,包括
      • multiply:相乘
      • replace:用function score替换query score
      • 其他,例如:sum、avg、max、min
  • function score的运行流程如下
    1. 根据原始条件查询搜索文档,并且计算相关性算分,称为原始算法(query score)
    2. 根据过滤条件,过滤文档
    3. 符合过滤条件的文档,基于算分函数运算,得到函数算分(function score)
    4. 原始算分(query score)和函数算分(function score)基于运算模式做运算,得到最终给结果,作为相关性算分
  • 因此,其中的关键点是:
    • 过滤条件:决定哪些文档的算分被修改
    • 算分函数:决定函数算分的算法
    • 运算模式:决定最终算分结果

需求:给如家这个品牌的酒店排名靠前一点
思路:过滤条件为"brand": "如家",算分函数和运算模式我们可以暴力一点,固定算分结果相乘

  • 对应的DSL语句如下,我们搜索外滩的酒店,对如家品牌过滤,最终的运算结果是10倍的原始算分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET /hotel/_search
{
"query": {
"function_score": {
"query": {
"match": {
"all": "外滩"
}
},
"functions": [
{
"filter": {
"term": {
"brand": "如家"
}
},
"weight": 10
}
],
"boost_mode": "multiply"
}
}
}
  • 可以看到,如家的算分达到了38,而第二名仅有6,成功将如家品牌的酒店提升到第一名

布尔查询

  • 布尔查询是一个或多个子查询的组合,每一个子句就是一个子查询。子查询的组合方式有
    1. must:必须匹配每个子查询,类似
    2. should:选择性匹配子查询,类似
    3. must_not:必须不匹配,不参与算分,类似
    4. filter:必须匹配,不参与算分
  • 例如在搜索酒店时,除了关键字搜索外,我们还可能根据酒店品牌、价格、城市等字段做过滤
  • 每一个不同的字段,其查询条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就需要用到布尔查询了

需要注意的是,搜索时,参与打分的字段越多,查询的性能就越差,所以在多条件查询时

  • 搜索框的关键字搜索,是全文检索查询,使用must查询,参与算分
  • 其他过滤条件,采用filter和must_not查询,不参与算分

需求:搜索名字中包含如家,价格不高于400,在坐标39.9, 116.4周围10km范围内的酒店
分析:

  • 名称搜索,属于全文检索查询,应该参与算分,放到must
  • 价格不高于400,用range查询,属于过滤条件,不参与算分,放到must_not
  • 周围10km范围内,用geo_distance查询,属于过滤条件,放到filter
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
GET /hotel/_search
{
"query": {
"bool": {
"must": [
{
"term": {
"name": {
"value": "如家"
}
}
}
],
"must_not": [
{
"range": {
"price": {
"gt": 400
}
}
}
],
"filter": [
{
"geo_distance": {
"distance": "10km",
"location": {
"lat": 39.9,
"lon": 116.4
}
}
}
]
}
}
}

需求:搜索城市在上海,品牌为皇冠假日华美达,价格不低于500,且用户评分在45分以上的酒店

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
GET /hotel/_search
{
"query": {
"bool": {
"must": [
{"term": {
"city": {
"value": "上海"
}
}}
],
"should": [
{"term": {
"brand": {
"value": "皇冠假日"
}
}},
{"term": {
"brand": {
"value": "华美达"
}
}}
],
"must_not": [
{"range": {
"price": {
"lte": 500
}
}}
],
"filter": [
{"range": {
"score": {
"gte": 45
}
}}
]
}
}
}
  • 如果细心一点,就会发现这里的should有问题,must和should一起用的时候,should会不生效,结果中会查询到除了皇冠假日华美达之外的品牌。
  • 对于DSL语句的解决方案比较麻烦,需要在must里再套一个bool,里面再套should,但是对于Java代码来说比较容易修改
  • 小结:布尔查询有几种逻辑关系?
    1. must:必须匹配的条件,可以理解为
    2. should:选择性匹配的条件,可以理解为
    3. must_not:必须不匹配的条件,不参与打分,可以理解为
    4. filter:必须匹配的条件,不参与打分

搜索结果处理

  • 搜索的结果可以按照用户指定的方式去处理或展示

排序

  • ES默认是根据相关度算分(_score)来排序,但是也支持自定义方式对搜索结果排序。可以排序的字段有:keyword类型、数值类型、地理坐标类型、日期类型等

普通字段排序

  • keyword、数值、日期类型排序的语法基本一致
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /hotel/_search
{
"query": {
"match_all": {
}
},
"sort": [
{
"FIELD": {
"order": "desc"
},
"FIELD": {
"order": "asc"
}
}
]
}
  • 排序条件是一个数组,也就是可以写读个排序条件。按照声明顺序,当第一个条件相等时,再按照第二个条件排序,以此类推

需求:酒店数据按照用户评价(score)降序排序,评价相同再按照价格(price)升序排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"score": {
"order": "desc"
},
"price": {
"order": "asc"
}
}
]
}

地理坐标排序

  • 语法说明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance": {
"FIELD": {
"lat": 40,
"lon": -70
},
"order": "asc",
"unit": "km"
}
}
]
}
  • 这个查询的含义是
    • 指定一个坐标,作为目标点
    • 计算每一个文档中,指定字段(必须是geo_point类型)的坐标,到目标点的距离是多少
    • 根据距离排序

需求:实现酒店数据按照到你位置坐标的距离升序排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance": {
"location": {
"lat": 39.9,
"lon": 116.4
},
"order": "asc",
"unit": "km"
}
}
]
}
  • 从结果中可以看到,最近的是1.6km

分页

  • ES默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。
  • ES中通过修改from、size参数来控制要返回的分页结果
    • from:从第几个文档开始
    • size:总共查询几个文档
  • 类似于mysql中的limit ?, ?

基本的分页

  • 分页的基本语法如下
1
2
3
4
5
6
7
8
GET /indexName/_search
{
"query": {
"match_all": {}
},
"from": 0,
"size": 20
}

深度分页问题

  • 现在,我要查询990~1000条数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 990,
"size": 10,
"sort": [
{
"price": {
"order": "asc"
}
}
]
}
  • 这里是查询990开始的数据,也就是第991~第1000条数据
  • 不过,ES内部分页时,必须先查询0~1000条,然后截取其中990~1000的这10条
  • 查询TOP1000,如果ES是单点模式,那么并无太大影响
  • 但是ES将来一定是集群部署模式,例如我集群里有5个节点,我要查询TOP1000的数据,并不是每个节点查询TOP200就可以了。
  • 因为节点A的TOP200,可能在节点B排在10000名开外了
  • 因此想获取整个集群的TOP1000,就必须先查询出每个节点的TOP1000,汇总结果后,重新排名,重新截取TOP1000
  • 那么如果要查询9900~10000的数据呢?是不是要先查询TOP10000,然后汇总每个节点的TOP10000,重新排名呢?
  • 当查询分页深度较大时,汇总数据过多时,会对内存和CPU产生非常大的压力,因此ES会禁止form + size > 10000的请求
  • 针对深度分页,ES提供而两种解决方案,官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html
    1. search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式
    2. scrool:原理是将排序后的文档id形成快照,保存在内存。官方已经不推荐使用

小结

  • 分页查询的常见实现方案以及优缺点
    • from + size:
      • 优点:支持随机翻页
      • 缺点:深度分页问题,默认查询上限是10000(from + size)
      • 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索(百度现在支持翻页到75页,然后显示提示:限于网页篇幅,部分结果未予显示。)
    • after search:
      • 优点:没有查询上限(单词查询的size不超过10000)
      • 缺点:只能向后逐页查询,不支持随机翻页
      • 场景:没有随机翻页需求的搜索,例如手机的向下滚动翻页
    • scroll:
      • 优点:没有查询上限(单词查询的size不超过10000)
      • 缺点:会有额外内存消耗,并且搜索结果是非实时的(快照保存在内存中,不可能每搜索一次都更新一次快照)
      • 场景:海量数据的获取和迁移。从ES7.1开始不推荐,建议使用after search方案

高亮

高亮原理

  • 什么是高亮呢?
  • 我们在百度搜索时,关键字会变成红色,比较醒目,这就叫高亮显示
  • 高亮显示的实现分为两步
    1. 给文档中的所有关键字都添加一个标签,例如<em>标签
    2. 页面给<em>标签编写CSS样式

实现高亮

  • 高亮的语法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET /indexName/_search
{
"query": {
"match": {
"FIELD": "TEXT"
}
},
"highlight": {
"fields": {
"FIELD": {
"pre_tags": "<em>",
"post_tags": "</em>"
}
}
}
}

注意:

  • 高亮是对关键词高亮,因此搜索条件必须带有关键字,而不能是范围这样的查询
  • 默认情况下,高亮的字段,必须与搜索指定的字段一致,否则无法高亮
  • 如果要对非搜索字段高亮,则需要添加一个属性:required_field_match=false
  • 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /hotel/_search
{
"query": {
"match": {
"all": "上海如家"
}
},
"highlight": {
"fields": {
"name": {
"pre_tags": "<em>",
"post_tags": "</em>",
"require_field_match": "false"
}
}
}
}

  • 但默认情况下就是加的<em>标签,所以我们也可以省略
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET /hotel/_search
{
"query": {
"match": {
"all": "上海如家"
}
},
"highlight": {
"fields": {
"name": {
"require_field_match": "false"
}
}
}
}

小结

  • 查询的DSL是一个大的JSON对象,包含以下属性
    • query:查询条件
    • from和size:分页条件
    • sort:排序条件
    • highlight:高亮条件
  • 示例
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
GET /hotel/_search
{
"query": {
"match": {
"all": "上海如家"
}
},
"from": 0,
"size": 20,
"sort": [
{
"_geo_distance": {
"location": {
"lat": 39.9,
"lon": 116.4
},
"order": "asc",
"unit": "km"
},
"price": "asc"
}
],
"highlight": {
"fields": {
"name": {
"require_field_match": "false"
}
}
}
}

RestClient查询文档

  • 文档的查询同样适用于RestHighLevelClient对象,基本步骤包括
    1. 准备Request对象
    2. 准备请求参数
    3. 发起请求
    4. 解析响应

快速入门

  • 我们以match_all为例

发起查询请求

  • DSL语句的match_all
1
2
3
4
5
6
GET /hotel/_search
{
"query": {
"match_all": {}
}
}
  • 对应的java代码
1
2
3
4
5
6
7
8
9
10
@Test
void testMatchAll() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数 对应 "query": {"match_all": {}}
request.source().query(QueryBuilders.matchAllQuery());
// 3. 发送请求,得到相应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
System.out.println(response);
}
  • 代码解读
    1. 创建SearchRequest对象,指定索引库名
    2. 利用request.source()构建DSL,DSL中可以包含查询、分页、排序、高亮等
    3. 利用client.search()发送请求,得到响应
  • 输出结果就是我们在kibana中看到的JSON字符串
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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
{
"took": 3,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 201,
"relation": "eq"
},
"max_score": 1.0,
"hits": [{
"_index": "hotel",
"_type": "_doc",
"_id": "36934",
"_score": 1.0,
"_source": {
"address": "静安交通路40号",
"brand": "7天酒店",
"business": "四川北路商业区",
"city": "上海",
"id": 36934,
"location": "31.251433, 121.47522",
"name": "7天连锁酒店(上海宝山路地铁站店)",
"pic": "https://m.tuniucdn.com/fb2/t1/G1/M00/3E/40/Cii9EVkyLrKIXo1vAAHgrxo_pUcAALcKQLD688AAeDH564_w200_h200_c1_t0.jpg",
"price": 336,
"score": 37,
"starName": "二钻"
}
}, {
"_index": "hotel",
"_type": "_doc",
"_id": "38609",
"_score": 1.0,
"_source": {
"address": "广灵二路126号",
"brand": "速8",
"business": "四川北路商业区",
"city": "上海",
"id": 38609,
"location": "31.282444, 121.479385",
"name": "速8酒店(上海赤峰路店)",
"pic": "https://m.tuniucdn.com/fb2/t1/G2/M00/DF/96/Cii-TFkx0ImIQZeiAAITil0LM7cAALCYwKXHQ4AAhOi377_w200_h200_c1_t0.jpg",
"price": 249,
"score": 35,
"starName": "二钻"
}
}, {
"_index": "hotel",
"_type": "_doc",
"_id": "38665",
"_score": 1.0,
"_source": {
"address": "兰田路38号",
"brand": "速8",
"business": "长风公园地区",
"city": "上海",
"id": 38665,
"location": "31.244288, 121.422419",
"name": "速8酒店上海中山北路兰田路店",
"pic": "https://m.tuniucdn.com/fb2/t1/G2/M00/EF/86/Cii-Tlk2mV2IMZ-_AAEucgG3dx4AALaawEjiycAAS6K083_w200_h200_c1_t0.jpg",
"price": 226,
"score": 35,
"starName": "二钻"
}
}, {
"_index": "hotel",
"_type": "_doc",
"_id": "38812",
"_score": 1.0,
"_source": {
"address": "徐汇龙华西路315弄58号",
"brand": "7天酒店",
"business": "八万人体育场地区",
"city": "上海",
"id": 38812,
"location": "31.174377, 121.442875",
"name": "7天连锁酒店(上海漕溪路地铁站店)",
"pic": "https://m.tuniucdn.com/fb2/t1/G2/M00/E0/0E/Cii-TlkyIr2IEWNoAAHQYv7i5CkAALD-QP2iJwAAdB6245_w200_h200_c1_t0.jpg",
"price": 298,
"score": 37,
"starName": "二钻"
}
}, {
"_index": "hotel",
"_type": "_doc",
"_id": "39106",
"_score": 1.0,
"_source": {
"address": "闵行莘庄镇七莘路299号",
"brand": "7天酒店",
"business": "莘庄工业区",
"city": "上海",
"id": 39106,
"location": "31.113812, 121.375869",
"name": "7天连锁酒店(上海莘庄地铁站店)",
"pic": "https://m.tuniucdn.com/fb2/t1/G2/M00/D8/11/Cii-T1ku2zGIGR7uAAF1NYY9clwAAKxZAHO8HgAAXVN368_w200_h200_c1_t0.jpg",
"price": 348,
"score": 41,
"starName": "二钻"
}
}, {
"_index": "hotel",
"_type": "_doc",
"_id": "39141",
"_score": 1.0,
"_source": {
"address": "杨浦国权路315号",
"brand": "7天酒店",
"business": "江湾、五角场商业区",
"city": "上海",
"id": 39141,
"location": "31.290057, 121.508804",
"name": "7天连锁酒店(上海五角场复旦同济大学店)",
"pic": "https://m.tuniucdn.com/fb2/t1/G2/M00/C7/E3/Cii-T1knFXCIJzNYAAFB8-uFNAEAAKYkQPcw1IAAUIL012_w200_h200_c1_t0.jpg",
"price": 349,
"score": 38,
"starName": "二钻"
}
}, {
"_index": "hotel",
"_type": "_doc",
"_id": "45845",
"_score": 1.0,
"_source": {
"address": "虹桥路100号",
"brand": "万怡",
"business": "徐家汇地区",
"city": "上海",
"id": 45845,
"location": "31.192714, 121.434717",
"name": "上海西藏大厦万怡酒店",
"pic": "https://m.tuniucdn.com/fb3/s1/2n9c/48GNb9GZpJDCejVAcQHYWwYyU8T_w200_h200_c1_t0.jpg",
"price": 589,
"score": 45,
"starName": "四钻"
}
}, {
"_index": "hotel",
"_type": "_doc",
"_id": "45870",
"_score": 1.0,
"_source": {
"address": "新元南路555号",
"brand": "豪生",
"business": "滴水湖临港地区",
"city": "上海",
"id": 45870,
"location": "30.871729, 121.81959",
"name": "上海临港豪生大酒店",
"pic": "https://m.tuniucdn.com/fb3/s1/2n9c/2F5HoQvBgypoDUE46752ppnQaTqs_w200_h200_c1_t0.jpg",
"price": 896,
"score": 45,
"starName": "四星级"
}
}, {
"_index": "hotel",
"_type": "_doc",
"_id": "46829",
"_score": 1.0,
"_source": {
"address": "恒丰路338号",
"brand": "万怡",
"business": "上海火车站地区",
"city": "上海",
"id": 46829,
"location": "31.242977, 121.455864",
"name": "上海浦西万怡酒店",
"pic": "https://m.tuniucdn.com/fb3/s1/2n9c/x87VCoyaR8cTuYFZmKHe8VC6Wk1_w200_h200_c1_t0.jpg",
"price": 726,
"score": 46,
"starName": "四钻"
}
}, {
"_index": "hotel",
"_type": "_doc",
"_id": "47066",
"_score": 1.0,
"_source": {
"address": "施新路958号",
"brand": "华美达",
"business": "浦东机场核心区",
"city": "上海",
"id": 47066,
"location": "31.147989, 121.759199",
"name": "上海浦东东站华美达酒店",
"pic": "https://m.tuniucdn.com/fb3/s1/2n9c/2pNujAVaQbXACzkHp8bQMm6zqwhp_w200_h200_c1_t0.jpg",
"price": 408,
"score": 46,
"starName": "四钻"
}
}]
}
}
  • 这里的关键API有两个
    • 一个是request.source(),其中包含了queryorderfromsizehighlight等所有功能
    • 另一个是QueryBuilders,其中包含了matchtermfunction_scorebool等各种查询

解析响应

  • 响应结果的解析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Test
void testMatchAll() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数 对应 "query": {"match_all": {}}
request.source().query(QueryBuilders.matchAllQuery());
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
SearchHits searchHits = response.getHits();
// 4.1 获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共查询到" + total + "条");
// 4.2 获取文档数组
SearchHit[] hits = searchHits.getHits();
// 4.3 遍历
for (SearchHit hit : hits) {
// 获取文档source
String json = hit.getSourceAsString();
// 转换为HotelDoc对象
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
}

  • ES返回的结果是一个JSON字符串,结构包含:

    • hits:命中的结果
      • total:总条数,其中的value是具体的总条数值
      • max_score:所有结果中得分最高的文档的相关性算分
      • hits:搜索结果的文档数组,其中的每个文档都是一个json对象
        • _source:文档中的原始数据,也是json对象
  • 因此,我们解析响应结果,就是逐层解析JSON字符串,流程如下:

    • SearchHits:通过response.getHits()获取,就是JSON中的最外层的hits,代表命中的结果
      • SearchHits.getTotalHits().value:获取总条数信息
      • SearchHits.getHits():获取SearchHit数组,也就是文档数组
        • SearchHit.getSourceAsString():获取文档结果中的_source,也就是原始的json文档数据

小结

  • 查询的基本步骤是
    1. 创建SearchRequest对象
    2. 准备Request.source(),也就是DSL
      • QueryBuilders来构建查询条件
      • 传入Request.source()的query()方法中作为参数
    3. 发送请求,得到结果
    4. 解析结果(参考JSON结果,从外到内,逐层解析)

match查询

  • 全文检索的match和multi_match查询与match_all的API基本一致。
  • 差别是查询条件,也就是query的那部分
1
2
3
4
5
6
GET /hotel/_search
{
"query": {
"match_all": {}
}
}
1
2
3
4
5
6
7
8
GET /hotel/_search
{
"query": {
"match": {
"all": "北京"
}
}
}
1
2
3
4
5
6
7
8
GET /hotel/_search
{
"query": {
"multi_match": {
"query": "如家",
"fields": ["brand", "name"]
}
}
  • 因此,Java代码上的差异主要是request.source.query()中的参数了。同样是利用QueryBuilders提供的方法
  • 单字段查询:QueryBuilders.matchQuery("all","北京")
1
2
3
4
5
6
7
8
9
10
11
@Test
void testMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchQuery("all","北京"));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}
  • 多字段查询:QueryBuilders.multiMatchQuery("如家","brand","name")
1
2
3
4
5
6
7
8
9
10
11
@Test
void testMultiMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.multiMatchQuery("如家","brand","name"));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}
  • 解析响应的代码都是相同的,所以这里抽取成了一个名为handleResponse的方法,使用IDEA的快捷键Ctrl + Alt + M可以快速抽取 (注意关闭网抑云的全局热键,不然会冲突)

精确查询

  • 精确查询主要是这两个
    1. term:词条精确匹配
    2. range:范围查询
  • 与之前的查询相比,差异同样在查询条件,其他的都一样
  • 精确匹配在北京的酒店:QueryBuilders.termQuery("city","北京")
1
2
3
4
5
6
7
8
9
10
11
@Test
void testTermMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.termQuery("city","北京"));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}
  • 范围查询价格在1000~2000的酒店:QueryBuilders.rangeQuery("price").gt(1000).lt(2000)
1
2
3
4
5
6
7
8
9
10
11
@Test
void testRangeMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.rangeQuery("price").gt(1000).lt(2000));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}

布尔查询

  • 布尔查询是用must、must_not、filter等方式组合其他查询
  • 例如:查询在上海华美达或者皇冠假日酒店,用户评分在45分以上,价格在500~2000
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
void testBoolMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 2.1 添加must条件
boolQuery.must(QueryBuilders.termQuery("city", "上海"));
// 2.2 添加should条件 (should有点问题,但是貌似可以用must配合termsQuery来达到should的效果)
boolQuery.must(QueryBuilders.termsQuery("brand", "华美达", "皇冠假日"));
// 2.3 添加mustNot条件
boolQuery.mustNot(QueryBuilders.rangeQuery("score").lt(45));
// 2.4 添加filter条件
boolQuery.filter(QueryBuilders.rangeQuery("price").gt(500).lt(2000));
request.source().query(boolQuery);
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}

破案了,Java代码的解决方案就是我这样的

排序、分页

  • 搜索结果的排序和分页是与query同级的参数,因此同样是使用request.source()来设置
  • 示例代码如下,ES的API都支持链式编程还挺舒服的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testSortMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchAllQuery())
.sort("price", SortOrder.ASC)
.from(0)
.size(5);
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}

高亮

  • 高亮的代码与之前的代码差异较大,有两点
    1. 查询的DSL,其中除了查询条件,还需要添加高亮条件,同样是与query同级
    2. 结果解析,结果除了要解析_source文档,还需要解析高亮结果

高亮请求构建

  • 高亮请求的API如下
1
2
3
4
request.source().query(QueryBuilders.matchAllQuery())
.highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
  • 对应的DSL语句
1
2
3
4
5
6
"highlight": {
"fields": {
"name": {
"require_field_match": "false"
}
}
  • 上述代码中省略了查询条件部分,但是千万别忘了:高亮查询必须使用全文检索查询,并且要有搜索关键字,将来才可以对关键字高亮
  • 示例代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testHighLightMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchQuery("all","如家"))
.highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}

高亮结果解析

  • 高亮的结果与查询的文档结果默认是分离的,并不是在一起
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
"hits" : {
"total" : {
"value" : 102,
"relation" : "eq"
},
"max_score" : null,
"hits" : [
{
"_index" : "hotel",
"_type" : "_doc",
"_id" : "1765008760",
"_score" : null,
"_source" : {
"address" : "西直门北大街49号",
"brand" : "如家",
"business" : "西直门/北京展览馆地区",
"city" : "北京",
"id" : 1765008760,
"location" : "39.945106, 116.353827",
"name" : "如家酒店(北京西直门北京北站店)",
"pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/4CLwbCE9346jYn7nFsJTQXuBExTJ_w200_h200_c1_t0.jpg",
"price" : 356,
"score" : 44,
"starName" : "二钻"
},
"highlight" : {
"name" : [
"<em>如家</em>酒店(北京西直门北京北站店)"
]
},
"sort" : [
6.376497864377032,
356
]
}
  • 因此解析高亮的代码需要额外处理

  • 代码解读:

    • 第一步:从结果中获取source。hit.getSourceAsString(),这部分是非高亮结果,json字符串。还需要反序列为HotelDoc对象
    • 第二步:获取高亮结果。hit.getHighlightFields(),返回值是一个Map,key是高亮字段名称,值是HighlightField对象,代表高亮值
    • 第三步:从map中根据高亮字段名称,获取高亮字段值对象HighlightField
    • 第四步:从HighlightField中获取Fragments,并且转为字符串。这部分就是真正的高亮字符串了
    • 第五步:用高亮的结果替换HotelDoc中的非高亮结果
  • 完整代码如下

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
@Test
void testHighLightMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchQuery("all", "如家"))
.highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
SearchHits searchHits = response.getHits();
TotalHits total = searchHits.getTotalHits();
System.out.println("共查询到" + total + "条数据");
SearchHit[] hits = searchHits.getHits();
for (SearchHit hit : hits) {
// 获取source
String json = hit.getSourceAsString();
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 获取高亮
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
// 健壮性判断
if (!CollectionUtils.isEmpty(highlightFields)) {
// 获取高亮字段结果
HighlightField highlightField = highlightFields.get("name");
// 健壮性判断
if (highlightField != null) {
// 取出高亮结果数组的第一个元素,就是酒店名称
String name = highlightField.getFragments()[0].string();
hotelDoc.setName(name);
}
}
System.out.println(hotelDoc);
}
}

黑马旅游案例

  • 这里只要实现四部分功能
    1. 酒店搜索和分页
    2. 酒店结果过滤
    3. 我周边的酒店
    4. 酒店竞价排名
  • 启动黑马提供好的hotel-demo项目,默认端口是8089,访问http://localhost:8089/, 就能看到项目页面了

酒店搜索和分页

  • 需求:实现黑马旅游的酒店搜索功能,完成关键字搜索和分页

需求分析

  • 在项目首页,有一个搜索框,还有分页按钮
  • 搜索框输入上海,页面翻到第2页,点击搜索,查看控制台发出的请求
1
2
请求网址: http://localhost:8089/hotel/list
请求方法: POST
  • 请求参数
1
{key: "上海", page: 2, size: 5, sortBy: "default"}
  • 由此可得
    • 请求方式:POST
    • 请求路径:/hotel/list
    • 请求参数:JSON对象,包含4个端
      1. key:搜索关键字
      2. page:页码
      3. size:每页大小
      4. sortBy:排序,目前暂不实现
    • 返回值:分页查询,需要发挥分页结果PageResult,包含两个属性
      1. total:总条数
      2. List<HotelDoc>:当页的数据
  • 因此,我们实现业务的流程如下
    1. 定义实体类,用于接收请求参数的对象和返回响应结果的对象
    2. 编写controller,接收页面的请求
    3. 编写业务实现,利用RestHighLevelClient实现搜索、分页

定义实体类

  • 实体类有两个,一个是前端的请求参数实体,另一个是服务端应该返回的响应结果实体
  • 请求参数
1
{key: "上海", page: 2, size: 5, sortBy: "default"}
  • 在pojo包下定义一个实体类
1
2
3
4
5
6
7
8
9
import lombok.Data;

@Data
public class RequestParams {
private String key;
private Integer page;
private Integer size;
private String sortBy;
}
  • 分页查询,需要返回分页结果PageResult
1
2
3
4
5
6
@Data
@AllArgsConstructor
public class PageResult {
private long total;
private List<HotelDoc> hotels;
}

定义controller

  • 定义一个HotelController,声明查询接口,满足以下要求
    1. 请求方式:POST
    2. 请求路径:/hotel/list
    3. 请求参数:RequestParams对象
    4. 返回值:PageResult
  • 在web.controller包下新建HotelController
1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/hotel")
public class HotelController {
@Autowired
private HotelService hotelService;

@PostMapping("/list")
public PageResult search(@RequestBody RequestParams params){
return hotelService.search(params);
}
}

实现搜索业务

  • 我们在controller中调用了IHotelService,那我们现在在IHotelService中定义方法,并实现业务逻辑
  • 定义方法
1
PageResult search(RequestParams params);
  • 实现搜索逻辑,我们需要实现将RestHighLevelClient注册到Spring中作为一个Bean
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@MapperScan("cn.blog.hotel.mapper")
@SpringBootApplication
public class HotelDemoApplication {

public static void main(String[] args) {
SpringApplication.run(HotelDemoApplication.class, args);
}

@Bean
public RestHighLevelClient client(){
return new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.128.130:9200")));
}
}
  • 实现搜索逻辑
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
@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
@Autowired
private RestHighLevelClient client;

@Override
public PageResult search(RequestParams params) {
try {
// 1. 准备request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL
// 2.1 获取搜索关键字
String key = params.getKey();
// 2.2 健壮性判断
if (StringUtils.isEmpty(key)) {
// 未输入搜索条件,则查询全部
request.source().query(QueryBuilders.matchAllQuery());
} else {
request.source().query(QueryBuilders.matchQuery("all", key));
}
// 2.3 分页
int page = params.getPage();
int size = params.getSize();
request.source()
.from((page - 1) * size)
.size(size);
// 3. 发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 结果解析
return handleResponse(response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

// 结果解析依旧是封装为了一个函数
private PageResult handleResponse(SearchResponse response) {
// 获取总条数
SearchHits searchHits = response.getHits();
long total = searchHits.getTotalHits().value;
// 获取文档数组
SearchHit[] hits = searchHits.getHits();
// 遍历
ArrayList<HotelDoc> hotels = new ArrayList<>();
for (SearchHit hit : hits) {
// 获取每条文档
String json = hit.getSourceAsString();
// 反序列化为对象
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 放入集合
hotels.add(hotelDoc);
}
// 封装返回
return new PageResult(total, hotels);
}
}

酒店结果过滤

  • 需求:添加品牌、城市、星级、价格等过滤功能

需求分析

  • 在页面的搜索框下,会有一些过滤项
  • 我们选中过滤项,查看传递的参数
1
2
3
4
5
6
7
8
9
10
11
{
brand: "7天酒店"
city: "上海"
key: "上海"
maxPrice: 999999
minPrice: 1500
page: 10
size: 5
sortBy: "default"
starName: "五钻"
}
  • 包含的过滤条件有
    • brand:品牌
    • city:城市
    • maxPrice~minPrice:价格范围
    • starName:星级
  • 那我们现在就需要修改我们的RequestParams,接收上述参数,并且还需要修改我们的业务逻辑,添加一些过滤条件

修改实体类

  • 在RequestParams中添加额外的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
public class RequestParams {
private String key;
private Integer page;
private Integer size;
private String sortBy;
// 额外参数
private String brand;
private String city;
private String starName;
private Integer maxPrice;
private Integer minPrice;
}

修改搜索逻辑

  • 这里就涉及到了符合查询,所以就需要用到布尔查询
    • 关键字放到must中,参与算分
    • 其余过滤条件放到filter中,不参与算分
  • 由于过滤条件比较复杂,所以这里先将其封装为一个名为buildBasicQuery函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public PageResult search(RequestParams params) {
try {
SearchRequest request = new SearchRequest("hotel");
buildBasicQuery(params, request);
int page = params.getPage();
int size = params.getSize();
request.source()
.from((page - 1) * size)
.size(size);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
return handleResponse(response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
  • buildBasicQuery函数
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
private void buildBasicQuery(RequestParams params, SearchRequest request) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
String key = params.getKey();
if (StringUtils.isEmpty(key)) {
boolQuery.must(QueryBuilders.matchAllQuery());
} else {
boolQuery.must(QueryBuilders.matchQuery("all", key));
}
// 品牌条件
if (params.getBrand() != null && !params.getBrand().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("brand", params.getBrand()));
}
// 城市条件
if (params.getCity() != null && !params.getCity().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("city", params.getCity()));
}
// 星级条件
if (params.getStarName() != null && !params.getStarName().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("starName", params.getStarName()));
}
// 价格条件
if (params.getMaxPrice() != null && params.getMinPrice() != null) {
boolQuery.filter(QueryBuilders
.rangeQuery("price")
.gt(params.getMinPrice())
.lt(params.getMaxPrice()));
}
request.source().query(boolQuery);
}

我周边的酒店

  • 需求:我附近的酒店

需求分析

  • 在酒店列表页的右侧,有一个小地图,点击地图定位按钮,会找到你所在位置,并且前端会发起查询请求,将你的坐标发送到服务器
1
2
3
4
5
6
7
{
key: "",
page: 1,
size: 5,
sortBy: "default",
location: "39.882165, 116.531421"
}
  • 那我们还需要在RequestParams类中添加一个新字段,用户获取location坐标
  • 然后修改搜索逻辑,如果location有值,则添加根据geo_distance排序的功能

修改实体类

  • 添加location字段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
public class RequestParams {
private String key;
private Integer page;
private Integer size;
private String sortBy;
private String brand;
private String city;
private String starName;
private Integer maxPrice;
private Integer minPrice;
// 新增location字段
private String location;
}

排序API

  • 基本语法
1
2
3
4
request.source().sort("price", SortOrder.ASC)
.sort(SortBuilders.geoDistanceSort("location",new GeoPoint("39.9, 131.6"))
.order(SortOrder.ASC)
.unit(DistanceUnit.KILOMETERS));

添加距离排序

  • 修改search方法,添加距离排序
1
2
3
4
5
6
7
8
9
String location = params.getLocation();
if (!StringUtils.isEmpty(location)) {
request.source()
.sort("price", SortOrder.ASC)
.sort(SortBuilders
.geoDistanceSort("location", new GeoPoint(location))
.order(SortOrder.ASC)
.unit(DistanceUnit.KILOMETERS));
}
  • 完整代码
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
@Override
public PageResult search(RequestParams params) {
try {
SearchRequest request = new SearchRequest("hotel");
buildBasicQuery(params, request);
int page = params.getPage();
int size = params.getSize();
request.source()
.from((page - 1) * size)
.size(size);
String location = params.getLocation();
if (!StringUtils.isEmpty(location)) {
request.source()
.sort("price", SortOrder.ASC)
.sort(SortBuilders
.geoDistanceSort("location", new GeoPoint(location))
.order(SortOrder.ASC)
.unit(DistanceUnit.KILOMETERS));
}
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
return handleResponse(response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

距离排序显示

  • 重启服务,测试酒店功能,但是现在没有显示酒店距离我有多远
  • 排序完成后,页面还需要获取我到附近酒店的具体距离值
  • 因此,在解析结果的时候,我们还需要获取sort部分,然后放到响应结果中
  • 修改HotelDoc类,添加排序距离字段,用于页面显示
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
@Data
@NoArgsConstructor
public class HotelDoc {
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;
// 排序时的距离值
private Object distance;

public HotelDoc(Hotel hotel) {
this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
}
}
  • 修改handleResponse方法,为HotelDoc对象赋sort值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private PageResult handleResponse(SearchResponse response) {
SearchHits searchHits = response.getHits();
long total = searchHits.getTotalHits().value;
SearchHit[] hits = searchHits.getHits();
ArrayList<HotelDoc> hotels = new ArrayList<>();
for (SearchHit hit : hits) {
String json = hit.getSourceAsString();
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 获取排序值
Object[] sortValues = hit.getSortValues();
if (sortValues.length > 0){
hotelDoc.setDistance(sortValues[0]);
}
hotels.add(hotelDoc);
}
return new PageResult(total, hotels);
}
  • 重启服务,这下就能成功显示距离了

酒店竞价排名

  • 需求:让指定的酒店在搜索结果中排名置顶(给一个超级大的算分)

需求分析

  • 如何才能让指定的酒店排名置顶呢?
  • 上面学的function_score查询可以影响算分,算分高了,自然排名也就高了。而function_score包含3个要素
    1. 过滤条件:哪些文档要加分
    2. 算分函数:如何计算function score
    3. 加权方式:function scorequery score如何运算
  • 这里的需求是:让指定酒店排名靠前。因此我们需要给这些酒店加一个标记,这样在过滤条件中就可以根据这个标记来判断,是否要提高算分
  • 例如我们给酒店添加一个boolean类型的isAD字段
    • true:是广告
    • false:不是广告
  • 这样function_score的3个要素就很好确定了
    • 过滤条件:判断idAD是否为true
    • 算分函数:这里用最简单暴力的weight,固定权值
    • 加权方式:可以使用默认的相乘,大大提高算分
  • 因此,提高排名的实现步骤包括
    1. 修改HotelDoc类,添加isAD字段
    2. 修改文档,随便挑几个酒店添加isAD字段为true
    3. 修改search方法,添加function score功能,给isAD为true的酒店加权重

修改HotelDoc类

  • 添加isAD字段
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
@Data
@NoArgsConstructor
public class HotelDoc {
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;
private Object distance;
// 是否为广告
private Boolean isAD;

public HotelDoc(Hotel hotel) {
this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
}
}

添加广告标记

  • 我们随便挑几个酒店,增加isAD字段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
POST /hotel/_update/2056126831
{
"doc": {
"isAD": true
}
}
POST /hotel/_update/1989806195
{
"doc": {
"isAD": true
}
}
POST /hotel/_update/2056105938
{
"doc": {
"isAD": true
}
}

增加算分函数查询

  • function_score查询结构如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET /hotel/_search
{
"query": {
"function_score": {
"query": {
"match": {
"all": "外滩"
}
},
"functions": [
{
"filter": {
"term": {
"city": "上海"
}
},
"weight": 1
}
],
"boost_mode": "multiply"
}
}
}
  • 算分函数对应的JavaAPI如下
1
2
3
4
5
6
7
FunctionScoreQueryBuilder functionScoreQuery =
QueryBuilders.functionScoreQuery(
QueryBuilders.matchQuery("name", "外滩"),
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
QueryBuilders.termQuery("brand", "如家"),
ScoreFunctionBuilders.weightFactorFunction(10))});

  • 我们可以将之前写布尔查询的boolQuery作为原始查询条件,放到function_score查询中,接下来就是添加过滤条件、算分函数、加权模式了。所以可以继续沿用我们的buildBasicQuery方法
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
private void buildBasicQuery(RequestParams params, SearchRequest request) {
// 1. 构建BoolQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
String key = params.getKey();
if (StringUtils.isEmpty(key)) {
boolQuery.must(QueryBuilders.matchAllQuery());
} else {
boolQuery.must(QueryBuilders.matchQuery("all", key));
}
// 品牌条件
if (params.getBrand() != null && !params.getBrand().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("brand", params.getBrand()));
}
// 城市条件
if (params.getCity() != null && !params.getCity().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("city", params.getCity()));
}
// 星级条件
if (params.getStarName() != null && !params.getStarName().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("starName", params.getStarName()));
}
// 价格条件
if (params.getMaxPrice() != null && params.getMinPrice() != null) {
boolQuery.filter(QueryBuilders
.rangeQuery("price")
.gt(params.getMinPrice())
.lt(params.getMaxPrice()));
}

// 2.算分控制
FunctionScoreQueryBuilder functionScoreQuery =
QueryBuilders.functionScoreQuery(
boolQuery, new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
QueryBuilders.termsQuery("isAD", true),
ScoreFunctionBuilders.weightFactorFunction(10))});
request.source().query(functionScoreQuery);
}
  • 重启服务,可以看到竞价排名已经生效,排名第一的酒店左上角有广告图标

数据聚合

  • 聚合(aggregations)可以让我们极其方便的实现对数据的统计、分析、运算。例如
    1. 什么品牌的手机最受欢迎?
    2. 这些手机的平均价格、最高价格、最低价格?
    3. 这些手机每月的销售情况如何?
  • 实现这些统计功能的比数据库的SQL要方便很多,而且查询速度非常快,可以实现近实时搜索的效果

聚合的种类

  • 常见的聚合有三类
    1. 桶(Bucket)聚合:用来对文档分组
      • TermAggregation:按照文档字段值分组,例如:按照品牌名称国家分组
      • DateHistogram:按照日期阶梯分组,例如:一周为一组,或者一月为一组
    2. 度量(Metric)聚合:用于计算一些值,例如:最大值最小值平均值
      • Avg:求平均值
      • Max:求最大值
      • Min:求最小值
      • Stats:同时求max、min、avg、sum等
    3. 管道(pipeline)聚合:以其他聚合的结果为基础做聚合

注意:参加聚合的字段必须是keyword、日期、数值、布尔类型

DSL实现聚合

  • 现在,我们要统计所有数据中的酒店品牌有几种,其实就是按照品牌对数据分组。此时可以根据酒店品牌的名称做聚合,也就是Bucket聚合

Bucket聚合语法

  • 基本语法如下
1
2
3
4
5
6
7
8
GET /indexName/_search
{
"aggs": { // 定义聚合
"NAME": { // 给聚合起个名字
"AGG_TYPE": {} // 聚合的类型
}
}
}
  • 示例
1
2
3
4
5
6
7
8
9
10
11
12
GET /hotel/_search
{
"size": 0, // 设置size为0,结果中不包含文档,只包含聚合结果
"aggs": { // 定义聚合
"bucketAggName": { // 给聚合起个名字
"terms": { // 聚合的类型,这里按照品牌值聚合,所以选择terms
"field": "brand", // 参与聚合的字段
"size": 5 // 希望获取的聚合结果数量,由于品牌值可能很多,这里只获取5条看看效果
}
}
}
}

聚合结果排序

  • 默认情况下,Bucket聚合会统计Bucket内的文档数量,记为count,并且按照count降序排序
  • 我们可以指定order属性,自定义聚合的排序方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET /hotel/_search
{
"size": 0,
"aggs": {
"bucketAgg": {
"terms": {
"field": "brand",
"order": {
"_count": "asc"
},
"size": 5
}
}
}
}

限定聚合范围

  • 默认情况下,Bucket聚合是对索引库的所有文档做聚合。
  • 但真实场景下,用户会输入搜索条件
  • 因此聚合必须是对搜索结果的聚合,那么聚合就必须添加限定条件
  • 我们可以限定要聚合的文档范围,只需要添加query条件即可
  • 这里假设用户选择了1000元以上的酒店
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
GET /hotel/_search
{
"query": {
"range": {
"price": {
"gte": 1000 // 只对1000元以上的文档做聚合
}
}
},
"size": 0,
"aggs": {
"bucketAgg": {
"terms": {
"field": "brand",
"order": {
"_count": "desc"
},
"size": 5
}
}
}
}

  • 从结果中看到,这次得到的酒店数量明显就减少了

Metric聚合语法

  • 现在我们需要对桶内酒店做运算,获取每个品牌的用户频分的min、max、avg等值
  • 那么就需要用到Metric聚合了,例如stat聚合,就尅获取min、max、avg等值
  • 语法如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
GET /hotel/_search
{
"size": 0,
"aggs": {
"bucketAgg": {
"terms": {
"field": "brand",
"order": {
"_count": "desc"
},
"size": 5
},
"aggs": { // 是bucketAgg聚合的子聚合,也就是分组后对每组分别进行计算
"scoreAgg": { // 子聚合名称
"stats": { // 聚合类型,stats可以计算min、max、avg等
"field": "score" // 聚合字段,这里计算用户评分的min、max、avg
}
}
}
}
}
}

  • 此外,我们还可以给聚合结果做排序,例如按照每个桶的酒店平均分做排序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET /hotel/_search
{
"size": 0,
"aggs": {
"bucketAgg": {
"terms": {
"field": "brand",
"order": {
"scoreAgg.avg": "desc" // 对scoreAgg.avg做降序排序
},
"size": 5
},
"aggs": {
"scoreAgg": {
"stats": {
"field": "score"
}
}
}
}
}
}

小结

  • aggs代表聚合,与query统计,此时query的作用是?
    • 限定聚合的文档范围
  • 聚合必须的三要素
    1. 聚合名称
    2. 聚合类型
    3. 聚合字段
  • 聚合可配置的属性有
    1. size:指定聚合结果数量
    2. order:指定聚合结果排序方式
    3. field:指定聚合字段

RestAPI实现聚合

API语法

  • 聚合条件与query统计,因此需要使用request.source()来指定聚合条件
  • 聚合条件的语法
1
2
3
4
5
6
request.source().size(0);
request.source().aggregation(
AggregationBuilders
.terms("brandAgg")
.field("brand")
.size(20));

  • 聚合的结果解析也与之前的查询结果解析不同,API比较特殊,但同样也是JSON逐层解析
1
2
3
4
5
6
7
8
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
Aggregations aggregations = response.getAggregations();
Terms brandTerms = aggregations.get("brandAgg");
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
for (Terms.Bucket bucket : buckets) {
String brandName = bucket.getKeyAsString();
System.out.println(brandName);
}

  • 完整代码如下
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
@Test
void brandAggregationTest() throws IOException {
// 1. 准备request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL
// 2.1 设置size
request.source().size(0);
// 2.2 聚合
request.source().aggregation(
AggregationBuilders
.terms("brandAgg")
.field("brand")
.size(20));
// 3. 发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析结果
Aggregations aggregations = response.getAggregations();
// 4.1 根据聚合名称获取聚合结果
Terms brandTerms = aggregations.get("brandAgg");
// 4.2 获取桶
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
// 4.3 遍历桶内元素
for (Terms.Bucket bucket : buckets) {
// 4.4 获取key,也就是品牌信息
String brandName = bucket.getKeyAsString();
System.out.println(brandName);
}
}

业务需求

需求:搜索页面的品牌、城市等信息,并不是在页面上直接写死的,而是通过聚合索引库中的酒店数据来动态获得的

  • 目前,页面的城市列表、星级列表、品牌列表都是写死的,并不会随着搜索结果的变化而变化。但是用户搜索条件改变时,搜索结果也会跟着变化
  • 例如:用户在搜索框输入王府井,那搜索的酒店肯定就只能在北京王府井附近,因此,城市只能是北京,此时城市列表中就不应该显示上海、深圳、杭州了
  • 也就是说,搜索结果中包含哪些城市,页面中就应该列出哪些城市;搜索结果中包含哪些品牌,页面就应该列出哪些品牌
  • 那么如何得知搜索结果中包含了哪些品牌?如何得知搜索结果中包含了哪些城市?
  • 使用聚合功能,利用Bucket聚合,对搜索结果中的文档,基于品牌分组、城市分组、星级分组等,就能得知包含哪些品牌、哪些城市了。
  • 因为是对搜索结果聚合,因此聚合是限定范围的聚合,且限定条件与搜索文档一致
  • 那么之前写的查询代码就可以直接用
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
private void buildBasicQuery(RequestParams params, SearchRequest request) {
// 1. 构建BoolQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
String key = params.getKey();
if (StringUtils.isEmpty(key)) {
boolQuery.must(QueryBuilders.matchAllQuery());
} else {
boolQuery.must(QueryBuilders.matchQuery("all", key));
}
// 品牌条件
if (params.getBrand() != null && !params.getBrand().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("brand", params.getBrand()));
}
// 城市条件
if (params.getCity() != null && !params.getCity().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("city", params.getCity()));
}
// 星级条件
if (params.getStarName() != null && !params.getStarName().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("starName", params.getStarName()));
}
// 价格条件
if (params.getMaxPrice() != null && params.getMinPrice() != null) {
boolQuery.filter(QueryBuilders
.rangeQuery("price")
.gt(params.getMinPrice())
.lt(params.getMaxPrice()));
}

// 2.算分控制
FunctionScoreQueryBuilder functionScoreQuery =
QueryBuilders.functionScoreQuery(
boolQuery, new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
QueryBuilders.termsQuery("isAD", true),
ScoreFunctionBuilders.weightFactorFunction(10))});
request.source().query(functionScoreQuery);
}

业务实现

  • 查看浏览器,前端其实已经发出了请求
1
2
3
请求网址: http://localhost:8089/hotel/filters
请求方法: POST
请求载荷:{key: "王府井", page: 1, size: 5, sortBy: "default"}
  • 请求参数与搜索文档的参数完全一致,其返回值类型就是页面要展示的最终结果
  • 在web包下的HotelController中添加一个方法,遵循下面的要求
    1. 请求方式:POST
    2. 请求路径:/hotel/filters
    3. 请求参数:RequestParams,与搜索文档一致
    4. 返回值类型:Map(String, List<String>)
1
2
3
4
@PostMapping("/filters")
public PageResult getFilters(@RequestBody RequestParams params){
return hotelService.getFilters(params);
}
  • 在IHotelService中定义方法
1
Map<String, List<String>> getFilters(RequestParams params);
  • 在HotelService中实现该方法
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
@Override
public Map<String, List<String>> getFilters(RequestParams params) {
try {
// 1. 准备Request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL
buildBasicQuery(params, request);
// 2.1 查询
buildBasicQuery(params, request);
// 2.2 设置size为0,不查询文档数据
request.source().size(0);
// 2.3 聚合
request.source().aggregation(
AggregationBuilders
.terms("brandAgg")
.field("brand")
.size(100)
);
request.source().aggregation(
AggregationBuilders
.terms("cityAgg")
.field("city")
.size(100)
);
request.source().aggregation(
AggregationBuilders
.terms("starAgg")
.field("starName")
.size(100)
);
// 3. 发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
HashMap<String, List<String>> result = new HashMap<>();
Aggregations aggregations = response.getAggregations();
// 4. 解析结果
// 4.1 解析品牌结果
Terms brandTerms = aggregations.get("brandAgg");
ArrayList<String> brandList = new ArrayList<>();
List<? extends Terms.Bucket> brandBuckets = brandTerms.getBuckets();
for (Terms.Bucket bucket : brandBuckets) {
String brandName = bucket.getKeyAsString();
brandList.add(brandName);
}
result.put("brand", brandList);
// 4.2 解析城市结果
Terms cityTerms = aggregations.get("cityAgg");
ArrayList<String> cityList = new ArrayList<>();
List<? extends Terms.Bucket> cityBuckets = cityTerms.getBuckets();
for (Terms.Bucket cityBucket : cityBuckets) {
String cityName = cityBucket.getKeyAsString();
cityList.add(cityName);
}
result.put("city", cityList);
// 4.3 解析星级结果
Terms starTerms = aggregations.get("starAgg");
ArrayList<String> starList = new ArrayList<>();
List<? extends Terms.Bucket> starBuckets = starTerms.getBuckets();
for (Terms.Bucket starBucket : starBuckets) {
String starName = starBucket.getKeyAsString();
starList.add(starName);
}
result.put("starName", starList);
return result;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
  • 这下就是动态获取的品牌、城市、星级数据了

  • 当我们在搜索框输入内容时,也会根据搜索的结果来动态展示品牌、城市、星级数据

  • 但是现在的代码并不是很优雅,所以我们可以把2.3的聚合操作,抽取为一个方法,IDEA中使用快捷键Ctrl + Alt + M可以快速抽取 (记得关闭网抑云的全局热键,不然会冲突)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void buildAggregation(SearchRequest request) {
request.source().aggregation(
AggregationBuilders
.terms("brandAgg")
.field("brand")
.size(100)
);
request.source().aggregation(
AggregationBuilders
.terms("cityAgg")
.field("city")
.size(100)
);
request.source().aggregation(
AggregationBuilders
.terms("starAgg")
.field("starName")
.size(100)
);
}
  • 4.1、4.2、4.3的解析结果,也可以抽取为一个方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 通过聚合名称获取对应的key的集合
* @param aggregations 聚合结果集
* @param aggName 聚合名称
* @return
*/
private ArrayList<String> getAggByName(Aggregations aggregations, String aggName) {
Terms brandTerms = aggregations.get(aggName);
ArrayList<String> brandList = new ArrayList<>();
List<? extends Terms.Bucket> brandBuckets = brandTerms.getBuckets();
for (Terms.Bucket bucket : brandBuckets) {
String brandName = bucket.getKeyAsString();
brandList.add(brandName);
}
return brandList;
}
  • 修改完的代码就优雅多了
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
109
110
111
112
113
114
115
116
117
118
119
@Override
public Map<String, List<String>> getFilters(RequestParams params) {
try {
// 1. 准备Request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL
buildBasicQuery(params, request);
// 2.1 查询
buildBasicQuery(params, request);
// 2.2 设置size为0,不查询文档数据
request.source().size(0);
// 2.3 聚合
buildAggregation(request);
// 3. 发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
HashMap<String, List<String>> result = new HashMap<>();
Aggregations aggregations = response.getAggregations();
// 4. 解析结果
// 4.1 解析品牌结果,获取品牌名称集合
ArrayList<String> brandList = getAggByName(aggregations, "brandName");
result.put("brand", brandList);
// 4.2 解析城市结果,获取城市名称集合
ArrayList<String> cityList = getAggByName(aggregations, "cityAgg");
result.put("city", cityList);
// 4.3 解析星级结果,获取星级名称集合
ArrayList<String> starList = getAggByName(aggregations, "starAgg");
result.put("starName", starList);
// 5. 返回Map集合
return result;
} catch (IOException e) {
throw new RuntimeException(e);
}
}

/**
* 通过聚合名称获取对应的key的集合
* @param aggregations 聚合结果集
* @param aggName 聚合名称
* @return
*/
private ArrayList<String> getAggByName(Aggregations aggregations, String aggName) {
Terms brandTerms = aggregations.get(aggName);
ArrayList<String> brandList = new ArrayList<>();
List<? extends Terms.Bucket> brandBuckets = brandTerms.getBuckets();
for (Terms.Bucket bucket : brandBuckets) {
String brandName = bucket.getKeyAsString();
brandList.add(brandName);
}
return brandList;
}

/**
* 聚合
* @param request
*/
private void buildAggregation(SearchRequest request) {
request.source().aggregation(
AggregationBuilders
.terms("brandAgg")
.field("brand")
.size(100)
);
request.source().aggregation(
AggregationBuilders
.terms("cityAgg")
.field("city")
.size(100)
);
request.source().aggregation(
AggregationBuilders
.terms("starAgg")
.field("starName")
.size(100)
);
}

/**
* 查询
* @param params 接收前端的查询参数
* @param request
*/
private void buildBasicQuery(RequestParams params, SearchRequest request) {
// 1. 构建BoolQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
String key = params.getKey();
if (StringUtils.isEmpty(key)) {
boolQuery.must(QueryBuilders.matchAllQuery());
} else {
boolQuery.must(QueryBuilders.matchQuery("all", key));
}
// 品牌条件
if (params.getBrand() != null && !params.getBrand().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("brand", params.getBrand()));
}
// 城市条件
if (params.getCity() != null && !params.getCity().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("city", params.getCity()));
}
// 星级条件
if (params.getStarName() != null && !params.getStarName().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("starName", params.getStarName()));
}
// 价格条件
if (params.getMaxPrice() != null && params.getMinPrice() != null) {
boolQuery.filter(QueryBuilders
.rangeQuery("price")
.gt(params.getMinPrice())
.lt(params.getMaxPrice()));
}

// 2.算分控制
FunctionScoreQueryBuilder functionScoreQuery =
QueryBuilders.functionScoreQuery(
boolQuery, new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
QueryBuilders.termsQuery("isAD", true),
ScoreFunctionBuilders.weightFactorFunction(10))});
request.source().query(functionScoreQuery);
}

自动补全

  • 当用户在搜索框输入字符时,我们应该显示出与该字符相关的搜索项
  • 这种根据用户输入的字母,提示完整词条的功能,就是自动补全
  • 因为需要根据拼音字母来推断,因此要用到拼音分词功能

拼音分词器

1
2
3
4
5
POST /_analyze
{
"text": ["深岩银河是真滴好玩"],
"analyzer": "pinyin"
}
  • 得到的分词结果如下
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
{
"tokens" : [
{
"token" : "shen",
"start_offset" : 0,
"end_offset" : 0,
"type" : "word",
"position" : 0
},
{
"token" : "syyhszdhw",
"start_offset" : 0,
"end_offset" : 0,
"type" : "word",
"position" : 0
},
{
"token" : "yan",
"start_offset" : 0,
"end_offset" : 0,
"type" : "word",
"position" : 1
},
{
"token" : "yin",
"start_offset" : 0,
"end_offset" : 0,
"type" : "word",
"position" : 2
},
{
"token" : "he",
"start_offset" : 0,
"end_offset" : 0,
"type" : "word",
"position" : 3
},
{
"token" : "shi",
"start_offset" : 0,
"end_offset" : 0,
"type" : "word",
"position" : 4
},
{
"token" : "zhen",
"start_offset" : 0,
"end_offset" : 0,
"type" : "word",
"position" : 5
},
{
"token" : "di",
"start_offset" : 0,
"end_offset" : 0,
"type" : "word",
"position" : 6
},
{
"token" : "hao",
"start_offset" : 0,
"end_offset" : 0,
"type" : "word",
"position" : 7
},
{
"token" : "wan",
"start_offset" : 0,
"end_offset" : 0,
"type" : "word",
"position" : 8
}
]
}
  • 暂时只是将每个字拆解成了拼音,还有一个首字母的全拼,但目前还不能满足我们的需求
  • 深岩银河是真滴好玩这句话,还没有被分词
  • 每一个字拆解成一个拼音没什么用,单独的shenyan显然没有syyh有用
  • 结果中也没有出现汉字,也就意味着只有当用户输入拼音的时候,才会补全
  • 为了满足我们的需求,我们需要来自定义分词器

自定义分词器

  • 默认的拼音分词器会将每个汉字单独分为拼音,而我们希望的是将每个词条形成一组拼音,需要对拼音分词器做个性化定制,形成自定义分词器
  • ES中分词器(analyzer)的组成包含三个部分
    1. character filters:在tokenizer之前对文本进行处理,例如删除字符、替换字符等(前导空格,末尾空格,字符表情转对应文字,:) -> 开心)
    2. tokenizer:将文本按照一定规则切割成词条(term)。例如keyword,就是不分词;ik_smart,就是最少切分
    3. tokenizer filter:将tokenizer输出的词条进一步处理。例如大小写切换、同一次处理、拼音处理等

例如:

  • 滋蹦瓦鸡打了个双锤:)
  • character filters:)替换为开心,那么转换后的文本就是滋蹦瓦鸡打了个双锤开心
  • tokenizer使用ik_smart分词器将文本分词:滋蹦瓦鸡打了个双锤开心
  • tokenizer filter处理切割好的词条:zibnegwajidalegeshuangchuikaixin
  • 基本语法如下
1
2
3
4
5
6
7
8
9
10
11
12
PUT /test
{
"settings": {
"analysis": {
"analyzer": { // 自定义分词器
"my_analyzer": { // 分词器名称
"tokenizer": "ik_max_word", // tokenizer部分
"filter": "pinyin" // tokenizer filter部分
} // 由于这里并不需要处理特殊字符,所以没有character filters
}
}
}
  • 但是默认的拼音分词器还是只能将滋蹦分解为zibengzb这三个,而没有zibeng
  • 所以我们还需要自定义tokenizer filter拼音分词器
  • 解决方案在其官方文档中给出了

The plugin includes analyzer: pinyin , tokenizer: pinyin and token-filter: pinyin.

Optional Parameters

keep_first_letter when this option enabled, eg: 刘德华>ldh, default: true
keep_separate_first_letter when this option enabled, will keep first letters separately, eg: 刘德华>l,d,h, default: false, NOTE: query result maybe too fuzziness due to term too frequency
limit_first_letter_length set max length of the first_letter result, default: 16
keep_full_pinyin when this option enabled, eg: 刘德华> [liu,de,hua], default: true
keep_joined_full_pinyin when this option enabled, eg: 刘德华> [liudehua], default: false
keep_none_chinese keep non chinese letter or number in result, default: true
keep_none_chinese_together keep non chinese letter together, default: true, eg: DJ音乐家 -> DJ,yin,yue,jia, when set to false, eg: DJ音乐家 -> D,J,yin,yue,jia, NOTE: keep_none_chinese should be enabled first
keep_none_chinese_in_first_letter keep non Chinese letters in first letter, eg: 刘德华AT2016->ldhat2016, default: true
keep_none_chinese_in_joined_full_pinyin keep non Chinese letters in joined full pinyin, eg: 刘德华2016->liudehua2016, default: false
none_chinese_pinyin_tokenize break non chinese letters into separate pinyin term if they are pinyin, default: true, eg: liudehuaalibaba13zhuanghan -> liu,de,hua,a,li,ba,ba,13,zhuang,han, NOTE: keep_none_chinese and keep_none_chinese_together should be enabled first
keep_original when this option enabled, will keep original input as well, default: false
lowercase lowercase non Chinese letters, default: true
trim_whitespace default: true
remove_duplicated_term when this option enabled, duplicated term will be removed to save index, eg: de的>de, default: false, NOTE: position related query maybe influenced
ignore_pinyin_offset after 6.0, offset is strictly constrained, overlapped tokens are not allowed, with this parameter, overlapped token will allowed by ignore offset, please note, all position related query or highlight will become incorrect, you should use multi fields and specify different settings for different query purpose. if you need offset, please set it to false. default: true.

  • 可以看到,默认情况下是keep_full_pinyin,会将滋蹦拆成zibeng,我们要将这个选项设置为false
  • 同时将keep_joined_full_pinyin设置为true,就会正常解析为zibeng
  • 我们还需要保留原始结果,故也需要将keep_original设置为true
  • 我们在实际应用中,根据自己的需求来配置就好了,那么这里声明的自定义分词器如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PUT /test
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "ik_max_word",
"filter": "py"
}
},
"filter": {
"py": {
"type": "pinyin",
"keep_full_pinyin": false,
"keep_joined_full_pinyin": true,
"keep_original": true,
"remove_duplicated_term": true,
"none_chinese_pinyin_tokenize": false
}
}
}
}
}
  • 我们自定义的分词器肯定是在mapping映射中用的,也就是在我们定义索引库的字段的时候用的
  • 这样对字段创建倒排索引的时候,除了会创建中文的倒排索引,也会创建拼音的倒排索引
  • 那我们建立一个索引库,定义一个name字段,其分词器不再使用之前的ik_max_word,而是使用我们的自定义分词器
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
PUT /test
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "ik_max_word",
"filter": "py"
}
},
"filter": {
"py": {
"type": "pinyin",
"keep_full_pinyin": false,
"keep_joined_full_pinyin": true,
"keep_original": true,
"remove_duplicated_term": true,
"none_chinese_pinyin_tokenize": false
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "my_analyzer"
}
}
}
}
  • 那我们现在来测试一下
1
2
3
4
5
POST /test/_analyze
{
"text": "实现自定义分词器",
"analyzer": "my_analyzer"
}
  • 得到的结果如下,符合我们的需求了
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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
{
"tokens" : [
{
"token" : "实现",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "shixian",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "sx",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "自定义",
"start_offset" : 2,
"end_offset" : 5,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "zidingyi",
"start_offset" : 2,
"end_offset" : 5,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "zdy",
"start_offset" : 2,
"end_offset" : 5,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "自定",
"start_offset" : 2,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "ziding",
"start_offset" : 2,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "zd",
"start_offset" : 2,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "定义",
"start_offset" : 3,
"end_offset" : 5,
"type" : "CN_WORD",
"position" : 3
},
{
"token" : "dingyi",
"start_offset" : 3,
"end_offset" : 5,
"type" : "CN_WORD",
"position" : 3
},
{
"token" : "dy",
"start_offset" : 3,
"end_offset" : 5,
"type" : "CN_WORD",
"position" : 3
},
{
"token" : "分词器",
"start_offset" : 5,
"end_offset" : 8,
"type" : "CN_WORD",
"position" : 4
},
{
"token" : "fenciqi",
"start_offset" : 5,
"end_offset" : 8,
"type" : "CN_WORD",
"position" : 4
},
{
"token" : "fcq",
"start_offset" : 5,
"end_offset" : 8,
"type" : "CN_WORD",
"position" : 4
},
{
"token" : "分词",
"start_offset" : 5,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 5
},
{
"token" : "fenci",
"start_offset" : 5,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 5
},
{
"token" : "fc",
"start_offset" : 5,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 5
},
{
"token" : "器",
"start_offset" : 7,
"end_offset" : 8,
"type" : "CN_CHAR",
"position" : 6
},
{
"token" : "qi",
"start_offset" : 7,
"end_offset" : 8,
"type" : "CN_CHAR",
"position" : 6
},
{
"token" : "q",
"start_offset" : 7,
"end_offset" : 8,
"type" : "CN_CHAR",
"position" : 6
}
]
}
  • 但是现在还存在一点小问题,这里举个例子说明
    • 我们在创建的索引库中添加两个同音字,狮子柿子
1
2
3
4
5
6
7
8
9
10
11
POST /test/_doc/1
{
"id": 1,
"name": "柿子"
}

POST /test/_doc/2
{
"id": 2,
"name": "狮子"
}
  • 那我们现在查询掉进狮子笼怎么办,结果中出现了狮子柿子,这显然不是我们想要的
1
2
3
4
5
6
7
8
GET /test/_search
{
"query": {
"match": {
"name": "掉进狮子笼怎么办?在线等,挺急的"
}
}
}

  • 那么为什么会出现这种问题呢?
    • 拼音分词器适合在创建倒排索引的时候使用,但是不能在搜索的时候使用。
      • 例如我们的狮子分词之后变成狮子shizisz,柿子分词后变成柿子shizisz
词条 文档编号
狮子 1
shizi 1, 2
sz 1, 2
柿子 2
  • 因为这两个次的拼音是一样的,所以创建倒排索引的时候,shizi对应1、2这两条文档,如果对shizi查询,则会把狮子柿子都查询出来
  • 用户搜索掉进狮子笼,如果用我们自定义的分词器,最终也会分出shizi这个词条,进行搜索的时候,当然会查询出两条数据
  • 那么怎么解决这个问题呢?
    • 我们在搜索的时候,使用ik_smart分词器,在mapping映射时,指定两个分词器,analyzersearch_analyzer
      • 其中analyzer是创建索引时使用的
      • search_analyzer是搜索时使用的
1
2
3
4
5
6
7
8
9
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "my_analyzer",
"search_analyzer": "ik_smart"
}
}
}

小结

  • 如何使用拼音分词器?
    1. 下载pinyin分词器
    2. 解压到ES的plugin目录
    3. 重启ES
  • 如何自定义分词器?
    • 创建索引库时,在settings中配置analysis,可以包含三部分
      1. character filter
      2. tokenizer
      3. filter
  • 拼音分词器的注意事项?
    • 为了避免搜索到同音字,搜索时不要使用拼音分词器,添加search_analyzer属性

自动补全查询

  • ES提供额Completion Suggester查询来实现自动补全功能。这个查询会匹配以用户输入内容开头的词条并返回。为了提高补全查询的效率,对于文档中字段的类型有一些约束
    • 参与补全查询的字段必须是completion类型
    • 字段的内容一般是用来补全的多个词条形成的数组
  • 例如这个索引库
1
2
3
4
5
6
7
8
9
10
PUT /test2
{
"mappings": {
"properties": {
"title": {
"type": "completion"
}
}
}
}
  • 然后插入一些测试数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
POST /test2/_doc/1
{
"title": ["Sony", "PS5"]
}


POST /test2/_doc/2
{
"title": ["SK-II", "PITERA"]
}


POST /test2/_doc/3
{
"title": ["Nitendo", "Switch"]
}


POST /test2/_doc/4
{
"title": ["Sony", "WF-1000XM4"]
}
  • 补全查询的DSL语句如下
1
2
3
4
5
6
7
8
9
10
11
12
13
GET /test2/_search
{
"suggest": {
"title_suggest": { // 给suggest取名
"text": "P", // 补全查询关键字
"completion": {
"field": "title", // 补全查询的字段
"skip_duplicates": true, // 跳过重复的
"size": 10 // 获取前10条结果
}
}
}
}
  • 结果中成功查询到PS5PITERA
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
{
"took" : 42,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 0,
"relation" : "eq"
},
"max_score" : null,
"hits" : [ ]
},
"suggest" : {
"title_suggest" : [
{
"text" : "P",
"offset" : 0,
"length" : 1,
"options" : [
{
"text" : "PITERA",
"_index" : "test2",
"_type" : "_doc",
"_id" : "2",
"_score" : 1.0,
"_source" : {
"title" : [
"SK-II",
"PITERA"
]
}
},
{
"text" : "PS5",
"_index" : "test2",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"title" : [
"Sony",
"PS5"
]
}
}
]
}
]
}
}

实现酒店搜索框自动补全

  • 现在我们的hotel索引库还没有设置拼音分词器,需要修改索引库中的配置。但是索引库是无法修改的,只能先删掉再重新创建
  • 另外,我们需要添加一个字段,用来做自动补全,将brandsuggestioncity等都放进去,作为自动补全的提示
  • 那我们现在总结一下需要做的事
    1. 修改hotel的索引结构,设置自定义拼音分词器
    2. 修改索引库的name、all字段,使用自定义分词器(其他字段已经是keyword类型的词条了,这两个text类型的还需要自定义分词)
    3. 索引库添加一个新字段suggestion,类型为completion类型,使用自定义的分词器
    4. 给HotelDoc类添加suggestion字段,内容包含brandbusiness
    5. 重新导入数据到hotel库

修改hotel映射结构

  • 代码如下
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
PUT /hotel
{
"settings": {
"analysis": {
"analyzer": {
"text_analyzer": {
"tokenizer": "ik_max_word",
"filter": "py"
},
"completion_analyzer": {
"tokenizer": "keyword",
"filter": "py"
}
},
"filter": {
"py": {
"type": "pinyin",
"keep_full_pinyin": false,
"keep_joined_full_pinyin": true,
"keep_original": true,
"remove_deplicated_term": true,
"none_chinese_pinyin_tokenize": false
}
}
}
},
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "text_analyzer",
"search_analyzer": "ik_smart",
"copy_to": "all"
},
"address": {
"type": "keyword",
"index": false
},
"price": {
"type": "integer"
},
"score": {
"type": "integer"
},
"brand": {
"type": "keyword",
"copy_to": "all"
},
"city": {
"type": "keyword"
},
"starName": {
"type": "keyword"
},
"business": -{
"type": "keyword"
, "copy_to": "all"
},
"location": {
"type": "geo_point"
},
"pic": {
"type": "keyword",
"index": false
},
"all":{
"type": "text",
"analyzer": "text_analyzer",
"search_analyzer": "ik_smart"
},
"suggestion": {
"type": "completion",
"analyzer": "completion_analyzer",
"search_analyzer": "ik_smart"
}
}
}
}

修改HotelDoc实体

  • HotelDoc中要添加一个字段,用来做数组补全,内容可以是酒店品牌、城市、商圈、名称等信息。按照自动补全的要求,最好是这些字段的数组
  • 因此我们可以在HotelDoc中添加一个suggestion字段,类型为List<String>,然后将brand、city、business等信息放到里面
  • 由于某些business的信息是包含多个关键字,所以我们需要对其切分
1
2
"business" : "天安门/王府井地区"
"business" : "永定门、南站、大红门、南苑地区"
  • 代码如下
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
@Data
@NoArgsConstructor
public class HotelDoc {
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;
private Object distance;
private Boolean isAD;
// 新增suggestion属性
private List<String> suggestion;

public HotelDoc(Hotel hotel) {
this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
// 组装suggestion
if (this.business.contains("/") || this.business.contains("、")) {
// business有多个值、需要切分,根据数据库中的数据,这里按照/和、来切分
String[] splits = this.business.split("/|、");
this.suggestion = new ArrayList<>();
// 添加元素
this.suggestion.add(this.brand);
this.suggestion.add(this.city);
// 添加切分business后的结果
Collections.addAll(this.suggestion, splits);
} else {
this.suggestion = Arrays.asList(brand, city, business);
}
}
}

重新导入

  • 运行之前编写的批量导入数据功能,可以看到新的酒店数据中包含了suggestion,且切分了business,后面我们就根据suggestion这个字段来自动补全
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
{
"_index" : "hotel",
"_type" : "_doc",
"_id" : "413460",
"_score" : 1.8905408,
"_source" : {
"address" : "东城天坛东里甲48号",
"brand" : "7天酒店",
"business" : "前门、崇文门商贸区",
"city" : "北京",
"id" : 413460,
"location" : "39.875786, 116.421987",
"name" : "7天连锁酒店(北京天坛店)",
"pic" : "https://m.tuniucdn.com/fb2/t1/G2/M00/C7/D8/Cii-T1knCK6IWTtxAAI0plLButMAAKYTAJu-woAAjS-422_w200_h200_c1_t0.jpg",
"price" : 753,
"score" : 38,
"starName" : "二钻",
"suggestion" : [
"7天酒店",
"北京",
"前门",
"崇文门商贸区"
]
}
}

自动补全查询的API

  • 前面我们只是学了自动补全查询的DSL,那现在我们来学习对应的JavaAPI
1
2
3
4
5
6
7
8
9
SearchRequest request = new SearchRequest("test2");
request.source()
.suggest(new SuggestBuilder().addSuggestion(
"title_suggest",
SuggestBuilders.completionSuggestion("title")
.prefix("p")
.skipDuplicates(true)
.size(10)
));

  • 自动补全的结果也比较特殊,解析的代码如下
1
2
3
4
5
6
7
8
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
Suggest suggest = response.getSuggest();
CompletionSuggestion suggestion = suggest.getSuggestion("title_suggest");
List<CompletionSuggestion.Entry.Option> options = suggestion.getOptions();
for (CompletionSuggestion.Entry.Option option : options) {
String text = option.getText().toString();
System.out.println(text);
}

实现搜索框自动补全

  • 当我们在搜索框输入s时,会看到请求
1
2
3
请求网址: http://localhost:8089/hotel/suggestion?key=s
请求方法: GET
请求载荷:key: s
  • 需求:当我们在搜索框输入s时,会显示以s为首字母的词条
  • 那我们在HotelController中定义方法,遵循以下要求
    1. 请求方式:GET
    2. 请求路径:/hotel/suggestion
    3. 请求参数:key
    4. 返回值类型: List<String>
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
@Override
public List<String> getSuggestion(String prefix) {
try {
// 1. 准备Request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL
request.source()
.suggest(new SuggestBuilder().addSuggestion(
"suggestions",
SuggestBuilders
.completionSuggestion("suggestion")
.prefix(prefix)
.skipDuplicates(true)
.size(10)
));
// 3. 发起请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析结果
Suggest suggest = response.getSuggest();
// 4.1 根据补全查询名称,获取补全结果
CompletionSuggestion suggestions = suggest.getSuggestion("suggestions");
// 4.2 获取options
List<CompletionSuggestion.Entry.Option> options = suggestions.getOptions();
// 4.3 遍历 这里可以提前声明集合的大小,只能是options.size()
ArrayList<String> list = new ArrayList<>(options.size());
for (CompletionSuggestion.Entry.Option option : options) {
// 将每条补全结果都加入到集合中
String text = option.getText().toString();
list.add(text);
}
// 5. 返回补全结果集合
return list;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
  • 重启服务,自动补全功能已经实现了
  • 无论用户输入中文还是拼音都能正确显示补全结果

如果搜中文不显示补全,那肯定是创建索引库的时候,没指定suggestion的search_analyzer为ik_smart

数据同步

  • ES中的酒店数据来自于MySQL的数据库,因此MySQL数据发生改变时,ES也必须跟着改变,这就是ES与MySQL之间的数据同步问题
  • 但是在微服务中,负责酒店管理(操作MySQL)的业务,与负责酒店搜索(操作ES)的业务可能在两个不同的微服务上,那么数据同步又该如何实现呢?

思路分析

  • 常见的数据沟通过不方案有三种
    1. 同步调用
    2. 异步通知
    3. 监听binlog

同步调用

  • 流程如下

    1. hotel-demo对外提供接口,用来更新ES中的数据
    2. 酒店管理服务在完成数据库操作后,直接调用hotel-demo提供的接口
  • 但是这样存在一些问题

    1. 耦合度太高,hotel-admin原本只需要将数据写入数据库,但现在写入数据库之后,还要调用hotel-demo提供的更新ES的接口,形成了业务耦合
    2. 性能差,例如原本的写入数据库只需要50ms,调用hotel-demo提供的更新ES的接口耗时150ms,那么总耗时就达到了0.2s,性能自然就下降了。且如果1.21.3发生了异常,那么整个业务也都出问题了

异步通知

  • 流程如下
    1. hotel-admin对MySQL数库数据完成增删改后,发送MQ消息
    2. hotel-demo监听MQ,接收到消息后完成ES数据修改
  • 这样就解除了业务间的耦合,也提高了性能,但是比较依赖于MQ的可靠性,并且引入了新的中间件,实现起来的复杂度有所上升

监听binlog

  • 流程如下
    1. 给mysql开启binlog功能
    2. mysql完成增删改操作,都会记录在binlog中
    3. hotel-demo基于canal监听binlog变化,实时更新ES中的内容
  • 相比较于异步通知来说,此种方式完全接触了耦合,既不用给MQ发消息,也不用调用hotel-demo提供的接口。但是由于要开启mysql的binlog功能,所以对mysql的压力很大,并且要引入新的中间件canal,实现起来比较复杂

小结

  • 同步调用
    • 优点:实现简单
    • 缺点:业务耦合度高
  • 异步通知
    • 优点:低耦合,实现难度一般
    • 缺点:依赖MQ的可靠性
  • 监听binlog
    • 优点:完全解除服务间的耦合
    • 缺点:开启binlog增加数据库负担、实现复杂度高

实现数据同步

思路

  • 使用黑马提供的hotel-admin项目作为酒店管理的微服务。

  • 当酒店数据发生增删改时,要求对ES中的数据也完成相同的操作

  • 需要注意的一点是,ES中新增数据和修改数据是同一个操作,因为在RestClient的API中,全量修改与新增的API完全一致,判断的依据是ID

    • 若新增时,ID已经存在,则修改(删除再新增)
    • 若新增时,ID不存在,则新增
  • 实现思路如下

    1. 导入黑马提供的hotel-admin,启动并测试酒店数据的CRUD
    2. 声明exchange、queue、RountingKey
    3. 在hotel-admin中的增删改业务中,完成消息发送
    4. 在hotel-demo中完成消息监听,并更新ES中数据
    5. 启动并测试数据同步功能
  • 如果你已经忘记了MQ的知识,可以来看我写的这篇文章

导入demo

  • 导入黑马提供的hotel-admin,修改application.yml配置文件,将数据库连接信息改成自己的,访问http://localhost:8099/ 就能看到管理页面了
  • 其中已经包含了酒店的CRUD功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@PostMapping
public void saveHotel(@RequestBody Hotel hotel){
hotelService.save(hotel);
}

@PutMapping()
public void updateById(@RequestBody Hotel hotel){
if (hotel.getId() == null) {
throw new InvalidParameterException("id不能为空");
}
hotelService.updateById(hotel);
}

@DeleteMapping("/{id}")
public void deleteById(@PathVariable("id") Long id) {
hotelService.removeById(id);
}

声明交换机、队列

  • MQ结构如图
  1. 引入依赖

    • 使用RabbitMQ,我们首先要在hotel-demo和hotel-admin引入SpringAMQP的依赖
    1
    2
    3
    4
    5
    <!--amqp-->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
  2. 配置RabbitMQ的连接信息

    • 在hotel-demo和hotel-admin的application.yml文件中添加配置
    1
    2
    3
    4
    5
    6
    7
    spring:
    rabbitmq:
    host: 192.168.96.128 ## 主机名
    port: 5672 #端口
    username: root ## 用户名
    password: root ## 密码
    virtual-host: / ## 虚拟主机
  3. 声明交换机和队列名称

    • 在hotel-demo和hotel-admin的constants包下新建一个MqConstants类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class MqConstants {
    /**
    * 交换机
    */
    public static final String HOTEL_EXCHANGE = "hotel.topic";
    /**
    * 监听新增和修改的队列
    */
    public static final String HOTEL_INSERT_QUEUE = "hotel.insert.queue";
    /**
    * 监听删除的队列
    */
    public static final String HOTEL_DELETE_QUEUE = "hotel.delete.queue";
    /**
    * 新增和修改的RoutingKey
    */
    public static final String HOTEL_INSERT_KEY = "hotel.insert";
    /**
    * 删除的RoutingKey
    */
    public static final String HOTEL_DELETE_KEY = "hotel.delete";
    }
  4. 声明交换机和队列

    • 在hotel-demo中,定义配置类,声明队列、交换机
    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
    @Configuration
    public class MqConfig {
    @Bean
    public TopicExchange topicExchange() {
    return new TopicExchange(MqConstants.HOTEL_EXCHANGE);
    }

    @Bean
    public Queue insertQueue() {
    return new Queue(MqConstants.HOTEL_INSERT_QUEUE, true);
    }

    @Bean
    public Queue deleteQueue() {
    return new Queue(MqConstants.HOTEL_DELETE_QUEUE, true);
    }

    @Bean
    public Binding insertQueueBinding() {
    return BindingBuilder.bind(insertQueue()).to(topicExchange()).with(MqConstants.HOTEL_INSERT_KEY);
    }

    @Bean
    public Binding deleteQueueBinding() {
    return BindingBuilder.bind(deleteQueue()).to(topicExchange()).with(MqConstants.HOTEL_DELETE_KEY);
    }
    }

发送MQ消息

  • 在hotel-admin中的增删改业务中,分别发送MQ消息
  • 但是发送的消息MQ是会保存的,而MQ又是基于内存的,所以我们要发送的内容要尽可能的小
    • 因此不建议直接把整个hotel对象发送,太消耗内存了
    • 只发送一个酒店id就足以满足需求了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    @PostMapping
public void saveHotel(@RequestBody Hotel hotel) {
hotelService.save(hotel);
+ rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_INSERT_KEY, hotel.getId());
}

@PutMapping()
public void updateById(@RequestBody Hotel hotel) {
if (hotel.getId() == null) {
throw new InvalidParameterException("id不能为空");
}
hotelService.updateById(hotel);
+ rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_INSERT_KEY, hotel.getId());
}

@DeleteMapping("/{id}")
public void deleteById(@PathVariable("id") Long id) {
hotelService.removeById(id);
+ rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_DELETE_KEY, id);
}

接收MQ消息

  • hotel-demo接收到MQ消息要做的事情包括
    1. 新增消息:根据传递的hotel的id查询hotel信息,然后新增一条数据到索引库(修改同理)
    2. 删除消息:根据传递的hotel的id删除索引库中的一条数据
  • 我们在mq包下新增一个类HotelListener
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class HotelListener {
@Autowired
private IHotelService hotelService;

/**
* 监听酒店新增/修改业务
* @param id 酒店的id
*/
@RabbitListener(queues = MqConstants.HOTEL_INSERT_QUEUE)
public void listenHotelInsertQueue(Long id) {
hotelService.insertById(id);
}

/**
* 监听酒店删除业务
* @param id 酒店的id
*/
@RabbitListener(queues = MqConstants.HOTEL_DELETE_QUEUE)
public void listenHotelDeleteQueue(Long id){
hotelService.deleteById(id);
}
}
  • 然后在IHotelService中创建这两个方法
1
2
3
4
5
6
7
public interface IHotelService extends IService<Hotel> {

void insertById(Long id);

void deleteById(Long id);
}

  • 并在HotelService中实现业务逻辑
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
@Override
public void insertById(Long id) {
try {
// 1. 根据id查询酒店数据
Hotel hotel = getById(id);
// 2. 转换为文档类型
HotelDoc hotelDoc = new HotelDoc(hotel);
// 3. 准备Request对象
IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString());
// 4. 准备文档
request.source(JSON.toJSON(hotelDoc), XContentType.JSON);
// 5. 发送请求
client.index(request,RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

@Override
public void deleteById(Long id) {
try {
// 1. 准备Request对象
DeleteRequest request = new DeleteRequest("hotel",id.toString());
// 2. 发送请求
client.delete(request,RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
  • 重启这两个服务,并测试

集群

  • 单机的ES做数据存储,必然会面临两个问题
    1. 海量数据存储问题:将索引库从逻辑上拆分为N个分片(shard),存储到多个节点
    2. 单点故障问题:将分片数据在不同节点备份(replica)
  • ES集群相关概念
    • 集群(cluster):一组拥有共同cluster name的节点
    • 节点(node):集群中的一个ES示例
    • 分片(shard):索引可以被拆分为不同的部分进行存储,称为分片。
      • 在集群环境下,一个索引的不同分片可以拆分到不同的节点中。
      • 解决问题:数据量太大,单点存储有限的问题
    • 主分片(Primary shard):相对于副本分片的定义
    • 副本分片(Replica shard):每个主分片都可以有一个或多个副本,数据与主分片一样
  • 数据备份可以保证高可用,但是每个分片备份一份,所需要的节点数量就会翻一倍,成本太高了
  • 为了在高可用和成本间寻求平衡,我们可以这样做
    1. 首先对数据分片,存储到不同节点
    2. 然后对每个分片进行备份,放到对方节点,完成互相备份
    • 现在,每个分片都有1个备份,存储在3个节点:
      • node0:保存了分片0和1
      • node1:保存了分片0和2
      • node2:保存了分片1和2

搭建ES集群

  • 部署es集群可以直接使用docker-compose来完成,不过虚拟机至少要有4G的内存空间
  • 首先编写一个docker-compose文件
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
version: '2.2'
services:
es01:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
container_name: es01
environment:
- node.name=es01
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es02,es03
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data01:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
- elastic
es02:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
container_name: es02
environment:
- node.name=es02
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es03
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data02:/usr/share/elasticsearch/data
networks:
- elastic
es03:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
container_name: es03
environment:
- node.name=es03
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es02
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data03:/usr/share/elasticsearch/data
networks:
- elastic

volumes:
data01:
driver: local
data02:
driver: local
data03:
driver: local

networks:
elastic:
driver: bridge
  • 运行docker-compose文件集群部署
1
docker-compose up

集群脑裂问题

集群职责划分

  • ES中集群节点有不同的职责划分
节点类型 配置参数 默认值 节点职责
master eligible node.master true 备选主节点:主节点可以管理和记录集群状态、决定分片在哪个节点、处理创建和删除索引库的请求
data node.data true 数据节点:存储数据、搜索、聚合、CRUD
ingest node.ingest true 数据存储之前的预处理
coordinating 上面3个参数都为false则为coordinating节点 路由请求到其它节点合并其它节点处理的结果,返回给用户
  • 默认情况下,急群众的任何一个节点都同时具备上述四种角色
  • 但真实的集群一定要将集群职责分离
    • master节点:对CPU要求高,但是对内存要求低
    • data节点:对CPU和内存要求都高
    • coordinating节点:对网络带宽、CPU要求高
  • 职责分离可以让我们根据不同节点的需求分配不同的硬件去部署。而且可以避免业务之间的互相干扰
  • 一个典型的ES集群职责划分如下图

脑裂问题

  • 脑裂是因为集群中的节点失联导致的。

  • 例如一个集群中,主节点与其他节点失联

  • 此时node2和node3会认为node1宕机,就会重新选主

  • 当node3当选后,集群继续对外提供服务,node2和node3自成一个集群,node1自成一个集群。这两个集群数据不同步,出现数据差异

  • 当网络恢复后,因为急群众有两个master节点,集群状态的不一致,会出现脑裂的情况

  • 解决脑裂的方案是,要求选品超过( eligible节点数量 + 1 )/ 2才能当选为主,因此eligible节点数量最好是奇数。对应配置项是discovery.zen.minimum_master_nodes,在es7.0以后,已经成为默认配置,因此一般不会发生脑裂问题

  • 例如:3个节点形成的集群,选票必须超过 (3 + 1) / 2 ,也就是2票。node3得到node2和node3的选票,当选为主。node1只有自己1票,没有当选。集群中依然只有1个主节点,没有出现脑裂。

小结

  • master eligible节点的作用是什么?
    • 参与集群选主
    • 主节点可以管理集群状态、管理分片信息、处理创建和删除索引库的请求
  • data节点的作用是什么?
    • 数据的CRUD
  • coordinating节点的作用是什么?
    • 路由请求到其他节点
    • 合并查询到的结果,返回给用户

集群分布式存储

  • 当新增文档时,应该保存到不同分片,保证数据均衡,那么coordinating node如何确定数据该存储到哪个分片呢?

  • ES会通过hash算法来计算文档应该存储到哪个分片

1
shard = hash(_routing) % number_of_shards
  • 说明:_routing默认是文档的id,算法与分片数量有关,因此索引库一旦创建,分片数量不能修改

  • 新增文档的流程如下

  • 解读

    1. 新增一个id=1的文档
    1. 对id做hash运算,假如得到的是2,则应该存储到shard-2
    1. shard-2的主分片在node3节点,将数据路由到node3
    1. 保存文档
    1. 同步给shard-2的副本replica-2,在node2节点
    1. 返回结果给coordinating-node节点

集群分布式查询

  • ES的查询分成两个阶段
    1. scatter phase:分散阶段,coordinating node会把请求分发到每一个分片
    2. gather phase:聚集阶段,coordinating node汇总data node的搜索结果,并处理为最终结果集返回给用户

集群故障转移

  • 集群的master节点会监控集群中的节点状态,如果发现有节点宕机,会立即将宕机节点的分片数据迁移到其它节点,确保数据安全,这个叫做故障转移。
  1. 例如一个集群结构如图:
    • 现在,node1是主节点,其它两个节点是从节点。
  2. 突然,node1发生了故障:
    • 宕机后的第一件事,需要重新选主,例如选中了node2:
    • node2成为主节点后,会检测集群监控状态,发现:shard-1、shard-0没有副本节点。因此需要将node1上的数据迁移到node2、node3: