文章目录
- 0. 前言
- 1.认识 ElasticSearch
- 2. 安装 ElasticSearch
- 3. 安装 kibana
- 4. 通过 Kibana 对 ElasticSearch 进行简单的操作
- 5. 正向索引和倒排索引
- 6. IK 分词器
- 7. ElasticSearch 的基本概念
- 8. 索引库操作
- 9. 文档操作
- 10. JavaRestClient
- 11. 创建索引库时如何编写 Mapping 映射
- 12. JavaRestClient 操作索引库
- 13. JavaRestClient 操作文档
- 14. DSL(Domain SpecificLanguage) 查询
- 15. JavaRestClient 查询
- 16. 数据聚合
0. 前言
搜索引擎技术在我们生活中的很多领域都有应用,例如商品搜索页、在 GitHub 上搜索项目、百度搜索等
虽然 MySQL 等数据库也可以实现搜索功能,但 MySQL 的搜索功能是基于模糊搜索来实现的,模糊搜索的性能比较低,如果数据量比较大,接口响应的速度就会变得很慢,用户的搜索体验就不是很好
ElasticSearch 是一个高性能的分布式的搜索引擎,而且 ElasticSearch 的搜索速度受数据量的影响比较小,也就是说,就算你的商品数量翻了几十倍、上百倍、甚至上千倍,利用 ElasticSearch 进行搜索的时间也不会受到很大影响,ElasticSearch 的响应速度非常快
当遇到大数据量搜索,而且还是模糊搜索时,不适合用传统的关系型数据库,更适合用搜索引擎,而且搜索引擎会对用户搜索的内容进行分析,为用户筛选出与搜索内容相关的数据(MySQL 的模糊匹配是严格匹配)
搜索引擎技术排名:
- Elasticsearch:开源的分布式搜索引擎
- Splunk:商业项目
- Solr:Apache的开源搜索引擎
1.认识 ElasticSearch
1.1 Lucene
Lucene 是一个Java语言的搜索引擎类库(类库可以理解为一套 API 工具包),是 Apache 公司的顶级项目,由 DougCutting 于 1999 年研发
Lucene 的官网地址:Lucene
Lucene 的优势:
- 易扩展
- 高性能(基于倒排索引)
1.2 ElasticSearch
2004 年 Shay Banon 基于 Lucene 开发了 Compass
2010 年 Shay Banon 重写了 Compass,取名为 Elasticsearch
ElasticSearch的官网地址:ElasticSearch,目前最新的版本是 8.x(国内应用较多的版本是 7.x 和 6.x ,因为 8.x 版本的 ElasticSearch 的 API 变化较大)
ElasticSearch 具备下列优势:
- 支持分布式,可水平扩展
- 提供 Restful 接口,可被任何语言调用(这也是 ElasticSearch 被广泛应用的原因)
ElasticSearch 结合 Kibana、Logstash、Beats,是一整套技术栈,被叫做 ELK ,广泛应用在日志数据分析、实时监控等领域
ELK 可以帮助我们收集日志数据,并且以图形化的方式展示,方便我们定位问题、排查问题
ElK 还能够实现实时监控功能,去监控微服务集群中节点的运行状态
2. 安装 ElasticSearch
2.1 下载 ElasticSearch
我们通过 docker 安装 ElasticSearch(版本为 7.17.18,之所以使用 7.17.18 版本,是因为 IK 分词器支持 7.x 的 ElasticSearch 的最后一个版本为 7.17.18)
先下载 ElasticSearch 的镜像
sudo docker pull elasticsearch:7.17.18
下载好 ElasticSearch 的镜像后,可以将将镜像保存为 tar 文件,下载到本地,方便下一次在另一个 Linux 系统上运行
sudo docker save elasticsearch:7.17.18 -o /tmp/elasticsearch:7.17.18.tar
sudo chmod +rx /tmp/elasticsearch:7.17.18.tar
2.2 启动 ElasticSearch
在启动 ElasticSearch 之前,我们先创建一个 docker 网络
sudo docker network create blog
接着启动 ElasticSearch
sudo docker run -d \
--name elasticsearch \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
-e "discovery.type=single-node" \
-v elasticsearch-data:/usr/share/elasticsearch/data \
-v elasticsearch-plugins:/usr/share/elasticsearch/plugins \
--network blog \
-p 9200:9200 \
-p 9300:9300 \
elasticsearch:7.17.18
指令的解释:
--name elasticsearch
:- 为容器指定一个名称,这里命名为
elasticsearch
- 为容器指定一个名称,这里命名为
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m"
:-e
:设置环境变量ES_JAVA_OPTS
:为Elasticsearch设置Java虚拟机(JVM)选项-Xms512m
:设置JVM初始堆大小为512MB-Xmx512m
:设置JVM最大堆大小为512MB(如果不指定堆大小,默认的堆大小为 1024 MB,考虑到机器的内存可能没有那么大,手动指定堆大小为 512 MB,但是最好不要低于 512 MB,因为低于 512 MB后 ElasticSearch 运行时可能会出现问题)
-e "discovery.type=single-node"
:- 设置Elasticsearch的发现类型为单节点模式,即不进行集群节点发现
-v elasticsearch-data:/usr/share/elasticsearch/data
:-v
:挂载卷,将宿主机的目录或命名卷挂载到容器内elasticsearch-data
:这是卷的名称,如果它不存在,Docker会自动创建一个命名卷/usr/share/elasticsearch/data
:这是容器内的路径,用于存储Elasticsearch的数据
-v elasticsearch-plugins:/usr/share/elasticsearch/plugins
:- 类似于上面的数据卷挂载,这里是将宿主机的命名卷
elasticsearch-plugins
挂载到容器内用于存储Elasticsearch插件的目录
- 类似于上面的数据卷挂载,这里是将宿主机的命名卷
--network blog
:- 将容器连接到名为
blog
的Docker网络
- 将容器连接到名为
-p 9200:9200
:-p
:端口映射,将宿主机的端口映射到容器的端口9200:9200
:将宿主机的9200端口映射到容器的9200端口,用于HTTP API访问
-p 9300:9300
:- 类似于上面的端口映射,这里是将宿主机的9300端口映射到容器的9300端口,通常用于Elasticsearch集群节点之间的通信
elasticsearch:7.17.18
:- 指定要运行的 ElasticSearch 镜像及其版本号
如果容器启动失败,可以通过以下命令查看 elasticsearch容器的运行日志
sudo docker logs elasticsearch
2.3 开放防火墙的 9200 端口
为了能够从外界访问 ElasticSearch,需要为 ElasticSearch 开放防火墙的 9200 端口
- 如果你使用的是云服务器,在安全组中放行 9200 端口
- 如果你安装了宝塔,除了在安全组中放行 9200 端口,还要在宝塔中放行 9200 端口
完成以上两个操作后,输入以下指令开放 9200 端口
Ubuntu
sudo ufw allow 9200
sudo ufw reload
CentOS
sudo firewall-cmd --zone=public --add-port=9200 /tcp --permanent
sudo firewall-cmd --reload
2.4 访问 ElasticSearch
在浏览器输入以下内容,访问 ElasticSearch(注意将 IP 地址改为你的虚拟机的 IP 地址)
http://127.0.0.1:9200
看到以下内容,就说明 ElasticSearch 启动成功了
3. 安装 kibana
3.1 下载 kibana
我们通过 docker 安装 kibana(版本为 7.17.18)
先下载 kibana的镜像
sudo docker pull kibana:7.17.18
下载好 kibana的镜像后,可以将将镜像保存为 tar 文件,下载到本地,方便下一次在另一个 Linux 系统上运行
sudo docker save kibana:7.17.18 -o /tmp/kibana:7.17.18.tar
sudo chmod +rx /tmp/kibana:7.17.18.tar
3.2 启动 kibana
sudo docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://elasticsearch:9200 \
--network=blog \
-p 5601:5601 \
kibana:7.17.18
指令的解释:
--name kibana
:- 为容器指定一个名称,这里命名为
kibana
- 为容器指定一个名称,这里命名为
-e ELASTICSEARCH_HOSTS=http://elasticsearch:9200
:-e
:设置环境变量ELASTICSEARCH_HOSTS
:这是Kibana用来连接Elasticsearch实例的环境变量http://elasticsearch:9200
:指定Elasticsearch实例的URL,其中elasticsearch
是Elasticsearch容器的名称,9200
是Elasticsearch服务的默认HTTP端口
--network=blog
:- 将容器连接到名为
blog
的Docker网络
- 将容器连接到名为
-p 5601:5601
:-p
:端口映射,将宿主机的端口映射到容器的端口5601:5601
:将宿主机的5601端口映射到容器的5601端口,用于访问Kibana的Web界面
kibana:7.17.18
:- 指定要运行的Kibana镜像及其版本号,这里使用的是7.17.18版本
如果容器启动失败,可以通过以下命令查看 kibana 容器的运行日志
sudo docker logs kibana
3.3 开放防火墙的 5601 端口
为了能够从外界访问 kibana,需要为 kibana开放防火墙的 5601 端口
- 如果你使用的是云服务器,在安全组中放行 5601 端口
- 如果你安装了宝塔,除了在安全组中放行 5601 端口,还要在宝塔中放行 5601 端口
完成以上两个操作后,输入以下指令开放 5601端口
Ubuntu
sudo ufw allow 5601
sudo ufw reload
CentOS
sudo firewall-cmd --zone=public --add-port=5601 /tcp --permanent
sudo firewall-cmd --reload
3.4 访问 kibana
在浏览器输入以下内容,访问 kibana(注意将 IP 地址改为你的虚拟机的 IP 地址)
http://127.0.0.1:5601
看到以下内容,就说明 kibana启动成功了
4. 通过 Kibana 对 ElasticSearch 进行简单的操作
我们想利用 Kibana 操作 ElasticSearch ,但不是基于图形化页面进行操作,而是基于 Http 请求
我们知道,ElasticSearch 对外暴露的是 Restful 接口,也就是说,你想操作 ElasticSearch ,就需要向 ElasticSearch 发送一个 Http 请求
但发请求的时候,你是不是得记住每一个请求对应的路径是什么,例如我想利用 ElasticSearch 进行搜索,搜索的访问路径就是 http://127.0.0.1:9200/_search
,那如果我想进行一些增删查改的操作,请求路径就变了
这么多请求路径,显然我们是记不住的,查询操作还好,如果进行的是删查改的操作,可能还会有比较复杂的参数,参数的具体格式我们也不是很清楚
Kibana 为我们提供了一个开放工具——Dev Tools,这个工具可以帮助我们向 ElasticSearch 发送 Http 请求(Dev Tools 的提示也非常智能)
那该怎么发呢,首先得告诉 Kibana 你的请求方式是什么,再告诉 Kibana 你的请求路径是什么,而且在发送请求的时候不需要指定 ElasticSearch 的 IP 地址和对应的端口,为什么不需要呢,因为我们在部署 Kibana 的时候已经将 ELasticSearch 的 IP 地址和对应的端口告诉 Kibana 了
以下是一个使用 Kibana 向 ElasticSearch 发送请求的例子
5. 正向索引和倒排索引
ElasticSearch 在处理海量数据的时候速度非常快,远远地高于传统的关系型数据库,之所以这么快,是因为 ElasticSearch 底层采用的索引方式比较特殊,也就是倒排索引
5.1 正向索引
传统的关系型数据库(例如 MySQL)采用正向索引,例如给表(tb_goods)中的 id 创建索引
MySQL 默认使用的引擎是 InnoDB ,InnoDB 引擎会为 id 创建基于 B+Tree 的聚簇索引,将每一行数据都挂到 id 对应的叶子节点上,我们就能够 id 快速地找到叶子节点,从而快速地找到每一行数据(对 MySQL 索引部分内容不是很熟悉的同学,可以看一下我的另一篇博文:MySQL-进阶篇-索引)
这也是正向索引的优势,当我们根据索引进行查询时,效率非常高,能够快速地找到我们需要的数据
但如果我们不是根据 id 查找数据,而是想根据商品标题查找数据,而且查找时不是精准匹配,而是模糊匹配,是不是就没有办法走索引了,即使给商品标题字段添加索引,也不能解决问题(搜索时的模糊匹配大多都是%华为手机%
这种形式,索引会失效,索引失效后就会采用逐行遍历的方式,效率非常低)
如果表里面有超过百万的数据,相当于要把这些数据全都遍历一遍,效率十分低下
5.2 倒排索引
ElasticSearch 采用倒排索引,倒排索引有两个非常重要的概念:
- 文档(document):每条数据就是一个文档
- 词条(term):文档按照语义分成的词语
ElasticSearch 在做倒排索引的时候,也会存储原始的文档,并且也会给每一个文档的 id 建立索引,这一点跟传统的关系型数据库是一样的,但在数据结构上可能有所不同,所以说 ElasticSearch 也具备正向索引的功能
我们来看一下词条的形成过程
把所有文档进行分词后,得到了有限数量的词条,而且每一个词条都是唯一的,我们是不是可以为词条建立索引(唯一索引),例如用哈希表建立唯一索引,不同的词条有不同的下标,从而快速地定位数据(只是举一个例子,ElasticSearch 的底层实现有可能使用到哈希表)
我们来看一个例子,假如用户搜索了与华为手机相关的内容,整个搜索过程大概如下
6. IK 分词器
6.1 分词器的作用
建立倒排索引的过程中有一个非常关键的步骤——分词
- 我们需要先对文档分词得到词条,然后对词条建立索引
- 我们也需要对用户输入的搜索内容进行分词,拿着分词后得到的词条去匹配
分词需要使用分词器,分词器能够帮我们将一句话或者一段话分成一个一个的词语,英文的分词往往比较简单,因为英文语句大部分是按照空格来进行分词的,而中文的分词往往需要根据语义分析,比较复杂
中文的分词需要用到中文分词器,例如 IK 分词器,虽然 ElasticSearch 官方也提供了一些中文分词器,但是分词效果都不太理想,通常我们都会使用由国人开发的中文分词器
在中文分词器中,最为出名的是 IK 分词器,IK 分词器是林良益在 2006 年开源发布的,其采用的正向迭代最细粒度切分算法一直沿用至今
IK 分词器的安装方式也比较简单,只要将资料提供好的分词器放入 ElasticSearch 的插件目录即可
6.2 下载 IK 分词器
下载地址:analysis-ik
6.3 安装 IK 分词器
解压缩后,将目录上传到 ElasticSearch 的插件目录
第一步:打开 /var/lib/docker/volumes/elasticsearch-plugins
目录的部分权限
sudo chmod +rx -R /var/lib/docker/volumes/elasticsearch-plugins
第二步:暂时打开 /var/lib/docker/volumes/elasticsearch-plugins/_data
目录的写权限
sudo chmod +w /var/lib/docker/volumes/elasticsearch-plugins/_data
第三步:将 IK 分词器上传到 /var/lib/docker/volumes/elasticsearch-plugins/_data
目录
第四步:恢复 /var/lib/docker/volumes/elasticsearch-plugins/_data
目录的权限
sudo chmod 755 /var/lib/docker/volumes/elasticsearch-plugins/_data
第五步:重启 ElasticSearch
sudo docker restart elasticsearch
6.4 测试 IK 分词器
6.4.1 standard 分词器
在 Kibana 的 Dev Tools 中使用下面的语句(使用标准分词器)来测试 IK 分词器
POST _analyze
{
"analyzer": "standard",
"text": [
"Java练习时长两年半"
]
}
语法说明:
- POST:请求方式
- _analyze:请求路径,这里省略了 ElasticSearch 的 IP 地址和端口,因为有 Kibana 帮我们补充
- 请求参数,json风格
- analyzer:分词器类型,这里是默认的 standard 分词器
- text:要分词的内容(可以是字符串数组,也可以是一个字符串)
测试结果如下
{
"tokens" : [
{
"token" : "java",
"start_offset" : 0,
"end_offset" : 4,
"type" : "<ALPHANUM>",
"position" : 0
},
{
"token" : "练",
"start_offset" : 4,
"end_offset" : 5,
"type" : "<IDEOGRAPHIC>",
"position" : 1
},
{
"token" : "习",
"start_offset" : 5,
"end_offset" : 6,
"type" : "<IDEOGRAPHIC>",
"position" : 2
},
{
"token" : "时",
"start_offset" : 6,
"end_offset" : 7,
"type" : "<IDEOGRAPHIC>",
"position" : 3
},
{
"token" : "长",
"start_offset" : 7,
"end_offset" : 8,
"type" : "<IDEOGRAPHIC>",
"position" : 4
},
{
"token" : "两",
"start_offset" : 8,
"end_offset" : 9,
"type" : "<IDEOGRAPHIC>",
"position" : 5
},
{
"token" : "年",
"start_offset" : 9,
"end_offset" : 10,
"type" : "<IDEOGRAPHIC>",
"position" : 6
},
{
"token" : "半",
"start_offset" : 10,
"end_offset" : 11,
"type" : "<IDEOGRAPHIC>",
"position" : 7
}
]
}
可以看到,standard 分词器对中文的分词效果不是很理想
6.4.2 IK 分词器
我们改成 IK 分词器后再次进行测试,IK 分词器有两种模式:
- ik_smart:在 ik_smart 模式下,IK 分词器会尽可能地给出最合理的分词方案,减少不必要的冗余词汇,并尝试理解上下文以提高分词准确性
- ik_max_word:在 ik_max_word 模式下,IK 分词器倾向于将文本切分成尽可能多的词汇,包括那些可能存在多种组合方式的词汇。这种模式可能会产生一些冗余或者非最优的分词结果,但它确保了覆盖更多的词汇可能性
6.4.2.1 ik_smart
POST _analyze
{
"analyzer": "ik_smart",
"text": [
"Java练习时长两年半"
]
}
测试结果
{
"tokens" : [
{
"token" : "java",
"start_offset" : 0,
"end_offset" : 4,
"type" : "ENGLISH",
"position" : 0
},
{
"token" : "练习",
"start_offset" : 4,
"end_offset" : 6,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "时长",
"start_offset" : 6,
"end_offset" : 8,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "两",
"start_offset" : 8,
"end_offset" : 9,
"type" : "COUNT",
"position" : 3
},
{
"token" : "年半",
"start_offset" : 9,
"end_offset" : 11,
"type" : "CN_WORD",
"position" : 4
}
]
}
6.4.2.2 ik_max_word
POST _analyze
{
"analyzer": "ik_max_word",
"text": [
"Java练习时长两年半"
]
}
测试结果如下
{
"tokens" : [
{
"token" : "java",
"start_offset" : 0,
"end_offset" : 4,
"type" : "ENGLISH",
"position" : 0
},
{
"token" : "练习",
"start_offset" : 4,
"end_offset" : 6,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "时长",
"start_offset" : 6,
"end_offset" : 8,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "两年",
"start_offset" : 8,
"end_offset" : 10,
"type" : "CN_WORD",
"position" : 3
},
{
"token" : "两",
"start_offset" : 8,
"end_offset" : 9,
"type" : "COUNT",
"position" : 4
},
{
"token" : "年半",
"start_offset" : 9,
"end_offset" : 11,
"type" : "CN_WORD",
"position" : 5
}
]
}
可以看到 IK 分词器的分词效果还是不错的
6.5 IK 分词器的原理
6.5.1 字典
IK 分词器是如何进行分词的呢,在 IK 分词器的内部,其实是有一个字典的,这个字典里包含了常见的中文词语、中文人名、约定成俗的词语、成语等
当 IK 分词器分词的时候,会遍历字符串中的每个汉字,一点一点地分析,比如两个字两个字地分析,然后去字典里面查找是否有匹配的词语,如果没有,说明不是一个词,如果有,说明是一个词,将词语分出来,两个字分析完了之后,再三个字三个字地分析、四个字四个字地分析。。。
当然,以上只是分词的一个基本思路,底层肯定会有一些优化,避免重复遍历的操作
通过以上的分析,我们知道 IK 分词器分词依赖于字典,字典里有的词能够分离出来,字典里没有的词分不出来
假如我现在写这么一段话:中国的运动健儿在巴黎奥运会上不断夺金,真是泰裤辣啦,由于巴黎奥运会是最近的时事,泰裤辣是最近流行的网络新词汇,IK 分词器的字典中应该没有对应的记录,我们可以来测试一下
POST _analyze
{
"analyzer": "ik_max_word",
"text": [
"中国的运动健儿在巴黎奥运会上不断夺金,真是泰裤辣啦"
]
}
测试结果如下
{
"tokens" : [
{
"token" : "中国",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "的",
"start_offset" : 2,
"end_offset" : 3,
"type" : "CN_CHAR",
"position" : 1
},
{
"token" : "运动",
"start_offset" : 3,
"end_offset" : 5,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "健儿",
"start_offset" : 5,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 3
},
{
"token" : "在",
"start_offset" : 7,
"end_offset" : 8,
"type" : "CN_CHAR",
"position" : 4
},
{
"token" : "巴黎",
"start_offset" : 8,
"end_offset" : 10,
"type" : "CN_WORD",
"position" : 5
},
{
"token" : "奥运会",
"start_offset" : 10,
"end_offset" : 13,
"type" : "CN_WORD",
"position" : 6
},
{
"token" : "奥运",
"start_offset" : 10,
"end_offset" : 12,
"type" : "CN_WORD",
"position" : 7
},
{
"token" : "会上",
"start_offset" : 12,
"end_offset" : 14,
"type" : "CN_WORD",
"position" : 8
},
{
"token" : "不断",
"start_offset" : 14,
"end_offset" : 16,
"type" : "CN_WORD",
"position" : 9
},
{
"token" : "夺金",
"start_offset" : 16,
"end_offset" : 18,
"type" : "CN_WORD",
"position" : 10
},
{
"token" : "真是",
"start_offset" : 19,
"end_offset" : 21,
"type" : "CN_WORD",
"position" : 11
},
{
"token" : "泰",
"start_offset" : 21,
"end_offset" : 22,
"type" : "CN_CHAR",
"position" : 12
},
{
"token" : "裤",
"start_offset" : 22,
"end_offset" : 23,
"type" : "CN_CHAR",
"position" : 13
},
{
"token" : "辣",
"start_offset" : 23,
"end_offset" : 24,
"type" : "CN_CHAR",
"position" : 14
},
{
"token" : "啦",
"start_offset" : 24,
"end_offset" : 25,
"type" : "CN_CHAR",
"position" : 15
}
]
}
可以看到 IK 分词器目前的分词效果不是很理想
6.5.2 增强 IK 分词器
IK 分词器允许我们配置拓展字典来增加自定义的词库,以达到增强 IK 分词器的效果
在 IK 分词器的 config
目录下,有一个 IKAnalyzer.cfg.xml 文件,在这个文件中可以添加自己的拓展字典
cd /var/lib/docker/volumes/elasticsearch-plugins/_data/elasticsearch-analysis-ik-7.17.18/config
<?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"></entry>
<!--用户可以在这里配置自己的扩展停止词字典-->
<entry key="ext_stopwords"></entry>
<!--用户可以在这里配置远程扩展字典 -->
<!-- <entry key="remote_ext_dict">words_location</entry> -->
<!--用户可以在这里配置远程扩展停止词字典-->
<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>
文件中的 ext_stopwords 是什么意思呢,有一些词语我们不希望它参与分词,例如我们日常说话时的语气词(啊、哦、嗯、的等),还可以把敏感词给过滤掉
现在我们自己来定义一个一个扩展字典
先在 IKAnalyzer.cfg.xml 文件所在的目录下新建一个文件,名为 ext.dic ,然后再将以下内容填充到文件中
巴黎奥运会
泰裤辣
接着修改 IKAnalyzer.cfg.xml 文件,新增一个 entry 标签
<entry key="ext_dict">ext.dic</entry>
我们再拓展一下停止词字典,IK 分词器的 config
目录下已经有一个 stopword.dic 文件,我们在文件末尾追加以下内容
sudo vim /var/lib/docker/volumes/elasticsearch-plugins/_data/elasticsearch-analysis-ik-7.17.18/config/stopword.dic
啦
啊
哦
嗯
的
最后重启 ElasticSearch
sudo docker restart elasticsearch
重启 ElasticSearch 后再次测试分词的效果
POST _analyze
{
"analyzer": "ik_max_word",
"text": [
"中国的运动健儿在巴黎奥运会上不断夺金,真是泰裤辣啦"
]
}
测试结果如下
{
"tokens" : [
{
"token" : "中国",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "运动",
"start_offset" : 3,
"end_offset" : 5,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "健儿",
"start_offset" : 5,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "在",
"start_offset" : 7,
"end_offset" : 8,
"type" : "CN_CHAR",
"position" : 3
},
{
"token" : "巴黎奥运会",
"start_offset" : 8,
"end_offset" : 13,
"type" : "CN_WORD",
"position" : 4
},
{
"token" : "巴黎",
"start_offset" : 8,
"end_offset" : 10,
"type" : "CN_WORD",
"position" : 5
},
{
"token" : "奥运会",
"start_offset" : 10,
"end_offset" : 13,
"type" : "CN_WORD",
"position" : 6
},
{
"token" : "奥运",
"start_offset" : 10,
"end_offset" : 12,
"type" : "CN_WORD",
"position" : 7
},
{
"token" : "会上",
"start_offset" : 12,
"end_offset" : 14,
"type" : "CN_WORD",
"position" : 8
},
{
"token" : "不断",
"start_offset" : 14,
"end_offset" : 16,
"type" : "CN_WORD",
"position" : 9
},
{
"token" : "夺金",
"start_offset" : 16,
"end_offset" : 18,
"type" : "CN_WORD",
"position" : 10
},
{
"token" : "真是",
"start_offset" : 19,
"end_offset" : 21,
"type" : "CN_WORD",
"position" : 11
},
{
"token" : "泰裤辣",
"start_offset" : 21,
"end_offset" : 24,
"type" : "CN_WORD",
"position" : 12
}
]
}
可以看到,我们自定义的拓展字典生效了(巴黎奥运会和泰裤辣已成功被分离出来,语气词的和啦也成功过滤掉)
7. ElasticSearch 的基本概念
ElasticSearch 中的文档数据会被序列化为 JSON 格式后存储在 ElasticSearch 中
索引(index):相同类型的文档的集合
映射(mapping):索引中文档的字段约束信息,类似表的结构约束
以下是传统的关系型数据库与 ElasticSearch 的对比
8. 索引库操作
8.1 Mapping 映射属性
Mapping 是对索引库中文档的约束,常见的 Mapping 属性包括:
- type:字段数据类型,常见的简单类型有:
- 字符串:text(可分词的文本)、keyword(精确值,如品牌、国家、IP 地址等)
- 数值:long、integer、short、byte、double、float
- 布尔:boolean
- 日期:date
- 对象:object
- index:是否创建索引,默认为 true
- analyzer:使用哪种分词器(一般字段数据为 type 时才需要指定)
- properties:字段的子字段
需要注意的是,如果某个字段的数据类型为一个数组,只需要指定数组中元素的字段数据类型
如果数组参与排序时,ElasticSearch 非常智能,假设现在有一个名为 scores 的字段,数据类型为数组
{
"scores": [
95.1,
96.1,
97.1
]
}
假如我们想按照 score 升序排列,ElasticSearch 就会拿数组中最小的元素参与排序
假如我们想按照 score 降排列,ElasticSearch 就会拿数组中最大的元素参与排序
当然,也可以自定义逻辑,例如按照平均分进行排列
8.2 索引库的 CRUD
Elasticsearch 提供的所有 API 都是 Restful 的接口,遵循 Restful 的基本规范:
8.2.1 创建索引库
创建索引库的语法
以下是一个创建索引库的请求
PUT /blog
{
"mappings": {
"properties": {
"info": {
"type": "text",
"analyzer": "ik_smart"
},
"age": {
"type": "byte"
},
"email": {
"type": "keyword",
"index": false
},
"name": {
"type": "object",
"properties": {
"firstName": {
"type": "keyword"
},
"lastName": {
"type": "keyword"
}
}
}
}
}
}
请求的结果
{
"acknowledged" : true,
"shards_acknowledged" : true,
"index" : "blog"
}
8.2.2 查询索引库
查询名为 blog 的索引库
GET /blog
查询结果
{
"blog" : {
"aliases" : { },
"mappings" : {
"properties" : {
"age" : {
"type" : "byte"
},
"email" : {
"type" : "keyword",
"index" : false
},
"info" : {
"type" : "text",
"analyzer" : "ik_smart"
},
"name" : {
"properties" : {
"firstName" : {
"type" : "keyword"
},
"lastName" : {
"type" : "keyword"
}
}
}
}
},
"settings" : {
"index" : {
"routing" : {
"allocation" : {
"include" : {
"_tier_preference" : "data_content"
}
}
},
"number_of_shards" : "1",
"provided_name" : "blog",
"creation_date" : "1724096992524",
"number_of_replicas" : "1",
"uuid" : "vYkKXE50R7SGiw07pWG50Q",
"version" : {
"created" : "7171899"
}
}
}
}
}
8.2.3 删除索引库
删除名为 blog 的索引库
DELETE /blog
删除结果
{
"acknowledged" : true
}
删除后再次查询 blog 索引库
{
"error" : {
"root_cause" : [
{
"type" : "index_not_found_exception",
"reason" : "no such index [blog]",
"resource.type" : "index_or_alias",
"resource.id" : "blog",
"index_uuid" : "_na_",
"index" : "blog"
}
],
"type" : "index_not_found_exception",
"reason" : "no such index [blog]",
"resource.type" : "index_or_alias",
"resource.id" : "blog",
"index_uuid" : "_na_",
"index" : "blog"
},
"status" : 404
}
8.2.4 修改索引库
ElasticSearch 的索引库是不支持修改的,为什么不支持修改呢?
假设现在有一个索引库已经创建好了,并且向索引库中导入了成千上万的数据,这些数据也已经成功分词,并且创建了倒排索引,全部工作都完成了
现在要修改这个索引库,例如把参与索引的字段变成不参与索引,把另一个不参与索引的字段变成参与索引,那之前根据分词创建的倒排索引是不是全都作废了,还要再次进行分词,建立倒排索引,对索引库的影响非常大
所以说,ElasticSearch 不允许修改索引库,其实说不能修改也不是很严谨,准确地来说,是不能对已有的索引库做修改,但允许向索引库中添加新的字段
添加新的字段的语法如下:
我们再次创建名为 blog 的索引库(去除了 age 字段)
PUT /blog
{
"mappings": {
"properties": {
"info": {
"type": "text",
"analyzer": "ik_smart"
},
"email": {
"type": "keyword",
"index": false
},
"name": {
"type": "object",
"properties": {
"firstName": {
"type": "keyword"
},
"lastName": {
"type": "keyword"
}
}
}
}
}
}
接着修改索引库(添加 age 字段)
PUT /blog/_mapping
{
"properties": {
"age": {
"type": "byte"
}
}
}
再次查询 blog 索引库,可以看到 age 字段已成功添加
{
"blog" : {
"aliases" : { },
"mappings" : {
"properties" : {
"age" : {
"type" : "byte"
},
"email" : {
"type" : "keyword",
"index" : false
},
"info" : {
"type" : "text",
"analyzer" : "ik_smart"
},
"name" : {
"properties" : {
"firstName" : {
"type" : "keyword"
},
"lastName" : {
"type" : "keyword"
}
}
}
}
},
"settings" : {
"index" : {
"routing" : {
"allocation" : {
"include" : {
"_tier_preference" : "data_content"
}
}
},
"number_of_shards" : "1",
"provided_name" : "blog",
"creation_date" : "1724098027537",
"number_of_replicas" : "1",
"uuid" : "gkTdIfVcTRuknrsDqsTmJA",
"version" : {
"created" : "7171899"
}
}
}
}
}
那如果我们修改一个已有的字段,例如 info 字段,会怎么样呢
PUT /blog/_mapping
{
"properties": {
"age": {
"type": "keyword"
}
}
}
返回结果如下
{
"error" : {
"root_cause" : [
{
"type" : "illegal_argument_exception",
"reason" : "mapper [age] cannot be changed from type [byte] to [keyword]"
}
],
"type" : "illegal_argument_exception",
"reason" : "mapper [age] cannot be changed from type [byte] to [keyword]"
},
"status" : 400
}
可以看到,修改失败了
9. 文档操作
9.1 文档的 CRUD
9.1.1 新增文档
新增文档的语法如下
如果不指定文档 id 的话,ElasticSearch 会默认生成一个 id 值,根据随机 id 创建索引,将来我们操作文档也不太方便,所以在新增文档时最好指定文档 id
以下是一个新增文档的示例
POST /blog/_doc/1
{
"info": "广东吴彦祖",
"email": "123456789@qq.com",
"name": {
"firstName": "吴",
"lastName": "彦祖"
}
}
新增结果
{
"_index" : "blog",
"_type" : "_doc",
"_id" : "1",
"_version" : 1,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 0,
"_primary_term" : 1
}
9.1.2 查询文档
GET /blog/_doc/1
查询结果(_source 是源数据)
{
"_index" : "blog",
"_type" : "_doc",
"_id" : "1",
"_version" : 1,
"_seq_no" : 0,
"_primary_term" : 1,
"found" : true,
"_source" : {
"info" : "广东吴彦祖",
"email" : "123456789@qq.com",
"name" : {
"firstName" : "吴",
"lastName" : "彦祖"
}
}
}
9.1.3 删除文档
DELETE /blog/_doc/1
删除结果
{
"_index" : "blog",
"_type" : "_doc",
"_id" : "1",
"_version" : 2,
"result" : "deleted",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 1,
"_primary_term" : 1
}
9.1.4 修改文档
ElasticSearch 修改文档有两种方式:
- 全量修改:删除旧文档,添加新文档(可以理解为覆盖原有的文档)
- 增量修改:修改指定字段值
9.1.4.1 全量修改
全量修改的语法如下
以下是一个全量修改的示例
PUT /blog/_doc/1
{
"info": "广东吴彦祖",
"email": "987654321@qq.com",
"name": {
"firstName": "吴",
"lastName": "彦祖"
}
}
修改结果
{
"_index" : "blog",
"_type" : "_doc",
"_id" : "1",
"_version" : 1,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 2,
"_primary_term" : 1
}
注意:如果全量修改时 id 值不存在会创建一个新的文档
9.1.4.2 增量修改(局部修改)
增量修改(局部修改)的语法如下
以下是一个局部修改(局部修改)的示例(请求方法为 POST)
POST /blog/_update/1
{
"doc": {
"info": "广东吴彦祖plus"
}
}
修改结果
{
"_index" : "blog",
"_type" : "_doc",
"_id" : "1",
"_version" : 2,
"result" : "updated",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 3,
"_primary_term" : 1
}
9.2 批量处理
ElasticSearch 中允许通过一次请求中携带多次文档操作,也就是批量处理,语法格式如下
10. JavaRestClient
ElasticSearch 为我们通过了 Java 客户端,名为 JavaRestClient,为什么带有 Rest 关键字呢,因为这个客户端本质上就是帮我们发送 Restful 风格的 Http 请求
Elasticsearch 目前的最新版本是 8.x,其 Java 客户端有很大变化,特别是 API 方面,8.x 版本的 API 大都是响应式编程、与 lambda 表达式相关的 API
由于大多数企业使用的还是 8 以下的版本,所以我们选择使用早期的 JavaRestClient 客户端(虽然已被标记为过时)来学习
官方文档地址:Elasticsearch Clients
10.1 导入依赖
引入 ElasticSearch 的 RestHighLevelClient 依赖
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
10.2 指定版本
如果是 SpringBoot 项目,会有一个默认的 ElasticSearch 版本,我们需要覆盖默认的 ElasticSearch 版本
在父工程的 pom.xml 文件中添加以下属性
<properties>
<elasticsearch.version>7.17.18</elasticsearch.version>
</properties>
10.3 编写测试类
编写一个测试类,测试能否成功连接 ElasticSearch
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;
public class ElasticSearchTests {
private RestHighLevelClient restHighLevelClient;
@Test
public void testConnect() {
System.out.println(restHighLevelClient);
}
@BeforeEach
public void setUp() {
restHighLevelClient = new RestHighLevelClient(RestClient.builder(
new HttpHost("127.0.0.1", 9200, "http")
));
}
@AfterEach
public void tearDown() throws Exception {
restHighLevelClient.close();
}
}
11. 创建索引库时如何编写 Mapping 映射
创建索引库前需要先准备好索引库的 Mapping 映射,我们以商品数据为例,分析该如何编写 Mapping 映射
我们要实现商品搜索,那么索引库的字段肯定要满足页面搜索的需求,但是我们不能照搬商品表的结构作为 Mapping 映射的结构,因为商品表里面有非常多的字段,但这些字段不一定是索引库所需要的,如果直接照搬商品表的结构作为 Mapping 映射的结构,会增加 ElasticSearch 的存储负担
编写 Mapping 字段,需要结合我们的业务逻辑,我们以商品搜索页面为例来分析
以下是商品表的结构
根据搜索逻辑,我们可以判断出表中的哪些字段需要保存到 ElasticSearch 中,哪些字段参与搜索
根据分析,我们可以得到以下创建索引库的语句
PUT /shopping_mall
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "ik_smart"
},
"price": {
"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"
}
}
}
}
12. JavaRestClient 操作索引库
12.1 新增索引库
新增索引库的语法如下
其中的 indices 是 index 的复数形式,indices 方法会返回一个对象,该对象中包含了操作索引库的所有方法
具体的 Java 代码如下
private static final String MAPPING_TEMPLATE = """
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "ik_smart"
},
"price": {
"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",
"format": ["yyyy-MM-dd HH:mm:ss"]
}
}
}
}""";
@Test
public void testCreateIndex() throws IOException {
// 1.创建 CreateIndexRequest 对象
CreateIndexRequest createIndexRequest = new CreateIndexRequest("shopping_mall");
// 2.指定请求参数,其中 MAPPING_TEMPLATE 是一个静态常量字符串,内容是 JSON 格式的请求
createIndexRequest.source(MAPPING_TEMPLATE, XContentType.JSON);
// 3.发起请求
restHighLevelClient.indices().create(createIndexRequest, RequestOptions.DEFAULT);
}
12.2 查询索引库
查询索引库的语法如下
具体的 Java 代码
注意:GetIndexRequest 类的包名为 org.elasticsearch.client.indices.GetIndexRequest
@Test
public void testGetIndex() throws IOException {
// 1.创建 GetIndexRequest 对象
GetIndexRequest shoppingMall = new GetIndexRequest("shopping_mall");
// 2.发起请求
boolean exists = restHighLevelClient.indices().exists(shoppingMall, RequestOptions.DEFAULT);
System.err.println("exists = " + exists);
}
12.3 删除索引库
删除索引库的语法如下
具体的 Java 代码如下
@Test
public void testDeleteIndex() throws IOException {
// 1.创建 DeleteIndexRequest 对象
DeleteIndexRequest shoppingMall = new DeleteIndexRequest("shopping_mall");
// 2.发起请求
AcknowledgedResponse acknowledgedResponse = restHighLevelClient.indices().delete(shoppingMall, RequestOptions.DEFAULT);
System.err.println("acknowledged = " + acknowledgedResponse.isAcknowledged());
}
12.4 查看当前有哪些索引库
在 kibana 提供的 Dev Tools 控制台中
GET _cat/indices?v
13. JavaRestClient 操作文档
我们新建一个名为 ElasticSearchDocumentTests 的测试类,在这个类中进行文档的 CRUD 操作
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;
public class ElasticSearchDocumentTests {
private RestHighLevelClient restHighLevelClient;
@BeforeEach
public void setUp() {
restHighLevelClient = new RestHighLevelClient(RestClient.builder(
new HttpHost("127.0.0.1", 9200, "http")
));
}
@AfterEach
public void tearDown() throws Exception {
restHighLevelClient.close();
}
}
13.1 新增文档
新增文档的 Java API 如下
具体的 Java 代码
private String sourceString = """
{
"id": "1",
"name": "小米11",
"price": 3999,
"image": "https://img.alicdn.com/imgextra/i4/O1CN01LX6jqs1E5W8Y8X1qG_!!6000000001648-2-tps-200-200.png",
"category": "手机",
"brand": "小米",
"sold": 100,
"commentCount": 100,
"isAD": true,
"updateTime": "2021-01-01"
}""";
@Test
public void testCreateDocument() throws IOException {
// 1.准备 IndexRequest 对象
IndexRequest indexRequest = new IndexRequest("shopping_mall").id("1");
// 2.准备请求参数
indexRequest.source(sourceString, XContentType.JSON);
// 3.发送请求
IndexResponse indexResponse = restHighLevelClient.index(indexRequest, RequestOptions.DEFAULT);
System.err.println("新增文档操作的返回结果 = " + indexResponse.getResult());
}
注意:
- 在实际业务中,会有一个专门的实体类用于往索引库中新增文档
- 将实体类转换成 JSON 格式的字符串可以使用 Alibaba 提供的 fastjson2 工具
13.2 查询文档
查询文档包含查询和解析响应结果两部分,对应的 Java API 如下
具体的 Java 代码
@Test
public void testGetDocument() throws IOException {
// 1.准备 GetRequest 对象
GetRequest getRequest = new GetRequest("shopping_mall", "1");
// 2.发送请求
GetResponse getResponse = restHighLevelClient.get(getRequest, RequestOptions.DEFAULT);
System.err.println("getResponse = " + getResponse);
System.err.println("source = " + getResponse.getSourceAsString());
}
13.3 删除文档
删除文档的 Java API 如下
具体的 Java 代码
@Test
public void testDeleteDocument() throws IOException {
// 1.准备 DeleteRequest 对象
DeleteRequest deleteRequest = new DeleteRequest("shopping_mall", "1");
// 2.发送请求
DeleteResponse deleteResponse = restHighLevelClient.delete(deleteRequest, RequestOptions.DEFAULT);
System.err.println("deleteResponse = " + deleteResponse.getResult());
}
13.4 修改文档
修改文档数据有两种方式:
- 方式一:全量更新,再次写入 id 一样的文档,就会制除旧文档,添加新文档,与新增的 Java API 一致(新增时返回的结果为 created ,全量更新时返回的结果为 updated)
- 方式二:局部更新,只更新指定部分字段
局部更新的 Java API 如下
具体的 Java 代码
@Test
public void testUpdateDocument() throws IOException {
// 1.准备 UpdateRequest 对象
UpdateRequest updateRequest = new UpdateRequest("shopping_mall", "1");
// 2.准备请求参数
HashMap<String, Object> stringObjectHashMap = new HashMap<>();
stringObjectHashMap.put("sold", 101);
updateRequest.doc(stringObjectHashMap, XContentType.JSON);
// 3.发送请求
UpdateResponse updateResponse = restHighLevelClient.update(updateRequest, RequestOptions.DEFAULT);
System.err.println("局部更新文档操作的返回结果 = " + updateResponse.getResult());
}
13.5 文档批处理(可以进行不同类型的文档操作)
批处理代码流程与之前类似,不过构建请求会用到一个名为 BulkRequest 的类来封装普通的 CRUD 请求
具体的 Java API 如下
具体的 Java 代码
@Test
public void testBulk() throws IOException {
// 1.准备 BulkRequest 对象
BulkRequest bulkRequest = new BulkRequest();
// 2.准备请求参数
// 2.1 新增文档
HashMap<String, Object> indexMap = new HashMap<>();
indexMap.put("sold", 102);
bulkRequest.add(new IndexRequest("shopping_mall").id("1").source(indexMap, XContentType.JSON));
// 2.2 修改文档
HashMap<String, Object> updateMap = new HashMap<>();
updateMap.put("commentCount", 103);
bulkRequest.add(new UpdateRequest("shopping_mall", "1").doc(updateMap, XContentType.JSON));
// 3.发起 bulk 请求
BulkResponse bulkResponse = restHighLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT);
System.err.println("批量处理文档操作的返回结果如下");
Arrays.stream(bulkResponse.getItems()).forEach(item -> System.err.println(item.getResponse()));
}
13.6 利用 JavaRestClient 批量新增文档
在批量新增文档前,我们先准备一些数据
13.6.1 建表
首先,创建一个名为 item 的表,建表语句如下
/*
Navicat Premium Data Transfer
Source Server : localhost
Source Server Type : MySQL
Source Server Version : 80034 (8.0.34)
Source Host : localhost:3306
Source Schema : blog
Target Server Type : MySQL
Target Server Version : 80034 (8.0.34)
File Encoding : 65001
Date: 25/08/2024 01:59:24
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for item
-- ----------------------------
DROP TABLE IF EXISTS `item`;
CREATE TABLE `item` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品id',
`name` varchar(200) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT 'SKU名称',
`price` int NOT NULL DEFAULT 0 COMMENT '价格(分)',
`stock` int UNSIGNED NOT NULL COMMENT '库存数量',
`image` varchar(200) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '商品图片',
`category` varchar(200) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '类目名称',
`brand` varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '品牌名称',
`spec` varchar(200) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '规格',
`sold` int NULL DEFAULT 0 COMMENT '销量',
`comment_count` int NULL DEFAULT 0 COMMENT '评论数',
`isAD` tinyint(1) NULL DEFAULT 0 COMMENT '是否是推广广告,true/false',
`status` int NULL DEFAULT 2 COMMENT '商品状态 1-正常,2-下架,3-删除',
`create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`creater` bigint NULL DEFAULT NULL COMMENT '创建人',
`updater` bigint NULL DEFAULT NULL COMMENT '修改人',
PRIMARY KEY (`id`) USING BTREE,
INDEX `status`(`status` ASC) USING BTREE,
INDEX `updated`(`update_time` ASC) USING BTREE,
INDEX `category`(`category` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 100002672305 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci COMMENT = '商品表' ROW_FORMAT = COMPACT;
SET FOREIGN_KEY_CHECKS = 1;
13.6.2 导入数据
运行 SQL 文件,导入数据,共有 88476 条数据(如果需要 SQL 文件,可以私聊我)
13.6.3 编写实体类
编写 service 类前,需要先导入 MyBatisPlus 的 Maven 依赖
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.7</version>
</dependency>
Item.java
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
@TableName("item")
public class Item implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 商品id
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* SKU名称
*/
private String name;
/**
* 价格(分)
*/
private Integer price;
/**
* 库存数量
*/
private Integer stock;
/**
* 商品图片
*/
private String image;
/**
* 类目名称
*/
private String category;
/**
* 品牌名称
*/
private String brand;
/**
* 规格
*/
private String spec;
/**
* 销量
*/
private Integer sold;
/**
* 评论数
*/
private Integer commentCount;
/**
* 是否是推广广告,true/false
*/
@TableField("isAD")
private Boolean isAD;
/**
* 商品状态 1-正常,2-下架,3-删除
*/
private Integer status;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 创建人
*/
private Long creater;
/**
* 修改人
*/
private Long updater;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getPrice() {
return price;
}
public void setPrice(Integer price) {
this.price = price;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public String getSpec() {
return spec;
}
public void setSpec(String spec) {
this.spec = spec;
}
public Integer getSold() {
return sold;
}
public void setSold(Integer sold) {
this.sold = sold;
}
public Integer getCommentCount() {
return commentCount;
}
public void setCommentCount(Integer commentCount) {
this.commentCount = commentCount;
}
public Boolean getIsAD() {
return isAD;
}
public void setIsAD(Boolean AD) {
isAD = AD;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
public LocalDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
public LocalDateTime getUpdateTime() {
return updateTime;
}
public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
public Long getCreater() {
return creater;
}
public void setCreater(Long creater) {
this.creater = creater;
}
public Long getUpdater() {
return updater;
}
public void setUpdater(Long updater) {
this.updater = updater;
}
@Override
public String toString() {
return "Item{" +
"id=" + id +
", name='" + name + '\'' +
", price=" + price +
", stock=" + stock +
", image='" + image + '\'' +
", category='" + category + '\'' +
", brand='" + brand + '\'' +
", spec='" + spec + '\'' +
", sold=" + sold +
", commentCount=" + commentCount +
", isAD=" + isAD +
", status=" + status +
", createTime=" + createTime +
", updateTime=" + updateTime +
", creater=" + creater +
", updater=" + updater +
'}';
}
}
ItemDocument.java
import java.time.LocalDateTime;
/**
* 索引库实体类
*/
public class ItemDocument {
private Long id;
private String name;
private Integer price;
private Integer stock;
private String image;
private String category;
private String brand;
private Integer sold;
private Integer commentCount;
private Boolean isAD;
private LocalDateTime updateTime;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getPrice() {
return price;
}
public void setPrice(Integer price) {
this.price = price;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public Integer getSold() {
return sold;
}
public void setSold(Integer sold) {
this.sold = sold;
}
public Integer getCommentCount() {
return commentCount;
}
public void setCommentCount(Integer commentCount) {
this.commentCount = commentCount;
}
public Boolean getIsAD() {
return isAD;
}
public void setIsAD(Boolean AD) {
isAD = AD;
}
public LocalDateTime getUpdateTime() {
return updateTime;
}
public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
@Override
public String toString() {
return "ItemDocument{" +
"id='" + id + '\'' +
", name='" + name + '\'' +
", price=" + price +
", stock=" + stock +
", image='" + image + '\'' +
", category='" + category + '\'' +
", brand='" + brand + '\'' +
", sold=" + sold +
", commentCount=" + commentCount +
", isAD=" + isAD +
", updateTime=" + updateTime +
'}';
}
}
13.6.4 编写配置文件
编写配置文件前,先导入 MySQL 连接驱动的 Maven 依赖
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
application.yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/blog?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 123456
编写完配置文件后,在项目的启动类上添加 @MapperScan
注解,指定 Mapper 所在的包
@MapperScan("cn.edu.scau.mapper")
13.6.5 编写 Mapper 类和 Service 类
ItemMapper.java
import cn.edu.scau.pojo.Item;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface ItemMapper extends BaseMapper<Item> {
}
ItemService.java
import cn.edu.scau.pojo.Item;
import com.baomidou.mybatisplus.extension.service.IService;
public interface ItemService extends IService<Item> {
}
ItemServiceImpl.java
import cn.edu.scau.mapper.ItemMapper;
import cn.edu.scau.pojo.Item;
import cn.edu.scau.service.ItemService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
@Service
public class ItemServiceImpl extends ServiceImpl<ItemMapper, Item> implements ItemService {
}
完成上述工作后,编写一个测试类,检查 ItemServiceImpl 类能否正常工作
import cn.edu.scau.service.ItemService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class ItemServiceTests {
@Autowired
private ItemService itemService;
@Test
public void test() {
System.out.println(itemService.getById(317578L));
}
}
13.6.6 批量新增文档
新增文档前,需要先引入 PageHelper 和 fastjson2 的 Maven 依赖
PageHelper
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>2.1.0</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
</exclusion>
</exclusions>
</dependency>
fastjson2
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.50</version>
</dependency>
由于数据库中有 87476 条数据,肯定不能一次性全部导入 ElasticSearch ,需要分批次导入
import cn.edu.scau.pojo.Item;
import cn.edu.scau.pojo.ItemDocument;
import cn.edu.scau.service.ItemService;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.github.pagehelper.PageHelper;
import org.apache.http.HttpHost;
import org.elasticsearch.action.bulk.BulkRequest;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.xcontent.XContentType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
@SpringBootTest
public class BulkInsertDocumentTests {
private RestHighLevelClient restHighLevelClient;
@Autowired
private ItemService itemService;
@Test
public void testBulkInsertDocument() throws Exception {
int pageNumber = 1;
int pageSize = 500;
while (true) {
// 1.准备文档数据
QueryWrapper<Item> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(Item::getStatus, 1);
PageHelper.startPage(pageNumber, pageSize);
List<Item> itemList = itemService.list(queryWrapper);
if (itemList == null || itemList.isEmpty()) {
return;
}
// 2.准备 BulkRequest 对象
BulkRequest bulkRequest = new BulkRequest();
// 3.准备请求参数
ItemDocument itemDocument;
for (Item item : itemList) {
itemDocument = new ItemDocument();
BeanUtils.copyProperties(item, itemDocument);
bulkRequest.add(new IndexRequest("shopping_mall")
.id(item.getId().toString())
.source(JSON.toJSONString(itemDocument), XContentType.JSON));
}
// 4.发送请求
restHighLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT);
// 5.翻页
pageNumber++;
}
}
@BeforeEach
public void setUp() {
restHighLevelClient = new RestHighLevelClient(RestClient.builder(
new HttpHost("127.0.0.1", 9200, "http")
));
}
@AfterEach
public void tearDown() throws Exception {
restHighLevelClient.close();
}
}
批量新增文档总耗时 34 秒 2 毫秒(耗时较慢,可以使用多线程 + 线程池的方案进行优化),我们来验证一下数据是否已经全部导入成功
返回的结果
{
"count" : 88476,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
}
}
item 表中共有 88476 条数据,其中有一条数据的 status 字段为 2 ,表示该商品已下架,再加上我们新增的小米手机数据,所以总共是 88476 条数据
13.6.7 可能遇到的问题
如果你在批量新增文档时遇到了以下错误,是因为 ElasticSearch 拒绝了执行该次批量操作请求
{“error”:{“root_cause”:[{“type”:“es_rejected_execution_exception”,“reason”:“rejected execution of coordinating operation [coordinating_and_primary_bytes=0, replica_bytes=0, all_bytes=0, coordinating_operation_bytes=54034916, max_coordinating_and_primary_bytes=53687091]”}],“type”:“es_rejected_execution_exception”,“reason”:“rejected execution of coordinating operation [coordinating_and_primary_bytes=0, replica_bytes=0, all_bytes=0, coordinating_operation_bytes=54034916, max_coordinating_and_primary_bytes=53687091]”},“status”:429}
at org.elasticsearch.client.RestClient.convertResponse(RestClient.java:347)
at org.elasticsearch.client.RestClient.performRequest(RestClient.java:313)
at org.elasticsearch.client.RestClient.performRequest(RestClient.java:288)
at org.elasticsearch.client.RestHighLevelClient.performClientRequest(RestHighLevelClient.java:2699)
at org.elasticsearch.client.RestHighLevelClient.internalPerformRequest(RestHighLevelClient.java:2171)
… 74 more
因为本次批量操作所需的内存大小超过了配置的最大限制,也就是说,ElasticSearch 为了防止内存溢出或资源耗尽,拒绝了该次请求
解决方法:减小分页查询时每一页的数据条数
14. DSL(Domain SpecificLanguage) 查询
前面我们都是根据 id 来查询文档的,这种查询方式满足不了我们的业务要求,比如想京东这样的电商网站,我们搜索商品的时候,搜索条件往往比较复杂,为了实现复杂搜索,我们需要一种新的搜索方式
ElasticSearch 为我们提供了 DSL 查询来实现复杂搜索
14.1 快速入门
DSL:Domain Specific Language,以 JSON 格式来定义查询条件,以下是一个示例
大家看到上面的请求,是不是一下就懵了,怎么这么复杂
不用担心,这是最终形态,我们在学习的时候肯定是一步一步拆分,从易到难进行学习
DSL 查询可以分为两大类:
- 叶子查询(Leaf query clauses):一般是在特定的字段里查询特定值,属于简单查询,很少单独使用
- 复合查询(Compound query clauses):以逻辑方式组合多个叶子查询或者更改叶子查询的行为方式
查询以后,还可以对查询的结果做处理,包括:
- 排序:按照 1 个或多个字段值做排序
- 分页:根据 from 和 size 做分页,类似于 MySQL 的分页
- 高亮:对搜索结果中的关键字添加特殊样式,使其更加醒目
- 聚合:对搜索结果做数据统计以形成报表
基于 DSL 的查询语法如下
我们先在 Dev Tools 中进行一个 DSL 查询
GET /shopping_mall/_search
{
"query": {
"match_all": {}
}
}
查询结果
{
"took" : 0,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "shopping_mall",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"sold" : 102,
"commentCount" : 103
}
}
]
}
}
以下是对查询结果的解释
- Elastic Search 单次查询涉及的数据量默认不能超过一万
- Elastic Search 单次查询默认只会返回 10 条数据
14.2 叶子查询
叶子查询还可以进一步细分,常见的有:
- 全文检索(fulltext)查询:利用分词器对用户输入的内容分词,然后去词条列表中匹配,例如:
- match_query
- multi_match_query
- 精确查询:不对用户输入内容分词,直接精确匹配,一般是查找keyword、数值、日期、布尔等类型,例如:
- ids
- range
- term
- 地理(geo)查询:用于搜索地理位置,搜索方式很多,例如(地理查询可用于实现搜索附近的人、搜索附近的车等功能):
- geo_distance
- geo_bounding_box
14.2.1 全文检索查询
参与全文检索查询的字段最好也是可以分词的(类型为 text 的字段),这样才可以找到与用户搜索内容匹配度更高的文档
14.2.1.1 match 查询
match 查询:全文检索查询的一种,会对用户输入内容分词,然后去倒排索引库检索,语法如下
示例
GET /shopping_mall/_search
{
"query": {
"match": {
"name": "脱脂牛奶"
}
}
}
14.2.1.2 multi_match 查询
multi_match:与 match 查询类似,只不过允许同时查询多个字段,语法如下(参与查询字段越多,查询性能越差)
示例
GET /shopping_mall/_search
{
"query": {
"multi_match": {
"query": "脱脂牛奶",
"fields": ["name"]
}
}
}
14.2.2 精确查询
精确查询,英文是 Term-level query,顾名思义,词条级别的查询,也就是说不会对用户输入的搜索条件再分词,而是将搜索条件作为一个词条,与搜索的字段内容精确值匹配
精确查询适用于查找 keyword、数值、日期、boolean 类型的字段,例如 id、price、城市、地名、人名等作为一个整体才有含义的字段
精确查询主要有三种:
- term 查询
- range 查询
- id 查询
14.2.2.1 term 查询
term 查询的语法如下
示例
GET /shopping_mall/_search
{
"query": {
"term": {
"brand": {
"value": "德亚"
}
}
}
}
14.2.2.2 range 查询
range 查询的语法如下
示例(价格以分为单位)
GET /shopping_mall/_search
{
"query": {
"range": {
"price": {
"gte": 500000,
"lte": 1000000
}
}
}
}
14.2.2.3 id 查询
示例
GET /shopping_mall/_search
{
"query": {
"ids": {
"values": [
"613359",
"613360"
]
}
}
}
14.3 复合查询(布尔查询)
叶子查询是比较简单的单字段查询,在真实的业务场景下,往往都会有比较复杂的组合条件查询,这个时候就需要使用复合查询了
复合查询大致可以分为两类:
- 第一类:基于逻辑运算组合叶子查询,实现组合条件,例如
- bool
- 第二类:基于某种算法修改查询时的文档相关性算分,从而改变文档排名,例如:
- function_score
- dis_max
布尔查询是一个或多个查询子句的组合,子查询的组合方式有:
- must:必须匹配每个子查询,类似与
- should:选择性匹配子查询,类似或
- must_not:必须不匹配,不参与算分,类似非
- filter:必须匹配,不参与算分
布尔查询的语法如下
我们来做一个小案例:搜索"智能手机",但品牌必须是华为,而且价格必须在 [900, 1599] 区间内
GET /shopping_mall/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"name": "智能手机"
}
}
],
"filter": [
{
"term": {
"brand": "华为"
}
},
{
"range": {
"price": {
"gte": 90000,
"lte": 159900
}
}
}
]
}
}
}
14.4 排序和分页
14.4.1 排序
Elastic Search 支持对搜索结果排序,默认是根据相关度算分(_score)来排序,也可以指定字段排序,可以排序的字段类型有:keyword 类型、数值类型、地理坐标类型、日期类型等
排序的语法如下
- 如果有多个字段参与排序,会先按照第一个字段进行排序
- 如果第一个字段相同,再按照第二个字段进行排序,以此类推
我们来做一个小案例:搜索商品,按照销量排序,销量一样则按照价格升序
GET /shopping_mall/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"sold": {
"order": "desc"
}
},
{
"price": {
"order": "asc"
}
}
]
}
14.4.2 分页
Elastic Search 默认情况下只返回 top10 的数据,如果要查询更多数据,需要修改分页参数,Elastic Search 中通过修改 from、size 参数来控制要返回的分页结果:
- from:从第几个文档开始
- size:总共查询几个文档
分页的语法如下
我们来做一个小案例:查询出销量排名前 10 的商品,销量一样时按照价格升序
GET /shopping_mall/_search
{
"query": {
"match_all": {}
},
"from": 0,
"size": 10,
"sort": [
{
"sold": {
"order": "desc"
}
},
{
"price": {
"order": "asc"
}
}
]
}
14.5 深度分页问题
ElasticSearch 的数据一般会采用分片存储,也就是把一个索引中的数据分成 N 份,存储到不同节点上,查询数据时需要汇总各个分片的数据
假如我们要查第 100 页的数据,每页查 10 条,那 ElasticSearch 是如何找到前 1000 名中的最后 10 名的呢?
要找到前 1000 名的最后 10 名,需要先知道前 1000 名是谁,整体的思路有两步:
- 对数据进行排序
- 找出 [991, 1000] 名
那前 1000 个如何找呢(假如现在有四个分片),是不是我每个分片找 250 个就行了呢?实际上不能这么操作,因为数据是混乱地保存在不同的分片上的,不能保证每个分片的前 250 名加起来就是总的前 1000 名
举个通俗的例子,学校有 10 个班级,现在要找到年级前十名,是选出每个班的第一名就可以了吗,显然不是的,因为每个班级中每个学生都有学得好的和学得差的,班级的整体水平不一样,有可能某个班级的第一名在另一个班级中是垫底的
那我要怎么找到年级的前十名呢,最简单的做法就是对所有学生的成绩进行统计,找出前十名,但这种做法的效率可能不是很高,其实我们只需要找出每个班级的前十名,接着将每个班级前十名的学生汇总在一起,再找出这些汇总学生的前十名即可(就算是最极端的情况,即年级前十名都在同一个班级,也能正确地找出年级前十名)
ElasticSearch 的分页也是基于这个思想,要找出前 1000 名的数据,需要从每个分片中取出前 1000 名的数据,汇总后进行排序,找到真正的前 1000 名的数据
其实这种做法在数据量较大的情况下也有一定的问题,比如说我要查第 1 万页的数据,每页查 10 条,也就是要找前 10 万条数据,再找出排名在 [99990, 100000] 之间的数据
根据上述思想,就需要从每个分片中分别取出前 10 万条数据,汇总在一起,再筛选出真正的排名在 [99900, 100000] 之间的数据,这个数据量是非常恐怖的,很有可能导致内存直接炸裂
针对深度分页,Elastic Search 提供了两种解决方案(官方文档:search after):
- search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据(官方推荐使用的方式)
- scroll:原理将排序数据形成快照,保存在内存,官方已经不推荐使用
search after 模式
- 优点:没有查询上限,支持深度分页
- 缺点:只能从前往后逐页查询,不能随机翻页
- 应用场景:数据迁移、手机滚动查询
那传统分页的应用场景有哪些呢?
百度搜索就是传统分页的一个典型例子,这个时候,有同学就有疑惑了,像百度这样的搜索引擎,ElasticSearch 中不是存储了数以千万计的数据吗,百度搜索是怎么解决深度分页问题的呢?
实际上,百度搜索对分页做了限制,最多只能跳转到 77 页,也就是说,你最多只能得到几百条数据
事实上,我们平时在用百度搜索的时候,一般只会看前三页的内容,很少会查看后面的数据,所以说,针对深度分页问题,最简单的解决方法就是直接对页码做一个限制,为页码制定一个上限(很多软件都是这么解决深度分页问题的)
ElasticSearch 对传统分页的页码和每页多少条数据也做了一个限制(也就是 from 字段和 size 字段),from 字段和 size 字段组合起来所需要用到的数据不能超过 1 万条,我们可以做一个测试
14.6 高亮显示
高亮显示:在搜索结果中把搜索关键字突出显示
我们在用百度等搜索引擎时,我们的搜索条件会在搜索结果中高亮显示(一般是标为红色),那具体是怎么样实现的呢,其实在搜索结果中,我们的搜索条件会被一个 <em></em>
标签包裹起来
那前端怎么知道我要给哪些内容添加高亮呢,前端工程师最多只能控制页面的排版,不能提前知道数据的内容
其实,添加标签这一步是由 ElasticSearch 完成的,ElasticSearch 为我们提供了高亮显示搜索词的功能
高亮显示的语法如下
示例
GET /shopping_mall/_search
{
"query": {
"match": {
"name": "脱脂牛奶"
}
},
"highlight": {
"fields": {
"name": {
"pre_tags": "<em>",
"post_tags": "</em>"
}
}
}
}
返回的结果如下
如果不指定标签,默认使用
<em></em>
标签
搜索的完整语法
15. JavaRestClient 查询
15.1 快速入门
数据搜索的 Java 代码我们分为两部分:
- 构建请求并发起请求
- 解析查询结果
具体的 Java 代码如下
import cn.edu.scau.pojo.ItemDocument;
import com.alibaba.fastjson2.JSON;
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.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
public class SearchTests {
private RestHighLevelClient restHighLevelClient;
@Test
public void testMatchAll() throws IOException {
// 1.创建 SearchRequest 对象
SearchRequest searchRequest = new SearchRequest("shopping_mall");
// 2.配置 request 参数
searchRequest.source().query(QueryBuilders.matchAllQuery());
// 3.发送请求
SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 4.解析结果
SearchHits searchHits = searchResponse.getHits();
// 4.1 获取总记录数
long totalHits = searchHits.getTotalHits().value;
System.err.println("总记录数 = " + totalHits);
// 4.2 获取命中的数据
for (SearchHit hit : searchHits) {
// 4.2.1 获取 source 结果
String sourceAsString = hit.getSourceAsString();
// 4.2.2 转换为 ItemDocument 对象
ItemDocument itemDocument = JSON.parseObject(sourceAsString, ItemDocument.class);
System.err.println("itemDocument = " + itemDocument);
}
}
@BeforeEach
public void setUp() {
restHighLevelClient = new RestHighLevelClient(RestClient.builder(
new HttpHost("127.0.0.1", 9200, "http")
));
}
@AfterEach
public void tearDown() throws Exception {
restHighLevelClient.close();
}
}
15.2 构造查询条件
在 JavaRestAPI 中,所有类型的 query 查询条件都是由 QueryBuilders 来构建的
全文检索的查询条件构造的 API 如下:
精确检索的查询条件构造的 API 如下:
布尔查询的查询条件构造 API 如下:
了解如何构造查询条件之后,我们来做一个小案例:利用 JavaRestClient 实现搜索功能,条件如下:
- 搜索关键字为脱脂牛奶
- 品牌必须为德亚
- 价格必须低于 300 元
具体的 Java 代码
import cn.edu.scau.pojo.ItemDocument;
import com.alibaba.fastjson2.JSON;
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.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
public class SearchTests {
private RestHighLevelClient restHighLevelClient;
@Test
public void testSearch() throws IOException {
// 1.创建 SearchRequest 对象
SearchRequest searchRequest = new SearchRequest("shopping_mall");
// 2.组织 DSL 参数
searchRequest.source().query(QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("name", "脱脂牛奶"))
.filter(QueryBuilders.termQuery("brand", "德亚"))
.filter(QueryBuilders.rangeQuery("price").lt(30000))
);
// 3.发送请求
SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 4.解析结果
parseSearchResponse(searchResponse);
}
private static void parseSearchResponse(SearchResponse searchResponse) {
SearchHits searchHits = searchResponse.getHits();
// 4.1 获取总记录数
long totalHits = searchHits.getTotalHits().value;
System.err.println("总记录数 = " + totalHits);
// 4.2 获取命中的数据
for (SearchHit hit : searchHits) {
// 4.2.1 获取 source 结果
String sourceAsString = hit.getSourceAsString();
// 4.2.2 转换为 ItemDocument 对象
ItemDocument itemDocument = JSON.parseObject(sourceAsString, ItemDocument.class);
System.err.println("itemDocument = " + itemDocument);
}
}
@BeforeEach
public void setUp() {
restHighLevelClient = new RestHighLevelClient(RestClient.builder(
new HttpHost("127.0.0.1", 9200, "http")
));
}
@AfterEach
public void tearDown() throws Exception {
restHighLevelClient.close();
}
}
15.3 排序和分页
与 query 类似,排序和分页参数都是基于 request.source() 来设置的
具体的 Java 代码如下
@Test
public void testSortAndPage() throws IOException {
// 0.模拟前端传递过来的分页参数
int pageNumber = 1;
int pageSize = 10;
// 1.创建 SearchRequest 对象
SearchRequest searchRequest = new SearchRequest("shopping_mall");
// 2.组织 DSL 参数
// 2.1 query条件
searchRequest.source().query(QueryBuilders.matchAllQuery());
// 2.2 分页
searchRequest.source().from((pageNumber - 1) * pageSize).size(pageSize);
// 2.3 排序
searchRequest.source().sort("price", SortOrder.DESC);
// 3.发送请求
SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 4.解析结果
parseSearchResponse(searchResponse);
}
private static void parseSearchResponse(SearchResponse searchResponse) {
SearchHits searchHits = searchResponse.getHits();
// 4.1 获取总记录数
long totalHits = searchHits.getTotalHits().value;
System.err.println("总记录数 = " + totalHits);
// 4.2 获取命中的数据
for (SearchHit hit : searchHits) {
// 4.2.1 获取 source 结果
String sourceAsString = hit.getSourceAsString();
// 4.2.2 转换为 ItemDocument 对象
ItemDocument itemDocument = JSON.parseObject(sourceAsString, ItemDocument.class);
System.err.println("itemDocument = " + itemDocument);
}
}
15.4 高亮显示
高亮显示的条件构造 API 如下
具体的 Java 代码
@Test
public void testHighlight() throws IOException {
// 1.创建 SearchRequest 对象
SearchRequest searchRequest = new SearchRequest("shopping_mall");
// 2.组织 DSL 参数
// 2.1 query条件
searchRequest.source().query(QueryBuilders.matchQuery("name", "脱脂牛奶"));
// 2.2 高亮条件
searchRequest.source().highlighter(SearchSourceBuilder.highlight()
.field("name")
.preTags("<em>")
.postTags("</em>")
);
// 3.发送请求
SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 4.解析结果
parseSearchResponse(searchResponse);
}
private static void parseSearchResponse(SearchResponse searchResponse) {
SearchHits searchHits = searchResponse.getHits();
// 4.1 获取总记录数
long totalHits = searchHits.getTotalHits().value;
System.err.println("总记录数 = " + totalHits);
// 4.2 获取命中的数据
for (SearchHit hit : searchHits) {
// 4.2.1 获取 source 结果
String sourceAsString = hit.getSourceAsString();
// 4.2.2 转换为 ItemDocument 对象
ItemDocument itemDocument = JSON.parseObject(sourceAsString, ItemDocument.class);
// 4.3 处理高亮结果
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
if (highlightFields != null && !highlightFields.isEmpty()) {
HighlightField highlightField = highlightFields.get("name");
String name = highlightField.getFragments()[0].toString();
itemDocument.setName(name);
}
System.err.println("itemDocument = " + itemDocument);
}
}
16. 数据聚合
ElasticSearch 不仅可以做数据的存储和搜索,还可以做海量数据的分析和运算,这种分析和运算的功能,被称为数据聚合
16.1 聚合的分类
聚合(aggregations)可以实现对文档数据的统计、分析、运算,常见的聚合有三类:
- 桶(Bucket)聚合:用来对文档做分组
- TermAggregation:按照文档字段值分组
- Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组
- 度量(Metric)聚合:用以计算一些值,比如最大值、最小值、平均值等
- Avg:求平均值
- Max:求最大值
- Min:求最小值
- Stats:同时求 max、min、avg、sum 等
- 管道(pipeline)聚合:以其它聚合的结果为基础做聚合
注意事项:
参与聚合的字段必须是不能分词的字段,例如 keyword、数值、日期、布尔等类型的字段
16.2 DSL 实现聚合
16.2.1 无条件的聚合
我们来做一个小案例:统计所有商品中共有哪些商品分类,其实就是以分类(category)字段对数据分组,category 值一样的放在同一组,属于 Bucket 聚合中的 Term 聚合
DSL 的语法如下
示例
GET /shopping_mall/_search
{
"query": {
"match_all": {}
},
"size": 0,
"aggs": {
"categoryAggregation": {
"terms": {
"field": "category",
"size": 20
}
}
}
}
返回的结果
{
"took" : 38,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 10000,
"relation" : "gte"
},
"max_score" : null,
"hits" : [ ]
},
"aggregations" : {
"categoryAggregation" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : "休闲鞋",
"doc_count" : 20612
},
{
"key" : "牛仔裤",
"doc_count" : 19611
},
{
"key" : "老花镜",
"doc_count" : 16222
},
{
"key" : "拉杆箱",
"doc_count" : 14347
},
{
"key" : "手机",
"doc_count" : 10101
},
{
"key" : "真皮包",
"doc_count" : 3064
},
{
"key" : "拉拉裤",
"doc_count" : 1706
},
{
"key" : "牛奶",
"doc_count" : 1296
},
{
"key" : "曲面电视",
"doc_count" : 1219
},
{
"key" : "硬盘",
"doc_count" : 298
}
]
}
}
}
16.2.2 有条件的聚合
默认情况下,Bucket 聚合是对索引库的所有文档做聚合,我们可以限定要聚合的文档范围,只要添加 query 条件即可
例如,我想知道价格高于 3000 元的手机品牌有哪些
示例
GET /shopping_mall/_search
{
"query": {
"bool": {
"filter": [
{
"term": {
"category": "手机"
}
},
{
"range": {
"price": {
"gt": 300000
}
}
}
]
}
},
"size": 0,
"aggs": {
"brandAggregation": {
"terms": {
"field": "brand",
"size": 20
}
}
}
}
16.2.3 度量聚合
除了对数据分组(Bucket)以外,我们还可以对每个 Bucket 内的数据进一步做数据计算和统计
例如:我想知道手机有哪些品牌,每个品牌的价格最小值、最大值、平均值
GET /shopping_mall/_search
{
"query": {
"term": {
"category": "手机"
}
},
"size": 0,
"aggs": {
"brandAggregation": {
"terms": {
"field": "brand",
"size": 20
},
"aggs": {
"priceStatistics": {
"stats": {
"field": "price"
}
}
}
}
}
}
16.3 Java 客户端实现聚合
聚合三要素:
- 聚合类型
- 聚合名称
- 聚合字段
我们以查询品牌为例
具体的 Java 代码
Terms 类所在的包为
org.elasticsearch.search.aggregations.bucket.terms.Terms
@Test
public void testAggregation() throws IOException {
// 1.创建 SearchRequest 对象
SearchRequest searchRequest = new SearchRequest("shopping_mall");
// 2.组织 DSL 参数
// 2.1 分页
searchRequest.source().size(0);
// 2.2 聚合条件
String brandAggregationName = "brandAggregationName";
searchRequest.source().aggregation(
AggregationBuilders.terms(brandAggregationName).field("brand").size(20)
);
// 3.发送请求
SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 4.解析结果
Aggregations aggregations = searchResponse.getAggregations();
// 4.1 根据聚合名称获取对应的聚合结果
Terms brandTerms = aggregations.get(brandAggregationName);
// 4.2 获取buckets
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
// 4.3 遍历获取每一个bucket
for (Terms.Bucket bucket : buckets) {
System.out.println("brand = " + bucket.getKeyAsString());
System.out.println("docCount = " + bucket.getDocCount());
}
}
注意事项:
- aggregations.get(brandAggregationName) 方法返回的是顶层的 Aggregation 接口,由于 aggregations.get(String name) 是一个统一的 API ,也就是说,设计者在设计这个 API 的时候不知道使用者会使用哪种聚合,所以使用顶层的 Aggregation 接口来接收
- Aggregation 接口有很多的子子孙孙,代表不同类型的聚合,我们使用的 Terms 接口实现了 Aggregation 接口,Terms 接口中有获取桶的方法
- 不同类型的聚合,结果也大不相同,我们使用的是 term 分组聚合,所以才能得到桶,如果不是使用 term 分组聚合,结果中不会有桶,所以顶层接口中不会有获取桶的方法