Elasticsearch 完结
本文最后更新于159 天前,其中的信息可能已经过时,如有错误请发送邮件到big_fw@foxmail.com

基础篇

数据库的模糊搜索功能单一,匹配条件非常苛刻,必须恰好包含用户搜索的关键字。而在搜索引擎中,用户输入出现个别错字,或者用拼音搜索、同义词搜索都能正确匹配到数据。

综上,在面临海量数据的搜索,或者有一些复杂搜索需求的时候,推荐使用专门的搜索引擎来实现搜索功能。

学习目标:

  • 理解倒排索引原理
  • 会使用IK分词器
  • 理解索引库Mapping映射的属性含义
  • 能创建索引库及映射
  • 能实现文档的CRUD

1.初识elasticsearch

Elasticsearch的官方网站如下:

https://www.elastic.co/cn/elasticsearch

本章我们一起来初步了解一下Elasticsearch的基本原理和一些基础概念。

1.1.认识和安装

Elasticsearch是由elastic公司开发的一套搜索引擎技术,它是elastic技术栈中的一部分。完整的技术栈包括:

  • Elasticsearch:用于数据存储、计算和搜索
  • Logstash/Beats:用于数据收集
  • Kibana:用于数据可视化

整套技术栈被称为ELK,经常用来做日志收集、系统监控和状态分析等等:

整套技术栈的核心就是用来存储搜索计算的Elasticsearch,因此我们接下来学习的核心也是Elasticsearch。

我们要安装的内容包含2部分:

  • elasticsearch:存储、搜索和运算
  • kibana:图形化展示

Elasticsearch,是提供核心的数据存储、搜索、分析功能的。

关于Kibana,Elasticsearch对外提供的是Restful风格的API,任何操作都可以通过发送http请求来完成。不过http请求的方式、路径、还有请求参数的格式都有严格的规范。这些规范我们肯定记不住,因此我们要借助于Kibana这个服务。

Kibana是elastic公司提供的用于操作Elasticsearch的可视化控制台。它的功能非常强大,包括:

  • 对Elasticsearch数据的搜索、展示
  • 对Elasticsearch数据的统计、聚合,并形成图形化报表、图形
  • 对Elasticsearch的集群状态监控
  • 它还提供了一个开发控制台(DevTools),在其中对Elasticsearch的Restful的API接口提供了语法提示(我们安装kibana的原因)

1.1.1.安装elasticsearch

通过下面的Docker命令即可安装单机版本的elasticsearch:

docker run -d \
  --name es \
  -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
  -e "discovery.type=single-node" \
  -v es-data:/usr/share/elasticsearch/data \
  -v es-plugins:/usr/share/elasticsearch/plugins \
  --privileged \
  --network hmall \
  -p 9200:9200 \
  -p 9300:9300 \
  elasticsearch:7.12.1

提前关防火墙!

  • -e “ES_JAVA_OPTS=-Xms512m -Xmx512m”:设置 Elasticsearch 的 Java 堆内存大小为 512MB(初始值和最大值)。
  • -e “discovery.type=single-node”:指定 Elasticsearch 为单节点模式,防止集群发现功能启动。

这里我们采用的是elasticsearch的7.12.1版本,由于8以上版本的JavaAPI变化很大,在企业中应用并不广泛

安装完成后,访问9200端口,即可看到响应的Elasticsearch服务的基本信息:

1.1.2.安装Kibana

通过下面的Docker命令,即可部署Kibana:

docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=hm-net \
-p 5601:5601  \
kibana:7.12.1

安装完成后,直接访问5601端口,即可看到控制台页面:

选择Explore on my own之后,进入主页面

然后选中Dev tools,进入开发工具页面:

1.2倒排索引

elasticsearch之所以有如此高性能的搜索表现,正是得益于底层的倒排索引技术。那么什么是倒排索引呢?

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

1.2.1.正向索引

其中的id字段已经创建了索引,由于索引底层采用了B+树结构,因此我们根据id搜索的速度会非常快。但是其他字段例如title,只在叶子节点上存在。

因此要根据title搜索的时候只能遍历树中的每一个叶子节点,判断title数据是否符合要求。

比如用户的SQL语句为:

select * from tb_goods where title like '%手机%';

那搜索的大概流程如图:

说明:

  • 1)检查到搜索条件为like '%手机%',需要找到title中包含手机的数据
  • 2)逐条遍历每行数据(每个叶子节点),比如第1次拿到id为1的数据
  • 3)判断数据中的title字段值是否符合条件
  • 4)如果符合则放入结果集,不符合则丢弃
  • 5)回到步骤1

综上,根据id精确匹配时,可以走索引,查询效率较高。而当搜索条件为模糊匹配时,由于索引无法生效,导致从索引查询退化为全表扫描,效率很差。

因此,正向索引适合于根据索引字段的精确搜索,不适合基于部分词条的模糊匹配。

而倒排索引恰好解决的就是根据部分词条模糊匹配的问题。

1.2.2.倒排索引

倒排索引中有两个非常重要的概念:

  • 文档(Document):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息
  • 词条(Term):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条

创建倒排索引是对正向索引的一种特殊处理和应用,流程如下:

  • 将每一个文档的数据利用分词算法根据语义拆分,得到一个个词条
  • 创建表,每行数据包括词条、词条所在文档id、位置等信息
  • 因为词条唯一性,可以给词条创建正向索引

倒排索引的搜索流程如下(以搜索”华为手机”为例),如图:

流程描述:

  • 用户搜索关键词:华为手机
  • 分词:华为、手机
  • 倒排索引查词条 → 得到文档 id 集合
  • 根据文档 id 查找具体文档内容(正向索引)

⚠️ 倒排索引能高效工作是因为“词条”和“文档 id”都建立了高效索引。

虽然要先查询倒排索引,再查询正向索引,但是无论是词条、还是文档id都建立了索引,查询速度非常快!无需全表扫描。

正向索引 VS 倒排索引

比较项目正向索引(传统数据库)倒排索引(Elasticsearch)
查询方式根据 id 查询某条记录,再看字段中是否包含关键词先对关键词分词,再查每个词出现在哪些文档中
核心逻辑文档 -> 词条词条 -> 文档
适用场景精确匹配:如根据 id、主键查询模糊搜索、全文检索:如搜索 “小米手机”
性能非索引字段模糊搜索性能差(会全表扫描)性能极高,分词+索引查找词条
缺点无法有效支持模糊匹配无法用于字段排序或非分词字段的复杂查询

1.2.3.正向和倒排

那么为什么一个叫做正向索引,一个叫做倒排索引呢?

  • 正向索引是最传统的,根据id索引的方式。但根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程
  • 倒排索引则相反,是先找到用户要搜索的词条,根据词条得到保护词条的文档的id,然后根据id获取文档。是根据词条找文档的过程

是不是恰好反过来了?

名称过程
正向索引文档 → 词条
倒排索引词条 → 文档

那么两者方式的优缺点是什么呢?

类型优势劣势
正向索引精确查询速度快,支持排序、范围查询模糊搜索时性能差,全表扫描
倒排索引模糊搜索性能优越,支持全文检索,适合搜索引擎不能用于排序,仅支持词条维度的索引

1.3.基础概念

elasticsearch中有很多独有的概念,与mysql中略有差别,但也有相似之处。

1.3.1.文档和字段

elasticsearch是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为json格式后存储在elasticsearch中:

因此,原本数据库中的一行数据就是ES中的一个JSON文档;而数据库中每行数据都包含很多列,这些列就转换为JSON文档中的字段(Field)

1.3.2.索引和映射

索引:某个字段词义逻辑雷同的一系列文档的“文档集合”。不一定要json格式完全一致。

随着业务发展,需要在es中存储的文档也会越来越多,比如有商品的文档、用户的文档、订单文档等等:

所有文档都散乱存放显然非常混乱,也不方便管理。

因此,我们要将类型相同的文档集中在一起管理,称为索引(Index)。例如:

  • 所有用户文档,就可以组织在一起,称为用户的索引;
  • 所有商品的文档,可以组织在一起,称为商品的索引;
  • 所有订单的文档,可以组织在一起,称为订单的索引;

因此,我们可以把索引当做是数据库中的表。(这里的索引也可以叫索引库)

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

1.3.3.mysql与elasticsearch

我们统一的把mysql与elasticsearch的概念做一下对比:

MySQLElasticsearch说明
TableIndex索引(index),就是文档的集合,类似数据库的表(table)
RowDocument文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式
ColumnField字段(Field),就是JSON文档中的字段,类似数据库中的列(Column)
SchemaMappingMapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
SQLDSLDSL是elasticsearch提供的JSON风格的请求语句,用来操作elasticsearch,实现CRUD
  • MySQL 的库 → ES 的索引库
  • MySQL 的表结构 → ES 的 Mapping
  • MySQL 的行 → ES 的文档
  • MySQL 的列 → ES 的字段

那是不是说,我们学习了elasticsearch就不再需要mysql了呢?

并不是如此,两者各自有自己的擅长之处:

  • Mysql:擅长事务类型操作,可以确保数据的安全和一致性
  • Elasticsearch:擅长海量数据的搜索、分析、计算

