【ElasticSearch】ElasticSearch

image-20211214223034124

简介

https://blog.csdn.net/u011863024/article/details/115721328

The Elastic Stack,包括 Elasticsearch、 Kibana、 Beats 和 Logstash(也称为 ELK Stack)。能够安全可靠地获取任何来源、任何格式的数据,然后实时地对数据进行搜索、分析和可视化。

Elaticsearch,简称为 ES, 是一个开源的高扩展的分布式全文搜索引擎, 是整个 ElasticStack 技术栈的核心,是一个可以用于检索存储分析的引擎。它可以近乎实时的存储、检索数据;本身扩展性很好,可以扩展到上百台服务器,处理 PB 级别的数据。

全文搜索引擎

Google,百度类的网站搜索,它们都是根据网页中的关键字生成索引,我们在搜索的时候输入关键字,它们会将该关键字即索引匹配到的所有网页返回;还有常见的项目中应用日志的搜索等等。对于这些非结构化的数据文本,关系型数据库搜索不是能很好的支持。

一般传统数据库,全文检索都实现的很鸡肋,因为一般也没人用数据库存文本字段。进行全文检索需要扫描整个表,如果数据量大的话即使对 SQL 的语法优化,也收效甚微。建立了索引,但是维护起来也很麻烦,对于 insert 和 update 操作都会重新构建索引。

基于以上原因可以分析得出,在一些生产环境中,使用常规的搜索方式,性能是非常差的:

  • 搜索的数据对象是大量的非结构化的文本数据
  • 文件记录量达到数十万或数百万个甚至更多。
  • 支持大量基于交互式文本的查询
  • 需求非常灵活的全文搜索查询
  • 对高度相关的搜索结果的有特殊需求,但是没有可用的关系数据库可以满足
  • 对不同记录类型、非文本数据操作或安全事务处理的需求相对较少的情况。为了解决结构化数据搜索和非结构化数据搜索性能问题,我们就需要专业,健壮,强大的全文搜索引擎

这里说到的全文搜索引擎指的是目前广泛应用的主流搜索引擎。它的工作原理是计算机索引程序通过扫描文章中的每一个词,对每一个词建立一个索引,指明该词在文章中出现的次数和位置,当用户查询时,检索程序就根据事先建立的索引进行查找,并将查找的结果反馈给用户的检索方式。这个过程类似于通过字典中的检索字表查字的过程(倒排索引)。

ELK

ELK 是 Elasticsearch、Logstash、 Kibana 三大开源框架首字母大写简称。市面上也被称为 Elastic Stack。

  • 其中 Elasticsearch 是一个基于 Lucene、分布式、通过 Restful 方式进行交互的近实时搜索平台框架。像百度、谷歌这种大数据全文搜索引擎的场景都可以使用 Elasticsearch 作为底层支持框架,可见 Elasticsearch 提供的搜索能力确实强大,市面上很多时候我们简称 Elasticsearch 为ES。
  • Logstash 是 ELK 的中央数据流引擎,用于从不同目标(文件/数据存储/MQ)收集的不同格式数据,经过过滤后支持输出到不同目的地(文件/MQ/redis/elasticsearch/kafka等)。
  • Kibana 可以将 ElasticSearch 的数据通过友好的页面展示出来,提供实时分析的功能。

市面上很多开发只要提到 ELK 能够一致说出它是一个日志分析架构技术栈总称,但实际上 ELK 不仅仅适用于日志分析,它还可以支持其它任何数据分析和收集的场景,日志分析和收集只是更具有代表性,并非唯一性。

收集清洗数据(Logstash) ==> 搜索、存储(ElasticSearch) ==> 展示(Kibana)

img

Elasticsearch 应用案例

  • GitHub:2013 年初,抛弃了 Solr,采取 Elasticsearch 来做 PB 级的搜索。 “GitHub 使用Elasticsearch 搜索 20TB 的数据,包括 13 亿文件和 1300 亿行代码”。
  • 维基百科:启动以 Elasticsearch 为基础的核心搜索架构
  • 百度:目前广泛使用 Elasticsearch 作为文本数据分析,采集百度所有服务器上的各类指标数据及用户自定义数据,通过对各种数据进行多维分析展示,辅助定位分析实例异常或业务层面异常。目前覆盖百度内部 20 多个业务线(包括云分析、网盟、预测、文库、直达号、钱包、 风控等),单集群最大 100 台机器, 200 个 ES 节点,每天导入 30TB+数据。
  • 新浪:使用 Elasticsearch 分析处理 32 亿条实时日志。
  • 阿里:使用 Elasticsearch 构建日志采集和分析体系。
  • Stack Overflow:解决 Bug 问题的网站,全英文,编程人员交流的网站。

Lucene

  • apache软件基金会 4 jakarta 项目组的一个子项目
  • 是一个开放源代码的全文检索引擎工具包
  • 不是一个完整的全文检索引擎,而是一个全文检索引擎的架构,提供了完整的查询引擎和索引引擎,部分文本分析引擎(英文与德文两种西方语言)
  • 当前以及最近几年最受欢迎的免费 Java 信息检索程序库

Lucene 和 ElasticSearch 的关系:ElasticSearch 基于 Lucene 做了封装和增强。ES 使用 Java 开发并使用 Lucene 作为其核心来实现所有索引和搜索的功能,但是它的目的是通过简单的 RESTful API 来隐藏 Lucene 的复杂性,从而让全文搜索变得简单。

Solr 简介

  • Solr 是 Apache 下的一个顶级开源项目,采用 Java 开发,它是基于Lucene的全文搜索服务器。Solr 提供了比 Lucene 更为丰富的查询语言,同时实现了可配置可扩展,并对索引、搜索性能进行了优化
  • Solr 可以独立运行,运行在 letty,Tomcat 等这些 Selrvlet 容器中,Solr 索引的实现方法很简单,用 POST 方法向 Solr 服务器发送一个描述 Field 及其内容的 XML 文档,Solr根据 XML 文档添加、删除、更新索引。Solr 搜索只需要发送HTTP GET请求,然后对 Solr 返回 XML、JSON 等格式的查询结果进行解析,组织页面布局
  • Solr 不提供构建 UI 的功能,Solr提供了一个管理界面,通过管理界面可以查询 Solr 的配置和运行情况
  • Solr 是基于 Lucene 开发企业级搜索服务器,实际上就是封装了lucene.
  • Solr 是一个独立的企业级搜索应用服务器,它对外提供类似于 Web-service 的 API 接口。用户可以通过 HTTP 请求,向搜索引擎服务器提交指定格式的文件,生成索引。也可以通过提出查找请求,并得到返回结果

ES 和 Solr

https://www.kuangstudy.com/bbs/1354069127022583809

  • ElasticSearch 是一个实时分布式搜索和分析引擎,它让你以前所未有的速度处理大数据成为可能。
  • 它用于全文搜索、结构化搜索、分析以及将这三者混合使用:
    • 维基百科使用 ElasticSearch 提供全文搜索高亮关键字,以及输入实时搜索(search-asyou-type)和搜索纠错(did-you-mean)等搜索建议功能。
    • 英国卫报使用 ElasticSearch 结合用户日志和社交网络数据提供给他们的编辑以实时的反馈,以便及时了解公众对新发表的文章的回应。
    • StackOverflow 结合全文搜索与地理位置查询,以及 more-like-this 功能来找到相关的问题和答案。
    • Github 使用 ElasticSearch 检索 1300 亿行的代码。
  • ElasticSearch 是一个基于 Apache Lucene 的开源搜索引擎。无论在开源还是专有领域,Lucene可被认为是迄今为止最先进、性能最好的、功能最全的搜索引擎库。但是,Lucene 只是一个库。 想要使用它,你必须使用 Java 来作为开发语言并将其直接集成到你的应用中。更糟糕的是,Lucene非常复杂,你需要深入了解检索的相关知识来理解它是如何工作的。
  • ElasticSearch 也使用 Java 开发并使用 Lucene 作为其核心来实现所有引和搜索的功能,但是它的目的是通过简单的 RESTful API 来隐藏 Lucene 的复杂性,从而让全文搜索变得简单。

