ElasticSearch快速入门(安装ElasticSearch、IK分词器、索引库操作、文档操作、在Java代码中操作ElasticSearch、数据聚合)

文章目录

0. 前言

搜索引擎技术在我们生活中的很多领域都有应用,例如商品搜索页、在 GitHub 上搜索项目、百度搜索等

虽然 MySQL 等数据库也可以实现搜索功能,但 MySQL 的搜索功能是基于模糊搜索来实现的,模糊搜索的性能比较低,如果数据量比较大,接口响应的速度就会变得很慢,用户的搜索体验就不是很好

ElasticSearch 是一个高性能的分布式的搜索引擎,而且 ElasticSearch 的搜索速度受数据量的影响比较小,也就是说,就算你的商品数量翻了几十倍、上百倍、甚至上千倍,利用 ElasticSearch 进行搜索的时间也不会受到很大影响,ElasticSearch 的响应速度非常快

当遇到大数据量搜索,而且还是模糊搜索时,不适合用传统的关系型数据库,更适合用搜索引擎,而且搜索引擎会对用户搜索的内容进行分析,为用户筛选出与搜索内容相关的数据(MySQL 的模糊匹配是严格匹配


搜索引擎技术排名:

  1. Elasticsearch:开源的分布式搜索引擎
  2. Splunk:商业项目
  3. Solr:Apache的开源搜索引擎

1.认识 ElasticSearch

1.1 Lucene

Lucene 是一个Java语言的搜索引擎类库(类库可以理解为一套 API 工具包),是 Apache 公司的顶级项目,由 DougCutting 于 1999 年研发

Lucene 的官网地址:Lucene


Lucene 的优势:

  1. 易扩展
  2. 高性能(基于倒排索引)

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 具备下列优势:

  1. 支持分布式,可水平扩展
  2. 提供 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 端口

  1. 如果你使用的是云服务器,在安全组中放行 9200 端口
  2. 如果你安装了宝塔,除了在安全组中放行 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 端口

  1. 如果你使用的是云服务器,在安全组中放行 5601 端口
  2. 如果你安装了宝塔,除了在安全组中放行 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 分词器有两种模式:

  1. ik_smart:在 ik_smart 模式下,IK 分词器会尽可能地给出最合理的分词方案,减少不必要的冗余词汇,并尝试理解上下文以提高分词准确性
  2. 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 修改文档有两种方式:

  1. 全量修改:删除旧文档,添加新文档(可以理解为覆盖原有的文档)
  2. 增量修改:修改指定字段值
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 修改文档

修改文档数据有两种方式:

  1. 方式一:全量更新,再次写入 id 一样的文档,就会制除旧文档,添加新文档,与新增的 Java API 一致(新增时返回的结果为 created ,全量更新时返回的结果为 updated)
  2. 方式二:局部更新,只更新指定部分字段

局部更新的 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、城市、地名、人名等作为一个整体才有含义的字段


精确查询主要有三种:

  1. term 查询
  2. range 查询
  3. 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 名是谁,整体的思路有两步:

  1. 对数据进行排序
  2. 找出 [991, 1000] 名

那前 1000 个如何找呢(假如现在有四个分片),是不是我每个分片找 250 个就行了呢?实际上不能这么操作,因为数据是混乱地保存在不同的分片上的,不能保证每个分片的前 250 名加起来就是总的前 1000 名

举个通俗的例子,学校有 10 个班级,现在要找到年级前十名,是选出每个班的第一名就可以了吗,显然不是的,因为每个班级中每个学生都有学得好的和学得差的,班级的整体水平不一样,有可能某个班级的第一名在另一个班级中是垫底的

那我要怎么找到年级的前十名呢,最简单的做法就是对所有学生的成绩进行统计,找出前十名,但这种做法的效率可能不是很高,其实我们只需要找出每个班级的前十名,接着将每个班级前十名的学生汇总在一起,再找出这些汇总学生的前十名即可(就算是最极端的情况,即年级前十名都在同一个班级,也能正确地找出年级前十名)

ElasticSearch 的分页也是基于这个思想,要找出前 1000 名的数据,需要从每个分片中取出前 1000 名的数据,汇总后进行排序,找到真正的前 1000 名的数据

在这里插入图片描述

在这里插入图片描述

其实这种做法在数据量较大的情况下也有一定的问题,比如说我要查第 1 万页的数据,每页查 10 条,也就是要找前 10 万条数据,再找出排名在 [99990, 100000] 之间的数据

根据上述思想,就需要从每个分片中分别取出前 10 万条数据,汇总在一起,再筛选出真正的排名在 [99900, 100000] 之间的数据,这个数据量是非常恐怖的,很有可能导致内存直接炸裂


针对深度分页,Elastic Search 提供了两种解决方案(官方文档:search after):

  1. search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据(官方推荐使用的方式)
  2. 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 代码我们分为两部分:

  1. 构建请求并发起请求
  2. 解析查询结果

在这里插入图片描述

在这里插入图片描述

具体的 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 客户端实现聚合

聚合三要素:

  1. 聚合类型
  2. 聚合名称
  3. 聚合字段

我们以查询品牌为例

在这里插入图片描述

在这里插入图片描述

具体的 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 分组聚合,结果中不会有桶,所以顶层接口中不会有获取桶的方法

在这里插入图片描述

  • 12
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

m0_62128476

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值