因此在企业中,往往是两者结合使用:

  • 对安全性要求较高的写操作,使用mysql实现
  • 对查询性能要求较高的搜索需求,使用elasticsearch实现
  • 两者再基于某种方式(Logstash 通过 JDBC 插件从 MySQL、PostgreSQL 等数据库读取数据,同步到 Elasticsearch 用于搜索。例如:将用户信息表同步到 ES,支持实时搜索),实现数据的同步,保证一致性

1.4.IK分词器

Elasticsearch的关键就是倒排索引,而倒排索引依赖于对文档内容的分词,而分词则需要高效、精准的分词算法,IK分词器就是这样一个中文分词算法。

1.4.1.安装IK分词器

方案一:在线安装

运行一个命令即可:

docker exec -it es ./bin/elasticsearch-plugin  install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip

然后重启es容器:

docker restart es

方案二:离线安装

如果网速较差,也可以选择离线安装。

首先,查看之前安装的Elasticsearch容器的plugins数据卷目录:

docker volume inspect es-plugins

结果如下:

[
    {
        "CreatedAt": "2024-11-06T10:06:34+08:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/es-plugins/_data",
        "Name": "es-plugins",
        "Options": null,
        "Scope": "local"
    }
]

可以看到elasticsearch的插件挂载到了/var/lib/docker/volumes/es-plugins/_data这个目录。我们需要把IK分词器上传至这个目录。

1.4.2.使用IK分词器

IK分词器包含两种模式:

模式名描述
ik_smart智能分词,粗粒度,只保留最核心词义(如搜索标题、精确匹配)
ik_max_word最大词粒度,细粒度,列出所有可能组合(如文章内容、模糊搜索)
  • ik_smart:智能语义切分(最小划分,只保留最核心语义的词。在不影响分词后词元的含义下“粗”粒度划分,减少过多划分后冗余词干扰。需要用户输入明确的关键词来严格匹配。适合主搜索字段(如标题、名称),和需要精准匹配的场景(如订单号、用户名)。)
  • ik_max_word:最细粒度切分(穷尽所有可能组合,覆盖所有可能子词。保证用户输入可能不完整或包含子词。例如:“中华人民共和国”,搜索:“人民共和国”,也可以搜到整个句子。适合长文本内容(如文章正文、评论),和需要模糊搜索或高召回的场景(如日志关键词检索)。)

我们在Kibana的DevTools上来测试分词器,首先测试Elasticsearch官方提供的标准分词器:

POST /_analyze
{
  "analyzer": "standard",
  "text": "搁浅学习java太棒了"
}

可以看到,标准分词器只能1字1词条,无法正确对中文做分词。

我们再测试IK分词器:

POST /_analyze
{
  "analyzer": "ik_smart",
  "text": "搁浅学习java太棒了"
}

1.4.3.拓展词典

随着互联网的发展,“造词运动”也越发的频繁。出现了很多新的词语,在原有的词汇列表中并不存在。比如:“泰裤辣”,“小黑子漏出鸡脚了吧” 等。

IK分词器无法对这些词汇分词,测试一下:

所以要想正确分词,IK分词器的词库也需要不断的更新,IK分词器提供了扩展词汇的功能。

1)打开IK分词器config目录:

2)在IKAnalyzer.cfg.xml配置文件内容添加:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
        <comment>IK Analyzer 扩展配置</comment>
        <!--用户可以在这里配置自己的扩展字典 *** 添加扩展词典-->
        <entry key="ext_dict">ext.dic</entry>
</properties>

3)在IK分词器的config目录新建一个 ext.dic,可以参考config目录下复制一个配置文件进行修改

王源
封个烟

4)重启elasticsearch

docker restart es

# 查看 日志
docker logs -f elasticsearch

再次测试,可以发现传智播客泰裤辣都正确分词了:

1.4.4.总结

分词器作用

  • 创建索引时对文档分词
  • 查询时对用户输入分词

IK 分词模式

  • ik_smart:粗粒度
  • ik_max_word:细粒度

拓展词库方法

  • 配置 IKAnalyzer.cfg.xml
  • 新建 ext.dic 添加自定义词条

2.索引库操作

2.1.Mapping映射属性

Mapping是对索引库中文档的约束,常见的Mapping属性包括:

  • type:字段数据类型,常见的简单类型有:
    • 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
    • 数值:longintegershortbytedoublefloat
    • 布尔:boolean
    • 日期:date
    • 对象:object
  • index:是否创建索引,默认为true
  • analyzer:使用哪种分词器
  • properties:该字段的子字段
类型说明
text可分词,适用于长文本,如文章、标题等
keyword精确匹配,如分类、邮箱、IP等
integer整型,适用于年龄、数量等
boolean布尔值 true / false
date日期时间格式
object嵌套对象(JSON子结构)

其他属性:

  • index: 是否创建倒排索引(默认 true)
    • 设置为 false 则该字段无法被搜索
  • analyzer: 使用的分词器(ik_smartstandard 等)
  • properties: 用于定义嵌套对象的字段结构(类似 JSON 的子字段)

例如下面的json文档:

{
    "age": 21,
    "weight": 52.1,
    "isMarried": false,
    "info": "黑马程序员Java讲师",
    "email": "zy@itcast.cn",
    "score": [99.1, 99.5, 98.9],
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}

2.2.索引库的CRUD

由于Elasticsearch采用的是Restful风格的API,因此其请求方式和路径相对都比较规范,而且请求参数也都采用JSON风格。

我们直接基于Kibana的DevTools来编写请求做测试,由于有语法提示,会非常方便。

2.2.1.创建索引库和映射

基本语法

  • 请求方式:PUT
  • 请求路径:/索引库名,可以自定义
  • 请求参数:mapping映射

格式

PUT /索引库名称
{
  "mappings": {
    "properties": {
      "字段名":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "字段名2":{
        "type": "keyword",
        "index": "false"
      },
      "字段名3":{
        "properties": {
          "子字段": {
            "type": "keyword"
          }
        }
      },
      // ...略
    }
  }
}

示例

PUT /heima
{
  "mappings": {
    "properties": {
      "info": { "type": "text", "analyzer": "ik_smart" },
      "email": { "type": "keyword", "index": false },
      "name": {
        "properties": {
          "firstName": { "type": "keyword" }
        }
      }
    }
  }
}

2.2.2.查询索引库

基本语法

  • 请求方式:GET
  • 请求路径:/索引库名
  • 请求参数:无

格式

GET /索引库名

示例

GET /heima

2.2.3.修改索引库

❗ 注意:Elasticsearch 不允许修改已有字段类型或分词器,只能添加新字段!

倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,无法修改mapping

语法说明

PUT /索引库名/_mapping
{
  "properties": {
    "新字段名":{
      "type": "integer"
    }
  }
}

示例

PUT /heima/_mapping
{
  "properties": {
    "age":{
      "type": "integer"
    }
  }
}

2.2.4.删除索引库

语法:

  • 请求方式:DELETE
  • 请求路径:/索引库名
  • 请求参数:无

格式:

DELETE /索引库名

示例:

DELETE /heima

2.2.5.总结

索引库操作有哪些?

  • 创建索引库:PUT /索引库名
  • 查询索引库:GET /索引库名
  • 删除索引库:DELETE /索引库名
  • 修改索引库,添加字段:PUT /索引库名/_mapping

可以看到,对索引库的操作基本遵循的Restful的风格,因此API接口非常统一,方便记忆。

操作请求方式请求路径请求体
创建索引PUT/索引名
查询索引GET/索引名
删除索引DELETE/索引名
添加字段PUT/索引名/_mapping

注:POST /索引库名 是用于向索引库中新增文档(数据),而不是创建索引结构

操作类型请求方法路径用途
创建索引库PUT/index_name创建索引和结构(Mapping)
添加文档(自动ID)POST/index_name插入数据文档
添加文档(指定ID)POST/index_name/_doc/{id}插入/更新数据文档

3.文档操作

有了索引库,接下来就可以向索引库中添加数据了。

Elasticsearch中的数据其实就是JSON风格的文档。操作文档自然保护等几种常见操作,我们分别来学习。

3.1.新增文档

语法:

POST /索引库名/_doc/文档id
{
    "字段1": "值1",
    "字段2": "值2",
    "字段3": {
        "子属性1": "值3",
        "子属性2": "值4"
    },
}

示例:

POST /heima/_doc/1
{
    "info": "黑马程序员Java讲师",
    "email": "zy@itcast.cn",
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}

说明:

  • 路径中的 _doc 是文档类型,ES7+ 虽然只支持一个类型,但这个字段保留。
  • 文档ID可指定(如 /1),也可不写让系统自动生成。

响应:

3.2.查询文档

根据rest风格,新增是post,查询应该是get,不过查询一般都需要条件,这里我们把文档id带上。

语法:

GET /{索引库名称}/_doc/{id}

示例:

GET /heima/_doc/1

说明:

  • 查询的是整个文档内容,包括字段值和元信息(如 _id, _index, _version 等)。

查看结果:

3.3.删除文档

删除使用DELETE请求,同样,需要根据id进行删除:

语法:

DELETE /{索引库名}/_doc/id值

示例:

DELETE /heima/_doc/1

说明:

  • 删除后,该文档将不再存在,查询也查不到。

结果:

3.4.修改文档

修改有两种方式:

  • 全量修改:直接覆盖原来的文档
  • 局部修改:修改文档中的部分字段

3.4.1.全量修改