ElasticSearch 与 Solr 比较:

  • 当单纯的对已有数据进行搜索时,Solr 更快
  • 当实时建立索引时,Solr 会产生 io 阻塞,查询性能较差,ElasticSearch 具有明显的优势
  • 随着数据量的增加,Solr 的搜索效率会变得更低,而 ElasticSearch 却没有明显的变化

总结

  • Solr 利用 Zookeeper 进行分布式管理,而 ElasticSearch 自身带有分布式协调管理功能
  • Solr 支持更多格式的数据,比如JSON/XML/CSV,而 Elasticsearch 仅支持 JSON 文件格式
  • Solr 官方提供的功能更多,而Elasticsearch本身更注重于核心功能,高级功能多有第三方插件提供,例如图形化界面需要 kibana 友好支撑
  • Solr 查询快,但更新索引时慢(即插入删除慢) ,用于电商等查询多的应用。ES 建立索引快(实时性查询快),用于 facebook,新浪等搜索。
  • Solr是传统搜索应用的有力解决方案,但 Elasticsearch 更适用于新兴的实时搜索应用。
  • Solr 比较成熟,有一个更大,更成熟的用户、开发和贡献者社区,而Elasticsearch相对开发维护者较少,更新太快,学习使用成本较高。

环境搭建

在 Docker 下快速安装 ElasticSearch 7.4.2:

  1. 下载镜像文件
1
2
3
$ docker pull elasticsearch:7.4.2

$ docker pull kibana:7.4.2
  1. 配置
1
2
3
4
5
$ mkdir -p /mydata/elasticsearch/config  # 存放配置文件
$ mkdir -p /mydata/elasticsearch/data # 存放数据
$ mkdir -p /mydata/elasticsearch/plugins # 存放插件
$ echo "http.host: 0.0.0.0" >/mydata/elasticsearch/config/elasticsearch.yml # 允许任何机器访问
$ chmod -R 777 /mydata/elasticsearch/ ## 设置elasticsearch文件可读写权限,否则将无法启动
  1. 启动(9300 端口为 Elasticsearch 集群间组件的通信端口, 9200 端口为浏览器访问的 HTTP 请求端口)
1
2
3
4
5
6
7
$ docker run --name elasticsearch -p 9200:9200 -p 9300:9300 \
-e "discovery.type=single-node" \
-e ES_JAVA_OPTS="-Xms64m -Xmx512m" \
-v /mydata/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \
-v /mydata/elasticsearch/data:/usr/share/elasticsearch/data \
-v /mydata/elasticsearch/plugins:/usr/share/elasticsearch/plugins \
-d elasticsearch:7.4.2
  1. 设置开启启动
1
$ docker update elasticsearch --restart=always
  1. 启动 Kibana
1
2
$ docker run --name kibana -e ELASTICSEARCH_HOSTS=http://yuyunzhao.cn:9200 -p 5601:5601 -d kibana:7.4.2
# http://xxx:9200 改成自己 Elasticsearch 的地址

在浏览器访问 5601 端口即可进入到界面

核心概念

Elasticsearch 是面向文档型数据库,一条数据在这里就是一个文档。将 Elasticsearch 里存储文档数据和关系型数据库 MySQL 存储数据的概念进行一个类比:

image-20220104162038740

ES 里的 Index 可以看做一个库,而 Types 相当于表, Documents 则相当于表的行。这里 Types 的概念已经被逐渐弱化, Elasticsearch 6.X 中,一个 index 下已经只能包含一个type, Elasticsearch 7.X 的一些版本中,Type 的概念已经被删除了。

物理设计

ElasticSearch 在后台把每个索引划分成多个分片,每分分片可以在集群中的不同服务器间迁移。一个人就是一个集群,即启动的 ElasticSearch 服务,默认就是一个集群,且默认集群名为ElasticSearch

逻辑设计

一个索引类型中,包含多个文档,比如说文档1,文档2。当我们索引一篇文档时,可以通过这样的顺序找到它:索引 => 类型 => 文档ID ,通过这个组合我们就能索引到某个具体的文档。 注意:ID不必是整数,实际上它是个字符串。

文档(“行”)

之前说 ElasticSearch 是面向文档的,那么就意味着索引和搜索数据的最小单位是文档,ElasticSearch中,文档有几个重要属性:

  • 自我包含,一篇文档同时包含字段和对应的值,也就是同时包含key:value
  • 可以是层次型的,一个文档中包含自文档,复杂的逻辑实体就是这么来的
  • 灵活的结构,文档不依赖预先定义的模式,我们知道关系型数据库中,要提前定义字段才能使用,在 ElasticSearch 中,对于字段是非常灵活的,有时候,我们可以忽略该字段,或者动态的添加一个新的字段。

尽管我们可以随意新增或者忽略某个字段,但是,每个字段的类型非常重要,比如一个年龄字段类型,可以是字符串也可以是整型。因为 ElasticSearch 会保存字段和类型之间的映射及其他的设置。这种映射具体到每个映射的每种类型,这也是为什么在 ElasticSearch 中,类型有时候也称为映射类型。

类型(“表”)

类型是文档的逻辑容器,就像关系型数据库一样,表格是行的容器。类型中对于字段的定义称为映射,比如 name 映射为字符串类型。我们说文档是无模式的,它们不需要拥有映射中所定义的所有字段,比如新增一个字段,那么 ElasticSearch 是怎么做的呢?

ElasticSearch 会自动将新字段加入映射,但是这个字段的不确定它是什么类型,ElasticSearch 就开始猜,如果这个值是18,那么 EasticSearch 会认为它是整型。但是 ElasticSearch 也可能猜不对,所以最安全的方式就是提前定义好所需要的映射,这点跟关系型数据库殊途同归了,先定义好字段,然后再使用。

索引(“库”)

索引是映射类型的容器, ElasticSearch 中的索引是一个非常大的文档集合。 索引存储了映射类型的字段和其他设置。然后它们被存储到了各个分片上了。

分片

一个集群至少有一个节点,而一个节点就是一个 ElasticSearch 进程,节点可以有多个索引默认的,如果你创建索引,那么索引将会有个 5 个分片(primary shard,又称主分片)构成的,每一个主分片会有一个副本(replica shard,又称复制分片)

img

