目录
引言
电商项目,商品的搜索肯定是访问频率最高的页面之一。目前搜索功能是基于数据库的模糊搜索来实现的,存在很多问题。
首先,查询效率较低。
改为基于搜索引擎后,查询表现如下:
需要注意的是,数据库模糊查询随着表数据量的增多,查询性能的下降会非常明显(他会根据模糊查询的条件一一查询数据来对比),而搜索引擎的性能则不会随着数据增多而下降太多。目前仅10万不到的数据量差距就如此明显,如果数据量达到百万、千万、甚至上亿级别,这个性能差距会非常夸张。
其次,功能单一
数据库的模糊搜索功能单一,匹配条件非常苛刻,必须恰好包含用户搜索的关键字。而在搜索引擎中,用户输入出现个别错字,或者用拼音搜索、同义词搜索都能正确匹配到数据。
综上,在面临海量数据的搜索,或者有一些复杂搜索需求的时候,推荐使用专门的搜索引擎来实现搜索功能。
目前全球的搜索引擎技术排名如下:
排名第一的就是我们今天要说的的elasticsearch.
elasticsearch是一款非常强大的开源搜索引擎,支持的功能非常多,例如:代码搜索 商品搜索
解决方案搜索 地图搜索 等。。。
1.初识elasticsearch
Elasticsearch的官方网站如下:https://www.elastic.co/cn/elasticsearch
1.1.认识和安装
Elasticsearch是由elastic公司开发的一套搜索引擎技术,它是elastic技术栈中的一部分。完整的技术栈包括:
Elasticsearch:用于数据存储、计算和搜索
Logstash/Beats:用于数据收集
Kibana:用于数据可视化
整套技术栈被称为ELK,经常用来做日志收集、系统监控和状态分析等等:
整套技术栈的核心就是用来存储、搜索、计算的Elasticsearch,
我们要安装的内容包含2部分:
-
elasticsearch:存储、搜索和运算
-
kibana:图形化展示
首先Elasticsearch不用多说,是提供核心的数据存储、搜索、分析功能的。
然后是Kibana,Elasticsearch对外提供的是Restful风格的API,任何操作都可以通过发送http请求来完成。不过http请求的方式、路径、还有请求参数的格式都有严格的规范。这些规范我们肯定记不住,因此我们要借助于Kibana这个服务。
Kibana是elastic公司提供的用于操作Elasticsearch的可视化控制台。它的功能非常强大,包括:
对Elasticsearch数据的搜索、展示
对Elasticsearch数据的统计、聚合,并形成图形化报表、图形
对Elasticsearch的集群状态监控
它还提供了一个开发控制台(DevTools),在其中对Elasticsearch的Restful的API接口提供了语法提示
1.1.1.安装elasticsearch
通过下面的Docker命令即可安装单机版本的elasticsearch
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 hm-net \
-p 9200:9200 \
-p 9300:9300 \
elasticsearch:7.12.1
安装完成后,访问9200端口,即可看到响应的Elasticsearch服务的基本信息:
1.1.2.安装Kibana
通过下面的Docker命令,即可部署Kibana:
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=hm-net \
-p 5601:5601 \
kibana:7.12.1
安装完成后,直接访问5601端口,即可看到控制台页面:
进入
我们就可以便捷操作Es了
1.2.倒排索引
elasticsearch之所以有如此高性能的搜索表现,正是得益于底层的倒排索引技术。那么什么是倒排索引呢?
倒排索引的概念是基于MySQL这样的正向索引而言的。
1.2.1.正向索引
我们先来回顾一下正向索引。
例如有一张名为tb_goods
的表:
其中的id
字段已经创建了索引,由于索引底层采用了B+树结构,因此我们根据id搜索的速度会非常快。但是其他字段例如title
,只在叶子节点上存在。
因此要根据title
搜索的时候只能遍历树中的每一个叶子节点,判断title数据是否符合要求。
比如用户的SQL语句为:
select * from tb_goods where title like '%手机%';
说明:
1)检查到搜索条件为
like '%手机%'
,需要找到title
中包含手机
的数据2)逐条遍历每行数据(每个叶子节点),比如第1次拿到
id
为1的数据3)判断数据中的
title
字段值是否符合条件4)如果符合则放入结果集,不符合则丢弃
5)回到步骤1
综上,根据id精确匹配时,可以走索引,查询效率较高。而当搜索条件为模糊匹配时,由于索引无法生效,导致从索引查询退化为全表扫描,效率很差。
因此,正向索引适合于根据索引字段的精确搜索,不适合基于部分词条的模糊匹配。
而倒排索引恰好解决的就是根据部分词条模糊匹配的问题。
1.2.2.倒排索引
倒排索引中有两个非常重要的概念:
文档(
Document
):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息词条(
Term
):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条
这样一来,根据用户输入得关键词,后续根据要求会把用户得词进行分词然后去匹配查询,根据分词查询得结果获取索引在去获取整体内容。2次都是O(1),所以效率高,空间换时间。
创建倒排索引是对正向索引的一种特殊处理和应用,流程如下:
将每一个文档的数据利用分词算法根据语义拆分,得到一个个词条
创建表,每行数据包括词条、词条所在文档id、位置等信息
因为词条唯一性,可以给词条创建正向索引
此时形成的这张以词条为索引的表,就是倒排索引表,两者对比如下:
倒排索引的搜索流程如下(以搜索"华为手机"为例),如图:
流程描述:
1)用户输入条件
"华为手机"
进行搜索。2)对用户输入条件分词,得到词条:
华为
、手机
。3)拿着词条在倒排索引中查找(由于词条有索引,查询效率很高),即可得到包含词条的文档id:
1、2、3
。4)拿着文档
id
到正向索引中查找具体文档即可(由于id
也有索引,查询效率也很高)。
虽然要先查询倒排索引,再查询倒排索引,但是无论是词条、还是文档id都建立了索引,查询速度非常快!无需全表扫描。
1.2.3.正向和倒排
那么为什么一个叫做正向索引,一个叫做倒排索引呢?
-
正向索引是最传统的,根据id索引的方式。但根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程。
-
而倒排索引则相反,是先找到用户要搜索的词条,根据词条得到保护词条的文档的id,然后根据id获取文档。是根据词条找文档的过程。
是不是恰好反过来了?
那么两者方式的优缺点是什么呢?
正向索引:
优点:
可以给多个字段创建索引
根据索引字段搜索、排序速度非常快
缺点:
根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描。
倒排索引:
优点:
根据词条搜索、模糊搜索时,速度非常快
缺点:
只能给词条创建索引,而不是字段
无法根据字段做排序
正序:先查文档 在根据文档查询是否包含词条
到排:先根据词条查询到文档,再根据文档id查询文档
1.3.基础概念
elasticsearch中有很多独有的概念,与mysql中略有差别,但也有相似之处。
1.3.1.文档和字段
elasticsearch是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为json
格式后存储在elasticsearch
中:
因此,原本数据库中的一行数据就是ES中的一个JSON文档;而数据库中每行数据都包含很多列,这些列就转换为JSON文档中的字段(Field)。
1.3.2.索引和映射
随着业务发展,需要在es中存储的文档也会越来越多,比如有商品的文档、用户的文档、订单文档等等:
所有文档都散乱存放显然非常混乱,也不方便管理。
因此,我们要将类型相同的文档集中在一起管理,称为索引(Index)。例如:
-
所有用户文档,就可以组织在一起,称为用户的索引;
-
所有商品的文档,可以组织在一起,称为商品的索引;
-
所有订单的文档,可以组织在一起,称为订单的索引;
因此,我们可以把索引当做是数据库中的表。
数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。因此,索引库中就有映射(mapping),是索引中文档的字段约束信息,类似表的结构约束。
1.3.3.mysql与elasticsearch
我们统一的把mysql与elasticsearch的概念做一下对比:
那是不是说,我们学习了elasticsearch就不再需要mysql了呢?
并不是如此,两者各自有自己的擅长之处:
Mysql:擅长事务类型操作,可以确保数据的安全和一致性
Elasticsearch:擅长海量数据的搜索、分析、计算
因此在企业中,往往是两者结合使用:
对安全性要求较高的写操作,使用mysql实现
对查询性能要求较高的搜索需求,使用elasticsearch实现
两者再基于某种方式,实现数据的同步,保证一致性
1.4.1.安装IK分词器
好处是:
提高搜索准确性:IK分词器能更准确地理解中文语境,对中文文本进行更精细的分词处理,从而提升搜索引擎的准确性和相关性。
支持多种分词模式:它提供了多种分词模式,如细粒度切分和智能切分,适应不同场景下的分词需求,灵活方便。
优化搜索效率:通过高效的分词算法,IK分词器能够快速处理大量文本数据,提高搜索和数据分析的效率。
扩展性强:它支持自定义词典,用户可以根据实际需求添加或修改词典,进一步提升分词准确性和适用性。
广泛兼容性:IK分词器通常与多种搜索引擎和数据处理平台兼容,易于集成和使用。
在线安装
Docker中去部署:运行一个命令即可:
docker exec -it es ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip
然后重启es容器:
docker restart es
1.4.2.使用IK分词器
IK分词器包含两种模式:
-
ik_smart
:智能语义切分 -
ik_max_word
:最细粒度切分
1.4.3.分词器区别
1. 标准分词器
- 特点:标准分词器(Standard Analyzer)是Elasticsearch默认的分词器,它主要用于英文文本的分词,通过空格、标点符号等规则将文本切分成单词。对于中文文本,标准分词器会将每个汉字视为一个独立的单词,这显然不符合中文的分词习惯。
- 分词效果:在处理中文时,标准分词器的分词效果较差,因为它没有考虑中文的词汇边界和语义信息。
2. IK分词器
IK分词器是一个专门为Elasticsearch设计的中文分词插件,它提供了ik_smart和ik_max_word两种分词模式。
- ik_smart(智能语义切分):
- 特点:采用最少切分策略,尽可能将文本切分成较长的、有意义的词汇单元。它结合了理解歧义和未知词的算法,对文本进行词典分词的同时,也会智能识别词汇的边界,从而提高分词的准确性。
- 分词效果:分词结果较少,但每个词汇都更有意义,适合用于搜索等需要精确匹配的场景。
- ik_max_word(最细粒度切分):
- 特点:采用最细粒度切分策略,尽可能将文本切分成更多的词汇单元。它主要通过条件随机场(CRF)模型来识别词汇边界,然后使用动态规划寻找最优的词段划分。即使在词典中有定义的词汇,ik_max_word模式仍然会尝试将文本切分成更多的词汇。
- 分词效果:分词结果较多,包括了许多可能的词汇组合,适合用于文本分析、索引等需要尽可能多地提取词汇信息的场景。
1.4.4、具体例子
以中文文本“南京市长江大桥”为例,我们来看看这三种分词器的分词结果:
- 标准分词器:南京市、长、江、大、桥。标准分词器将每个汉字都视为一个独立的单词,没有考虑中文的词汇边界。
- IK分词器(ik_smart):南京市、长江大桥。ik_smart模式将文本切分成了两个有意义的词汇单元,“南京市”和“长江大桥”,符合中文的语义习惯。
- IK分词器(ik_max_word):南京市、长江、大桥、南京、江大桥。ik_max_word模式将文本切分成了更多的词汇单元,包括了一些可能的词汇组合,如“南京”、“江大桥”等。这些分词结果虽然有些在实际语境中可能并不常见,但它们有助于更全面地理解文本内容。
通过这个例子可以看出,IK分词器的ik_smart和ik_max_word模式在处理中文文本时具有显著的优势,能够根据实际需求选择合适的分词策略。而标准分词器在处理中文时则显得力不从心
ik_smart
ik_max_word 更细
会根据词典里面得数据来进行分词 ,有些词没有也可以在文件中自行配置
2.索引库操作
Index就类似数据库表,Mapping映射就类似表的结构。我们要向es中存储数据,必须先创建Index和Mapping
2.1.Mapping映射属性
Mapping是对索引库中文档的约束,常见的Mapping属性包括:
type
:字段数据类型,常见的简单类型有:
字符串:
text
(可分词的文本)、keyword
(精确值,例如:品牌、国家、ip地址)数值:
long
、integer
、short
、byte
、double
、float
、布尔:
boolean
日期:
date
对象:
object
index
:是否创建索引,默认为true
analyzer
:使用哪种分词器
properties
:该字段的子字段
2.2.索引库的CRUD
由于Elasticsearch采用的是Restful风格的API,因此其请求方式和路径相对都比较规范,而且请求参数也都采用JSON风格。
我们直接基于Kibana的DevTools来编写请求做测试,由于有语法提示,会非常方便。
2.2.1.创建索引库和映射
基本语法:
请求方式:
PUT
请求路径:
/索引库名
,可以自定义请求参数:
mapping
映射
格式:
PUT /索引库名称
{
"mappings": {
"properties": {
"字段名":{
"type": "text",
"analyzer": "ik_smart"
},
"字段名2":{
"type": "keyword",
"index": "false"
},
"字段名3":{
"properties": {
"子字段": {
"type": "keyword"
}
}
},
// ...略
}
}
}
示例:
jaa# PUT /hezheng
{
"mappings": {
"properties": {
"info":{
"type": "text",
"analyzer": "ik_smart"
},
"email":{
"type": "keyword",
"index": "false"
},
"name":{
"properties": {
"firstName": {
"type": "keyword"
}
}
}
}
}
}
效果:
2.2.2.查询索引库
基本语法:
请求方式:GET
请求路径:/索引库名
请求参数:无
格式:
GET /索引库名
示例:
GET /hezheng
2.2.3.修改索引库
倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,无法修改mapping。
虽然无法修改mapping中已有的字段,但是却允许添加新的字段到mapping中,因为不会对倒排索引产生影响。因此修改索引库能做的就是向索引库中添加新字段,或者更新索引库的基础属性。
语法说明:
PUT /索引库名/_mapping
{
"properties": {
"新字段名":{
"type": "integer"
}
}
}
示例:
PUT /hezheng/_mapping
{
"properties": {
"age":{
"type": "integer"
}
}
}
2.2.4.删除索引库
语法:
请求方式:DELETE
请求路径:/索引库名
请求参数:无
格式:
DELETE /索引库名
示例:
DELETE /hezheng
全码:
# 创建一个索引库
PUT /user
{
"mappings": {
"properties": {
"age":{
"type": "byte"
},
"email":{
"type": "keyword",
"index": false
},
"name":{
"type": "object",
"properties": {
"firstName":{
"type":"keyword"
},
"lastName":{
"type":"keyword"
}
}
}
}
}
}
# 查询索引库
GET /user
# 删除索引库
DELETE /user
# 修改索引库
PUT /user/_mapping
{
"properties":{
"where":{
"type": "text",
"analyzer": "ik_smart"
}
}
}
效果:
2.2.5.总结
索引库操作有哪些?
创建索引库:PUT /索引库名
查询索引库:GET /索引库名
删除索引库:DELETE /索引库名
修改索引库,添加字段:PUT /索引库名/_mapping
3.文档操作
有了索引库,接下来就可以向索引库中添加数据了。
Elasticsearch中的数据其实就是JSON风格的文档。操作文档自然保护增
、删
、改
、查
等几种常见操作,我们分别来学习。
3.1.新增文档
POST /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
"字段3": {
"子属性1": "值3",
"子属性2": "值4"
},
}
需要指定ID的
示例:
POST /hezheng/_doc/1
{
"info": "何政程序员啊",
"email": "[email protected]",
"name": {
"firstName": "政",
"lastName": "何"
}
}
响应:
3.2.查询文档
根据rest风格,新增是post,查询应该是get,不过查询一般都需要条件,这里我们把文档id带上。
语法:
GET /{索引库名称}/_doc/{id}
示例:
GET /hezheng/_doc/1
效果:
3.3.删除文档
删除使用DELETE请求,同样,需要根据id进行删除:
语法:
DELETE /{索引库名}/_doc/id值
示例:
DELETE /hezheng/_doc/1
效果:
3.4.修改文档
修改有两种方式:
-
全量修改:直接覆盖原来的文档
-
局部修改:修改文档中的部分字段
3.4.1.全量修改
全量修改是覆盖原来的文档,其本质是两步操作:
-
根据指定的id删除文档
-
新增一个相同id的文档
注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。
语法:
PUT /{索引库名}/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
// ... 略
}
示例:
PUT /hezheng/_doc/1
{
"info": "何政程序员啊",
"email": "[email protected]",
"name": {
"firstName": "政",
"lastName": "何"
}
}
由于id
为1
的文档已经被删除,所以第一次执行时,得到的反馈是created
:
所以如果执行第2次时,得到的反馈则是updated
:
3.4.2.局部修改
局部修改是只修改指定id匹配的文档中的部分字段。
语法:
POST /{索引库名}/_update/文档id
{
"doc": {
"字段名": "新的值",
}
}
注意:局部修改得用Post
示例:
POST /hezheng/_update/1
{
"doc": {
"email": "[email protected]"
}
}
执行结果:
3.5.批处理
批处理采用POST请求,基本语法如下:
POST _bulk
{ "index" : { "_index" : "test", "_id" : "1" } }
{ "field1" : "value1" }
{ "delete" : { "_index" : "test", "_id" : "2" } }
{ "create" : { "_index" : "test", "_id" : "3" } }
{ "field1" : "value3" }
{ "update" : {"_id" : "1", "_index" : "test"} }
{ "doc" : {"field2" : "value2"} }
其中:
index
代表新增操作
_index
:指定索引库名
_id
指定要操作的文档id
{ "field1" : "value1" }
:则是要新增的文档内容
delete
代表删除操作
_index
:指定索引库名
_id
指定要操作的文档id
update
代表更新操作
_index
:指定索引库名
_id
指定要操作的文档id
{ "doc" : {"field2" : "value2"} }
:要更新的文档字段
3.6.总结
文档操作有哪些?
创建文档:
POST /{索引库名}/_doc/文档id { json文档 }
查询文档:
GET /{索引库名}/_doc/文档id
删除文档:
DELETE /{索引库名}/_doc/文档id
修改文档:
全量修改:
PUT /{索引库名}/_doc/文档id { json文档 }
局部修改:
POST /{索引库名}/_update/文档id { "doc": {字段}}
图片:
4.RestAPI
当然我们要使用JAVA代码来操作这些数据得
ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。
官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html
目前8.0以上得版本跟7以下版本变化比较大,忒别是他得API,基本采用得都是响应式编程,lambda表达式这种语发跟之前语法差别较大,现在企业用得7以下版本还是较多。就选择
然后选择7.12版本,HighLevelRestClient版本:
4.1.初始化RestClient
在elasticsearch提供的API中,与elasticsearch一切交互都封装在一个名为RestHighLevelClient
的类中,必须先完成这个对象的初始化,建立与elasticsearch的连接。
分为三步:1.引依赖 2.覆盖版本 3.初始化
1)在模块中引入ES的RestHighLevelClient
依赖:
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
2)因为SpringBoot默认的ES版本是7.17.10
,所以我们需要覆盖默认的ES版本,改成我们下载的版本:
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.12.1</version>
</dependency>
3)初始化RestHighLevelClient 建立连接:
初始化的代码如下:
RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.184.128:9200")
));
单元测试方便,我们创建一个测试类IndexTest
,然后将初始化的代码编写在@BeforeEach
方法中:
public class EsMatchAllTest {
private RestHighLevelClient restHighLevelClient;
@BeforeEach
void setUp() {
this.restHighLevelClient = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.184.128:9200")
));
}
@Test
void testConnect() {
System.out.println(restHighLevelClient);
}
@AfterEach
void tearDown() throws IOException {
this.restHighLevelClient.close();
}
效果:
可以看到连接成功!!!
4.1.创建索引库
由于要实现对商品搜索,所以我们需要将商品添加到Elasticsearch中,不过需要根据搜索业务的需求来设定索引库结构,而不是一股脑的把MySQL数据写入Elasticsearch.
比如我们有这样子一个索引库表:
因此,最终我们的索引库文档结构应该是这样:
PUT /items
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name":{
"type": "text",
"analyzer": "ik_max_word"
},
"price":{
"type": "integer"
},
"stock":{
"type": "integer"
},
"image":{
"type": "keyword",
"index": false
},
"category":{
"type": "keyword"
},
"brand":{
"type": "keyword"
},
"sold":{
"type": "integer"
},
"commentCount":{
"type": "integer",
"index": false
},
"isAD":{
"type": "boolean"
},
"updateTime":{
"type": "date"
}
}
}
}
4.1.2.创建索引
创建索引库的API如下:
代码分为三步:
1)创建Request对象。
因为是创建索引库的操作,因此Request是
CreateIndexRequest
。2)添加请求参数
其实就是Json格式的Mapping映射参数。因为json字符串很长,这里是定义了静态字符串常量
MAPPING_TEMPLATE
,让代码看起来更加优雅。MAPPING_TEMPLATE就可以自定义我们的实体JSON数据就行
3)发送请求
client.
indices
()
方法的返回值是IndicesClient
类型,封装了所有与索引库操作有关的方法。例如创建索引、删除索引、判断索引是否存在等
代码:
public class EsMatchAllTest {
private RestHighLevelClient restHighLevelClient;
@BeforeEach
void setUp() {
this.restHighLevelClient = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.184.128:9200")
));
}
@AfterEach
void tearDown() throws IOException {
this.restHighLevelClient.close();
}
@Test
void testCreateIndex() throws IOException {
// 1.创建Request对象
CreateIndexRequest request = new CreateIndexRequest("items");
// 2.准备请求参数
request.source(MAPPING_TEMPLATE, XContentType.JSON);
// 3.发送请求
client.indices().create(request, RequestOptions.DEFAULT);
}
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" +
" },\n" +
" \"price\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"stock\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"image\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"category\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"brand\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"sold\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"commentCount\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"isAD\":{\n" +
" \"type\": \"boolean\"\n" +
" },\n" +
" \"updateTime\":{\n" +
" \"type\": \"date\"\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
}
4.2.删除索引库
删除索引库的请求非常简单:
DELETE /items
与创建索引库相比:
请求方式从PUT变为DELTE
请求路径不变
无请求参数
代码:
@Test
void testDeleteIndex() throws IOException {
// 1.创建Request对象
DeleteIndexRequest request = new DeleteIndexRequest("items");
// 2.发送请求
client.indices().delete(request, RequestOptions.DEFAULT);
}
所以代码的差异,注意体现在Request对象上。流程如下:
1)创建Request对象。这次是DeleteIndexRequest对象
2)准备参数。这里是无参,因此省略
3)发送请求。改用delete方法
4.3.判断索引库是否存在
判断索引库是否存在,本质就是查询,对应的请求语句是:
GET /items
因此与删除的Java代码流程是类似的,流程如下:
1)创建Request对象。这次是GetIndexRequest对象
2)准备参数。这里是无参,直接省略
3)发送请求。改用exists方法
代码:
@Test
void testExistsIndex() throws IOException {
// 1.创建Request对象
GetIndexRequest request = new GetIndexRequest("items");
// 2.发送请求
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
// 3.输出
System.err.println(exists ? "索引库已经存在!" : "索引库不存在!");
}
4.4.总结
JavaRestClient操作elasticsearch的流程基本类似。核心是client.indices()
方法来获取索引库的操作对象。
索引库操作的基本步骤:
初始化
RestHighLevelClient
创建XxxIndexRequest。XXX是
Create(创建索引库)
、Get(查询索引库)
、Delete(删除索引库)
准备请求参数(
Create
时需要,其它是无参,可以省略)发送请求。调用
RestHighLevelClient.indices().xxx()
方法,xxx是create
、exists
、delete
5.RestClient操作文档
索引库准备好以后,就可以CRUD文档了
5.1.新增文档
我们需要将数据库中的商品信息导入elasticsearch中,而不是造假数据了。
5.1.1.实体类
索引库结构与数据库结构还存在一些差异,因此我们要定义一个索引库结构对应的实体。
在hm-service
模块的com.hmall.item.domain.dto
包中定义一个新的DTO:
5.1.2.API语法
新增文档的请求语法如下:
POST /{索引库名}/_doc/1
{
"name": "Jack",
"age": 21
}
对应的JavaAPI如下:
与索引库操作的API非常类似,同样是三步走:
1)创建Request对象,这里是
IndexRequest
,因为添加文档就是创建倒排索引的过程2)准备请求参数,本例中就是Json文档
3)发送请求
根据业务需求我们的数据库字段可能不会同Es索引库字段类型不一致的。字段要么多要么少,根据业务需求,此时我们可以自定义一个实体类来封装Es的数据
根据业务场景数据库添加了一条数据后,会发送MQ异步消息,并且把KeyId传过去,这边服务通过MQ接收到消息后就通过业务ID查询到了数据库的消息 并且封装到对应的数据库匹配的实体Item,然后在转化成ItemDoc序列化JSON获取到数据在添加文档
因此,代码整体步骤如下:
1)根据id查询商品数据
Item
2)将
Item
封装为ItemDoc
3)将
ItemDoc
序列化为JSON4)创建IndexRequest,指定索引库名和id
5)准备请求参数,也就是JSON文档
6)发送请求
代码参考:
@Test
void testAddDocument() throws IOException {
// 1.根据id查询商品数据
Item item = itemService.getById(100002644680L);
// 2.转换为文档类型
ItemDoc itemDoc = BeanUtil.copyProperties(item, ItemDoc.class);
// 3.将ItemDTO转json
String doc = JSONUtil.toJsonStr(itemDoc);
// 1.准备Request对象
IndexRequest request = new IndexRequest("items").id(itemDoc.getId());
// 2.准备Json文档
request.source(doc, XContentType.JSON);
// 3.发送请求
client.index(request, RequestOptions.DEFAULT);
}
变化的地方在于,这里直接使用client.xxx()
的API,不再需要client.indices()
了。
5.2.查询文档
我们以根据id查询文档为例
5.2.1.语法说明
查询的请求语句如下:
GET /{索引库名}/_doc/{id}
与之前的流程类似,代码大概分2步:
创建Request对象
准备请求参数,这里是无参,直接省略发送请求
当然这次会返回一个Json类型结果,我们获取结果可以通过JSONUtil.toBean(json, ItemDoc.class);转化成我们设定好的实体类型
效果图:
其它代码与之前类似,流程如下:
1)准备Request对象。这次是查询,所以是
GetRequest
2)发送请求,得到结果。因为是查询,这里调用
client.get()
方法3)解析结果,就是对JSON做反序列化
5.2.2.参考代码
@Test
void testGetDocumentById() throws IOException {
// 1.准备Request对象
GetRequest request = new GetRequest("items").id("100002644680");
// 2.发送请求
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3.获取响应结果中的source
String json = response.getSourceAsString();
ItemDoc itemDoc = JSONUtil.toBean(json, ItemDoc.class);
System.out.println("itemDoc= " + ItemDoc);
}
5.3.删除文档
删除的请求语句如下:
DELETE /索引库名字/_doc/{id}
与查询相比,仅仅是请求方式从DELETE
变成GET
,可以想象Java代码应该依然是2步走:
1)准备Request对象,因为是删除,这次是
DeleteRequest
对象。要指定索引库名和id2)
准备参数,无参,直接省略3)发送请求。因为是删除,所以是
client.delete()
方法
参考代码
@Test
void testDeleteDocument() throws IOException {
// 1.准备Request,两个参数,第一个是索引库名,第二个是文档id
DeleteRequest request = new DeleteRequest("item", "100002644680");
// 2.发送请求
client.delete(request, RequestOptions.DEFAULT);
}
5.4.修改文档
-
全量修改:本质是先根据id删除,再新增
-
局部修改:修改文档中的指定字段值
在RestClient的API中,全量修改与新增的API完全一致,判断依据是ID:
-
如果新增时,ID已经存在,则内部是先删除这个Id对应数据在添加新的数据
-
如果新增时,ID不存在,则新增
所以就不解释全局修改用法了
5.4.1.语法说明
局部修改的请求语法如下:
POST /{索引库名}/_update/{id}
{
"doc": {
"字段名": "字段值",
"字段名": "字段值"
}
}
代码示例如图:
与之前类似,也是三步走:
1)准备
Request
对象。这次是修改,所以是UpdateRequest
2)准备参数。也就是JSON文档,里面包含要修改的字段
3)更新文档。这里调用
client.update()
方法
5.4.2.代码参考
@Test
void testUpdateDocument() throws IOException {
// 1.准备Request
UpdateRequest request = new UpdateRequest("items", "100002644680");
// 2.准备请求参数
request.doc(
"price", 58800,
"commentCount", 1
);
// 3.发送请求
client.update(request, RequestOptions.DEFAULT);
}
5.5.批量导入文档
在之前的案例中,我们都是操作单个文档。而数据库中的商品数据实际会达到数十万条,某些项目中可能达到数百万条。
我们如果要将这些数据导入索引库,肯定不能逐条导入,而是采用批处理方案。常见的方案有:
利用Logstash批量导入
需要安装Logstash
对数据的再加工能力较弱
无需编码,但要学习编写Logstash导入配置
利用JavaAPI批量导入
需要编码,但基于JavaAPI,学习成本低
更加灵活,可以任意对数据做再加工处理后写入索引库
接下来,我们就学习下如何利用JavaAPI实现批量文档导入。
5.5.1.语法说明
批处理与前面讲的文档的CRUD步骤基本一致:
创建Request,但这次用的是
BulkRequest
准备请求参数
发送请求,这次要用到
client.bulk()
方法
BulkRequest
本身其实并没有请求参数,其本质就是将多个普通的CRUD请求组合在一起发送。例如:
-
批量新增文档,就是给每个文档创建一个
IndexRequest
请求,然后封装到BulkRequest
中,一起发出。 -
批量删除,就是创建N个
DeleteRequest
请求,然后封装到BulkRequest
,一起发出
因此BulkRequest
中提供了add
方法,用以添加其它CRUD的请求:
简单点就是通过
BulkRequest中提供的add方法添加CRUD方法同填加文档方法一致
可以看到,能添加的请求有:
IndexRequest
,也就是新增
UpdateRequest
,也就是修改
DeleteRequest
,也就是删除
因此Bulk中添加了多个IndexRequest
,就是批量新增功能了。示例:
@Test
void testBulk() throws IOException {
// 1.创建Request
BulkRequest request = new BulkRequest();
// 2.准备请求参数
request.add(new IndexRequest("items").id("1").source("hz doc1", XContentType.JSON));
request.add(new IndexRequest("items").id("2").source("hz doc2", XContentType.JSON));
// 3.发送请求
client.bulk(request, RequestOptions.DEFAULT);
}
5.5.2.完整代码
当我们要导入商品数据时,由于商品数量达到数十万,因此不可能一次性全部导入。建议采用循环遍历方式,每次导入1000条左右的数据。
代码参考
@Test
void testLoadItemDocs() throws IOException {
// 分页查询商品数据
int pageNo = 1;
int size = 1000;
while (true) {
Page<Item> page = itemService.lambdaQuery().eq(Item::getStatus, 1).page(new Page<Item>(pageNo, size));
// 非空校验
List<Item> items = page.getRecords();
if (CollUtils.isEmpty(items)) {
return;
}
log.info("加载第{}页数据,共{}条", pageNo, items.size());
// 1.创建Request
BulkRequest request = new BulkRequest("items");
// 2.准备参数,添加多个新增的Request
for (Item item : items) {
// 2.1.转换为文档类型ItemDTO
ItemDoc itemDoc = BeanUtil.copyProperties(item, ItemDoc.class);
// 2.2.创建新增文档的Request对象
request.add(new IndexRequest()
.id(itemDoc.getId())
.source(JSONUtil.toJsonStr(itemDoc), XContentType.JSON));
}
// 3.发送请求
client.bulk(request, RequestOptions.DEFAULT);
// 翻页
pageNo++;
}
}
可以快速把我们数据同步导入到我们Es中
5.6.小结
文档操作的基本步骤:
初始化
RestHighLevelClient(建立连接)
创建XxxRequest。
XXX是
Index(添加文档)
、Get(读取)
、Update(修改)
、Delete(删除)
、Bulk(批处理)
准备参数(
Index
、Update
、Bulk
时需要)发送请求。
调用
RestHighLevelClient#.xxx()
方法,xxx是index
、get
、update
、delete
、bulk
解析结果(
Get
时需要)
图片:
上面已经导入了大量数据到elasticsearch中,学会了如何在Kibana ,java中创建索引库,对文档的CRUD,不过查询商品数据时依然采用的是根据id查询,而非模糊搜索。
接下来研究下elasticsearch的数据搜索功能。Elasticsearch提供了基于JSON的DSL(Domain Specific Language)语句来定义查询条件,其JavaAPI就是在组织DSL条件。
因此,我们先学习DSL的查询语法,然后再基于DSL来对照学习JavaAPI,就会事半功倍。
6.DSL查询
Elasticsearch的查询可以分为两大类:
-
叶子查询(Leaf query clauses):一般是在特定的字段里查询特定值,属于简单查询,很少单独使用。
-
复合查询(Compound query clauses):以逻辑方式组合多个叶子查询或者更改叶子查询的行为方式。
在查询以后,还可以对查询结果处理,包括:
排序:按照1个或多个字段值做排序
分页:根据from和size做分页,类似于MySQL中的
LIMIT
和OFFSET
高亮:对搜索结果中的关键字添加特殊样式,可以自定义标签,在前端通过CSS来高亮显示
聚合:对搜索结果做数据统计形成报表就是
实现对数据的统计、分析、运算。
6.1.快速入门
我们依然在Kibana的DevTools中学习查询的DSL语法。首先来看查询的语法结构:
GET /{索引库名}/_search
{
"query": {
"查询类型": {
// .. 查询条件
}
}
}
说明:
-
GET /{索引库名}/_search
:其中的_search
是固定路径,不能修改
例如,我们以最简单的无条件查询为例,无条件查询的类型是:match_all,因此其查询语句如下:
GET /items/_search
{
"query": {
"match_all": {
}
}
}
由于match_all无条件,所以条件位置不写即可。
执行结果如下:
你会发现虽然是match_all,但是响应结果中并不会包含索引库中的所有文档,而是仅有10条。这是因为处于安全考虑,elasticsearch设置了默认的查询页数。
6.2.叶子查询
叶子查询的类型也可以做进一步细分,详情大家可以查看官方文档:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/query-dsl.html
这里列举一些常见的,例如:
全文检索查询(Full Text Queries):利用分词器对用户输入搜索条件先分词,得到词条,然后再利用倒排索引搜索词条。例如:
match
:
multi_match
精确查询(Term-level queries):不对用户输入搜索条件分词,根据字段内容精确值匹配。但只能查找keyword、数值、日期、boolean类型的字段。例如:
ids 。。in文档Id
term。。范围
range。。指定内容
地理坐标查询:用于搜索地理位置,搜索方式很多,例如:
geo_bounding_box
:按矩形搜索
geo_distance
:按点和半径搜索...略
6.2.1.全文检索查询
全文检索的种类也很多,详情可以参考官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/full-text-queries.html
-
match
: (会对用户输入内容分词,然后倒排索引库检索) -
multi_match(与上类似,
对多个字段搜索,而且多个字段都要满足)
match
语法如下:
GET /{索引库名}/_search
{
"query": {
"match": {
"字段名": "搜索条件"
}
}
}
示例:
multi_match
与match
类似的还有multi_match
,区别在于可以同时对多个字段搜索,而且多个字段都要满足,语法示例:
GET /{索引库名}/_search
{
"query": {
"multi_match": {
"query": "搜索条件",
"fields": ["字段1", "字段2"]
}
}
}
参考:
GET /items/_search
{
"query": {
"multi_match": {
"query": "诺基亚",
"fields": ["name","brand"]
}
}
}
效果:
他们内部有一套算法打分机制,会根据符合度高得排在前面
如下 高得显示在前面
6.2.2.精确查询
精确查询,英文是Term-level query
,顾名思义,词条级别的查询。也就是说不会对用户输入的搜索条件再分词,而是作为一个词条,与搜索的字段内容精确值匹配。因此推荐查找keyword
、数值、日期、boolean
类型的字段。例如:
id
price
城市
地名
人名
等等,作为一个整体才有含义的字段。
详情可以查看官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/term-level-queries.html
term
其语法如下:
GET /{索引库名}/_search
{
"query": {
"term": {
"字段名": {
"value": "搜索条件"
}
}
}
}
参考:
GET /items/_search
{
"query": {
"term": {
"brand": {
"value": "小米"
}
}
}
}
效果:
range
语法如下:
GET /{索引库名}/_search
{
"query": {
"range": {
"字段名": {
"gte": {最小值},
"lte": {最大值}
}
}
}
}
range
是范围查询,对于范围筛选的关键字有:
gte
:大于等于
gt
:大于
lte
:小于等于
lt
:小于
参考:
GET /items/_search
{
"query": {
"range": {
"price": {
"gte": 10000,
"lte": 900000
}
}
}
}
效果:
ids
指定多个id查询
效果如下:
所有精确查询没有分评估的都是精确查询自然就没有模糊匹配哪样会根据匹配度来打分
总:
6.3.复合查询
复合查询大致可以分为两类:
-
第一类:基于逻辑运算组合叶子查询,实现组合条件,例如
-
bool
-
-
第二类:基于某种算法修改查询时的文档相关性算分,从而改变文档排名。例如:
-
function_score
-
dis_max
-
其它复合查询及相关语法可以参考官方文档:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/compound-queries.html
一般我们用的Bool类型比较多就够了,介绍Bool就行了
1.3.1.bool查询
bool查询,即布尔查询。就是利用逻辑运算来组合一个或多个查询子句的组合。bool查询支持的逻辑运算有:
must:必须匹配每个子查询,类似“与”
should:选择性匹配子查询,类似“或”
must_not:必须不匹配,不参与算分,类似“非”
filter:必须匹配,不参与算分
bool查询的语法如下:
GET /items/_search
{
"query": {
"bool": {
"must": [
{"match": {"name": "手机"}}
],
"should": [
{"term": {"brand": { "value": "vivo" }}},
{"term": {"brand": { "value": "小米" }}}
],
"must_not": [
{"range": {"price": {"gte": 2500}}}
],
"filter": [
{"range": {"price": {"lte": 1000}}}
]
}
}
}
就是在bool下在原来的叶子查询条件前在加一些过滤条件罢了,照猫画虎同理操作就可以了!
出于性能考虑,与搜索关键字无关的查询尽量采用must_not或filter逻辑运算,避免参与相关性算分。
例如商城的搜索页面:
其中输入框的搜索条件肯定要参与相关性算分,可以采用match。但是价格范围过滤、品牌过滤、分类过滤等尽量采用filter,不要参与相关性算分。
比如,我们要搜索手机
,但品牌必须是华为
,价格必须是900~1599
,那么可以这样写:
GET /items/_search
{
"query": {
"bool": {
"must": [
{"match": {"name": "手机"}}
],
"should": [
{"term": {"brand": { "value": "vivo" }}},
{"term": {"brand": { "value": "小米" }}}
],
"must_not": [
{"range": {"price": {"gte": 2500}}}
],
"filter": [
{"range": {"price": {"lte": 1000}}}
]
}
}
}
效果:
6.4.排序
elasticsearch默认是根据相关度算分(_score
)来排序,但是也支持自定义方式对搜索结果排序。不过分词字段无法排序,能参与排序字段类型有:keyword
类型、数值类型、地理坐标类型、日期类型等。
详细说明可以参考官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/paginate-search-results.html
语法说明:
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"排序字段": {
"order": "排序方式asc和desc"
}
}
]
}
注意排序,分页,高亮,聚合都是同查询query同级的。
参考:
GET /items/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"sold": {
"order": "desc"
},
"price": {
"order": "desc"
}
}
]
}
按销量降序排序,如果相同就按价格降序排序
6.5.分页
elasticsearch 默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。
1.5.1.基础分页
elasticsearch中通过修改from
、size
参数来控制要返回的分页结果:
-
from
:从第几个文档开始 -
size
:总共查询几个文档
类似于mysql中的limit ?, ?
官方文档如下:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/paginate-search-results.html
语法如下:
在原来基础上同级添加from和size就可以了
GET /items/_search
{
"query": {
"match_all": {}
},
"from": 0, // 分页开始的位置,默认为0
"size": 10, // 每页文档数量,默认10
"sort": [
{
"price": {
"order": "desc"
}
}
]
}
6.5.1.深度分页
elasticsearch的数据一般会采用分片存储,也就是把一个索引中的数据分成N份,存储到不同节点上。这种存储方式比较有利于数据扩展,但给分页带来了一些麻烦。
比如一个索引库中有100000条数据,分别存储到4个分片,每个分片25000条数据。现在每页查询10条,查询第99页。那么分页查询的条件如下:
GET /items/_search
{
"from": 990, // 从第990条开始查询
"size": 10, // 每页查询10条
"sort": [
{
"price": "asc"
}
]
}
从语句来分析,要查询第990~1000名的数据。
从实现思路来分析,肯定是将所有数据排序,找出前1000名,截取其中的990~1000的部分。但问题来了,我们如何才能找到所有数据中的前1000名呢?
要知道每一片的数据都不一样,第1片上的第900~1000,在另1个节点上并不一定依然是900~1000名。所以我们只能在每一个分片上都找出排名前1000的数据,然后汇总到一起,重新排序,才能找出整个索引库中真正的前1000名,此时截取990~1000的数据即可。
如图:
试想一下,假如我们现在要查询的是第999页数据呢,是不是要找第9990~10000的数据,那岂不是需要把每个分片中的前10000名数据都查询出来,汇总在一起,在内存中排序?如果查询的分页深度更深呢,需要一次检索的数据岂不是更多?
由此可知,当查询分页深度较大时,汇总数据过多,对内存和CPU会产生非常大的压力。
因此elasticsearch会禁止from+ size
超过10000的请求。
针对深度分页,elasticsearch提供了两种解决方案:
search after
:分页时需要排序,每次查询会记录上一次的数据id 原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。 同mysql中的避免使用OFFSET
在某些情况下,使用OFFSET进行分页查询可能会导致性能问题。因为OFFSET需要跳过指定数量的记录,这可能会导致MySQL扫描大量的无用数据。如果可能的话,我们可以尝试使用其他方法进行分页,例如“seek method”。这种方法通过记录上一页的最后一条记录的ID,然后在下一次查询时使用这个ID作为起点进行查询。
scroll
:原理将排序后的文档id形成快照,保存下来,基于快照做分页。官方已经不推荐使用。
其实:大多数情况下,我们采用普通分页就可以了。查看百度、京东等网站,会发现其分页都有限制。例如百度最多支持77页,每页不足20条。京东最多100页,每页最多60条。
因此,一般我们采用限制分页深度的方式即可,无需实现深度分页。
6.6.高亮
6.6.1.高亮原理
什么是高亮显示呢?
我们在百度,京东搜索时,关键字会变成红色,比较醒目,这叫高亮显示:如搜索何政
观察页面源码,你会发现两件事情:
-
高亮词条都被加了
<strong>
标签 -
<strong>
标签都添加了红色样式
css样式肯定是前端实现页面的时候写好的,但是前端编写页面的时候是不知道页面要展示什么数据的,不可能给数据加标签。而服务端实现搜索功能,要是有elasticsearch
做分词搜索,是知道哪些词条需要高亮的。
因此词条的高亮标签肯定是由服务端提供数据的时候已经加上的。
因此实现高亮的思路就是:
用户输入搜索关键字搜索数据
服务端根据搜索关键字到elasticsearch搜索,并给搜索结果中的关键字词条添加
html
标签前端提前给约定好的
html
标签添加CSS
样式
6.6.2.实现高亮
事实上elasticsearch已经提供了给搜索关键字加标签的语法,无需我们自己编码。
基本语法如下:
GET /{索引库名}/_search
{
"query": {
"match": {
"搜索字段": "搜索关键字"
}
},
"highlight": {
"fields": {
"高亮字段名称": {
"pre_tags": "<em>",
"post_tags": "</em>"
}
}
}
}
注意:
搜索必须有查询条件,而且是全文检索类型的查询条件,例如
match
参与高亮的字段必须是
text
类型的字段默认情况下参与高亮的字段要与搜索字段一致,除非添加:
required_field_match=false
示例:
6.7.总结
查询的DSL是一个大的JSON对象,包含下列属性:
-
query
:查询条件 -
from
和size
:分页条件 -
sort
:排序条件 -
highlight
:高亮条件
示例图片:
7.RestClient查询
文档的查询依然使用学习的 RestHighLevelClient
对象,查询的基本步骤如下:
1)创建
request
对象,这次是搜索,所以是SearchRequest
2)准备请求参数,也就是查询DSL对应的JSON参数
3)发起请求
4)解析响应,响应结果相对复杂,需要逐层解析
7.1.快速入门
之前说过,由于Elasticsearch对外暴露的接口都是Restful风格的接口,因此JavaAPI调用就是在发送Http请求。而我们核心要做的就是利用利用Java代码组织请求参数,解析响应结果。
这个参数的格式完全参考DSL查询语句的JSON结构,因此我们在操作的过程中,会不把JavaAPI与DSL语句对比就可以了。
7.1.1.发送请求
首先以match_all
查询为例,其DSL和JavaAPI的对比如图:
代码解读:
第一步,创建
SearchRequest
对象,指定索引库名第二步,利用
request.source()
构建DSL,DSL中可以包含查询、分页、排序、高亮等
query()
:代表查询条件,利用QueryBuilders.matchAllQuery()
构建一个match_all
查询的DSL第三步,利用
client.search()
发送请求,得到响应
这里关键的API有两个,一个是request.source()
,它构建的就是DSL中的完整JSON参数。其中包含了query
、sort
、from
、size
、highlight
等所有功能:
另一个是QueryBuilders
,其中包含了我们学习过的各种叶子查询、复合查询等:
7.1.2.解析响应结果
在发送请求以后,得到了响应结果SearchResponse
,这个类的结构与我们在kibana中看到的响应结果JSON结构完全一致:
因此,我们解析SearchResponse
的代码就是在解析这个JSON结果,对比如下:
代码解读:
elasticsearch返回的结果是一个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
文档数据
-
-
因此我们简单测试一下发送请求去查询数据在解析一下:
@Test
void MatchAll() throws IOException {
SearchRequest searchRequest=new SearchRequest("items");
searchRequest.source().equals(QueryBuilders.matchAllQuery());
try {
SearchResponse search = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
System.out.println("search = " + search);
}catch(IOException e) {
e.printStackTrace();
}
}
控制台显示
我们把数据拿到文本去格式化可以发现是一致的,
我们可以根据这个格式来解析我们需要的数据在返回给前端就可以了!!!
开始解析
@Test
void MatchAll() throws IOException {
// 创建搜索请求,指定索引为"items"
SearchRequest searchRequest=new SearchRequest("items");
searchRequest.source().equals(QueryBuilders.matchAllQuery());
// 发送搜索请求并获取响应
SearchResponse search = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
//获取查询的数据数量
long value = search.getHits().getTotalHits().value;
System.out.println("value = " + value);
//获取数据
//ArrayList<ItemDoc> list = new ArrayList<>();
SearchHit[] hits = search.getHits().getHits();
for (SearchHit hit : hits) {
String json = hit.getSourceAsString();
ItemDoc item = JSONUtil.toBean(json, ItemDoc.class);
//list.add(item); 添加到集合中在返回给前端
System.out.println("item = " + item);
}
}
控制台查看
So:可以看到我们拿到了每一个数据,这个时候也可以根据业务需求添加到集合中返回给前端就可以了!!!
7.2.叶子查询
所有的查询条件都是由QueryBuilders来构建的,叶子查询也不例外。因此整套代码中变化的部分仅仅是query条件构造的方式,其它不动,简单上手,逻辑上都差不多。
全文检索查询
例如match
查询:
@Test
void testMatch() throws IOException {
// 1.创建Request
SearchRequest request = new SearchRequest("items");
// 2.组织请求参数
request.source().query(QueryBuilders.matchQuery("name", "脱脂牛奶"));
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
再比如multi_match
查询:
@Test
void testMultiMatch() throws IOException {
// 1.创建Request
SearchRequest request = new SearchRequest("items");
// 2.组织请求参数
request.source().query(QueryBuilders.multiMatchQuery("脱脂牛奶", "name", "category"));
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
精确查询
还有range
查询:
@Test
void testRange() throws IOException {
// 1.创建Request
SearchRequest request = new SearchRequest("items");
// 2.组织请求参数
request.source().query(QueryBuilders.rangeQuery("price").gte(10000).lte(30000));
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
还有term
查询:
@Test
void testTerm() throws IOException {
// 1.创建Request
SearchRequest request = new SearchRequest("items");
// 2.组织请求参数
request.source().query(QueryBuilders.termQuery("brand", "华为"));
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
7.3.复合查询
复合查询也是由QueryBuilders
来构建,我们以bool
查询为例,DSL和JavaAPI的对比如图:
void BoolSerCh() throws IOException {
// 创建一个搜索请求对象,指定搜索的索引名为"items"
SearchRequest searchRequest = new SearchRequest("items");
// 创建一个布尔查询构建器
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 添加一个必须匹配的查询条件,搜索名为"脱脂牛奶"的商品
boolQuery.must(QueryBuilders.matchQuery("name", "脱脂牛奶"));
// 添加一个过滤条件,指定品牌为"德亚"
boolQuery.filter(QueryBuilders.termQuery("brand", "德亚"));
// 添加另一个过滤条件,指定价格小于30000
boolQuery.filter(QueryBuilders.rangeQuery("price").lt(30000));
// 将构建好的布尔查询设置到搜索请求中
searchRequest.source().query(boolQuery);
// 执行搜索请求,并获取搜索结果
SearchResponse search = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 调用jiex方法解析搜索结果
jiex(search);
}
// 这是一个辅助方法,用于解析并打印搜索结果
void jiex(SearchResponse search) {
// 从搜索结果中获取所有的搜索命中记录
SearchHit[] hits = search.getHits().getHits();
// 遍历所有的搜索命中记录
for (SearchHit hit : hits) {
// 从搜索命中记录中获取源数据的JSON字符串
String json = hit.getSourceAsString();
// 使用hutool工具包将JSON字符串转换为ItemDoc对象
ItemDoc item = JSONUtil.toBean(json, ItemDoc.class);
// 打印转换后的ItemDoc对象
System.out.println("item = " + item);
}
}
如下可以看到我们的解析结果:
7.4.排序和分页
requeset.source()
就是整个请求JSON参数,所以排序、分页都是基于这个来设置,其DSL和JavaAPI的对比如下:
都是同级的,因此就更简单了:
参考:
@Test
void testPageAndSort() throws IOException {
int pageNo = 1, pageSize = 5;
// 1.创建Request
SearchRequest request = new SearchRequest("items");
// 2.组织请求参数
// 2.1.搜索条件参数
request.source().query(QueryBuilders.matchQuery("name", "脱脂牛奶"));
// 2.2.排序参数
request.source().sort("price", SortOrder.ASC);
// 2.3.分页参数
request.source().from((pageNo - 1) * pageSize).size(pageSize);
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
7.5.高亮
高亮查询与前面的查询有两点不同:
-
条件同样是在
request.source()
中指定,只不过高亮条件要基于HighlightBuilder
来构造 -
特别的的是:高亮响应结果与搜索的文档结果不在一起,需要单独解析
示例代码如下:
@Test
void testHighlight() throws IOException {
// 1.创建Request
SearchRequest request = new SearchRequest("items");
// 2.组织请求参数
// 2.1.query条件
request.source().query(QueryBuilders.matchQuery("name", "脱脂牛奶"));
// 2.2.高亮条件
request.source().highlighter(
SearchSourceBuilder.highlight()
.field("name")
.preTags("<em>")
.postTags("</em>")
);
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
首先来看高亮条件构造,其DSL和JavaAPI的对比如图:
代码解读:
第
3、4
步:从结果中获取_source
。hit.getSourceAsString()
,这部分是非高亮结果,json字符串。还需要反序列为ItemDoc
对象第
5
步:获取高亮结果。hit.getHighlightFields()
,返回值是一个Map
,key是高亮字段名称,值是HighlightField
对象,代表高亮值第
5.1
步:从Map
中根据高亮字段名称,获取高亮字段值对象HighlightField
第
5.2
步:从HighlightField
中获取Fragments
,并且转为字符串。这部分就是真正的高亮字符串了最后:用高亮的结果替换
ItemDoc
中的非高亮结果
完整代码如下:
private void handleResponse(SearchResponse response) {
SearchHits searchHits = response.getHits();
// 1.获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到" + total + "条数据");
// 2.遍历结果数组
SearchHit[] hits = searchHits.getHits();
for (SearchHit hit : hits) {
// 3.得到_source,也就是原始json文档
String source = hit.getSourceAsString();
// 4.反序列化
ItemDoc item = JSONUtil.toBean(source, ItemDoc.class);
// 5.获取高亮结果
Map<String, HighlightField> hfs = hit.getHighlightFields();
if (CollUtils.isNotEmpty(hfs)) {
// 5.1.有高亮结果,获取name的高亮结果
HighlightField hf = hfs.get("name");
if (hf != null) {
// 5.2.获取第一个高亮结果片段,就是商品名称的高亮值
String hfName = hf.getFragments()[0].string();
item.setName(hfName);
}
}
System.out.println(item);
}
}
优化搜索功能
private RestHighLevelClient restHighLevelClient = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.184.128:9200")));
//TODO 优化页面
@ApiOperation("搜索商品")
@GetMapping("/list")
public PageDTO<ItemDTO> search(ItemPageQuery query) throws IOException {
SearchRequest searchRequest = new SearchRequest();
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
if (StrUtil.isNotBlank(query.getKey())) {
boolQuery.mustNot(QueryBuilders.matchQuery("name", query.getKey()));
}
if (StrUtil.isNotBlank(query.getBrand())) {
boolQuery.filter(QueryBuilders.termQuery("brand", query.getKey()));
}
if (StrUtil.isNotBlank(query.getCategory())) {
boolQuery.filter(QueryBuilders.termQuery("category", query.getKey()));
}
if (query.getMinPrice() != null) {
boolQuery.filter(QueryBuilders.rangeQuery("price").gte(query.getMinPrice()));
}
if (query.getMaxPrice() != null) {
boolQuery.filter(QueryBuilders.rangeQuery("price").lte(query.getMaxPrice()));
}
searchRequest.source().query(boolQuery);
//分页
searchRequest.source().from((query.getPageNo() - 1 * query.getPageSize())).size(query.getPageSize());
//排序
if (StrUtil.isNotBlank(query.getBrand())) {
//排序
searchRequest.source().sort(query.getBrand(), query.getIsAsc() ? SortOrder.ASC : SortOrder.DESC);
} else {
searchRequest.source().sort("updateTime", query.getIsAsc() ? SortOrder.ASC : SortOrder.DESC);
}
SearchResponse search = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
PageDTO<ItemDTO> pageDTO= searchHitsArray(search,query);
return pageDTO;
}
private PageDTO<ItemDTO> searchHitsArray(SearchResponse search, ItemPageQuery query) {
List<ItemDoc> list = new ArrayList<>();
SearchHits hits = search.getHits();
// 获取总条数
long value = hits.getTotalHits().value;
SearchHit[] hits1 = hits.getHits();
for (SearchHit hit : hits1) {
String sourceAsString = hit.getSourceAsString();
ItemDoc itemDoc = JSONUtil.toBean(sourceAsString, ItemDoc.class);
list.add(itemDoc);
}
List<ItemDTO> itemDTOS = BeanUtil.copyToList(list, ItemDTO.class);
long pageSize= query.getPageSize().longValue();
PageDTO<ItemDTO> pageDTO = new PageDTO<>(value, pageSize, itemDTOS);
return pageDTO;
}
8.数据聚合
ES不仅可以用来搜索和存储,还可以对海量数据得分析及运算这种功能就叫做聚合(类似我们数据库中得求平均值,求和等但是Es聚合更加强大)
聚合(aggregations
)可以让我们极其方便的实现对数据的统计、分析、运算。例如:
-
什么品牌的手机最受欢迎?
-
这些手机的平均价格、最高价格、最低价格?
-
这些手机每月的销售情况如何?
实现这些统计功能的比数据库的sql要方便的多,而且查询速度非常快,可以实现近实时搜索效果。
官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/search-aggregations.html
聚合常见的有三类:
桶(
Bucket
)聚合:用来对文档做分组
TermAggregation
:按照文档字段值分组,例如按照品牌值分组、按照国家分组
Date Histogram
:按照日期阶梯分组,例如一周为一组,或者一月为一组度量(
Metric
)聚合:用以计算一些值,比如:最大值、最小值、平均值等
Avg
:求平均值
Max
:求最大值
Min
:求最小值
Stats
:同时求max
、min
、avg
、sum
等管道(
pipeline
)聚合:其它聚合的结果为基础做进一步运算
注意:参加聚合的字段必须是keyword、日期、数值、布尔类型
8.1.DSL实现聚合
与之前的搜索功能类似,我们依然先了解DSL的语法,再学习JavaAPI.
8.1.1.Bucket聚合
例如我们要统计所有商品中共有哪些商品分类,其实就是以分类(category)字段对数据分组。category值一样的放在同一组,属于Bucket
聚合中的Term
聚合。
基本语法如下:
GET /items/_search
{
"size": 0,
"aggs": {
"category_agg": {
"terms": {
"field": "category",
"size": 20
}
}
}
}
三要素嘛 自定义名称 类型 查询得字段
语法说明:
-
size
:设置size
为0,就是每页查0条,则结果中就不包含文档,只包含聚合 -
aggs
:定义聚合-
category_agg
:聚合名称,自定义,但不能重复-
terms
:聚合的类型,按分类聚合,所以用term
-
field
:参与聚合的字段名称 -
size
:希望返回的聚合结果的最大数量
-
-
-
来看下查询的结果:
8.1.2.带条件聚合
默认情况下,Bucket聚合是对索引库的所有文档做聚合,例如我们统计商品中所有的品牌,结果如下:
可以看到统计出的品牌非常多。
但真实场景下,用户会输入搜索条件,因此聚合必须是对搜索结果聚合。那么聚合必须添加限定条件。
例如,我想知道价格高于3000元的手机品牌有哪些,该怎么统计呢?
我们需要从需求中分析出搜索查询的条件和聚合的目标:
-
搜索查询条件:
-
价格高于3000
-
必须是手机
-
-
聚合目标:统计的是品牌,肯定是对brand字段做term聚合
语法如下:
GET /items/_search
{
"query": {
"bool": {
"filter": [
{
"term": {
"category": "手机"
}
},
{
"range": {
"price": {
"gte": 300000
}
}
}
]
}
},
"size": 0,
"aggs": {
"brand_agg": {
"terms": {
"field": "brand",
"size": 20
}
}
}
}
效果
8.1.3.Metric聚合
上面我们统计了价格高于3000的手机品牌,形成了一个个桶。现在我们需要对桶内的商品做运算,获取每个品牌价格的最小值、最大值、平均值。
这就要用到Metric
聚合了,例如stat
聚合,就可以同时获取min
、max
、avg
等结果。
语法如下:
GET /items/_search
{
"query": {
"bool": {
"filter": [
{
"term": {
"category": "手机"
}
},
{
"range": {
"price": {
"gte": 300000
}
}
}
]
}
},
"size": 0,
"aggs": {
"brand_agg": {
"terms": {
"field": "brand",
"size": 20
},
"aggs": { //内嵌聚合
"stats_meric": {
"stats": {
"field": "price"
}
}
}
}
}
}
query
部分就不说了,我们重点解读聚合部分语法。
可以看到我们在brand_agg
聚合的内部,我们新加了一个aggs
参数。这个聚合就是brand_agg
的子聚合,会对brand_agg
形成的每个桶中的文档分别统计。
-
stats_meric
:聚合名称-
stats
:聚合类型,stats是metric
聚合的一种-
field
:聚合字段,这里选择price
,统计价格
-
-
由于stats是对brand_agg形成的每个品牌桶内文档分别做统计,因此每个品牌都会统计出自己的价格最小、最大、平均值。
结果如下:
8.1.4.总结
aggs代表聚合,与query同级,此时query的作用是?
-
限定聚合的的文档范围
聚合必须的三要素:
-
聚合名称
-
聚合类型
-
聚合字段
聚合可配置属性有:
-
size:指定聚合结果数量
-
order:指定聚合结果排序方式
-
field:指定聚合字段
8.2.RestClient实现聚合
可以看到在DSL中,aggs
聚合条件与query
条件是同一级别,都属于查询JSON参数。因此依然是利用request.source()
方法来设置。
不过聚合条件的要利用AggregationBuilders
这个工具类来构造。DSL与JavaAPI的语法对比如下:
聚合结果与搜索文档同一级别,因此需要单独获取和解析。具体解析语法如下:
java客服端;来测试一下
我们查询得条件名称是手机得数据,然后通过品牌聚合,
要获取桶里面得内容,父类没有这个方法得,所以用子类
代码
public class EsMatchAllTest {
private RestHighLevelClient restHighLevelClient;
@BeforeEach
void setUp() {
this.restHighLevelClient = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.184.128:9200")
));
}
@AfterEach
void tearDown() throws IOException {
this.restHighLevelClient.close();
}
@Test
void test () throws IOException {
// 创建一个搜索请求对象,索引名为 "items"
SearchRequest searchRequest=new SearchRequest("items");
// 设置查询结果返回的记录数为 0,即不返回具体文档内容
searchRequest.source().size(0);
// 创建一个布尔查询构建器
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
// 添加一个过滤条件,指定分类为 "手机"
boolQueryBuilder.filter(QueryBuilders.termQuery("category","手机"));
// 将布尔查询构建器设置到搜索请求对象的查询条件中
searchRequest.source().query(boolQueryBuilder);
// 添加聚合查询,首先按照品牌进行分组
searchRequest.source().aggregation(AggregationBuilders.terms("brandName").field("brand")
/* //内嵌聚合 在品牌分组的基础上,再对价格进行统计聚合
.subAggregation(AggregationBuilders.stats("statsmeric").field("price"))*/);
// 执行搜索请求,并获取搜索结果
SearchResponse search = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 解析搜索结果
Aggregations aggregations = search.getAggregations();
// 获取品牌聚合的结果
Terms terms = aggregations.get("brandName");
// 遍历品牌聚合结果中的每个桶(即每个品牌)
terms.getBuckets().forEach(bucket -> {
// 打印品牌名称
System.out.println("品牌名称:" + bucket.getKeyAsString());
// 打印品牌数量
System.out.println("品牌数量:" + bucket.getDocCount());
});
}
获取解析到得我们想要得聚合效果
当然也可以通过嵌套聚合在原来基础上进一步获取详细数据比如价格得详情只需要在 .subAggregation来嵌套一个聚合就可以了
代码
package com.hmall.item;
import cn.hutool.json.JSONUtil;
import com.hmall.item.domain.po.ItemDoc;
import org.apache.http.HttpHost;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.aggregations.AggregationBuilders;
import org.elasticsearch.search.aggregations.Aggregations;
import org.elasticsearch.search.aggregations.bucket.terms.Terms;
import org.elasticsearch.search.aggregations.metrics.Stats;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
//@SpringBootTest(properties = "Spring.profiles.active=local")
public class EsMatchAllTest {
private RestHighLevelClient restHighLevelClient;
@BeforeEach
void setUp() {
this.restHighLevelClient = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.184.128:9200")
));
}
@AfterEach
void tearDown() throws IOException {
this.restHighLevelClient.close();
}
@Test
void test () throws IOException {
// 创建一个搜索请求对象,索引名为 "items"
SearchRequest searchRequest=new SearchRequest("items");
// 设置查询结果返回的记录数为 0,即不返回具体文档内容
searchRequest.source().size(0);
// 创建一个布尔查询构建器
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
// 添加一个过滤条件,指定分类为 "手机"
boolQueryBuilder.filter(QueryBuilders.termQuery("category","手机"));
// 将布尔查询构建器设置到搜索请求对象的查询条件中
searchRequest.source().query(boolQueryBuilder);
// 添加聚合查询,首先按照品牌进行分组
searchRequest.source().aggregation(AggregationBuilders.terms("brandName").field("brand")
//内嵌聚合 在品牌分组的基础上,再对价格进行统计聚合
.subAggregation(AggregationBuilders.stats("statsmeric").field("price")));
// 执行搜索请求,并获取搜索结果
SearchResponse search = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 解析搜索结果
Aggregations aggregations = search.getAggregations();
// 获取品牌聚合的结果
Terms terms = aggregations.get("brandName");
// 遍历品牌聚合结果中的每个桶(即每个品牌)
terms.getBuckets().forEach(bucket -> {
// 打印品牌名称
System.out.println("品牌名称:" + bucket.getKeyAsString());
// 打印品牌数量
System.out.println("品牌数量:" + bucket.getDocCount());
// 获取品牌下的价格统计聚合结果
Stats stats = bucket.getAggregations().get("statsmeric");// 获取Stats聚合
// 打印平均价格
System.out.println("平均价格:" + stats.getAvg()); // 打印平均价格
// 打印最高价格
System.out.println("最高价格:" + stats.getMax()); // 打印最高价格
// 还可以打印其他统计信息,如stats.getMin(), stats.getSum(), stats.getCount()等
});
}
效果
可以看到就成功拿到我们想要得数据了,这些就可以根据业务需求添加到自定义实体集合中在返回给前端高亮显示处理就可以了!!!
整理不易,点个赞在走把!!!
标签:Java,进阶,查询,索引,文档,elasticsearch,query,搜索,全攻略 From: https://blog.csdn.net/2301_77058976/article/details/140575189