全量修改是覆盖原来的文档,其本质是两步操作:

  • 根据指定的id删除文档
  • 新增一个相同id的文档

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

语法:

PUT /{索引库名}/_doc/文档id
{
    "字段1": "值1",
    "字段2": "值2",
    // ... 略
}

示例:

PUT /heima/_doc/1
{
    "info": "黑马程序员高级Java讲师",
    "email": "zy@itcast.cn",
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}

说明:

  • 会完全覆盖原文档(未提供的字段将被删除)。
  • 如果 ID 不存在,会创建新文档(created);存在则覆盖(updated)。

由于id1的文档已经被删除,所以第一次执行时,得到的反馈是created

所以如果执行第2次时,得到的反馈则是updated

3.4.2.局部修改

局部修改是只修改指定id匹配的文档中的部分字段。

语法:

POST /{索引库名}/_update/文档id
{
    "doc": {
         "字段名": "新的值",
    }
}

示例:

POST /heima/_update/1
{
  "doc": {
    "email": "ZhaoYun@itcast.cn"
  }
}

说明:

  • 只修改指定字段,不影响其他字段。

执行结果

3.5.批处理

批处理采用POST请求,基本语法如下:

POST _bulk
{ "index" : { "_index" : "test", "_id" : "1" } }
{ "field1" : "value1" }
{ "delete" : { "_index" : "test", "_id" : "2" } }
{ "create" : { "_index" : "test", "_id" : "3" } }
{ "field1" : "value3" }
{ "update" : {"_id" : "1", "_index" : "test"} }
{ "doc" : {"field2" : "value2"} }

说明:

  • 每个操作是一对 JSON 对象,一行一个,不能少。
  • 用于提高写入或删除的性能,非常适合大批量数据导入。

其中:

  • index代表新增操作
    • _index:指定索引库名
    • _id指定要操作的文档id
    • { "field1" : "value1" }:则是要新增的文档内容
  • delete代表删除操作
    • _index:指定索引库名
    • _id指定要操作的文档id
  • update代表更新操作
    • _index:指定索引库名
    • _id指定要操作的文档id
    • { "doc" : {"field2" : "value2"} }:要更新的文档字段

示例,批量新增:

POST /_bulk
{"index": {"_index":"heima", "_id": "3"}}
{"info": "黑马程序员C++讲师", "email": "ww@itcast.cn", "name":{"firstName": "五", "lastName":"王"}}
{"index": {"_index":"heima", "_id": "4"}}
{"info": "黑马程序员前端讲师", "email": "zhangsan@itcast.cn", "name":{"firstName": "三", "lastName":"张"}}

批量删除:

POST /_bulk
{"delete":{"_index":"heima", "_id": "3"}}
{"delete":{"_index":"heima", "_id": "4"}}

3.6.总结

文档操作有哪些?

  • 创建文档:POST /{索引库名}/_doc/文档id { json文档 }
  • 查询文档:GET /{索引库名}/_doc/文档id
  • 删除文档:DELETE /{索引库名}/_doc/文档id
  • 修改文档:
    • 全量修改:PUT /{索引库名}/_doc/文档id { json文档 }
    • 局部修改:POST /{索引库名}/_update/文档id { "doc": {字段}}
操作类型方法路径结构内容
新增文档POST/index/_doc/id整个文档内容
查询文档GET/index/_doc/id无内容体
删除文档DELETE/index/_doc/id无内容体
修改文档(全)PUT/index/_doc/id完整替换原文档
修改文档(局)POST/index/_update/id{ "doc": { "字段": 值 }}
批量操作POST/_bulk结构化一组操作

4.RestAPI

ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。

官方文档地址:

https://www.elastic.co/guide/en/elasticsearch/client/index.html

由于ES目前最新版本是8.8,提供了全新版本的客户端,老版本的客户端已经被标记为过时。而我们采用的是7.12版本,因此只能使用老版本客户端:

然后选择7.12版本,HighLevelRestClient版本

4.1.初始化RestClient

Elasticsearch 官方提供的 Java 客户端叫做 RestHighLevelClient,所有操作(增删改查、索引管理等)都通过这个对象完成。

分为三步:

① 添加 Maven 依赖

<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>

② 指定 ES 版本(避免与 Spring Boot 默认版本冲突)

  <properties>
      <maven.compiler.source>11</maven.compiler.source>
      <maven.compiler.target>11</maven.compiler.target>
      <elasticsearch.version>7.12.1</elasticsearch.version>
  </properties>

③ 编写连接代码(推荐放在单元测试的 @BeforeEach 中)

初始化的代码如下:

RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
        HttpHost.create("http://192.168.32.128:9200")
));

这里为了单元测试方便,我们创建一个测试类IndexTest,然后将初始化的代码编写在@BeforeEach方法中:

package com.hmall.item.es;

import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.io.IOException;

public class IndexTest {

    private RestHighLevelClient client;

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

    @Test
    void testConnect() {
        System.out.println(client);
    }

    @AfterEach//结束方法
    void tearDown() throws IOException {
        client.close();
    }
}

别忘了释放资源,防止连接泄漏!

4.2.创建索引库

由于要实现对商品搜索,所以我们需要将商品添加到Elasticsearch中,不过需要根据搜索业务的需求来设定索引库结构,而不是一股脑的把MySQL数据写入Elasticsearch.

4.2.1.Mapping映射

实现搜索功能需要的字段包括三大部分:

  • 搜索过滤字段
    • 分类
    • 品牌
    • 价格
  • 排序字段
    • 默认:按照更新时间降序排序
    • 销量
    • 价格
  • 展示字段
    • 商品id:用于点击后跳转
    • 图片地址
    • 是否是广告推广商品
    • 名称
    • 价格
    • 评价数量
    • 销量

对应的商品表结构如下,索引库无关字段已经划掉:

结合数据库表结构,以上字段对应的mapping映射属性如下:

最终我们的索引库文档结构应该是这样:

PUT /items
{
  "mappings": {
    "properties": {
      "id": {
        "type": "keyword"
      },
      "name":{
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "price":{
        "type": "integer"
      },
      "stock":{
        "type": "integer"
      },
      "image":{
        "type": "keyword",
        "index": false
      },
      "category":{
        "type": "keyword"
      },
      "brand":{
        "type": "keyword"
      },
      "sold":{
        "type": "integer"
      },
      "commentCount":{
        "type": "integer",
        "index": false
      },
      "isAD":{
        "type": "boolean"
      },
      "updateTime":{
        "type": "date"
      }
    }
  }
}

4.2.2.创建索引

创建索引库的API如下:

代码分为三步:

  • 1)创建Request对象。
    • 因为是创建索引库的操作,因此Request是CreateIndexRequest
  • 2)添加请求参数
    • 其实就是Json格式的Mapping映射参数。因为json字符串很长,这里是定义了静态字符串常量MAPPING_TEMPLATE,让代码看起来更加优雅。
  • 3)发送请求
    • client.indices()方法的返回值是IndicesClient类型,封装了所有与索引库操作有关的方法。例如创建索引、删除索引、判断索引是否存在等

item-service中的IndexTest测试类中,具体代码如下:

@Test
void testCreateIndex() throws IOException {
    // 1.创建Request对象
    CreateIndexRequest request = new CreateIndexRequest("items");
    // 2.准备请求参数
    request.source(MAPPING_TEMPLATE, XContentType.JSON);
    // 3.发送请求
    client.indices().create(request, RequestOptions.DEFAULT);
}

static final String MAPPING_TEMPLATE = "{\n" +
            "  \"mappings\": {\n" +
            "    \"properties\": {\n" +
            "      \"id\": {\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"name\":{\n" +
            "        \"type\": \"text\",\n" +
            "        \"analyzer\": \"ik_max_word\"\n" +
            "      },\n" +
            "      \"price\":{\n" +
            "        \"type\": \"integer\"\n" +
            "      },\n" +
            "      \"stock\":{\n" +
            "        \"type\": \"integer\"\n" +
            "      },\n" +
            "      \"image\":{\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"index\": false\n" +
            "      },\n" +
            "      \"category\":{\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"brand\":{\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"sold\":{\n" +
            "        \"type\": \"integer\"\n" +
            "      },\n" +
            "      \"commentCount\":{\n" +
            "        \"type\": \"integer\"\n" +
            "      },\n" +
            "      \"isAD\":{\n" +
            "        \"type\": \"boolean\"\n" +
            "      },\n" +
            "      \"updateTime\":{\n" +
            "        \"type\": \"date\"\n" +
            "      }\n" +
            "    }\n" +
            "  }\n" +
            "}";

4.3.删除索引库

注:删除索引库的操作时,索引库以及其中的所有文档会被直接删除。这意味着索引及其包含的数据、设置和映射都会被移除。此操作是不可逆的,所以在执行之前应确保已经备份了所有需要的数据。

删除索引库的请求非常简单:

DELETE /hotel

与创建索引库相比:

  • 请求方式从PUT变为DELTE
  • 请求路径不变
  • 无请求参数

所以代码的差异,注意体现在Request对象上。流程如下:

  • 1)创建Request对象。这次是DeleteIndexRequest对象
  • 2)准备参数。这里是无参,因此省略
  • 3)发送请求。改用delete方法

item-service中的IndexTest测试类中,编写单元测试,实现删除索引:

@Test
void testDeleteIndex() throws IOException {
    // 1.创建Request对象
    DeleteIndexRequest request = new DeleteIndexRequest("items");
    // 2.发送请求
    client.indices().delete(request, RequestOptions.DEFAULT);
}

4.4.判断索引库是否存在

判断索引库是否存在,本质就是查询,对应的请求语句是:

GET /hotel

因此与删除的Java代码流程是类似的,流程如下:

  • 1)创建Request对象。这次是GetIndexRequest对象
  • 2)准备参数。这里是无参,直接省略
  • 3)发送请求。改用exists方法
@Test
void testExistsIndex() throws IOException {
    // 1.创建Request对象
    GetIndexRequest request = new GetIndexRequest("items");
    // 2.发送请求
    boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
    // 3.输出
    System.err.println(exists ? "索引库已经存在!" : "索引库不存在!");
}

4.5.总结

JavaRestClient操作elasticsearch的流程基本类似。核心是client.indices()方法来获取索引库的操作对象。

索引库操作的基本步骤:

  • 初始化RestHighLevelClient
  • 创建XxxIndexRequest。XXX是CreateGetDelete
  • 准备请求参数( Create时需要,其它是无参,可以省略)
  • 发送请求。调用RestHighLevelClient#indices().xxx()方法,xxx是createexistsdelete

5.RestClient操作文档

索引库准备好以后,就可以操作文档了。为了与索引库操作分离,我们再次创建一个测试类,做两件事情:

  • 初始化RestHighLevelClient
  • 我们的商品数据在数据库,需要利用IItemService去查询,所以注入这个接口
package com.hmall.item.es;

import com.hmall.item.service.IItemService;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.IOException;

@SpringBootTest(properties = "spring.profiles.active=local")
public class DocumentTest {

    private RestHighLevelClient client;
    @Autowired
    private IItemService itemService;

    @BeforeEach
    void setUp() {
        this.client = new RestHighLevelClient(RestClient.builder(
                HttpHost.create("http://192.168.150.101:9200")
        ));
    }
    
    @AfterEach
    void tearDown() throws IOException {
        this.client.close();
    }
}

5.1.新增文档

数据源是数据库(如 MySQL)

不直接造假数据,而是将真实商品导入 ES

5.1.1.实体类

索引库结构与数据库结构还存在一些差异,因此我们要定义一个索引库结构对应的实体。

在item-service模块的com.hmall.item.domain.po包中定义一个新的DTO:

package com.hmall.item.domain.po;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@ApiModel(description = "索引库实体")
public class ItemDoc{

    @ApiModelProperty("商品id")
    private String id;

    @ApiModelProperty("商品名称")
    private String name;

    @ApiModelProperty("价格(分)")
    private Integer price;

    @ApiModelProperty("商品图片")
    private String image;

    @ApiModelProperty("类目名称")
    private String category;

    @ApiModelProperty("品牌名称")
    private String brand;

    @ApiModelProperty("销量")
    private Integer sold;

    @ApiModelProperty("评论数")
    private Integer commentCount;

    @ApiModelProperty("是否是推广广告,true/false")
    private Boolean isAD;

    @ApiModelProperty("更新时间")
    private LocalDateTime updateTime;
}

5.1.2.API语法

新增文档的请求语法如下:

POST /{索引库名}/_doc/1
{
    "name": "Jack",
    "age": 21
}

对应的JavaAPI如下:

可以看到与索引库操作的API非常类似,同样是三步走:

  • 1)创建Request对象,这里是IndexRequest,因为添加文档就是创建倒排索引的过程
  • 2)准备请求参数,本例中就是Json文档
  • 3)发送请求

变化的地方在于,这里直接使用client.xxx()的API,不再需要client.indices()了。

5.1.3.完整代码

我们导入商品数据,除了参考API模板“三步走”以外,还需要做几点准备工作:

  • 商品数据来自于数据库,我们需要先查询出来,得到Item对象
  • Item对象需要转为ItemDoc对象
  • ItemDoc需要序列化为json格式

因此,代码整体步骤如下:

  • 1)根据id查询商品数据Item
  • 2)将Item封装为ItemDoc
  • 3)将ItemDoc序列化为JSON
  • 4)创建IndexRequest,指定索引库名和id
  • 5)准备请求参数,也就是JSON文档
  • 6)发送请求

item-serviceDocumentTest测试类中,编写单元测试:

@Test
void testAddDocument() throws IOException {
    // 1.根据id查询商品数据
    Item item = itemService.getById(100002644680L);
    // 2.转换为文档类型
    ItemDoc itemDoc = BeanUtil.copyProperties(item, ItemDoc.class);
    // 3.将ItemDTO转json
    String doc = JSONUtil.toJsonStr(itemDoc);

    // 1.准备Request对象
    IndexRequest request = new IndexRequest("items").id(itemDoc.getId());
    // 2.准备Json文档
    request.source(doc, XContentType.JSON);
    // 3.发送请求
    client.index(request, RequestOptions.DEFAULT);
}

5.2.查询文档

我们以根据id查询文档为例

5.2.1.语法说明

查询的请求语句如下:

GET /{索引库名}/_doc/{id}

与之前的流程类似,代码大概分2步:

  • 创建Request对象
  • 准备请求参数,这里是无参,直接省略
  • 发送请求

不过查询的目的是得到结果,解析为ItemDTO,还要再加一步对结果的解析。示例代码如下:

可以看到,响应结果是一个JSON,其中文档放在一个_source属性中,因此解析就是拿到_source,反序列化为Java对象即可。

其它代码与之前类似,流程如下:

  • 1)准备Request对象。这次是查询,所以是GetRequest
  • 2)发送请求,得到结果。因为是查询,这里调用client.get()方法
  • 3)解析结果,就是对JSON做反序列化

5.2.2.完整代码

item-serviceDocumentTest测试类中,编写单元测试:

@Test
void testGetDocumentById() throws IOException {
    // 1.准备Request对象
    GetRequest request = new GetRequest("items").id("100002644680");
    // 2.发送请求
    GetResponse response = client.get(request, RequestOptions.DEFAULT);
    // 3.获取响应结果中的source
    String json = response.getSourceAsString();
    
    ItemDoc itemDoc = JSONUtil.toBean(json, ItemDoc.class);
    System.out.println("itemDoc= " + ItemDoc);
}

特点:

  • 返回内容在 _source 字段中
  • 需要进行反序列化成 Java 对象

5.3.删除文档

删除的请求语句如下:

DELETE /{索引库名称}/_doc/{id}

与查询相比,仅仅是请求方式从DELETE变成GET,可以想象Java代码应该依然是2步走:

  • 1)准备Request对象,因为是删除,这次是DeleteRequest对象。要指定索引库名和id
  • 2)准备参数,无参,直接省略
  • 3)发送请求。因为是删除,所以是client.delete()方法

item-serviceDocumentTest测试类中,编写单元测试:

@Test
void testDeleteDocument() throws IOException {
    // 1.准备Request,两个参数,第一个是索引库名,第二个是文档id
    DeleteRequest request = new DeleteRequest("item", "100002644680");
    // 2.发送请求
    client.delete(request, RequestOptions.DEFAULT);
}

5.4.修改文档

修改我们讲过两种方式:

  • 全量修改:本质是先根据id删除,再新增
  • 局部修改:修改文档中的指定字段值

在RestClient的API中,全量修改与新增的API完全一致,判断依据是ID:

  • 如果新增时,ID已经存在,则修改
  • 如果新增时,ID不存在,则新增

我们主要关注局部修改的API即可。

5.4.1.语法说明

局部修改的请求语法如下:

POST /{索引库名}/_update/{id}
{
  "doc": {
    "字段名": "字段值",
    "字段名": "字段值"
  }
}

代码示例如图:

与之前类似,也是三步走:

  • 1)准备Request对象。这次是修改,所以是UpdateRequest
  • 2)准备参数。也就是JSON文档,里面包含要修改的字段
  • 3)更新文档。这里调用client.update()方法

5.4.2.完整代码

item-serviceDocumentTest测试类中,编写单元测试:

@Test
void testUpdateDocument() throws IOException {
    // 1.准备Request
    UpdateRequest request = new UpdateRequest("items", "100002644680");
    // 2.准备请求参数
    request.doc(
            "price", 58800,
            "commentCount", 1
    );
    // 3.发送请求
    client.update(request, RequestOptions.DEFAULT);
}

5.5.批量导入文档

一次性导入大量文档,避免一条一条处理,提升效率。

  • 利用Logstash批量导入
    • 需要安装Logstash
    • 对数据的再加工能力较弱
    • 无需编码,但要学习编写Logstash导入配置
  • 利用JavaAPI批量导入
    • 需要编码,但基于JavaAPI,学习成本低
    • 更加灵活,可以任意对数据做再加工处理后写入索引库

接下来,我们就学习下如何利用JavaAPI实现批量文档导入。

5.5.1.语法说明

批处理与前面讲的文档的CRUD步骤基本一致:

  • 创建Request,但这次用的是BulkRequest
  • 准备请求参数
  • 发送请求,这次要用到client.bulk()方法

BulkRequest本身其实并没有请求参数,其本质就是将多个普通的CRUD请求组合在一起发送。例如:

  • 批量新增文档,就是给每个文档创建一个IndexRequest请求,然后封装到BulkRequest中,一起发出。
  • 批量删除,就是创建N个DeleteRequest请求,然后封装到BulkRequest,一起发出