上图是一个有3个节点的集群,可以看到主分片和对应的复制分片都不会在同一个节点内,这样有利于某个节点挂掉了,数据也不至于失。实际上,一个分片是一个Lucene索引(一个ElasticSearch索引包含多个Lucene索引一个包含倒排索引的文件目录,倒排索引的结构使得elasticsearch在不扫描全部文档的情况下,就能告诉你哪些文档包含特定的关键字。不过,等等,倒排索引是什么鬼?

倒排索引

正排索引(传统):

id content
1001 my name is zhang san
1002 my name is li si

倒排索引(将一条语句按照关键字进行分词拆分,保存每个关键字的 id):

keyword id
name 1001, 1002
zhang 1001

更多高级原理见博客:https://blog.csdn.net/u011863024/article/details/115721328

基础增删改查

对比关系型数据库,创建索引就等同于创建数据库。四种类型的 RESTful 请求概览:

method URL 描述
PUT(创建,修改) localhost:9200/索引名称/类型名称/文档id 创建文档(指定文档id)
POST(创建) localhost:9200/索引名称/类型名称 创建文档(随机文档id)
POST(修改) localhost:9200/索引名称/类型名称/文档id 修改文档
POST(修改) localhost:9200/索引名称/类型名称/文档id/_update 修改文档(会进行数据对比)
DELETE(删除) localhost:9200/索引名称/类型名称/文档id 删除文档
GET(查询) localhost:9200/索引名称/类型名称/文档id 通过文档ID查询
GET(查询) localhost:9200/索引名称/类型名称/_search 条件查询

创建文档

创建文档有两种方式:

  • PUT 请求幂等性操作。必须指定文档 id(必须明确知道要操作的对象);如果该文档不存在,就创建该文档;如果文档已经存在,就直接整个替换文档内容(此时为修改请求)。
  • POST 请求非幂等性操作。可以不指定文档 id(也可以指定);如果不指定 id,则新增数据时服务器自动为该文档创建一个 id;如果指定 id,则以该 id 创建文档(如果文档已存在,则 POST 修改请求会修改目标对象的部分内容

PUT 和 POST 幂等性的讨论

  • PUT:幂等性操作。因为想发出 PUT 请求时必须指定文档 id(必须明确要操作的对象),那么无论发出多少次请求,始终都是在操作该对象,对其内容进行修改,并不会导致创建重复的该对象。
  • POST:非幂等性操作。因为 POST 请求可以不指定文档 id,这样在发出多次相同的 POST 请求时,如果不指定 id,服务器会创建多个重复的对象(内容相同,但 id 都是服务器随机生成的)

https://cloud.tencent.com/developer/news/39873

总结:使用 PUT 时,必须明确知道要操作的对象,如果对象不存在,创建对象;如果对象存在,则全部替换目标对象。同样 POST 既可以创建对象,也可以修改对象。但用 POST 创建对象时,之前并不知道要操作的对象,由 HTTP 服务器为新创建的对象生成一个唯一的 URI;使用 POST 修改已存在的对象时,一般只是修改目标对象的部分内容

查询文档

查询数据通常用 GET 请求,同时可以选择是否指定文档 id:

  • 如果指定文档 id,类似于 MySQL 中以指定主键 id 的方式(主键查询)来查询数据,只会查出指定 id 的文档数据。
  • 如果不指定文档 id,则需要在 URL 里添加 _search 字段,表明进行条件查询(类似于 MySQL 里的 WHERE), 同时需要在请求体里添加条件查询的条件。

示例:http://localhost:9200/custom/external/1

1
2
3
4
5
6
7
8
9
10
11
12
{
"_index": "customer", // 在哪个索引
"_type": "external", // 在哪个类型
"_id": "1", // 文档 id
"_version": 1, // 版本号,代表该文档被修改了几次
"_seq_no": 0, // 并发控制字段,每次更新就会+1,用来做乐观锁
"_primary_term": 1, //同上,主分片重新分配,如重启,就会变化
"found": true,
"_source": {
"name": "John Doe" // 真正的内容
}
}

修改文档

修改文档有两种方式:

  • PUT 请求全量更新。必须指定所有字段的值,否则漏写的字段将被覆盖为默认值。并且每次修改后,_version 字段的值都会加一。
  • POST 请求局部更新。漏写的字段不会被覆盖。只会更新 POST 请求体里携带的字段,没有指定的字段的值不会被改变。如果 URL 里指定了 _update,则此时的 POST 请求会先进行一次检查,判断原数据的值是否和要更新的值相等:
    • 如果相等,则不执行修改操作,_version 字段的值不会增加
    • 如果不相等,才会执行修改操作,_version 字段的值加一

不论是 PUT 请求还是 POST 请求,只要 URL 里不指定 _update,就不会执行重复校验。只有 POST 请求中指定了 _update,才会进行重复校验

删除文档

删除文档只能使用 DELETE 请求,并明确指定文档 id。

bulk 批量操作

在请求中添加 _bulk 关键字可以进行批量操作:

1
2
3
4
5
6
7
POST customer/external/_bulk
# 两行是一条文档
{"index":{"_id":"1"}}
{"name":"John Doe"}
# 两行是一条文档
{"index":{"_id":"2"}}
{"name":"John Doe"}

语法格式

1
2
3
4
5
6
# 两行是一条文档
{action:{metadata}}
{requeestBody}
# 两行是一条文档
{action:{metadata}}
{requesetbod }
  • index 是新建索引,会覆盖文档;
  • create 是新建文档,不会覆盖文档

复杂实例:

1
2
3
4
5
6
7
8
POST /_bulk
{"delete":{"_index":"website","_type":"blog","_id":"123"}}
{"create":{"_index":"website","_type":"blog","_id":"123"}}
{"title":"my first blog post"}
{"index":{"_index":"website","_type":"blog"}}
{"title":"my second blog post"}
{"update":{"_index":"website","_type":"blog","_id":"123"}}
{"doc":{"title":"my updated blog post"}}

bulk API 以此按顺序执行所有的action (动作)。如果某个单个的动作因任何原因而失败,它将继续处理它后面剩余的动作。当 bulkAPI 返回时,它将提供每个动作的状态(与发送的顺序相同),可以借此检查一个指定的动作是否失败了。

_cat

在请求中添加 _cat 字段,可以用于查看 ES 服务器中的一些信息,例如:

  • GET /_cat/nodes:查看所有节点
  • GET /_cat/health:查看 es 健康状况
  • GET /_cat/master:查看主节点
  • GET /_cat/incices:查看所有索引 show databases

其他表头:

表头 含义
health 当前服务器健康状态: green(集群完整) yellow(单点正常、集群不完整) red(单点不正常)
status 索引打开、关闭状态
index 索引名
uuid 索引统一编号
pri 主分片数量
rep 副本数量
docs.count 可用文档数量
docs.deleted 文档删除状态(逻辑删除)
store.size 主分片和副分片整体占空间大小
pri.store.size 主分片占空间大小

复杂查询

前面介绍到,在请求的 URL 中添加 _search 字段,表明进行条件查询(类似于 MySQL 里的 WHERE), 同时需要在请求体里添加条件查询的条件。

常用参数:

  • query:类似于 where,在其内添加各种匹配规则以实现条件查询
  • _source:过滤字段
  • sort:排序
  • formsize 分页

img

匹配

匹配查询分为以下几种:

  • match:针对 text 文本类型的文档
    • 如果字段类型是数字类型,则会精确匹配,只匹配出等于该值的文档
    • 如果字段类型是字符串类型,则会模糊匹配,先使用分词器解析分析文档,然后进行查询。分词器会将该字符串拆分成多个单词,从而匹配出包含这些单词中的任何一个的文档,并再按照评分 score (包含这些单词的比例)对匹配到的文档进行排序
    • 如果某个字段上加了 keyword(例如 name.keyword),就代表该字段查询时不进行分词,要精确匹配
  • match_all:查询所有,不做条件匹配
  • match_phrase:将需要匹配的值当成一个整体单词(不分词)进行检索
  • multi_match:同时设置多个条件,需要同时满足这些条件才算匹配
  • term:针对非文本类型的文档(例如 number/date/keyword),精确匹配具体数值,不进行分词,接通过倒排索引指定词条查询。
  • terms:针对对非文本类型的文档,可以制定多个值,表示精确匹配这些值中的任意一个(或逻辑)

textkeyword 的区别:

  • text
    • 支持分词全文检索、支持模糊、精确查询,不支持聚合、排序操作;
    • text 类型的最大支持的字符长度无限制,适合大字段存储;
  • keyword
    • 不进行分词直接索引、支持模糊、支持精确匹配,支持聚合、排序操作。
    • keyword类型的最大支持的长度为——32766个UTF-8类型的字符,可以通过设置ignore_above 指定自持字符长度,超过给定长度后的数据将不被索引,无法通过term精确匹配检索返回结果

复合查询

复合语句可以合并任何其他嵌套语句,包括复合语句,了解这一点是很重要的,这就意味着,复合语句之间可以互相嵌套,可以表达式非常复杂的逻辑

多条件查询bool),其都需要写在 query 字段内,都属于条件查询:

  • must:必须达到 must列举的所有条件
  • should:应该达到 should列举的条件,如果达到会增加相关文档的评分,并不会改变查询的结果,如果 query 中只有 should 且只有一种匹配规则,那么 should 的条件就会被作为默认匹配条件而去改变查询结果。should 是加分项不满足也能查出来。只是如果满足会加分
  • must_not:必须不是指定的情况。must_not 不会额外贡献得分,但是其是一个 filter,不满足直接不显示
  • filter:条件过滤。效果和 must 类似,也能检索出目标记录,但是不会记录相关性得分,不满足 filter 的直接过滤,满足的留下。且满足的也不会额外增加相关性得分filter 因为不涉及计算评分,因此查询效率更高

img

上图中的相当于 and 不正确

高亮查询

可以添加高亮查询参数,使得匹配到的分词添加一段高亮标签 <em>xxxx</em>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 高亮查询
GET blog/user/_search
{
"query": {
"match": {
"name":"流"
}
}
,
"highlight": {
"fields": {
"name": {} // 设置需要高亮的字段,该例中将返回 <em>流</em>
}
}
}

自定义前缀和后缀:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET blog/user/_search
{
"query": {
"match": {
"name":"流"
}
}
,
"highlight": {
"pre_tags": "<p class='key' style='color:red'>",
"post_tags": "</p>",
"fields": {
"name": {}
}
}
}

img

聚合查询

聚合查询(aggregations)允许使用者对 ES 文档进行统计分析,类似于关系型数据库中的 group by,当然还有很多其他的聚合,例如取最大值 max、平均值 avg 等等。使用格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET bank/_search
{
"query": {
"match": {
"address": "mill"
}
},
"aggs":{ // 聚合操作
"price_group":{ // 名称,随意起名
"terms":{ // 聚合类型
"field":"price" // 聚合字段
}
}
}
}

示例一:搜索 address 中包含 mill 的所有人的年龄分布以及平均年龄

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
GET bank/_search
{
"query": {
"match": {
"address": "mill"
}
},
"aggs": {
"ageAgg": {
"terms": {
"field": "age",
"size": 10
}
},
"ageAvg":{
"avg": {
"field": "age"
}
},
"balanceAvg":{
"avg": {
"field": "balance"
}
}

},
"size":0
}

示例二:按照年龄聚合,并且请求这些年龄段的这些人的平均薪资

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
GET bank/_search
{
"query":{
"match_all": {}
},
"aggs": {
"ageAgg": {
"terms": {
"field": "age",
"size": 10
},
"aggs": {
"ageAvg": {
"avg": {
"field": "balance"
}
}
}
}
}
}

示例三:查出所有年龄分布,并且这些年龄段中M的平均薪资和 F 的平均薪资以及这个年龄段的总体平均薪资

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
GET /bank/_search
{
"query": {
"match_all": {}
},
"aggs": {
"ageAgg": {
"terms": {
"field": "age",
"size": 100
},
"aggs": {
"genderAgg": {
"terms": {
"field": "gender.keyword",
"size": 10
},
"aggs": {
"balanceAvg": {
"avg": {
"field": "balance"
}
}
}
}
}
}
}
}

其中:

  • terms 用于数据统计(统计某个字段的数据分布情况)
  • avg 计算平均值

nested

nested 用于定义嵌入式对象

文档的内部对象需要定义为 nested 类型。这是因为,ES 内部的 Lucene 会将普通类型的对象进行扁平化处理。如果 Lucene 对文档进行了扁平化,则各个不同文档间的内部对象数据就会被存储在一起,就不符合实际存储情况了。例如,某个文档拥有一个内部数据 comments,其又有多个字段(name/comment/age等),那么被扁平化后的文档数据:

1
2
3
4
5
6
7
8
9
10
11
{
"title": [ invest, money ],
"body": [ as, investing, money, please, soon, start ],
"tags": [ invest, money ],
"published_on": [ 18 Oct 2017 ]
"comments.name": [ smith, john, william ],
"comments.comment": [ after, article, good, i, investing, nice, post, reading, started, this, very ],
"comments.age": [ 33, 34, 38 ],
"comments.rating": [ 7, 8, 9 ],
"comments.commented_on": [ 20 Nov 2017, 25 Nov 2017, 30 Nov 2017 ]
}

显然多个文档的 comments信息被混在一起了,就不符合实际存储情况了。

更多详细介绍见:https://elastic.blog.csdn.net/article/details/82950393

因此,当一个对象中又嵌套包含另一个对象时,需要将该对象类型设置为 nested。这样 ES 内部的 Lucene 就不会将这个对象进行扁平化处理。

使用 nested 的示例:将内部对象 attrs 设置为 nested 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PUT product
{
"mappings":{
"properties": {
"attrs": {
"type": "nested",
"properties": {
"attrId": {"type": "long"},
"attrName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"attrValue": {"type": "keyword" }
}
}
}
}
}

类型映射

Mapping(映射) 是用来定义一个文档(document)以及他所包含的属性(field)是如何存储索引的,比如使用 mapping 来定义的:

  • 哪些字符串属性应该被看做全文本属性(full text fields)
  • 那些属性包含数字,日期或者地理位置
  • 文档中的所有属性是能被索引(_all 配置)
  • 日期的格式
  • 自定义映射规则来执行动态添加属性

查看某个索引的所有类型信息:

1
GET indexName/_mapping

如果在创建索引时不显式指定每个字段的类型,则 ES 会根据字段的值自动推测该字段的类型。

创建映射

  1. 创建索引,指定映射(不设置值):
1
2
3
4
5
6
7
8
9
PUT /my_index
{
"mappings": {
"properties": {
"age": {"type": "integer"},
"email": {"type": "keyword"}
}
}
}
  1. 如果想在一个已存在的索引上添加新的字段映射:
1
2
3
4
5
6
7
8
9
PUT /my_index/_mapping
{
"properties":{
"employeeid":{
"type":"keyword",
"index":false
}
}
}

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
PUT product
{
"mappings":{
"properties":{
"skuId":{
"type":"long"
},
"spuId":{
"type":"keyword"
},
"skuTitle":{
"type":"text",
"analyzer": "ik_smart"
},
"skuPrice":{
"type":"keyword"
},
"skuImg":{
"type":"text",
"analyzer": "ik_smart"
},
"saleCount":{
"type":"long"
},
"hasStock":{
"type":"boolean"
},
"hotScore":{
"type":"long"
},
"brandId":{
"type":"long"
},
"catelogId":{
"type":"long"
},
"brandName":{
"type":"keyword",
"index": false, // 这个字段只能被查出来,不可用来被检索,不会建立倒排索引
"doc_values": false // 这个字段不能用来聚合。冗余存储的字段都这么设置,节省资源
},
"brandImg":{
"type":"keyword",
"index": false,
"doc_values": false // 这些字段仅用来展示,不做索引。冗余存储
},
"catalogName":{
"type":"keyword",
"index": false,
"doc_values": false
},
"attrs":{
"type":"nested", // 设置为嵌入式字段,不会被扁平化处理
"properties": {
"attrId":{
"type":"long"
},
"attrName":{
"type":"keyword",
"index":false,
"doc_values":false
},
"attrValue": {
"type":"keyword"
}
}
}
}
}
}

其中,一旦为 字段设置了 "doc_values": false,则其就不能用来进行聚合了。如果想要对该字段进行聚合,就不能设置为 false。

不同索引间的数据迁移

如果想将某个索引内的数据迁移到另一个索引内,则可以使用如下方法:

  1. 先创建新索引 new_twitter 的正确映射
  2. 然后进行迁移:
1
2
3
4
5
6
7
8
9
POST _reindex // [固定写法]
{
"source":{
"index":"twitter"
},
"dest":{
"index":"new_twitter"
}
}

如果想将旧索引的 type下的数据进行迁移,则方法为:

1
2
3
4
5
6
7
8
9
10
POST _reindex
{
"source": {
"index":"twitter",
"type":"tweet"
},
"dest":{
"index":"twweets"
}
}

更多细节参考:

Java High Level REST Client

Java 中的 ES 客户端有多种选择,具体见官方文档: https://www.elastic.co/guide/en/elasticsearch/client/index.html

本章将介绍 Java REST Client 的配置和使用。与 Spring Data 的整合配置与使用见博客:https://blog.csdn.net/u011863024/article/details/115721328

配置

  1. 导入依赖:
1
2
3
4
5
6
<!-- 必须指定版本,覆盖 spring-boot-dependencies 中的依赖版本 -->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.4.2</version>
</dependency>
  1. 配置类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@NoArgsConstructor
@Data
@Configuration
public class MallElasticSearchConfig {
/**
* 通用的设置项
*/
public static final RequestOptions COMMON_OPTIONS;
static {
RequestOptions.Builder builder = RequestOptions.DEFAULT.toBuilder();
COMMON_OPTIONS = builder.build();
}

/**
* 向容器中注入ES的客户端
*/
@Bean
public RestHighLevelClient esRestClient() {
RestClientBuilder builder = RestClient.builder(
new HttpHost("localhost", 9200, "http"));
RestHighLevelClient client = new RestHighLevelClient(builder);
return client;
}
}
  1. 注入 RestHighLevelClient
1
2
@Autowired
public RestHighLevelClient restHighLevelClient;

操作索引

  1. 创建索引
1
2
3
4
5
6
7
8
9
// 测试索引的创建, Request PUT liuyou_index
@Test
public void testCreateIndex() throws IOException {
CreateIndexRequest request = new CreateIndexRequest("liuyou_index");
CreateIndexResponse response = restHighLevelClient.indices().create(request, RequestOptions.DEFAULT);
System.out.println(response.isAcknowledged()); // 查看是否创建成功
System.out.println(response); // 查看返回对象
restHighLevelClient.close();
}
  1. 获取索引:
1
2
3
4
5
6
7
8
// 测试获取索引,并判断其是否存在
@Test
public void testIndexIsExists() throws IOException {
GetIndexRequest request = new GetIndexRequest("index");
boolean exists = restHighLevelClient.indices().exists(request, RequestOptions.DEFAULT);
System.out.println(exists); // 索引是否存在
restHighLevelClient.close();
}
  1. 删除索引:
1
2
3
4
5
6
7
8
// 测试索引删除
@Test
public void testDeleteIndex() throws IOException {
DeleteIndexRequest request = new DeleteIndexRequest("liuyou_index");
AcknowledgedResponse response = restHighLevelClient.indices().delete(request, RequestOptions.DEFAULT);
System.out.println(response.isAcknowledged());// 是否删除成功
restHighLevelClient.close();
}

操作文档

  1. 添加文档:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 测试添加文档(先创建一个User实体类,添加fastjson依赖)
@Test
public void testAddDocument() throws IOException {
// 创建一个User对象
User liuyou = new User("liuyou", 18);
// 创建请求
IndexRequest request = new IndexRequest("liuyou_index");
// 制定规则 PUT /liuyou_index/_doc/1
request.id("1");// 设置文档ID
request.timeout(TimeValue.timeValueMillis(1000));// request.timeout("1s")
// 将我们的数据放入请求中
request.source(JSON.toJSONString(liuyou), XContentType.JSON);
// 客户端发送请求,获取响应的结果
IndexResponse response = restHighLevelClient.index(request, RequestOptions.DEFAULT);
System.out.println(response.status());// 获取建立索引的状态信息 CREATED
System.out.println(response);// 查看返回内容 IndexResponse[index=liuyou_index,type=_doc,id=1,version=1,result=created,seqNo=0,primaryTerm=1,shards={"total":2,"successful":1,"failed":0}]
}
  1. 获取文档信息:
1
2
3
4
5
6
7
8
9
// 测试获得文档信息
@Test
public void testGetDocument() throws IOException {
GetRequest request = new GetRequest("liuyou_index","1");
GetResponse response = restHighLevelClient.get(request, RequestOptions.DEFAULT);
System.out.println(response.getSourceAsString()); // 打印文档内容
System.out.println(request); // 返回的全部内容和命令是一样的
restHighLevelClient.close();
}
  1. 获取文档并判断其是否存在:
1
2
3
4
5
6
7
8
9
10
// 获取文档,判断是否存在 get /liuyou_index/_doc/1
@Test
public void testDocumentIsExists() throws IOException {
GetRequest request = new GetRequest("liuyou_index", "1");
// 不获取返回的 _source的上下文了
request.fetchSourceContext(new FetchSourceContext(false));
request.storedFields("_none_");
boolean exists = restHighLevelClient.exists(request, RequestOptions.DEFAULT);
System.out.println(exists);
}
  1. 更新文档:
1
2
3
4
5
6
7
8
9
10
// 测试更新文档内容
@Test
public void testUpdateDocument() throws IOException {
UpdateRequest request = new UpdateRequest("liuyou_index", "1");
User user = new User("lmk",11);
request.doc(JSON.toJSONString(user),XContentType.JSON);
UpdateResponse response = restHighLevelClient.update(request, RequestOptions.DEFAULT);
System.out.println(response.status()); // OK
restHighLevelClient.close();
}
  1. 删除文档:
1
2
3
4
5
6
7
8
// 测试删除文档
@Test
public void testDeleteDocument() throws IOException {
DeleteRequest request = new DeleteRequest("liuyou_index", "1");
request.timeout("1s");
DeleteResponse response = restHighLevelClient.delete(request, RequestOptions.DEFAULT);
System.out.println(response.status());// OK
}
  1. 查询文档:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 查询
// SearchRequest 搜索请求
// SearchSourceBuilder 条件构造
// HighlightBuilder 高亮
// TermQueryBuilder 精确查询
// MatchAllQueryBuilder
// xxxQueryBuilder ...
@Test
public void testSearch() throws IOException {
// 1.创建查询请求对象
SearchRequest searchRequest = new SearchRequest();
// 2.构建搜索条件
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
// (1)查询条件 使用QueryBuilders工具类创建
// 精确查询
TermQueryBuilder termQueryBuilder = QueryBuilders.termQuery("name", "liuyou");
// // 匹配查询
// MatchAllQueryBuilder matchAllQueryBuilder = QueryBuilders.matchAllQuery();
// (2) 其他<可有可无>:(可以参考 SearchSourceBuilder 的字段部分)
// 设置高亮
searchSourceBuilder.highlighter(new HighlightBuilder());
// // 分页
// searchSourceBuilder.from();
// searchSourceBuilder.size();
searchSourceBuilder.timeout(new TimeValue(60, TimeUnit.SECONDS));
// (3)条件投入
searchSourceBuilder.query(termQueryBuilder);
// 3.添加条件到请求
searchRequest.source(searchSourceBuilder);
// 4.客户端查询请求
SearchResponse search = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 5.查看返回结果
SearchHits hits = search.getHits();
System.out.println(JSON.toJSONString(hits));
System.out.println("=======================");
for (SearchHit documentFields : hits.getHits()) {
System.out.println(documentFields.getSourceAsMap());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Test
public void searchData() throws IOException {
SearchRequest searchRequest = new SearchRequest();
// 1. 指定索引
searchRequest.indices("bank");
// 2. 指定DSL,检索条件
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
// 2.1 条件查询
sourceBuilder.query(QueryBuilders.matchQuery("address", "mill"));
// 2.2 按照年龄值分布进行聚合
TermsAggregationBuilder ageAgg = AggregationBuilders.terms("ageAgg").field("age").size(10);
sourceBuilder.aggregation(ageAgg);
// 2.3 按照平均薪资进行聚合
AvgAggregationBuilder balanceAvg = AggregationBuilders.avg("balanceAvg").field("balance");
sourceBuilder.aggregation(balanceAvg);
// 为查询请求设置建造器
searchRequest.source(sourceBuilder);

System.out.println("检索条件:" + sourceBuilder.toString());

// 3. 执行检索
SearchResponse searchResponse = client.search(searchRequest, MallElasticSearchConfig.COMMON_OPTIONS);
System.out.println(searchResponse.toString());

// 4. 拿到命中得结果
SearchHits hits = searchResponse.getHits();
// 5.搜索请求的匹配
SearchHit[] searchHits = hits.getHits();
// 6. 进行遍历
for (SearchHit hit : searchHits) {
// 7. 拿到完整结果字符串
String sourceAsString = hit.getSourceAsString();
// 8. 转换成实体类
Account accout = JSON.parseObject(sourceAsString, Account.class);
System.out.println("account:" + accout );
}

// 9. 拿到聚合
Aggregations aggregations = searchResponse.getAggregations();

// 10. 通过先前名字拿到对应聚合
Terms ageAgg1 = aggregations.get("ageAgg");
for (Terms.Bucket bucket : ageAgg1.getBuckets()) {
// 11、拿到结果
String keyAsString = bucket.getKeyAsString();
System.out.println("年龄:" + keyAsString);
long docCount = bucket.getDocCount();
System.out.println("个数:" + docCount);
}
Avg balanceAvg1 = aggregations.get("balanceAvg");
System.out.println("平均薪资:" + balanceAvg1.getValue());
System.out.println(searchResponse.toString());
}
  1. 批量保存文档:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void testBulk() throws IOException {
BulkRequest bulkRequest = new BulkRequest();
bulkRequest.timeout("10s");
ArrayList<User> users = new ArrayList<>();
users.add(new User("liuyou-1",1));
users.add(new User("liuyou-2",2));
users.add(new User("liuyou-3",3));
users.add(new User("liuyou-4",4));
users.add(new User("liuyou-5",5));
users.add(new User("liuyou-6",6));
// 批量请求处理
for (int i = 0; i < users.size(); i++) {
bulkRequest.add(
// 这里是数据信息
new IndexRequest("bulk")
.id(""+(i + 1)) // 没有设置id 会自定生成一个随机id
.source(JSON.toJSONString(users.get(i)),XContentType.JSON)
);
}
BulkResponse bulk = restHighLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT);
System.out.println(bulk.status());// ok
}

真实案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void testBulk() throws IOException {
BulkRequest bulkRequest = new BulkRequest();
for (SkuEsModel skuEsModel : skuEsModelList) {
//构造保存请求
IndexRequest indexRequest = new IndexRequest(EsConstant.PRODUCT_INDEX);
indexRequest.id(skuEsModel.getSkuId().toString());
String jsonString = JSON.toJSONString(skuEsModel);
indexRequest.source(jsonString, XContentType.JSON);
bulkRequest.add(indexRequest);
}
// 3. 批量保存
BulkResponse bulk = restHighLevelClient.bulk(bulkRequest, MallElasticSearchConfig.COMMON_OPTIONS);
}

分词

https://blog.csdn.net/u011863024/article/details/115721328

一个 tokenizer(分词器)接收一个字符流,将之分割为独立的 tokens(词元,通常是独立的单词),然后输出 token 流。例如,witespace tokenizer 遇到的空白字符时分割文本,它会将文本 “Quick brown fox” 分割为 【Quick brown fox】。

该分词器还负责记录各个 term(词条)的顺序或 position 位置(用于 phrase 短语和 word proximity 词近邻查询),以及 term(词条)所代表的原始 word 的 start 和end 的 character offsets (字符偏移量),用于高亮显示搜索的内容。

Elasticsearch 提供了很多内置的分词器,也可以按照其他开源的分词器(例如 ik 分词器)。例如标准分词器:

1
2
3
4
5
GET http://localhost:9200/_analyze
{
"analyzer": "standard",
"text": "Text to analyze"
}

结果中每个元素代表一个单独的词条:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"tokens": [
{
"token": "text",
"start_offset": 0,
"end_offset": 4,
"type": "<ALPHANUM>",
"position": 1
},
{
"token": "to",
"start_offset": 5,
"end_offset": 7,
"type": "<ALPHANUM>",
"position": 2
},
{
"token": "analyze",
"start_offset": 8,
"end_offset": 15,
"type": "<ALPHANUM>",
"position": 3
}
]
}
  • token 是实际存储到索引中的词条
  • start_offsetend_offset 指明字符在原始字符串中的位置
  • position 指明词条在原始文本中出现的位置

安装 ik 分词器

ik 分词器是一款优秀的汉语分词器。安装方法:从 https://github.com/medcl/elasticsearch-analysis-ik/releases 下载 ik 分词器并拷贝解压到 ES 的插件目录 /mydata/elasticsearch/plugins 下即可。

自定义词库

  1. 修改 /mydata/elasticsearch/plugins/ik/config 目录下的 IKAnalyzer.cfg.xml 文件,在其内写上远程字典地址:
1
2
3
4
5
6
7
8
9
10
11
12
13
<?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">http://yuyunzhao.cn/es/fenci.txt</entry>
<!--用户可以在这里配置远程扩展停止词字典-->
<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>

其中,远程字典文件 /es/fenci.txt 创建在了 nginx 的 /mydata/nginx/html 目录下,这样才可以被其他服务器访问到。

  1. 在词典中添加自定义的分词:
1
乔碧萝
  1. 添加了分词后记得重新启动 elasticsearch 服务:
1
docker restart elasticsearch
  1. 这样在检索时就可以指定分词器为 ik 分词器:
1
2
3
4
5
POST _analyze
{
"analyzer": "ik_max_word", // 还有 ik_smart 等分词器
"text": ["乔碧萝殿下"]
}

实战案例

本章将介绍云商城项目中检索服务的 DSL 语句与业务代码。

检索服务负责根据前端传来的关键词等参数对商品进行检索。首先介绍前端页面中用户可以用来进行检索的条件:

image-20220110185226858

数据模型设计

我们需要根据这些条件构造出搜索条件实体类 SearchParam,该对象中的每个属性都对应了前端传来的查询参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 封装页面所有可能传递过来的查询条件
* @author yuyun.zhao
* @Create 2022-01-07
*/
@Data
public class SearchParam {
/**
* 页面传递过来的全文匹配关键字
*/
private String keyword;
/**
* 三级分类id
*/
private Long catalog3Id;
/**
* sort=saleCout_asc/desc
* sort=skuPrice_asc/desc
* sort=hotScore_asc/desc
* 排序条件
*/
private String sort;
/**
* 是否显示有货。hasStock(是否有货) skuPrice区间,brandId、catalog3Id、attrs
*/
private Integer hasStock = 0;
/**
* 价格区间查询
*/
private String skuPrice;
/**
* 按照品牌进行查询,可以多选
*/
private List<Long> brandId;
/**
* 按照属性进行筛选
*/
private List<String> attrs;
/**
* 页码
*/
private Integer pageNum = 1;
}

同时我们还需要为检索结果设计一个 VO 类 SearchResult ,该类负责保存检索到的所有商品信息 SkuEsModel(具体定义见商品上架),并且保存查询结果所涉及到的品牌、商品分类以及商品属性等信息。这些信息将返回给前端进行展示,这样前端就可以根据用户的检索条件显示出符合该条件的所有产品以及其所涉及到的品牌、商品分类以及商品属性等信息。

查询结果实体类 SearchResult

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
* 查询结果返回
* @author yuyun.zhao
* @Create 2022-01-07
*/
@Data
public class SearchResult {
/**
* 查询到所有商品的商品信息
*/
private List<SkuEsModel> products;
/**
* 以下是分页信息
* 当前页码
*/
private Integer pageNum;
/**
* 总共记录数
*/
private Long total;
/**
* 总页码
*/
private Integer totalPages;
/**
* 当前查询到的结果,所有设计的品牌
*/
private List<BrandVo> brands;
/**
* 当前查询结果,所有涉及到的分类
*/
private List<CatalogVo> catalogs;
/**
* 当前查询到的结果,所有涉及到的所有属性
*/
private List<AttrVo> attrs;
/**
* 页码
*/
private List<Integer> pageNavs;

// ================== 以上是要返回给页面的所有信息 ==================
@Data
public static class BrandVo {
/**
* 品牌id
*/
private Long brandId;
/**
* 品牌名字
*/
private String brandName;
/**
* 品牌图片
*/
private String brandImg;
}
@Data
public static class CatalogVo {
/**
* 分类id
*/
private Long catalogId;
/**
* 品牌名字
*/
private String CatalogName;
}
@Data
public static class AttrVo {
/**
* 属性id
*/
private Long attrId;

/**
* 属性名字
*/
private String attrName;
/**
* 属性值
*/
private List<String> attrValue;
}
}

检索 DSL 语句

构建出 DSL 语句,要包含以下几个部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET product/_search
{
"query": {
"bool": {
"must": [...], // 必须满足的条件
"filter": [...] // 过滤条件
}
},
"sort": [...], // 排序条件
"from": 0, // 起始页
"size": 1, // 分页大小
"hightlight": {...}, // 高亮显示查询的keyword
"aggs": {} // 聚合查询
}

DSL 语句示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
GET product/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"skuTitle": "华为" // 按照关键字查询
}
}
],
"filter": [
{
"term": {
"catalogId": "225" // 根据分类id过滤
}
},
{
"terms": {
"brandId": [ // 根据品牌id过滤
"1",
"5",
"9"
]
}
},
{
"nested": { // 嵌套查询:根据属性id以及属性值进行过滤
"path": "attrs",
"query": {
"bool": {
"must": [
{
"term": {
"attrs.attrId": {
"value": "8"
}
}
},
{
"terms": {
"attrs.attrValue": [
"2019"
]
}
}
]
}
}
}
},
{
"term": { // 是否有库存
"hasStock": {
"value": "false"
}
}
},
{
"range": { // 价格区间
"skuPrice": {
"gte": 0,
"lte": 7000
}
}
}
]
}
},
"sort": [ // 排序
{
"skuPrice": {
"order": "desc"
}
}
],
"from": 0,
"size":4,
"highlight": { // 对搜索条件进行高亮
"fields": {"skuTitle": {}},
"pre_tags": "<b style=color:red>",
"post_tags": "</b>"
},
"aggs": {
"brand_agg": { // 品牌进行聚合
"terms": {
"field": "brandId",
"size": 10
},
"aggs": {
"brand_name_agg": { // 品牌名字
"terms": {
"field": "brandName",
"size": 10
}
},
"brand_img_agg": { // 品牌图片
"terms": {
"field": "brandImg",
"size": 10
}
}
}
},
"catalog_agg": { // 分类
"terms": {
"field": "catalogId",
"size": 10
},
"aggs": {
"catalog_name_agg": { // 分类名字
"terms": {
"field": "catalogName",
"size": 10
}
}
}
},
"attr_agg":{
"nested": {
"path": "attrs"
},
"aggs": { // 属性聚合
"attr_id_agg": {
"terms": {
"field": "attrs.attrId",
"size": 10
},
"aggs": {
"attr_name_agg": { // 属性名字
"terms": {
"field": "attrs.attrName",
"size": 10
}
},
"attr_value_agg":{ // 属性的值
"terms": {
"field": "attrs.attrValue",
"size": 10
}
}
}
}
}
}
}
}