因此BulkRequest中提供了add方法,用以添加其它CRUD的请求:

可以看到,能添加的请求有:

  • IndexRequest,也就是新增
  • UpdateRequest,也就是修改
  • DeleteRequest,也就是删除

因此Bulk中添加了多个IndexRequest,就是批量新增功能了。示例:

@Test
void testBulk() throws IOException {
    // 1.创建Request
    BulkRequest request = new BulkRequest();
    // 2.准备请求参数
    request.add(new IndexRequest("items").id("1").source("json doc1", XContentType.JSON));
    request.add(new IndexRequest("items").id("2").source("json doc2", XContentType.JSON));
    // 3.发送请求
    client.bulk(request, RequestOptions.DEFAULT);
}

5.5.2.完整代码

从数据库中 分页读取商品数据,每次取 1000 条,然后 批量写入到 Elasticsearch 的 items 索引中

item-serviceDocumentTest测试类中,编写单元测试:

@Test
void testLoadItemDocs() throws IOException {
    // 分页查询商品数据
    int pageNo = 1;
    int size = 1000;
    while (true) {
        Page<Item> page = itemService.lambdaQuery().eq(Item::getStatus, 1).page(new Page<Item>(pageNo, size));
        // 非空校验
        List<Item> items = page.getRecords();
        if (CollUtils.isEmpty(items)) {
            return;
        }
        log.info("加载第{}页数据,共{}条", pageNo, items.size());
        // 1.创建Request
        BulkRequest request = new BulkRequest("items");
        // 2.准备参数,添加多个新增的Request
        for (Item item : items) {
            // 2.1.转换为文档类型ItemDTO
            ItemDoc itemDoc = BeanUtil.copyProperties(item, ItemDoc.class);
            // 2.2.创建新增文档的Request对象
            request.add(new IndexRequest()
                            .id(itemDoc.getId())
                            .source(JSONUtil.toJsonStr(itemDoc), XContentType.JSON));
        }
        // 3.发送请求
        client.bulk(request, RequestOptions.DEFAULT);

        // 翻页
        pageNo++;
    }
}

GET /items/_count

索引库里有这么多数据

5.6.小结

文档操作的基本步骤:

  • 初始化RestHighLevelClient
  • 创建XxxRequest。
    • XXX是IndexGetUpdateDeleteBulk
  • 准备参数(IndexUpdateBulk时需要)
  • 发送请求。
    • 调用RestHighLevelClient#.xxx()方法,xxx是indexgetupdatedeletebulk
  • 解析结果(Get时需要)
操作类型Java 类方法调用参数形式
新增IndexRequestclient.index()JSON 文档
查询GetRequestclient.get()文档 ID
删除DeleteRequestclient.delete()文档 ID
修改UpdateRequestclient.update()"doc": {字段}
批量操作BulkRequestclient.bulk()多个 Index/Update 等

基础篇完

高级篇

我们已经导入了大量数据到elasticsearch中,实现了商品数据的存储。不过查询商品数据时依然采用的是根据id查询,而非模糊搜索。

我们来研究下elasticsearch的数据搜索功能。Elasticsearch提供了基于JSON的DSL(Domain Specific Language)语句来定义查询条件,其JavaAPI就是在组织DSL条件。

1.DSL查询

Elasticsearch的查询可以分为两大类:

复合查询(Compound query clauses):以逻辑方式组合多个叶子查询或者更改叶子查询的行为方式。

叶子查询(Leaf query clauses):一般是在特定的字段里查询特定值,属于简单查询,很少单独使用。

1.1.快速入门

我们依然在Kibana的DevTools中学习查询的DSL语法。首先来看查询的语法结构:

说明:

  • GET /{索引库名}/_search:其中的_search是固定路径,不能修改

例如,我们以最简单的无条件查询为例,无条件查询的类型是:match_all,因此其查询语句如下:

//查询所有
GET /items/_search
{
  "query": {
    "match_all": {
      
    }
  }
}

这个例子是一个最基础的无条件查询,使用 match_all,相当于 SQL 中的 SELECT * FROM table

⚠️:默认返回前 10 条记录,可以通过 size 参数设置更多返回结果。

执行结果如下:

你会发现虽然是match_all,但是响应结果中并不会包含索引库中的所有文档,而是仅有10条。这是因为处于安全考虑,elasticsearch设置了默认的查询页数。

1.2.叶子查询

叶子查询的类型也可以做进一步细分,详情大家可以查看官方文档:

Query DSL | Elasticsearch Guide [7.12] | Elastic

这里列举一些常见的,例如:

  • 全文检索查询(Full Text Queries):利用分词器对用户输入搜索条件先分词,得到词条,然后再利用倒排索引搜索词条。例如:
    • match
    • multi_match
  • 精确查询(Term-level queries):不对用户输入搜索条件分词,根据字段内容精确值匹配。但只能查找keyword、数值、日期、boolean类型的字段。例如:
    • ids
    • term
    • range
  • 地理坐标查询用于搜索地理位置,搜索方式很多,例如:
    • geo_bounding_box:按矩形搜索
    • geo_distance:按点和半径搜索
  • …略

1.2.1.全文检索查询(Full Text Queries)

全文检索的种类也很多,详情可以参考官方文档:

Full text queries | Elasticsearch Guide [7.12] | Elastic

原理:分词器先对搜索条件进行分词,然后匹配倒排索引。

类型用途说明
match匹配单个字段,支持分词
multi_match匹配多个字段,任一字段命中即可

1.以全文检索中的match为例,语法如下:

GET /{索引库名}/_search
{
  "query": {
    "match": {
      "字段名": "搜索条件"
    }
  }
}

示例:

2.与match类似的还有multi_match,区别在于可以同时对多个字段搜索,而且多个字段其中之一满足即可,语法示例:

GET /{索引库名}/_search
{
  "query": {
    "multi_match": {
      "query": "搜索条件",
      "fields": ["字段1", "字段2"]
    }
  }
}

示例:

1.2.2.精确查询(Term-level Queries)

原理:不分词,直接匹配输入值,适合 keyword、数字、日期、boolean 等字段。

精确查询,英文是Term-level query,顾名思义,词条级别的查询。也就是说不会对用户输入的搜索条件再分词,而是作为一个词条,与搜索的字段内容精确值匹配。因此推荐查找keyword、数值、日期、boolean类型的字段。例如:

  • id
  • price
  • 城市
  • 地名
  • 人名

等等,作为一个整体才有含义的字段。

详情可以查看官方文档:

Term-level queries | Elasticsearch Guide [7.12] | Elastic

类型用途说明
term精确匹配一个值
terms精确匹配多个值之一
ids根据 id 查询
range范围查询(适用于数字、日期)

1.以term查询为例,其语法如下:

GET /{索引库名}/_search
{
  "query": {
    "term": {
      "字段名": {
        "value": "搜索条件"
      }
    }
  }
}

示例:

当你输入的搜索条件不是词条,而是短语时,由于不做分词,你反而搜索不到:

2.再来看下range查询,语法如下:

GET /{索引库名}/_search
{
  "query": {
    "range": {
      "字段名": {
        "gte": {最小值},
        "lte": {最大值}
      }
    }
  }
}

range是范围查询,对于范围筛选的关键字有:

  • gte:大于等于
  • gt:大于
  • lte:小于等于
  • lt:小于

示例:

再来看下range查询,语法如下:

GET /{索引库名}/_search
{
  "query": {
    "range": {
      "字段名": {
        "gte": {最小值},
        "lte": {最大值}
      }
    }
  }
}

range是范围查询,对于范围筛选的关键字有:

  • gte:大于等于
  • gt:大于
  • lte:小于等于
  • lt:小于

示例:

总结对比

类型分词?精度适合字段示例用途
match模糊text(分词字段)文章搜索
term精确keyword、数字等状态码、用户名
range精确数值、时间价格、日期筛选
multi_match模糊多个 text 字段多字段搜索

1.3.复合查询

复合查询大致分为两类:

类别说明示例
组合逻辑条件用逻辑运算组合多个叶子查询bool
控制文档相关性得分修改搜索结果文档的相关性得分 _scorefunction_score, dis_max

其它复合查询及相关语法可以参考官方文档:

Compound queries | Elasticsearch Guide [7.12] | Elastic

1.3.1.算分函数查询

当我们利用match查询时,文档结果会根据与搜索词条的关联度打分_score),返回结果时按照分值降序排列。

例如,我们搜索 “手机”,结果如下:

例如:页面搜索steam第一个确实steam游戏管家

基本语法

function score 查询中包含四部分内容:

  • 原始查询条件:query部分,基于这个条件搜索文档,并且基于BM25算法给文档打分,原始算分(query score)
  • 过滤条件:filter部分,符合该条件的文档才会重新算分
  • 算分函数:符合filter条件的文档要根据这个函数做运算,得到的函数算分(function score),有四种函数
    • weight:函数结果是常量
    • field_value_factor:以文档中的某个字段值作为函数结果
    • random_score:以随机数作为函数结果
    • script_score:自定义算分函数算法
  • 运算模式:算分函数的结果、原始查询的相关性算分,两者之间的运算方式,包括:
    • multiply:相乘
    • replace:用function score替换query score
    • 其它,例如:sum、avg、max、min

Function Score 查询结构

{
  "query": {
    "function_score": {
      "query": {...},             // 原始查询(计算原始得分)
      "functions": [              // 算分函数数组
        {
          "filter": {...},        // 过滤条件,哪些文档应用函数
          "weight": 10            // 算分函数结果(此处为常量)
        }
      ],
      "boost_mode": "multiply"    // 原始得分 × 函数得分
    }
  }
}

function score的运行流程如下:

  • 原始查询:用 BM25 计算 _score,称为原始得分 query score
  • 过滤条件:只对满足条件的文档重新打分
  • 算分函数:对符合条件的文档执行函数打分 function score
  • 得分合并:根据 boost_mode 决定如何合并两个得分(如乘法、替换、求和)

因此,其中的关键点是:

  • 过滤条件:决定哪些文档的算分被修改
  • 算分函数:决定函数算分的算法
  • 运算模式:决定最终算分结果

示例:给IPhone这个品牌的手机算分提高十倍,分析如下:

实现逻辑:

步骤内容
原始查询搜索所有手机
过滤条件brand = IPhone
算分函数weight = 10
合并模式multiply(原始得分 × 10)

对应代码如下:

GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "all": "手机"
        }
      },
      "functions": [
        {
          "filter": {
            "term": {
              "brand": "Iphone"
            }
          },
          "weight": 10
        }
      ],
      "boost_mode": "multiply"
    }
  }
}

1.3.2.bool查询

bool查询,即布尔查询。就是利用逻辑运算来组合一个或多个查询子句的组合。它的本质是:

用多种逻辑关系组合多个查询子句,控制哪些文档匹配,哪些文档得分。

bool查询支持的逻辑运算有:

  • must:必须匹配每个子查询,类似“与”
  • should:选择性匹配子查询,类似“或”
  • must_not:必须不匹配,不参与算分,类似“非”
  • filter:必须匹配,不参与算分

bool查询的语法如下:

{
  "query": {
    "bool": {
      "must": [         // 必须匹配所有的子查询(类似 AND)
        {"match": {"name": "手机"}}
      ],
      "should": [       // 选择性匹配任意一个子查询(类似 OR)
        {"term": {"brand": "vivo"}},
        {"term": {"brand": "小米"}}
      ],
      "must_not": [     // 必须不匹配(类似 NOT),**不参与算分**
        {"range": {"price": {"gte": 2500}}}
      ],
      "filter": [       // 必须匹配,但不参与算分
        {"range": {"price": {"lte": 1000}}}
      ]
    }
  }
}

例如黑马商城的搜索页面:

类型建议用法是否参与算分
输入框must✅ 是
品牌选择filter❌ 否
分类选择filter❌ 否
价格区间filter❌ 否
排除高价must_not❌ 否

比如,我们要搜索手机,但品牌必须是华为,价格必须是900~1599,那么可以这样写:

GET /items/_search
{
  "query": {
    "bool": {
      "must": [
        {"match": {"name": "手机"}}
      ],
      "filter": [
        {"term": {"brand": { "value": "华为" }}},
        {"range": {"price": {"gte": 90000, "lt": 159900}}}
      ]
    }
  }
}
  • must 中匹配关键词 “手机”
  • filter 中品牌和价格的约束,不参与算分,提升查询效率
关键词匹配要求是否参与算分类比逻辑典型用途
must必须匹配✅ 参与 _scoreAND主查询条件,例如关键词
should匹配任意即可✅ 参与 _scoreOR加分项、推荐项
must_not不能匹配❌ 不参与算分NOT排除项,例如黑名单、价格上限
filter必须匹配❌ 不参与算分AND过滤项,例如分类、价格区间

1.4.排序

elasticsearch默认是根据相关度算分(_score)来排序,但是也支持自定义方式对搜索结果排序。

不能对所有字段排序,比如以下类型就不能排序:

字段类型是否支持排序说明
text(分词)❌ 不支持被分析器切分,不支持排序
keyword✅ 支持原始不分词字符串
数值类型(int等)✅ 支持常用于排序(价格、权重等)
日期类型✅ 支持比如发布时间、更新时间等
地理坐标类型✅ 支持可用于地理距离排序(更复杂)

详细说明可以参考官方文档:

https://www.elastic.co/guide/en/elasticsearch/reference/7.12/sort-search-results.html

语法说明:

GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "排序字段": {
        "order": "asc" 或 "desc"
      }
    }
  ]
}

sort: 是一个数组,支持多字段排序(按优先级逐层排序)

order: 可以是 "asc"(升序) 或 "desc"(降序)

示例,我们按照商品价格排序:

GET /items/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "price": {
        "order": "desc"
      }
    }
  ]
}

这个查询会把所有 items 索引中的商品按 price 字段从高到低排列。

1.5.分页

Elasticsearch 分页查询机制

elasticsearch 默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。

1.5.1.基础分页

elasticsearch中通过修改fromsize参数来控制要返回的分页结果:

  • from:从第几个文档开始
  • size:总共查询几个文档

类似于mysql中的limit ?, ?

官方文档如下:

https://www.elastic.co/guide/en/elasticsearch/reference/7.12/paginate-search-results.html

语法如下:

GET /items/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0, // 分页开始的位置,默认为0
  "size": 10,  // 每页文档数量,默认10
  "sort": [
    {
      "price": {
        "order": "desc"
      }
    }
  ]
}

1.5.2.深度分页

当你查询比如 第 1000 页,每页 10 条,那就是 from=9990, size=10,这就需要从每个分片中取出前 10000 条 数据,然后汇总后排序、再取 9990~10000 的部分,对内存和 CPU 开销巨大!

因此 Elasticsearch 默认限制:

from + size <= 10000,否则报错。

GET /items/_search
{
  "from": 990, // 从第990条开始查询
  "size": 10, // 每页查询10条
  "sort": [
    {
      "price": "asc"
    }
  ]
}

针对深度分页,elasticsearch提供了两种解决方案:

  • search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。
  • scroll:原理将排序后的文档id形成快照,保存下来,基于快照做分页。官方已经不推荐使用。

总结:

深度分页是 Elasticsearch 的性能陷阱,分页方式要结合实际业务场景合理选用。

大多数情况下,我们采用普通分页就可以了。查看百度、京东等网站,会发现其分页都有限制。例如百度最多支持77页,每页不足20条。京东最多100页,每页最多60条。

因此,一般我们采用限制分页深度的方式即可,无需实现深度分页。

1.6.高亮

1.6.1.高亮原理

什么是高亮显示呢?

观察页面源码,你会发现两件事情:

  • 高亮词条都被加了<em>标签
  • <em>标签都添加了红色样式

css样式肯定是前端实现页面的时候写好的,但是前端编写页面的时候是不知道页面要展示什么数据的,不可能给数据加标签。而服务端实现搜索功能,要是有elasticsearch做分词搜索,是知道哪些词条需要高亮的。

因此词条的高亮标签肯定是由服务端提供数据的时候已经加上的

因此实现高亮的思路就是:

  • 用户输入搜索关键字
  • 服务端通过 Elasticsearch 执行搜索(如 match 查询)
  • ES 会在匹配的字段中把关键字用 <em> 包裹
  • 前端拿到数据后展示,并通过样式控制 <em> 的表现

1.6.2.实现高亮

事实上elasticsearch已经提供了给搜索关键字加标签的语法,无需我们自己编码。

基本语法如下:

GET /{索引库名}/_search
{
  "query": {
    "match": {
      "搜索字段": "搜索关键字"
    }
  },
  "highlight": {
    "fields": {
      "高亮字段名称": {
        "pre_tags": "<em>",
        "post_tags": "</em>"
      }
    }
  }
}

注意

条件说明
查询类型必须是 全文检索类型,如 matchmulti_match
字段类型高亮字段必须是 text 类型(可分词)
默认行为只有搜索字段和高亮字段一致才会高亮
特殊设置若想搜索字段和高亮字段不一致,需加 required_field_match: false

示例:

1.7总结

返回结果示意

查询的DSL是一个大的JSON对象,包含下列属性:

  • query:查询条件
  • fromsize:分页条件
  • sort:排序条件
  • highlight:高亮条件

2.RestClient查询

这部分讲的是如何通过 Java 的 RestHighLevelClient API 实现对 Elasticsearch 的文档查询,核心是模拟 Kibana 中的 DSL 查询,但用 Java 代码方式实现。

文档的查询依然使用昨天学习的 RestHighLevelClient对象,查询的基本步骤如下:

  • 1)创建request对象,这次是搜索,所以是SearchRequest
  • 2)准备请求参数,也就是查询DSL对应的JSON参数
  • 3)发起请求
  • 4)解析响应,响应结果相对复杂,需要逐层解析

2.1.快速入门

之前说过,由于Elasticsearch对外暴露的接口都是Restful风格的接口,因此JavaAPI调用就是在发送Http请求。而我们核心要做的就是利用利用Java代码组织请求参数解析响应结果

这个参数的格式完全参考DSL查询语句的JSON结构,因此我们在学习的过程中,会不断的把JavaAPI与DSL语句对比。大家在学习记忆的过程中,也应该这样对比学习。

DSL 示例Java API 构建
match_all 查询QueryBuilders.matchAllQuery()
带分页的查询request.source().from(x).size(y)
带排序的查询request.source().sort("field", SortOrder)
高亮查询HighlightBuilder 构造高亮规则