业务代码

整体逻辑:

  • 前端发送用户选择的检索条件
  • Spring MVC 将前端发来的请求中的查询参数自动封装到 SearchParam 对象中
  • Service 层负责根据 SearchParam 去 ES 中检索出符合的商品数据 SkuEsModel,并将其涉及到的品牌、商品分类以及商品属性等信息封装到 SearchResult
  • Spring MVC 将 SearchResult 放到请求域中转发给检索页 search.html,其进行数据渲染

Controller 层代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Spring MVC 会将前端发来的请求中的查询参数自动封装到 SearchParam 对象中
* @param searchParam
* @return
*/
@GetMapping(value = {"/search.html","/"})
public String getSearchPage(SearchParam searchParam, Model model, HttpServletRequest request) {
// 获取前端传来的完整查询条件
searchParam.set_queryString(request.getQueryString());
SearchResult result = mallSearchService.getSearchResult(searchParam);
model.addAttribute("result", result);
return "search";
}

Service 层代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
/**
* @author yuyun zhao
* @date 2022/1/9 17:41
*/
@Slf4j
@Service
public class MallSearchSeviceImpl implements MallSearchService {
@Autowired
private RestHighLevelClient restHighLevelClient;

@Override
public SearchResult getSearchResult(SearchParam searchParam) {
SearchResult searchResult = null;
// 1. 准备检索请求
SearchRequest request = buildSearchRequest(searchParam);
try {
// 2. 执行检索请求
SearchResponse searchResponse = restHighLevelClient.search(request, MallElasticSearchConfig.COMMON_OPTIONS);
// 3. 分析响应数据,将结果封装成 searchResult
searchResult = buildSearchResult(searchParam, searchResponse);
} catch (IOException e) {
e.printStackTrace();
}
return searchResult;
}

private SearchResult buildSearchResult(SearchParam searchParam, SearchResponse searchResponse) {
SearchResult result = new SearchResult();
SearchHits hits = searchResponse.getHits();
//1. 封装查询到的商品信息
if (hits.getHits() != null && hits.getHits().length > 0) {
List<SkuEsModel> skuEsModels = new ArrayList<>();
for (SearchHit hit : hits) {
String sourceAsString = hit.getSourceAsString();
SkuEsModel skuEsModel = JSON.parseObject(sourceAsString, SkuEsModel.class);
//设置高亮属性
if (!StringUtils.isEmpty(searchParam.getKeyword())) {
HighlightField skuTitle = hit.getHighlightFields().get("skuTitle");
String highLight = skuTitle.getFragments()[0].string();
skuEsModel.setSkuTitle(highLight);
}
skuEsModels.add(skuEsModel);
}
result.setProduct(skuEsModels);
}

//2. 封装分页信息
//2.1 当前页码
result.setPageNum(searchParam.getPageNum());
//2.2 总记录数
long total = hits.getTotalHits().value;
result.setTotal(total);
//2.3 总页码
Integer totalPages = (int) total % EsConstant.PRODUCT_PAGESIZE == 0 ?
(int) total / EsConstant.PRODUCT_PAGESIZE : (int) total / EsConstant.PRODUCT_PAGESIZE + 1;
result.setTotalPages(totalPages);
List<Integer> pageNavs = new ArrayList<>();
for (int i = 1; i <= totalPages; i++) {
pageNavs.add(i);
}
result.setPageNavs(pageNavs);

//3. 查询结果涉及到的品牌
List<SearchResult.BrandVo> brandVos = new ArrayList<>();
Aggregations aggregations = searchResponse.getAggregations();
//ParsedLongTerms用于接收terms聚合的结果,并且可以把key转化为Long类型的数据
ParsedLongTerms brandAgg = aggregations.get("brandAgg");
for (Terms.Bucket bucket : brandAgg.getBuckets()) {
//3.1 得到品牌id
Long brandId = bucket.getKeyAsNumber().longValue();

Aggregations subBrandAggs = bucket.getAggregations();
//3.2 得到品牌图片
ParsedStringTerms brandImgAgg = subBrandAggs.get("brandImgAgg");
String brandImg = brandImgAgg.getBuckets().get(0).getKeyAsString();
//3.3 得到品牌名字
Terms brandNameAgg = subBrandAggs.get("brandNameAgg");
String brandName = brandNameAgg.getBuckets().get(0).getKeyAsString();
SearchResult.BrandVo brandVo = new SearchResult.BrandVo(brandId, brandName, brandImg);
brandVos.add(brandVo);
}
result.setBrands(brandVos);

//4. 查询涉及到的所有分类
List<SearchResult.CatalogVo> catalogVos = new ArrayList<>();
ParsedLongTerms catalogAgg = aggregations.get("catalogAgg");
for (Terms.Bucket bucket : catalogAgg.getBuckets()) {
//4.1 获取分类id
Long catalogId = bucket.getKeyAsNumber().longValue();
Aggregations subcatalogAggs = bucket.getAggregations();
//4.2 获取分类名
ParsedStringTerms catalogNameAgg = subcatalogAggs.get("catalogNameAgg");
String catalogName = catalogNameAgg.getBuckets().get(0).getKeyAsString();
SearchResult.CatalogVo catalogVo = new SearchResult.CatalogVo(catalogId, catalogName);
catalogVos.add(catalogVo);
}
result.setCatalogs(catalogVos);

//5 查询涉及到的所有属性
List<SearchResult.AttrVo> attrVos = new ArrayList<>();
//ParsedNested用于接收内置属性的聚合
ParsedNested parsedNested = aggregations.get("attrs");
ParsedLongTerms attrIdAgg = parsedNested.getAggregations().get("attrIdAgg");
for (Terms.Bucket bucket : attrIdAgg.getBuckets()) {
//5.1 查询属性id
Long attrId = bucket.getKeyAsNumber().longValue();

Aggregations subAttrAgg = bucket.getAggregations();
//5.2 查询属性名
ParsedStringTerms attrNameAgg = subAttrAgg.get("attrNameAgg");
String attrName = attrNameAgg.getBuckets().get(0).getKeyAsString();
//5.3 查询属性值
ParsedStringTerms attrValueAgg = subAttrAgg.get("attrValueAgg");
List<String> attrValues = new ArrayList<>();
for (Terms.Bucket attrValueAggBucket : attrValueAgg.getBuckets()) {
String attrValue = attrValueAggBucket.getKeyAsString();
attrValues.add(attrValue);
List<SearchResult.NavVo> navVos = new ArrayList<>();
}
SearchResult.AttrVo attrVo = new SearchResult.AttrVo(attrId, attrName, attrValues);
attrVos.add(attrVo);
}
result.setAttrs(attrVos);

// 6. 构建面包屑导航
List<String> attrs = searchParam.getAttrs();
if (attrs != null && attrs.size() > 0) {
List<SearchResult.NavVo> navVos = attrs.stream().map(attr -> {
String[] split = attr.split("_");
SearchResult.NavVo navVo = new SearchResult.NavVo();
//6.1 设置属性值
navVo.setNavValue(split[1]);
//6.2 查询并设置属性名
try {
R r = productFeignService.info(Long.parseLong(split[0]));
if (r.getCode() == 0) {
AttrResponseVo attrResponseVo = JSON.parseObject(JSON.toJSONString(r.get("attr")), new TypeReference<AttrResponseVo>() {
});
navVo.setNavName(attrResponseVo.getAttrName());
}
} catch (Exception e) {
log.error("远程调用商品服务查询属性失败", e);
}
//6.3 设置面包屑跳转链接
String queryString = searchParam.get_queryString();
String replace = queryString.replace("&attrs=" + attr, "").replace("attrs=" + attr+"&", "").replace("attrs=" + attr, "");
navVo.setLink("http://search.gulimall.com/search.html" + (replace.isEmpty()?"":"?"+replace));
return navVo;
}).collect(Collectors.toList());
result.setNavs(navVos);
}
return result;
}


private SearchRequest buildSearchRequest(SearchParam searchParam) {
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
//1. 构建bool query
BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();
//1.1 bool must
if (!StringUtils.isEmpty(searchParam.getKeyword())) {
boolQueryBuilder.must(QueryBuilders.matchQuery("skuTitle", searchParam.getKeyword()));
}

//1.2 bool filter
//1.2.1 catalog
if (searchParam.getCatalog3Id() != null) {
boolQueryBuilder.filter(QueryBuilders.termQuery("catalogId", searchParam.getCatalog3Id()));
}
//1.2.2 brand
if (searchParam.getBrandId() != null && searchParam.getBrandId().size() > 0) {
boolQueryBuilder.filter(QueryBuilders.termsQuery("brandId", searchParam.getBrandId()));
}
//1.2.3 hasStock
if (searchParam.getHasStock() != null) {
boolQueryBuilder.filter(QueryBuilders.termQuery("hasStock", searchParam.getHasStock() == 1));
}
//1.2.4 priceRange
RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("skuPrice");
if (!StringUtils.isEmpty(searchParam.getSkuPrice())) {
String[] prices = searchParam.getSkuPrice().split("_");
if (prices.length == 1) {
if (searchParam.getSkuPrice().startsWith("_")) {
rangeQueryBuilder.lte(Integer.parseInt(prices[0]));
} else {
rangeQueryBuilder.gte(Integer.parseInt(prices[0]));
}
} else if (prices.length == 2) {
//_6000会截取成["","6000"]
if (!prices[0].isEmpty()) {
rangeQueryBuilder.gte(Integer.parseInt(prices[0]));
}
rangeQueryBuilder.lte(Integer.parseInt(prices[1]));
}
boolQueryBuilder.filter(rangeQueryBuilder);
}
//1.2.5 attrs-nested
//attrs=1_5寸:8寸&2_16G:8G
List<String> attrs = searchParam.getAttrs();
BoolQueryBuilder queryBuilder = new BoolQueryBuilder();
if (attrs != null && attrs.size() > 0) {
attrs.forEach(attr -> {
String[] attrSplit = attr.split("_");
queryBuilder.must(QueryBuilders.termQuery("attrs.attrId", attrSplit[0]));
String[] attrValues = attrSplit[1].split(":");
queryBuilder.must(QueryBuilders.termsQuery("attrs.attrValue", attrValues));
});
}
NestedQueryBuilder nestedQueryBuilder = QueryBuilders.nestedQuery("attrs", queryBuilder, ScoreMode.None);
boolQueryBuilder.filter(nestedQueryBuilder);
//1. bool query构建完成
searchSourceBuilder.query(boolQueryBuilder);

//2. sort eg:sort=saleCount_desc/asc
if (!StringUtils.isEmpty(searchParam.getSort())) {
String[] sortSplit = searchParam.getSort().split("_");
searchSourceBuilder.sort(sortSplit[0], sortSplit[1].equalsIgnoreCase("asc") ? SortOrder.ASC : SortOrder.DESC);
}

//3. 分页
searchSourceBuilder.from((searchParam.getPageNum() - 1) * EsConstant.PRODUCT_PAGESIZE);
searchSourceBuilder.size(EsConstant.PRODUCT_PAGESIZE);

//4. 高亮highlight
if (!StringUtils.isEmpty(searchParam.getKeyword())) {
HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.field("skuTitle");
highlightBuilder.preTags("<b style='color:red'>");
highlightBuilder.postTags("</b>");
searchSourceBuilder.highlighter(highlightBuilder);
}

//5. 聚合
//5.1 按照brand聚合
TermsAggregationBuilder brandAgg = AggregationBuilders.terms("brandAgg").field("brandId");
TermsAggregationBuilder brandNameAgg = AggregationBuilders.terms("brandNameAgg").field("brandName");
TermsAggregationBuilder brandImgAgg = AggregationBuilders.terms("brandImgAgg").field("brandImg");
brandAgg.subAggregation(brandNameAgg);
brandAgg.subAggregation(brandImgAgg);
searchSourceBuilder.aggregation(brandAgg);

//5.2 按照catalog聚合
TermsAggregationBuilder catalogAgg = AggregationBuilders.terms("catalogAgg").field("catalogId");
TermsAggregationBuilder catalogNameAgg = AggregationBuilders.terms("catalogNameAgg").field("catalogName");
catalogAgg.subAggregation(catalogNameAgg);
searchSourceBuilder.aggregation(catalogAgg);

//5.3 按照attrs聚合
NestedAggregationBuilder nestedAggregationBuilder = new NestedAggregationBuilder("attrs", "attrs");
//按照attrId聚合
TermsAggregationBuilder attrIdAgg = AggregationBuilders.terms("attrIdAgg").field("attrs.attrId");
//按照attrId聚合之后再按照attrName和attrValue聚合
TermsAggregationBuilder attrNameAgg = AggregationBuilders.terms("attrNameAgg").field("attrs.attrName");
TermsAggregationBuilder attrValueAgg = AggregationBuilders.terms("attrValueAgg").field("attrs.attrValue");
attrIdAgg.subAggregation(attrNameAgg);
attrIdAgg.subAggregation(attrValueAgg);

nestedAggregationBuilder.subAggregation(attrIdAgg);
searchSourceBuilder.aggregation(nestedAggregationBuilder);

log.debug("构建的DSL语句 {}", searchSourceBuilder.toString());

SearchRequest request = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, searchSourceBuilder);
return request;
}
}