2.1.1.发送请求

首先以match_all查询为例,其DSL和JavaAPI的对比如图:

代码解读:

  • 第一步,创建SearchRequest对象,指定索引库名
  • 第二步,利用request.source()构建DSL,DSL中可以包含查询、分页、排序、高亮等
    • query():代表查询条件,利用QueryBuilders.matchAllQuery()构建一个match_all查询的DSL
  • 第三步,利用client.search()发送请求,得到响应

这里关键的API有两个,一个是request.source(),它构建的就是DSL中的完整JSON参数。其中包含了querysortfromsizehighlight等所有功能:

另一个是QueryBuilders,其中包含了我们学习过的各种叶子查询复合查询等:

2.1.2.解析响应结果

在发送请求以后,得到了响应结果SearchResponse,这个类的结构与我们在kibana中看到的响应结果JSON结构完全一致:

{
    "took" : 0,
    "timed_out" : false,
    "hits" : {
        "total" : {
            "value" : 2,
            "relation" : "eq"
        },
        "max_score" : 1.0,
        "hits" : [
            {
                "_index" : "heima",
                "_type" : "_doc",
                "_id" : "1",
                "_score" : 1.0,
                "_source" : {
                "info" : "Java讲师",
                "name" : "赵云"
                }
            }
        ]
    }
}

因此,我们解析SearchResponse的代码就是在解析这个JSON结果,对比如下:

// 4.解析响应结果
SearchHits hits = search.getHits();
hits.forEach(e -> {    
String sourceAsString = e.getSourceAsString();    ItemDoc item = JSON.parseObject(sourceAsString, ItemDoc.class);    
System.out.println("item = " + item);});

代码解读

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

  • hits:命中的结果
    • total:总条数,其中的value是具体的总条数值
    • max_score:所有结果中得分最高的文档的相关性算分
    • hits:搜索结果的文档数组,其中的每个文档都是一个json对象
      • _source:文档中的原始数据,也是json对象

因此,我们解析响应结果,就是逐层解析JSON字符串,流程如下:

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

2.1.3.总结

文档搜索的基本步骤是:

  1. 创建SearchRequest对象
  2. 准备request.source(),也就是DSL。
    1. QueryBuilders来构建查询条件
    2. 传入request.source() query() 方法
  3. 发送请求,得到结果
  4. 解析结果(参考JSON结果,从外到内,逐层解析)

完整代码如下:

@Test
void testMatchAll() throws IOException {
    // 1.创建Request
    SearchRequest request = new SearchRequest("items");
    // 2.组织请求参数
    request.source().query(QueryBuilders.matchAllQuery());
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

private void handleResponse(SearchResponse response) {
    SearchHits searchHits = response.getHits();
    // 1.获取总条数
    long total = searchHits.getTotalHits().value;
    System.out.println("共搜索到" + total + "条数据");
    // 2.遍历结果数组
    SearchHit[] hits = searchHits.getHits();
    for (SearchHit hit : hits) {
        // 3.得到_source,也就是原始json文档
        String source = hit.getSourceAsString();
        // 4.反序列化并打印
        ItemDoc item = JSONUtil.toBean(source, ItemDoc.class);
        System.out.println(item);
    }
}

小结

步骤说明
创建请求new SearchRequest("索引名")
构造查询request.source().query(...)
发送请求client.search(request, ...)
解析结果SearchHits 提取每个文档 _source

接下来讲解如何使用 Java API 实现 Elasticsearch 中的两种查询方式:叶子查询复合查询,核心是通过 QueryBuilders 来构建查询条件,然后发送请求获取结果。

2.2.叶子查询

叶子查询是最基本的查询方式,它直接作用于某个字段的值,不涉及多层组合。

所有的查询条件都是由QueryBuilders来构建的,叶子查询也不例外。因此整套代码中变化的部分仅仅是query条件构造的方式,其它不动。

例如match查询:

@Test
void testMatch() throws IOException {
    // 1.创建Request
    SearchRequest request = new SearchRequest("items");
    // 2.组织请求参数
    request.source().query(QueryBuilders.matchQuery("name", "脱脂牛奶"));
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

再比如multi_match查询:

@Test
void testMultiMatch() throws IOException {
    // 1.创建Request
    SearchRequest request = new SearchRequest("items");
    // 2.组织请求参数
    request.source().query(QueryBuilders.multiMatchQuery("脱脂牛奶", "name", "category"));
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

还有range查询:

@Test
void testRange() throws IOException {
    // 1.创建Request
    SearchRequest request = new SearchRequest("items");
    // 2.组织请求参数
    request.source().query(QueryBuilders.rangeQuery("price").gte(10000).lte(30000));
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

还有term查询:

@Test
void testTerm() throws IOException {
    // 1.创建Request
    SearchRequest request = new SearchRequest("items");
    // 2.组织请求参数
    request.source().query(QueryBuilders.termQuery("brand", "华为"));
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

2.3.复合查询

复合查询是将多个查询组合到一起的方式,常用 boolfunction_score 等。

复合查询也是由QueryBuilders来构建

算分的代码示例:

@Test
void testFunctionScore() throws IOException {
    // 1. 创建 Request 对象    
SearchRequest request = new SearchRequest("items"); 
   // 2. 构建 Function Score 查询    
// 2.1 创建Function Score查询   
 FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery(            QueryBuilders.matchQuery("name", "手机"), 
// 基础查询            
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{                   
 // 添加过滤条件函数:品牌为华为时权重x10                   
 new FunctionScoreQueryBuilder.FilterFunctionBuilder(                            QueryBuilders.termQuery("brand", "华为"),  
// 过滤条件                           
 ScoreFunctionBuilders.weightFactorFunction(10) // 权重 )    }    ).boostMode(CombineFunction.MULTIPLY); 
// 分数计算模式    
// 2.2 将查询加入请求    
request.source().query(functionScoreQuery);   
 // 3. 发送请求   
 SearchResponse response = client.search(request, RequestOptions.DEFAULT);    
// 4. 解析响应    
handleResponse(response);}

bool查询为例,DSL和JavaAPI的对比如图:

完整代码如下:

@Test
void testBool() throws IOException {
    // 1.创建Request
    SearchRequest request = new SearchRequest("items");
    // 2.组织请求参数
    // 2.1.准备bool查询
    BoolQueryBuilder bool = QueryBuilders.boolQuery();
    // 2.2.关键字搜索
    bool.must(QueryBuilders.matchQuery("name", "脱脂牛奶"));
    // 2.3.品牌过滤
    bool.filter(QueryBuilders.termQuery("brand", "德亚"));
    // 2.4.价格过滤
    bool.filter(QueryBuilders.rangeQuery("price").lte(30000));
    request.source().query(bool);
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}
查询方式QueryBuilders 示例特点
matchQuerymatchQuery("name", "牛奶")分词模糊匹配
multiMatchmultiMatchQuery("牛奶", "name", "category")多字段模糊匹配
termQuerytermQuery("brand", "华为")精确匹配,适用于 keyword
rangeQueryrangeQuery("price").gte(100).lte(300)数值/时间范围匹配
boolQuerymust + filter 等组合查询多条件组合
functionScorefunctionScoreQuery(...).boostMode(...)加权评分

接下来讲解如何通过 Java API 实现 Elasticsearch 的 排序、分页和高亮查询

2.4.排序和分页

之前说过,requeset.source()就是整个请求JSON参数,所以排序、分页都是基于这个来设置,其DSL和JavaAPI的对比如下:

完整示例代码:

@Test
void testPageAndSort() throws IOException {
    int pageNo = 1, pageSize = 5;

    // 1.创建Request
    SearchRequest request = new SearchRequest("items");
    // 2.组织请求参数
    // 2.1.搜索条件参数
    request.source().query(QueryBuilders.matchQuery("name", "脱脂牛奶"));
    // 2.2.排序参数
    request.source().sort("price", SortOrder.ASC);
    // 2.3.分页参数
    request.source().from((pageNo - 1) * pageSize).size(pageSize);
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

2.5.高亮

高亮查询与前面的查询有两点不同:

  • 条件同样是在request.source()中指定,只不过高亮条件要基于HighlightBuilder来构造
  • 高亮响应结果与搜索的文档结果不在一起,需要单独解析

首先来看高亮条件构造,其DSL和JavaAPI的对比如图:

示例代码如下:

@Test
void testHighlight() throws IOException {
    // 1.创建Request
    SearchRequest request = new SearchRequest("items");
    // 2.组织请求参数
    // 2.1.query条件
    request.source().query(QueryBuilders.matchQuery("name", "脱脂牛奶"));
    // 2.2.高亮条件
    request.source().highlighter(
            SearchSourceBuilder.highlight()
                    .field("name")// 设置高亮字段
                    .preTags("<em>") // 高亮前缀
                    .postTags("</em>")  // 高亮后缀
    );
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

再来看结果解析,文档解析的部分不变,主要是高亮内容需要单独解析出来,其DSL和JavaAPI的对比如图:

代码解读:

  • 3、4步:从结果中获取_sourcehit.getSourceAsString(),这部分是非高亮结果,json字符串。还需要反序列为ItemDoc对象
  • 5步:获取高亮结果。hit.getHighlightFields(),返回值是一个Map,key是高亮字段名称,值是HighlightField对象,代表高亮值
  • 5.1步:从Map中根据高亮字段名称,获取高亮字段值对象HighlightField
  • 5.2步:从HighlightField中获取Fragments,并且转为字符串。这部分就是真正的高亮字符串了
  • 最后:用高亮的结果替换ItemDoc中的非高亮结果

完整代码如下:

private void handleResponse(SearchResponse response) {
    SearchHits searchHits = response.getHits();
    // 1.获取总条数
    long total = searchHits.getTotalHits().value;
    System.out.println("共搜索到" + total + "条数据");
    // 2.遍历结果数组
    SearchHit[] hits = searchHits.getHits();
    for (SearchHit hit : hits) {
        // 3.得到_source,也就是原始json文档
        String source = hit.getSourceAsString();
        // 4.反序列化
        ItemDoc item = JSONUtil.toBean(source, ItemDoc.class);
        // 5.获取高亮结果
        Map<String, HighlightField> hfs = hit.getHighlightFields();
        if (CollUtils.isNotEmpty(hfs)) {
            // 5.1.有高亮结果,获取name的高亮结果
            HighlightField hf = hfs.get("name");
            if (hf != null) {
                // 5.2.获取第一个高亮结果片段,就是商品名称的高亮值
                String hfName = hf.getFragments()[0].string();
                item.setName(hfName);
            }
        }
        System.out.println(item);
    }
}
功能JavaAPI写法示例说明
分页from(x).size(y)from 是起始偏移,size 是每页大小
排序.sort("字段", SortOrder.ASC/DESC)指定排序字段及顺序
高亮.highlighter(SearchSourceBuilder.highlight()...)高亮字段需独立解析并替换
高亮结果获取getHighlightFields().get("字段").getFragments()取出高亮片段并替换进原始字段

3.数据聚合

这部分内容讲解的是 Elasticsearch 中的数据聚合(Aggregations),它是 ES 非常强大的功能之一,允许我们像写 SQL 的 GROUP BY 一样,对数据做分组、统计、计算,非常适合做报表分析和数据洞察。

聚合(aggregations)可以让我们极其方便的实现对数据的统计、分析、运算。例如:

  • 什么品牌的手机最受欢迎?
  • 这些手机的平均价格、最高价格、最低价格?
  • 这些手机每月的销售情况如何?

实现这些统计功能的比数据库的sql要方便的多,而且查询速度非常快,可以实现近实时搜索效果。

官方文档:

https://www.elastic.co/guide/en/elasticsearch/reference/7.12/search-aggregations.html

聚合常见的有三类:

  • 桶(Bucket聚合:用来对文档做分组
    • TermAggregation:按照文档字段值分组,例如按照品牌值分组、按照国家分组
    • Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组
  • 度量(Metric聚合:用以计算一些值,比如:最大值、最小值、平均值等
    • Avg:求平均值
    • Max:求最大值
    • Min:求最小值
    • Stats:同时求maxminavgsum
  • 管道(pipeline聚合:其它聚合的结果为基础做进一步运算

聚合的三大类型

聚合类型英文名作用示例
桶聚合Bucket分组按品牌分类、按日期分组
度量聚合Metric计算最大价格、平均价格
管道聚合Pipeline基于其它聚合结果再计算最大值差值、百分比变化

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

3.1.DSL实现聚合

与之前的搜索功能类似,我们依然先学习DSL的语法,再学习JavaAPI.

3.1.1.Bucket聚合

例如我们要统计所有商品中共有哪些商品分类,其实就是以分类(category)字段对数据分组。category值一样的放在同一组,属于Bucket聚合中的Term聚合。

基本语法如下:

GET /items/_search
{
  "size": 0, 
  "aggs": {
    "category_agg": {
      "terms": {
        "field": "category",
        "size": 20
      }
    }
  }
}

语法说明:

  • size:设置size为0,就是每页查0条,则结果中就不包含文档,只包含聚合
  • aggs:定义聚合
    • category_agg:聚合名称,自定义,但不能重复
      • terms:聚合的类型,按分类聚合,所以用term
        • field:按照哪个字段聚合(如 category
        • size:希望返回的聚合结果的最大数量(最多返回多少个桶)

来看下查询的结果:

3.1.2.带条件聚合

例子:统计价格 > 3000 的手机品牌有哪些

默认情况下,Bucket聚合是对索引库的所有文档做聚合,例如我们统计商品中所有的品牌,结果如下:

可以看到统计出的品牌非常多。

但真实场景下,我想知道价格高于3000元的手机品牌有哪些,该怎么统计呢?

我们需要从需求中分析出搜索查询的条件和聚合的目标:

  • 搜索查询条件:
    • 价格高于3000
    • 必须是手机
  • 聚合目标:统计的是品牌,肯定是对brand字段做term聚合

语法如下:

GET /items/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "category": "手机"
          }
        },
        {
          "range": {
            "price": {
              "gte": 300000
            }
          }
        }
      ]
    }
  }, 
  "size": 0, 
  "aggs": {
    "brand_agg": {
      "terms": {
        "field": "brand",
        "size": 20
      }
    }
  }
}

❗️注意:query 用于限定参与聚合的文档范围。

聚合结果如下:

{
  "took" : 2,
  "timed_out" : false,
  "hits" : {
    "total" : {
      "value" : 13,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "brand_agg" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "华为",
          "doc_count" : 7
        },
        {
          "key" : "Apple",
          "doc_count" : 5
        },
        {
          "key" : "小米",
          "doc_count" : 1
        }
      ]
    }
  }
}

可以看到,结果中只剩下3个品牌了。

3.1.3.Metric聚合

例子:每个品牌的手机价格最大值、最小值、平均值

这就要用到Metric聚合了,例如stats聚合,就可以同时获取minmaxavg等结果。

语法如下:

GET /items/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "category": "手机"
          }
        },
        {
          "range": {
            "price": {
              "gte": 300000
            }
          }
        }
      ]
    }
  }, 
  "size": 0, 
  "aggs": {
    "brand_agg": {
      "terms": {
        "field": "brand",
        "size": 20
      },
      "aggs": {
        "stats_meric": {
          "stats": {
            "field": "price"
          }
        }
      }
    }
  }
}
  • brand_agg 是外层的 分组(桶聚合)
  • stats_metric 是对每个桶里的文档做 统计(度量聚合)

结果会返回每个品牌下的最小值、最大值、平均值、总和、计数。

可以看到我们在brand_agg聚合的内部,我们新加了一个aggs参数。这个聚合就是brand_agg的子聚合,会对brand_agg形成的每个桶中的文档分别统计。

  • stats_meric:聚合名称
    • stats:聚合类型,stats是metric聚合的一种
      • field:聚合字段,这里选择price,统计价格

由于stats是对brand_agg形成的每个品牌桶内文档分别做统计,因此每个品牌都会统计出自己的价格最小、最大、平均值。

结果如下:

另外,我们还可以让聚合按照每个品牌的价格平均值排序:

聚合使用注意点

项目说明
aggs聚合顶层关键字,和 query 同级
必须字段聚合名称、聚合类型、聚合字段
类型限制只能用 keyword、数值、布尔、日期字段
子聚合可用于桶中进一步聚合,比如 桶 -> 度量

3.1.4.总结

aggs代表聚合,与query同级,此时query的作用是?

  • 限定聚合的的文档范围

聚合必须的三要素:

  • 聚合名称
  • 聚合类型
  • 聚合字段

聚合可配置属性有:

  • size:指定聚合结果数量(聚合结果有多个桶,size可以选择保留多少个桶)
  • order:指定聚合结果排序方式
  • field:指定聚合字段

3.2.RestClient实现聚合

可以看到在DSL中,aggs聚合条件与query条件是同一级别,都属于查询JSON参数。因此依然是利用request.source()方法来设置。

不过聚合条件的要利用AggregationBuilders这个工具类来构造。DSL与JavaAPI的语法对比如下:

聚合结果与搜索文档同一级别,因此需要单独获取和解析。具体解析语法如下:

完整代码如下:

@Test
void testAgg() throws IOException {
    // 1. 创建请求对象,指定索引
    SearchRequest request = new SearchRequest("items");

    // 2. 构建 bool 查询条件(只查询“手机”且价格大于等于 300000)
    BoolQueryBuilder bool = QueryBuilders.boolQuery()
        .filter(QueryBuilders.termQuery("category", "手机"))
        .filter(QueryBuilders.rangeQuery("price").gte(300000));
    request.source().query(bool).size(0); // 不需要文档内容,只要聚合结果

    // 3. 添加聚合条件:按 brand 字段分组聚合,取前5个
    request.source().aggregation(
        AggregationBuilders.terms("brand_agg").field("brand").size(5)
    );

    // 4. 执行查询
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);

    // 5. 获取聚合结果
    Aggregations aggregations = response.getAggregations();
    Terms brandTerms = aggregations.get("brand_agg"); // 根据名称取聚合结果

    // 6. 遍历每个桶,获取 brand 值和文档数
    for (Terms.Bucket bucket : brandTerms.getBuckets()) {
        String brand = bucket.getKeyAsString(); // 分组 key
        long count = bucket.getDocCount();      // 每组文档数
        System.out.println("brand = " + brand + "; count = " + count);
    }
}

高级篇完

完结

文末附加内容
暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