Elasticsearch-分布式搜索引擎

初识 elasticsearch

了解 ES

elasticsearch 的作用

elasticsearch 是一款非常强大的开源搜索引擎,具备非常多强大功能,可以帮助我们从海量数据中快速找到需要的内容

例如:

  • 在 GitHub 搜索代码

  • 在电商网站搜索商品

  • 在百度搜索答案

  • 在打车软件搜索附近的车

ELK 技术栈

elasticsearch 结合 kibana、Logstash、Beats,也就是 elastic stack(ELK)。被广泛应用在日志数据分析、实时监控等领域

而 elasticsearch 是 elastic stack 的核心,负责存储、搜索、分析数据。

elasticsearch 和 lucene

elasticsearch 底层是基于lucene来实现的。

Lucene 是一个 Java 语言的搜索引擎类库,是 Apache 公司的顶级项目,由 DougCutting 于 1999 年研发。官网地址:https://lucene.apache.org/

Lucene 优势

  • 易扩展
  • 高性能(基于倒排索引)

Lucene 缺点

  • 只限于 Java 语言开发
  • 学习曲线陡峭
  • 不支持水平扩展

elasticsearch的发展历史:

  • 2004 年 Shay Banon 基于 Lucene 开发了 Compass
  • 2010 年 Shay Banon 重写了 Compass,取名为 Elasticsearch。

相对于 lucene,elasticsearch 的优势是

  • 支持分布式,可水平扩展
  • 提供 Restful 接口,可被任何语言调用

为什么不是其他搜索技术?

目前比较知名的搜索引擎技术排名(浏览器搜索 DB ranking)

虽然在早期,Apache Solr 是最主要的搜索引擎技术,但随着发展 elasticsearch 已经渐渐超越了 Solr

倒排索引

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

正向索引

那么什么是正向索引呢?例如给下表(tb_goods)中的id创建索引

如果是根据 id 查询,那么直接走索引,查询速度非常快。

但如果是基于title做模糊查询,只能是逐行扫描数据,流程如下

  1. 用户搜索数据,条件是 title 符合 "%手机%"
  2. 逐行获取数据,比如 id 为 1 的数据
  3. 判断数据中的 title 是否符合用户搜索条件
  4. 如果符合则放入结果集,不符合则丢弃。回到步骤 1

逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。

倒排索引

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

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

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

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

倒排索引的搜索流程如下(以搜索"华为手机"为例)

  1. 用户输入条件"华为手机"进行搜索
  2. 对用户输入内容分词 ,得到词条:华为手机
  3. 拿着词条在倒排索引中查找,可以得到包含词条的文档id:1、2、3
  4. 拿着文档 id 到正向索引中查找具体文档

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

正向和倒排

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

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

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

正向索引

  • 优点:
    • 可以给多个字段创建索引
    • 根据索引字段搜索、排序速度非常快
  • 缺点:
    • 根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描。

倒排索引

  • 优点:
    • 根据词条搜索、模糊搜索时,速度非常快
  • 缺点:
    • 只能给词条创建索引,而不是字段
    • 无法根据字段做排序

ES 的一些概念

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

文档和字段

elasticsearch 是面向**文档(Document)**存储的,可以是数据库中的一条商品数据,一个订单信息。

文档数据会被序列化为 json 格式后存储在 elasticsearch 中

而 Json 文档中往往包含很多的字段(Field),类似于数据库中的列。

索引和映射

索引(Index),就是相同类型的文档的集合。

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

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

mysql 与 elasticsearch

mysql 与 elasticsearch 的概念对比

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

{% note warning %}

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

并不是如此,两者各自有自己的擅长支出

  • Mysql:擅长事务类型操作,可以确保数据的安全和一致性
  • Elasticsearch:擅长海量数据的搜索、分析、计算 --> 基于 http 请求发送,所以只要语言能发 http 请求就能发送 DSL 语句使用 ES。
    {% endnote %}
    在企业中,往往是两者结合使用
  • 对安全性要求较高的写操作,使用 mysql 实现
  • 对查询性能要求较高的搜索需求,使用 elasticsearch 实现
  • 两者再基于某种方式,实现数据的同步,保证一致性

安装es、kibana

安装 es

先创建一个网络,让 es 和 kibana 容器互联

sh 复制代码
docker network create es-net

部署单点 es(非集群)

在本机存储数据的文件夹下创建三个文件夹 data/plugins/secrets,我的目录是 /Users/ice/Desktop/cola/environment/elasticsearch

sh 复制代码
docker run -d \
	--name es \
	-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
	-e "discovery.type=single-node" \
	-e "xpack.security.enabled=false" \
	-v es-data:/usr/share/elasticsearch/data \
	-v es-plugins:/usr/share/elasticsearch/plugins \
	-v es-config:/usr/share/elasticsearch/config \
	--privileged \
	--network es-net \
	-p 9200:9200 \
	-p 9300:9300 \
	elasticsearch:9.4.2
  • -e "cluster.name=es-docker-cluster":设置集群名称
  • -e "http.host=0.0.0.0":监听的地址,可以外网访问
  • -e "ES_JAVA_OPTS=-Xms512m -Xmx512m":内存大小,底层基于Java实现,默认是 1G,如果我们内存够用就不用管。
  • -e "discovery.type=single-node":非集群模式
  • -v es-data:/usr/share/elasticsearch/data:挂载逻辑卷,绑定es的数据目录
  • -v es-logs:/usr/share/elasticsearch/logs:挂载逻辑卷,绑定es的日志目录
  • -v es-plugins:/usr/share/elasticsearch/plugins:挂载逻辑卷,绑定es的插件目录
  • --privileged:授予逻辑卷访问权
  • --network es-net :加入一个名为es-net的网络中
  • -p 9200:9200:端口映射配置

访问 http://localhost:9200

安装 kibana

sh 复制代码
docker run -d \
	--name kibana \
	-e ELASTICSEARCH_HOSTS=http://es:9200 \
	--network=es-net \
	-p 5601:5601  \
	kibana:9.4.2
  • --network es-net :加入一个名为 es-net 的网络中,与 elasticsearch 在同一个网络中
  • -e ELASTICSEARCH_HOSTS=http://es:9200":设置elasticsearch的地址,因为kibana已经与elasticsearch在一个网络,因此可以用容器名直接访问elasticsearch
  • -p 5601:5601:端口映射配置

{% note danger %}

注意 kibana 要和 es 的版本要保持一致

{% endnote %}

访问 http://localhost:5601

分词器

es 在创建倒排索引时需要对文档分词;在搜索时,需要对用户输入内容分词。但默认的分词规则对中文处理并不友好。

安装 IK 插件

sh 复制代码
# 进入容器内部
docker exec -it es /bin/bash

# 在线下载并安装
./bin/elasticsearch-plugin install https://get.infini.cloud/elasticsearch/analysis-ik/9.4.2

# 退出重启容器
exit
docker restart es

测试,没啥问题

IK分词器有几种模式?

  • ik_smart:智能切分,粗粒度
  • ik_max_word:最细切分,细粒度

IK分词器如何拓展词条?如何停用词条?

  • 利用 config 目录的 IkAnalyzer.cfg.xml 文件添加拓展词典和停用词典
  • 在词典中添加拓展词条或者停用词条

config/analysis-ik 目录下

修改配置如下

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>
	 <!--用户可以在这里配置自己的扩展停止词字典-->
	<entry key="ext_stopwords">stopword.dic</entry>
	<!--用户可以在这里配置远程扩展字典 -->
	<!-- <entry key="remote_ext_dict">words_location</entry> -->
	<!--用户可以在这里配置远程扩展停止词字典-->
	<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>

之后我们还需要在目录下新建一个 ext.dic。stopword.dic 已经有了就不用新建了。

ext.dic 我们可以放入我们想加入的词,比如加入下面三个

text 复制代码
二狗
白嫖
奥力给

放进去还得跑个命令

sh 复制代码
docker cp /Users/ice/Desktop/cola/environment/elasticsearch/ext.dic \      
  es:/usr/share/elasticsearch/config/analysis-ik/

然后在 stopword.dic 放入我们想过滤掉的词,里面已经有一些英文了

text 复制代码
的
啊
嗯
哈

然后重启 es

sh 复制代码
docker restart es

测试

索引库操作

索引库就类似数据库表,mapping 映射就类似表的结构。

我们要向 es 中存储数据,必须先创建"库"和"表"。

mapping 映射属性

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

  • type:字段数据类型,常见的简单类型有:
    • 字符串:text(可分词的文本)、keyword(也是文本,但是是精确值,拆分后没意义了,例如:品牌、国家、ip地址、邮箱、年龄、体重)
    • 数值:long、integer、short、byte、double、float
    • 布尔:boolean
    • 日期:date
    • 对象:object
  • index:是否创建索引,默认为 true --> 是否创建倒排索引
  • analyzer:使用哪种分词器(只有 text 类型才用这个分词器)
  • properties:该字段的子字段

例如下面的json文档

json 复制代码
{
	"age": 21,
	"weight": 52.1,
	"isMarried": false,
	"info": "黑马程序员Java讲师",
    "email": "zy@itcast.cn",
    "score": [99.1, 99.5, 98.9], // es 没有针对数组的类型,直接用数组元素的类型 double 即可
	"name": {
		"firstName": "云",
		"lastName": "赵"
	}
}

对应的每个字段映射(mapping)

  • age:类型为 integer;参与搜索,因此需要 index 为 true;无需分词器
  • weight:类型为 float;参与搜索,因此需要 index 为 true;无需分词器
  • isMarried:类型为 boolean;参与搜索,因此需要 index 为 true;无需分词器
  • info:类型为字符串,需要分词,因此是 text;参与搜索,因此需要 index 为 true;分词器可以用 ik_smart
  • email:类型为字符串,但是不需要分词,因此是 keyword;不参与搜索,因此需要 index为false;无需分词器
  • score:虽然是数组,但是我们只看元素的类型,类型为 float;参与搜索,因此需要 index 为 true;无需分词器
  • name:类型为 object,需要定义多个子属性
    • name.firstName;类型为字符串,但是不需要分词,因此是 keyword;参与搜索,因此需要 index 为 true;无需分词器
    • name.lastName;类型为字符串,但是不需要分词,因此是 keyword;参与搜索,因此需要 index 为 true;无需分词器

索引库的 CRUD

这里我们统一使用 Kibana 编写 DSL 的方式来演示。

创建索引库和映射

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

格式:

json 复制代码
PUT/索引库名称
{
	"mappings": {
	"properties": {
      "字段名":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "字段名2":{
        "type": "keyword",
        "index": "false"
      },
      "字段名3":{
        "properties": {
          "子字段": {
            "type": "keyword"
          }
        }
      },
      // ...略
    }
  }
}
示例
sh 复制代码
PUT /heima
{
  "mappings": {
    "properties": {
      "info": {
        "type": "text",
        "analyzer": "ik_smart"
      },
      "email": {
        "type": "keyword",
        "index": false
      },
      "name": {
        "type": "object",
        "properties": {
          "firstName": {
            "type": "keyword"
          },
          "lastName": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

查询索引库

基本语法

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

格式

复制代码
GET /索引库名

示例

复制代码
GET /heima

修改索引库

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

虽然无法修改 mapping 中已有的字段,但是却允许添加新的字段到 mapping 中,因为不会对倒排索引产生影响。

语法说明

json 复制代码
PUT /索引库名/_mapping
{
  "properties": { // 必须是全新的字段名
    "新字段名":{
      "type": "integer"
    }
  }
}
json 复制代码
PUT /heima/_mapping
{
    "properties": {
        "age": {
            "type": "integer"
        }
    }
}

删除索引库

语法:

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

格式

复制代码
DELETE /索引库名

DELETE /heima

文档操作

新增文档

语法

如果不指定文档 id,es 会自动生成随机 id。这不好

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

示例

json 复制代码
POST /heima/_doc/1
{
    "info": "黑马程序员Java讲师",
    "email": "zy@itcast.cn",
    "name": {
        "firstName": "狗",
        "lastName": "二"
    }
}

可以重复插入,也就是没有主键冲突。执行两遍这个语句不会报错,版本号会增加

查询文档

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

语法

json 复制代码
GET /{索引库名称}/_doc/{id}
json 复制代码
GET /heima/_doc/1
  • _index 代表索引库名
  • _id 代表文档 id
  • _version 代表文档版本号,每被修改一次,版本号就加 1
  • _source 就是原始内容

删除文档

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

语法

js 复制代码
DELETE /{索引库名}/_doc/id值

示例

json 复制代码
# 根据id删除数据
DELETE /heima/_doc/1

执行删除,版本变为了 2

再插入一次试试

版本变成 3 了,所以猜测并没有真的删除,针对 id,每被删除、更新、插入一次,版本就加 1

修改文档

修改有两种方式

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

全量修改

全量修改是覆盖原来的文档,其本质是

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

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

语法

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

示例

json 复制代码
PUT /heima/_doc/1
{
    "info": "黑马程序员高级Java讲师",
    "email": "ergou@itcast.cn",
    "name": {
        "firstName": "狗",
        "lastName": "二"
    }
}

但是如果你 PUT 时,这个文档 id 不存在,这个 result 会变成 created,即新增,而不是 updated。所以这个功能既能修改也能新增

增量修改

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

语法:

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

示例

json 复制代码
POST /heima/_update/1
{
  "doc": {
    "email": "eg@itcast.cn"
  }
}

RestAPI

ES 官方提供了各种不同语言的客户端,用来操作 ES。这些客户端的本质就是组装 DSL 语句,通过 http 请求发送给 ES。官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html

其中的Java Rest Client又包括两种

  • Java Low Level Rest Client
  • Java High Level Rest Client

我们学习的是 Java HighLevel Rest Client 客户端 API,不过从 7.15 开始已经废弃了

Demo 工程

导入数据

数据结构如下

sql 复制代码
CREATE TABLE `tb_hotel` (
  `id` bigint(20) NOT NULL COMMENT '酒店id',
  `name` varchar(255) NOT NULL COMMENT '酒店名称;例:7天酒店',
  `address` varchar(255) NOT NULL COMMENT '酒店地址;例:航头路',
  `price` int(10) NOT NULL COMMENT '酒店价格;例:329',
  `score` int(2) NOT NULL COMMENT '酒店评分;例:45,就是4.5分',
  `brand` varchar(32) NOT NULL COMMENT '酒店品牌;例:如家',
  `city` varchar(32) NOT NULL COMMENT '所在城市;例:上海',
  `star_name` varchar(16) DEFAULT NULL COMMENT '酒店星级,从低到高分别是:1星到5星,1钻到5钻',
  `business` varchar(255) DEFAULT NULL COMMENT '商圈;例:虹桥',
  `latitude` varchar(32) NOT NULL COMMENT '纬度;例:31.2497',
  `longitude` varchar(32) NOT NULL COMMENT '经度;例:120.3925',
  `pic` varchar(255) DEFAULT NULL COMMENT '酒店图片;例:/img/1.jpg',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

大致内容如下

项目结构就是 mapper、pojo、service

mapping 映射分析

创建索引库,最关键的是 mapping 映射,而 mapping 映射要考虑的信息包括

  • 字段名
  • 字段数据类型
  • 是否参与搜索
  • 是否需要分词
  • 如果分词,分词器是什么?

其中

  • 字段名、字段数据类型,可以参考数据表结构的名称和类型
  • 是否参与搜索要分析业务来判断,例如图片地址,就无需参与搜索,酒店名称就需要分词
  • 是否分词呢要看内容,内容如果是一个整体就无需分词,反之则要分词
  • 分词器,我们可以统一使用 ik_max_word

来看下酒店数据的索引库结构

json 复制代码
PUT /hotel
{
  "mappings": {
    "properties": {
      "id": {
        "type": "keyword"
      },
      "name":{
        "type": "text",
        "analyzer": "ik_max_word",
        "copy_to": "all"
      },
      "address":{ // 博主说没人按照地址搜索,所以 index=false --> 不一定吧
        "type": "keyword",
        "index": false
      },
      "price":{
        "type": "integer"
      },
      "score":{
        "type": "integer"
      },
      "brand":{
        "type": "keyword",
        "copy_to": "all"
      },
      "city":{
        "type": "keyword",
        "copy_to": "all"
      },
      "starName":{
        "type": "keyword"
      },
      "business":{
        "type": "keyword"
      },
      "location":{
        "type": "geo_point"
      },
      "pic":{
        "type": "keyword",
        "index": false
      },
      "all":{
        "type": "text",
        "analyzer": "ik_max_word"
      }
    }
  }
}

几个特殊字段说明

  • id:你可以看看前面的,文档 id 其实都是字符串类型,"_id": "1",所以这里我们也是设置的字符串,并且不分词,为 keyword
  • location:地理坐标,里面包含精度、纬度
  • all:一个组合字段,其目的是将多字段的值利用 copy_to 合并,提供给用户搜索

需要根据信息搜索的,就 index: true

地理坐标说明

  • geo_point: 由维度和经度确定的一个点。例如 "32.8752345, 120.2981576"
  • geo_shape: 有多个 geo_point 组成点复杂几何图形。例如一条直线,"LINESTRING(-77.03653 38.897676, -77.009051 38.889939)"

copy_to 说明

字段拷贝可以使用 copy_to 属性将当前字段拷贝到指定字段

json 复制代码
"all":{ // 搜能根据它搜,但是它实际不存在,只是根据这个建立了倒排索引
  "type": "text",
  "analyzer": "ik_max_word"
}
"brand":{
  "type": "keyword",
  "copy_to": "all"
},

初始化 RestClient

{% note warning %}

这里我把 JDK 版本调到了 17,SpringBoot 3.5.16

{% endnote %}

在 elasticsearch 提供的API中,与 elasticsearch 一切交互都封装在一个名为 RestHighLevelClient 的类中,必须先完成这个对象的初始化,建立与 elasticsearch 的连接。

分为三步

引入 es 的依赖

xml 复制代码
<dependency>
    <groupId>co.elastic.clients</groupId>
    <artifactId>elasticsearch-java</artifactId>
    <version>9.4.2</version>
</dependency>

版本注意和之前一致,官网也这么推荐的

Getting started with the Elasticsearch Java client

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

java 复制代码
public class HotelIndexTest {

    private ElasticsearchClient esClient;

    @Test
    void testInit() {
        System.out.println(esClient); // 成功打印,连接成功
    }

    @BeforeEach
    void setUp() {
        String url = "http://localhost:9200";
        esClient = ElasticsearchClient.of(builder
                -> builder.host(url)); // 连接索引库
    }

    @AfterEach
    void tearDown() throws IOException {
        esClient.close(); // 关闭索引库
    }
}

创建索引库

java 复制代码
@Test
void createHotelIndex() throws IOException {
    boolean exists = esClient.indices().exists(e -> e.index("hotel")).value();
    if (exists) {
        System.out.println("hotel 索引已经存在");
        return;
    }

    esClient.indices() // ElasticsearchIndicesClient, 所有操作索引库的 API 都在这里
            .create(c -> c.index("hotel")
                    .withJson(new StringReader(MAPPING_TEMPLATE))
            );
}

{% hideToggle MAPPING_TEMPLATE %}

java 复制代码
private static final String MAPPING_TEMPLATE = """
{
  "mappings": {
    "properties": {
      "id": {
        "type": "keyword"
      },
      "name": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "address": {
        "type": "keyword"
      },
      "price": {
        "type": "integer"
      },
      "score": {
        "type": "integer"
      },
      "brand": {
        "type": "keyword"
      },
      "city": {
        "type": "keyword"
      },
      "starName": {
        "type": "keyword"
      },
      "business": {
        "type": "keyword"
      },
      "location": {
        "type": "geo_point"
      },
      "pic": {
        "type": "keyword",
        "index": false
      }
    }
  }
}
""";

{% endhideToggle %}

删除索引库

java 复制代码
@Test
void deleteHotelIndex() throws IOException {
    esClient.indices().delete(e -> e.index("hotel"));
}

判断索引库是否存在

java 复制代码
@Test
void existsHotelIndex() throws IOException {
    boolean exists = esClient.indices().exists(e -> e.index("hotel")).value();
    if (!exists) {
        System.out.println("索引库不存在");
    }
}

追加字段

java 复制代码
client.indices().putMapping(p -> p
        .index("hotel")
        .properties("isAD", property -> property
                .boolean_(b -> b)
        )
);

client.indices().putMapping(p -> p
        .index("hotel")
        .properties("remark", property -> property
                .text(t -> t
                        .analyzer("ik_max_word")
                )
        )
);

RestClient 操作文档

新增文档

索引库实体类

数据库查询后的结果是一个 Hotel 类型的对象。结构如下

java 复制代码
@Data
@TableName("tb_hotel")
public class Hotel {
    @TableId(type = IdType.INPUT)
    private Long id;
    private String name;
    private String address;
    private Integer price;
    private Integer score;
    private String brand;
    private String city;
    private String starName;
    private String business;
    private String longitude;
    private String latitude;
    private String pic;
}

与我们的索引库结构存在差异

  • longitude 和 latitude 需要合 并为 location,因此,我们需要定义一个新的类型,与索引库结构吻合
java 复制代码
@Data
@NoArgsConstructor
public class HotelDoc {
	private String id; // 这个也写成 String 比较好
    // 其余和 Hotel 一样
    private String location;
    // ...

    public HotelDoc(Hotel hotel) {
        // ...
        this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
        // ...
    }
}

语法说明

java 复制代码
@Test
void testAddDocument() throws IOException {
    Hotel hotel = hotelService.getById(39141L);
    HotelDoc hotelDoc = new HotelDoc(hotel);

    IndexResponse response = esClient.index(i -> i
            .index("hotel")
            .id(hotelDoc.getId()) // id 要变为 String,因为 es 中是 String
            .document(hotelDoc)
    );

    log.info("Indexed with version " + response.version());
}

查询文档

关键是拿到响应结果,获取 _resource

java 复制代码
@Test
void testGetDocumentById() throws IOException {
    GetResponse<HotelDoc> response = esClient.get(g -> g
                    .index("hotel")
                    .id("39141"),
            HotelDoc.class
    );
    if (response.found()) { // found 字段
        HotelDoc hotelDoc = response.source(); // 获取文档
        System.out.println(hotelDoc);
    } else {
        log.info("not found");
    }
}
  • 其他的返回信息比如版本、是否查询到等 也可以从 response 拿到

删除文档

java 复制代码
@Test
void testDeleteDocument() throws IOException {
    DeleteResponse response = esClient.delete(d -> d
            .index("hotel")
            .id("61083")
    );

    log.info("删除结果:{}", response.result()); // Deleted 或者 NotFound
}

修改文档

局部更新

java 复制代码
@Test
void testUpdateDocument() throws IOException {
    Map<String, Object> doc = new HashMap<>();
    doc.put("price", 999);
    doc.put("score", 45);

    UpdateResponse<HotelDoc> response = esClient.update(u -> u
                    .index("hotel")
                    .id("61083")
                    .doc(doc),
            HotelDoc.class
    );

    log.info("更新结果:{}", response.result());
}

只更新传入的字段,其他字段不变

或者是用对象也可以局部更新,如果值为 null 是不更新的。--> 所以如果想更新为 null 是没办法的

java 复制代码
@Test
void testUpdateDocumentByObject() throws IOException {
    HotelDoc hotelDoc = new HotelDoc();
    hotelDoc.setPrice(999);
    hotelDoc.setScore(45);

    UpdateResponse<HotelDoc> response = esClient.update(u -> u
                    .index("hotel")
                    .id("61083")
                    .doc(hotelDoc),
            HotelDoc.class
    );

    log.info("更新结果:{}", response.result());
}

不存在就插入

直接跟个配置就可以

java 复制代码
UpdateResponse<HotelDoc> response = esClient.update(u -> u
                .index("hotel")
                .id("61083")
                .doc(hotelDoc)
                .docAsUpsert(true),
        HotelDoc.class
);

如果想全量覆盖,直接用前面说的插入,也就是 .index() 就可以。

批量导入文档

案例需求:利用 bulk 批量将数据库数据导入到索引库中。

bulk 里可以同时放新增、修改、删除。

{% tabs 批量操作, 1 %}

java 复制代码
@Test
void testBulkAddDocument() throws IOException {
    List<Hotel> hotels = hotelService.list();

    BulkResponse response = esClient.bulk(b -> {
        for (Hotel hotel : hotels) {
            HotelDoc hotelDoc = new HotelDoc(hotel);

            b.operations(op -> op
                    .index(idx -> idx
                            .index("hotel")
                            .id(hotelDoc.getId())
                            .document(hotelDoc)
                    )
            );
        }
        return b;
    });

    if (response.errors()) {
        for (BulkResponseItem item : response.items()) {
            if (item.error() != null) {
                log.error("批量新增失败,id={}, 原因={}",
                        item.id(),
                        item.error().reason());
            }
        }
    } else {
        log.info("批量新增成功,共 {} 条", hotels.size());
    }
}
java 复制代码
@Test
void testBulkDeleteDocument() throws IOException {
    List<Long> ids = List.of(61083L, 61084L, 61085L);

    BulkResponse response = esClient.bulk(b -> {
        for (Long id : ids) {
            b.operations(op -> op
                    .delete(d -> d
                            .index("hotel")
                            .id(id.toString())
                    )
            );
        }
        return b;
    });

    response.items().forEach(item -> {
        if (item.error() != null) {
            log.error("删除失败,id={}, 原因={}",
                    item.id(),
                    item.error().reason());
        } else {
            log.info("删除结果,id={}, result={}",
                    item.id(),
                    item.result());
        }
    });
}
java 复制代码
@Test
void testBulkMixed() throws IOException {
    Hotel hotel = hotelService.getById(61083L);
    HotelDoc hotelDoc = new HotelDoc(hotel);

    Map<String, Object> updateDoc = new HashMap<>();
    updateDoc.put("price", 888);
    updateDoc.put("score", 50);

    BulkResponse response = esClient.bulk(b -> b
            .operations(op -> op
                    .index(idx -> idx
                            .index("hotel")
                            .id(hotelDoc.getId().toString())
                            .document(hotelDoc)
                    )
            )
            .operations(op -> op
                    .update(u -> u
                            .index("hotel")
                            .id("61084")
                            .action(a -> a
                                    .doc(updateDoc)
                            )
                    )
            )
            .operations(op -> op
                    .delete(d -> d
                            .index("hotel")
                            .id("61085")
                    )
            )
    );

    if (response.errors()) {
        response.items().forEach(item -> {
            if (item.error() != null) {
                log.error("操作失败,id={}, 原因={}",
                        item.id(),
                        item.error().reason());
            }
        });
    } else {
        log.info("批量操作全部成功");
    }
}

{% endtabs %}

DSL查询文档

elasticsearch 的查询依然是基于 JSON 风格的 DSL 来实现的。

DSL 查询分类

Elasticsearch 提供了基于 JSON 的 DSL(Domain Specific Language)来定义查询。常见的查询类型包括:

  • 查询所有:查询出所有数据,一般测试用。例如:match_all
  • 全文检索(full text)查询 :利用分词器对用户输入内容分词,然后去倒排索引库中匹配。例如:
    • match_query
    • multi_match_query
  • 精确查询 :根据精确词条值查找数据,一般是查找 keyword、数值、日期、boolean 等类型字段。例如:
    • ids
    • range
    • term
  • 地理(geo)查询 :根据经纬度查询。例如:
    • geo_distance
    • geo_bounding_box
  • 复合(compound)查询 :复合查询可以将上述各种查询条件组合起来,合并查询条件。例如:
    • bool
    • function_score

查询的语法基本一致

json 复制代码
GET /indexName/_search
{
  "query": {
    "查询类型": {
      "查询条件": "条件值"
    }
  }
}

我们以查询所有为例,其中

  • 查询类型为match_all
  • 没有查询条件
json 复制代码
// 查询所有
GET /hotel/_search
{
    "query": {
        "match_all": {}
    }
}

默认查出来 10 条。

其它查询无非就是查询类型查询条件的变化。

全文检索查询

使用场景

全文检索查询的基本流程如下

  • 对用户搜索的内容做分词,得到词条
  • 根据词条去倒排索引库中匹配,得到文档 id
  • 根据文档id找到文档,返回给用户

比较常用的场景包括

  • 商城的输入框搜索
  • 百度输入框搜索

例如京东

因为是拿着词条去匹配,因此参与搜索的字段也必须是可分词的 text 类型的字段。

基本语法

常见的全文检索查询包括

  • match 查询:单字段查询
  • multi_match 查询:多字段查询,任意一个字段符合条件就算符合查询条件

match 查询语法如下

json 复制代码
GET /indexName/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT"
    }
  }
}

mulit_match 语法如下:

json 复制代码
GET /indexName/_search
{
  "query": {
    "multi_match": {
      "query": "TEXT",
      "fields": ["FIELD1", " FIELD12"]
    }
  }
}

示例

json 复制代码
GET /hotel/_search
{
    "query": {
        "match": {
          "all": "外滩如家"
        }
    }
}

all 是拼接的字段,name + brand + city

json 复制代码
GET /hotel/_search
{
    "query": {
        "multi_match": {
          "query": "外滩如家",
          "fields": ["brand", "name", "business"]
        }
    }
}

两种查询结果是一样的,为什么?

因为我们将 brand、name、business 值都利用 copy_to 复制到了 all 字段中。因此根据三个字段搜索,和根据 all 字段搜索效果是一样的。

但是,搜索字段越多,对查询性能影响越大,因此建议采用 copy_to,然后单字段查询的方式。

精准查询

精确查询一般是查找 keyword、数值、日期、boolean 等类型字段。所以不会对搜索条件分词。常见的有

  • term:根据词条精确值查询
  • range:根据值的范围查询

term 查询

因为精确查询的字段搜是不分词的字段,因此查询的条件也必须是不分词的词条。查询时,用户输入的内容跟自动值完全匹配时才认为符合条件。如果用户输入的内容过多,反而搜索不到数据。

语法说明

json 复制代码
// term查询
GET /indexName/_search
{
  "query": {
    "term": {
      "FIELD": {
        "value": "VALUE"
      }
    }
  }
}
json 复制代码
GET /hotel/_search
{
    "query": {
        "term": {
          "city": {
            "value": "上海"
          }
        }
    }
}

当搜索的是精确词条时,能正确查询出结果

这个查询是完全相同,== 的情况下才能查到,如果你写 "value": "上海杭州" 是查不到上海的。

{% note warning %}

这里的 total 中的两个属性,第一个是值 83,第二个等于。含义是查出来的文档数是等于 83 个。

{% endnote %}

range 查询

范围查询,一般应用在对数值类型做范围过滤的时候。比如做价格范围过滤。

基本语法

json 复制代码
// range查询
GET /indexName/_search
{
  "query": {
    "range": {
      "FIELD": {
        "gte": 10, // 这里的gte代表大于等于,gt则代表大于
        "lte": 20 // lte代表小于等于,lt则代表小于
      }
    }
  }
}
json 复制代码
GET /hotel/_search
{
    "query": {
        "range": {
          "price": {
            "gte": 100,
            "lte": 200
          }
        }
    }
}

地理坐标查询

所谓的地理坐标查询,其实就是根据经纬度查询,官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html

常见的使用场景包括

  • 携程:搜索我附近的酒店
  • 滴滴:搜索我附近的出租车
  • 微信:搜索我附近的人

矩形范围查询

矩形范围查询,也就是 geo_bounding_box 查询,查询坐标落在某个矩形范围的所有文档,适合按地图找房之类的

查询时,需要指定矩形的左上右下两个点的坐标,然后画出一个矩形,落在该矩形内的都是符合条件的点。

语法如下:

json 复制代码
// geo_bounding_box查询
GET /indexName/_search
{
  "query": {
    "geo_bounding_box": {
      "FIELD": {
        "top_left": { // 左上点
          "lat": 31.1,
          "lon": 121.5
        },
        "bottom_right": { // 右下点
          "lat": 30.9,
          "lon": 121.7
        }
      }
    }
  }
}

附近查询

附近查询,也叫做距离查询(geo_distance):查询到指定中心点小于某个距离值的所有文档。

换句话来说,在地图上找一个点作为圆心,以指定距离为半径,画一个圆,落在圆内的坐标都算符合条件

语法说明

json 复制代码
// geo_distance 查询
GET /indexName/_search
{
  "query": {
    "geo_distance": {
      "distance": "15km", // 半径
      "FIELD": "31.21,121.5" // 圆心
    }
  }
}
json 复制代码
GET /hotel/_search
{
	"query": {
		"geo_distance": {
			"distance": "5km",
			"location": "31.21, 121.5"
		}
	}
}

复合查询

复合(compound)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑。常见的有两种

  • fuction score:算分函数查询,可以控制文档相关性算分,控制文档排名(比如百度,谁花钱打广告谁排名靠前)
  • bool query:布尔查询,利用逻辑关系组合多个其它的查询,实现复杂搜索

相关性算分

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

例如,我们搜索 "虹桥如家",结果如下:

json 复制代码
[
  {
    "_score" : 17.850193,
    "_source" : {
      "name" : "虹桥如家酒店真不错",
    }
  },
  {
    "_score" : 12.259849,
    "_source" : {
      "name" : "外滩如家酒店真不错",
    }
  },
  {
    "_score" : 11.91091,
    "_source" : {
      "name" : "迪士尼如家酒店真不错",
    }
  }
]

在 elasticsearch 中,早期使用的打分算法是 TF-IDF 算法,公式如下

比如 3 个文档中,只有一个词条包含外滩,那么 IDF 就高,打分就会高一点。3 个文档中都包含如家,所以 IDF 就为 0。

在后来的 5.1 版本升级中,elasticsearch 将算法改进为 BM25 算法,公式如下

TF-IDF算法有一各缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。而BM25则会让单个词条的算分有一个上限,曲线更加平滑:

算分函数查询

根据相关度打分是比较合理的需求,但合理的不一定是产品经理需要的。

以百度为例,你搜索的结果中,并不是相关度越高排名越靠前,而是谁掏的钱多排名就越靠前。要想认为控制相关性算分,就需要利用 elasticsearch 中的 function score 查询了。

语法说明

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 的运行流程如下

  1. 根据原始条件 查询搜索文档,并且计算相关性算分,称为原始算分(query score)
  2. 根据过滤条件,过滤文档
  3. 符合过滤条件 的文档,基于算分函数 运算,得到函数算分(function score)
  4. 原始算分 (query score)和函数算分 (function score)基于运算模式做运算,得到最终结果,作为相关性算分。

因此,其中的关键点是

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

需求:给"如家"这个品牌的酒店排名靠前一些

json 复制代码
GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {  .... }, // 原始查询,可以是任意条件
      "functions": [ // 算分函数
        {
          "filter": { // 满足的条件,品牌必须是如家
            "term": {
              "brand": "如家"
            }
          },
          "weight": 2 // 算分权重为2
        }
      ],
      "boost_mode": "sum" // 加权模式,求和
    }
  }
}

这个含义是原始分数 + 2 作为如家的新分数

布尔查询

布尔查询是一个或多个查询子句的组合,每一个子句就是一个子查询。子查询的组合方式有

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

比如在搜索酒店时,除了关键字搜索外,我们还可能根据品牌、价格、城市等字段做过滤。

每一个不同的字段,其查询的条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就必须用 bool 查询了。

需要注意的是,搜索时,参与打分的字段越多,查询的性能也越差。因此这种多条件查询时,建议这样做:

  • 搜索框的关键字搜索,是全文检索查询,使用 must 查询,参与算分
  • 其它过滤条件,采用 filter 查询。不参与算分
语法示例
json 复制代码
GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {"city": "上海" }}
      ],
      "should": [
        {"term": {"brand": "皇冠假日" }},
        {"term": {"brand": "华美达" }}
      ],
      "must_not": [
        { "range": { "price": { "lte": 500 } }}
      ],
      "filter": [
        { "range": {"score": { "gte": 45 } }}
      ]
    }
  }
}

城市必须在上海,品牌为皇冠假日或者华美达,价格在 500 以上,评分大于 45

示例

需求:搜索名字包含"如家",价格不高于 400,在坐标 31.21,121.5 周围 10km 范围内的酒店。

  • 名称搜索,属于全文检索查询,应该参与算分。放到must中
  • 价格不高于 400,用 range 查询,属于过滤条件,不参与算分。放到 must_not 中
  • 周围 10km 范围内,用 geo_distance 查询,属于过滤条件,不参与算分。放到 filter 中
json 复制代码
GET /hotel/_search
{
    "query": {
        "bool": {
            "must": [
              {
                "match": {
                    "name": "如家"
                }
              }
            ],
            "must_not": [
                {
                    "range": {
                        "price": {
                            "gt": 400
                        }
                    }
                }
            ],
            "filter": [
              {
                "geo_distance": {
                  "distance": "10km",
                  "location": {
                    "lat": 31.21,
                    "lon": 121.5
                  }
                }
              }
            ]
        }
    }
}

搜索结果处理

搜索的结果可以按照用户指定的方式去处理或展示。

排序

elasticsearch 默认是根据相关度算分(_score)来排序,但是也支持自定义方式对搜索结果排序。可以排序字段类型有:keyword 类型、数值类型、地理坐标类型、日期类型等。

如果自己指定排序方式,就会放弃打分,性能也会稍微好一点

普通字段排序

keyword、数值、日期类型排序的语法基本一致。

语法

json 复制代码
GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "FIELD": "desc"  // 排序字段、排序方式ASC、DESC
    }
  ]
}

排序条件是一个数组,也就是可以写多个排序条件。按照声明的顺序,当第一个条件相等时,再按照第二个条件排序,以此类推

示例

酒店数据按照用户评价(score)降序排序,评价相同的按照价格(price)升序排序

json 复制代码
GET /hotel/_search
{
    "query": {
        "match_all": {}
    },
    "sort": [
      {
        "score": {
          "order": "desc"
        },
        "price": "asc" // 简化写法
      }
    ]
}

对查询的文档不算分数了 _scorenull

地理坐标排序

地理坐标排序略有不同。

语法

json 复制代码
GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance" : {
          "FIELD" : "纬度,经度", // 文档中geo_point类型的字段名、目标坐标点
          "order" : "asc", // 排序方式
          "unit" : "km" // 排序的距离单位
      }
    }
  ]
}

这个查询的含义是

  • 指定一个坐标,作为目标点
  • 计算每一个文档中,指定字段(必须是geo_point类型)的坐标 到目标点的距离是多少
  • 根据距离排序

示例

实现对酒店数据按照到你的位置坐标的距离升序排序

假设位置是:31.034661,121.612282,寻找距离最近的酒店。

json 复制代码
GET /hotel/_search
{
    "query": {
        "match_all": {}
    },
    "sort": [
      {
        "_geo_distance": {
          "location": {
            "lat": 31.034661, // 用前面 "location": "31.034661, 121.612282" 方式也可以
            "lon": 121.612282
          },
          "order": "asc",
          "unit": "km"
        }
      }
    ]
}

sort 值是距离值,xx km

分页

elasticsearch 默认情况下只返回 top10 的数据。而如果要查询更多数据就需要修改分页参数了。elasticsearch 中通过修改 from、size 参数来控制要返回的分页结果

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

类似于 mysql 中的 limit ?, ?

基本的分页

分页的基本语法如下

json 复制代码
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0, // 分页开始的位置,默认为0
  "size": 10, // 期望获取的文档总数,默认是 10
  "sort": [
    {"price": "asc"}
  ]
}

深度分页问题

现在,我要查询 990~1000 的数据,查询逻辑要这么写

json 复制代码
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 990, // 分页开始的位置,默认为0
  "size": 10, // 期望获取的文档总数
  "sort": [
    {"price": "asc"}
  ]
}

这里是查询 990 开始的数据,也就是 第 990~1000 条数据。

不过,elasticsearch 内部分页时,必须先查询 0~1000 条,然后截取其中的 990 ~ 1000 的这 10 条

查询 TOP1000,如果 es 是单点模式,这并无太大影响。

但是 elasticsearch 将来一定是集群,例如集群有 5 个节点,我要查询 TOP1000 的数据,并不是每个节点查询 200 条就可以了。

因此要想获取整个集群的 TOP1000,必须先查询出每个节点的 TOP1000,汇总结果后,重新排名,重新截取 TOP1000。

那如果要查询 9900~10000 的数据呢?是不是要先查询 TOP10000 呢?那每个节点都要查询 10000 条?然后汇总到内存中?

当查询分页深度较大时,汇总数据过多,对内存和 CPU 会产生非常大的压力,因此 elasticsearch 会禁止 from+ size 超过 10000 的请求。

针对深度分页,ES 提供了两种解决方案,官方文档

  • search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。这样就不支持向前翻页了,只能往后翻页
  • scroll:原理将排序后的文档 id 形成快照,保存在内存,翻页的时候就从内存取,对内存消耗很大,另外如果又有新数据插入了,此时用的还是快照,不是最新的排序数据。
    官方已经不推荐使用。

小结

分页查询的常见实现方案以及优缺点

  • from + size
    • 优点:支持随机翻页
    • 缺点:深度分页问题,默认查询上限(from + size)是 10000
    • 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索
  • after search
    • 优点:没有查询上限(单次查询的 size 不超过 10000)
    • 缺点:只能向后逐页查询,不支持随机翻页
    • 场景:没有随机翻页需求的搜索,例如手机向下滚动翻页
  • scroll
    • 优点:没有查询上限(单次查询的 size 不超过 10000)
    • 缺点:会有额外内存消耗,并且搜索结果是非实时的
    • 场景:海量数据的获取和迁移。从 ES7.1 开始不推荐,建议用 after search 方案。

高亮

高亮原理

我们在百度,京东搜索时,关键字会变成红色,比较醒目,这叫高亮显示

高亮显示的实现分为两步

  1. 给文档中的所有关键字都添加一个标签,例如 <em> 标签
  2. 页面给 <em> 标签编写CSS样式

实现高亮

高亮的语法

json 复制代码
GET /hotel/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT" // 查询条件,高亮一定要使用全文检索查询
    }
  },
  "highlight": {
    "fields": { // 指定要高亮的字段
      "FIELD": {
        "pre_tags": "<em>",  // 用来标记高亮字段的前置标签
        "post_tags": "</em>" // 用来标记高亮字段的后置标签
      }
    }
  }
}

注意

  • 高亮是对关键字高亮,因此搜索条件必须带有关键字,而不能是范围这样的查询。
  • 默认情况下,高亮的字段,必须与搜索指定的字段一致,否则无法高亮
  • 如果要对非搜索字段高亮,则需要添加一个属性:required_field_match=false

这里必须添加 required_field_match=false,因为查询字段是 all,而高亮字段是 name,这不一致,所以无法高亮,添加这个属性之后才可以。

总结

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

  • query:查询条件
  • from 和 size:分页条件
  • sort:排序条件
  • highlight:高亮条件

RestClient查询文档

快速入门

我们以 match_all 查询为例

json 复制代码
GET /hotel/_search
{
	"query": {
		"match_all": {}
	}
}

发起查询请求

java 复制代码
SearchResponse<HotelDoc> response = esClient.search(s -> s
                .index("hotel") // 索引库
                .query(q -> q
                        .matchAll(m -> m) 
                ),
        HotelDoc.class
);

解析响应

响应结果的解析

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

  • hits:命中的结果
    • total:总条数,其中的value是具体的总条数值
    • max_score:所有结果中得分最高的文档的相关性算分
    • hits:搜索结果的文档数组,其中的每个文档都是一个 json 对象
      • _source:文档中的原始数据,也是 json 对象
java 复制代码
// 解析结果
HitsMetadata<HotelDoc> searchHits = response.hits();
// 查询的总条数
long total = searchHits.total().value();
System.out.println(total);
// 查询的结果数组
List<Hit<HotelDoc>> hits = searchHits.hits();
for (Hit<HotelDoc> hit : hits) {
    System.out.println(hit.source());
}

match 查询

全文检索的 match 和 multi_match 查询与 match_all 的 API 基本一致。差别是查询条件,也就是 query 的部分。

java 复制代码
// match
SearchResponse<HotelDoc> response = esClient.search(s -> s
                .index("hotel")
                .query(q -> q
                        .match(m -> m
                                .field("name")
                                .query("如家"))
                ),
        HotelDoc.class
);
java 复制代码
// multiMatch
SearchResponse<HotelDoc> response = esClient.search(s -> s
                .index("hotel")
                .query(q -> q
                        .multiMatch(m -> m
                                .query("如家 上海")
                                .fields("name", "business", "city"))
                ),
        HotelDoc.class
);

精确查询

精确查询主要是两个

  • term:词条精确匹配
  • range:范围查询
java 复制代码
// term
SearchResponse<HotelDoc> response = esClient.search(s -> s
            .index("hotel")
            .query(q -> q
                .term(m -> m
                    .field("city")
                    .value("上海"))
        ),
        HotelDoc.class
);

// range
SearchResponse<HotelDoc> response = esClient.search(s -> s
        .index("hotel")
        .query(q -> q
            .range(r -> r
                .number(n -> n
                    .field("price")
                    .lte(200.0)
            	)
            )
        ),
        HotelDoc.class
);

布尔查询

布尔查询是用 must、must_not、filter 等方式组合其它查询

java 复制代码
@Test
void testBoolQuery() throws IOException {
    SearchResponse<HotelDoc> response = esClient.search(s -> s
            .index("hotel")
            .query(q -> q
                .bool(b -> b
                    // must:必须匹配,参与打分
                    .must(m -> m
                        .match(mm -> mm
                            .field("name")
                            .query("如家")
                        )
                    )

                    // filter:必须匹配,不参与打分
                    .filter(f -> f
                        .term(t -> t
                            .field("city")
                            .value(v -> v.stringValue("上海"))
                        )
                    )
                    .filter(f -> f
                        .range(r -> r
                            .number(n -> n
                                .field("price")
                                .lte(300.0)
                            )
                        )
                    )

                    // mustNot:必须不匹配,不参与打分
                    .mustNot(mn -> mn
                        .term(t -> t
                            .field("brand")
                            .value(v -> v.stringValue("7天"))
                        )
                    )

                    // should:可以匹配,参与打分
                    .should(sh -> sh
                        .match(m -> m
                            .field("business")
                            .query("外滩")
                        )
                    )
                    .should(sh -> sh
                        .match(m -> m
                            .field("business")
                            .query("人民广场")
                        )
                    )

                    // 至少满足一个 should
                    .minimumShouldMatch("1")
                )
            ),
            HotelDoc.class
    );
}

{% note success %}

查询就是 .index().query() 下面弄的

{% endnote %}

排序、分页、高亮

搜索结果的排序和分页是与 query 同级的参数。

java 复制代码
SearchResponse<HotelDoc> response = esClient.search(s -> s
        .index("hotel")

        // 查询条件
        .query(q -> q
            .match(m -> m
                .field("name")
                .query("如家")
            )
        )

        // 分页
        .from((page - 1) * size)
        .size(size)

        // 排序:price 升序
        .sort(sort -> sort
            .field(f -> f
                .field("price")
                .order(SortOrder.Asc)
            )
        )

        // 高亮
        .highlight(h -> h
            .preTags("<em>")
            .postTags("</em>")
            .requireFieldMatch(false)
            .fields(NamedValue.of(
                "name",
                HighlightField.of(hf -> hf)
            ))
        ),
        HotelDoc.class
);

// 高亮获取在每个结果里面
for (Hit<HotelDoc> hit : hits) {
    System.out.println(hit.source());
    System.out.println(hit.highlight()); // 是个数组,根据这个来覆盖 HotelDoc 对应的属性值
}

黑马旅游案例

我们实现四部分功能

  • 酒店搜索和分页
  • 酒店结果过滤
  • 我周边的酒店
  • 酒店竞价排名

酒店搜索和分页

案例需求:实现黑马旅游的酒店搜索功能,完成关键字搜索和分页

需求分析

在项目的首页,有一个搜索框,还有分页按钮

请求信息如下

  • 请求方式:POST
  • 请求路径:/hotel/list
  • 请求参数:JSON 对象,包含4个字段
    • key:搜索关键字
    • page:页码
    • size:每页大小
    • sortBy:排序,目前暂不实现
  • 返回值:分页查询,需要返回分页结果 PageResult,包含两个属性
    • total:总条数
    • List<HotelDoc>:当前页的数据

定义实体类

实体类有两个,一个是前端的请求参数实体,一个是服务端应该返回的响应结果实体。

请求参数

cn.itcast.hotel.pojo 包下定义一个实体类

java 复制代码
@Data
public class RequestParams {
    private String key;
    private Integer page;
    private Integer size;
    private String sortBy;
}

返回值

分页查询,需要返回分页结果 PageResult,包含两个属性

  • total:总条数
  • List<HotelDoc>:当前页的数据

cn.itcast.hotel.pojo 中定义返回结果

java 复制代码
@Data
public class PageResult {
    private Long total;
    private List<HotelDoc> hotels;

    public PageResult() {
    }

    public PageResult(Long total, List<HotelDoc> hotels) {
        this.total = total;
        this.hotels = hotels;
    }
}

定义controller

cn.itcast.hotel.web 中定义 HotelController

java 复制代码
@RestController
@RequestMapping("/hotel")
public class HotelController {

    @Autowired
    private IHotelService hotelService;
	// 搜索酒店数据
    @PostMapping("/list")
    public PageResult search(@RequestBody RequestParams params){
        return hotelService.search(params);
    }
}

注入 esClient

实现搜索业务,我们需要先把 esClient 注册到 Spring 中。

这里我们没有用 Spring Data 去整合,就自己配置,现在 yaml 中把自己的信息写入

yaml 复制代码
elasticsearch:
  host: 127.0.0.1
  port: 9200
  scheme: http

创建一个配置类

java 复制代码
@Configuration
public class ElasticsearchConfig {

    @Value("${elasticsearch.host}")
    private String host;

    @Value("${elasticsearch.port}")
    private int port;

    @Value("${elasticsearch.scheme}")
    private String scheme;

    @Bean(destroyMethod = "close")
    public Rest5Client rest5Client() {
        return Rest5Client.builder(
                new HttpHost(scheme, host, port)
        ).build();
    }

    @Bean
    public ElasticsearchClient elasticsearchClient(Rest5Client rest5Client) {
        Rest5ClientTransport transport = new Rest5ClientTransport(
                rest5Client,
                new JacksonJsonpMapper()
        );

        return new ElasticsearchClient(transport);
    }
}

实现搜索业务

cn.itcast.hotel.service 中的 IHotelService 接口中定义一个方法

java 复制代码
/**
 * 根据关键字搜索酒店信息
 * @param params 请求参数对象,包含用户输入的关键字 
 * @return 酒店文档列表
 */
PageResult search(RequestParams params);

cn.itcast.hotel.service.impl 中的 HotelService 中实现 search 方法

java 复制代码
@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {

    @Autowired
    private ElasticsearchClient esClient;

    @Override
    public PageResult search(RequestParams params){
        SearchResponse<HotelDoc> response = null;
        try {
            String key = params.getKey();

            int page = params.getPage();
            int size = params.getSize();

            // 1 构造查询条件
            //   查询条件为空就 match_all
            Query query = StringUtils.hasText(key)
                    ? Query.of(q -> q
                        .match(m -> m
                            .field("all")
                            .query(key))
                    ) : Query.of(q -> q
                        .matchAll(m -> m)
                    );

            // 2 发起查询
            response = esClient.search(s -> s
                            .index("hotel")
                            .query(query)
                            .from((page - 1) * size)
                            .size(size),
                    HotelDoc.class
            );
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        return handleResponse(response);
    }

    private PageResult handleResponse(SearchResponse<HotelDoc> response) {
        PageResult pageResult = new PageResult();

        // 解析结果
        HitsMetadata<HotelDoc> searchHits = response.hits();
        // 查询的总条数
        long total = searchHits.total().value();

        pageResult.setTotal(total);
        // 查询的结果数组
        List<Hit<HotelDoc>> hits = searchHits.hits();
        List<HotelDoc> list = hits.stream().map(Hit::source).toList();
        pageResult.setHotels(list);

        return pageResult;
    }
}

{% note primary %}

选中代码,ctrl + alt + t 可以选择在代码外层包裹东西,比如 iftry catch

{% endnote %}

酒店结果过滤

需求:添加品牌、城市、星级、价格等过滤功能

包含的过滤条件有

  • brand:品牌值
  • city:城市
  • minPrice~maxPrice:价格范围
  • starName:星级

修改实体类

修改在 cn.itcast.hotel.pojo 包下的实体类 RequestParams:

java 复制代码
@Data
public class RequestParams {
    private String key;
    private Integer page;
    private Integer size;
    private String sortBy;
    // 下面是新增的过滤条件参数
    private String city;
    private String brand;
    private String starName;
    private Integer minPrice;
    private Integer maxPrice;
}

修改搜索业务

在 HotelService 的 search 方法中,只有一个地方需要修改也就是查询条件的构建。

在之前的业务中,只有 match 查询,根据关键字搜索,现在要添加条件过滤,包括

  • 品牌过滤:是 keyword 类型,用 term 查询
  • 星级过滤:是 keyword 类型,用 term 查询
  • 价格过滤:是数值类型,用 range 查询
  • 城市过滤:是 keyword 类型,用 term 查询

多个查询条件组合,用是 boolean 查询来组合

  • 关键字搜索放到 must 中,参与算分
  • 其它过滤条件放到 filter 中,不参与算分

代码如下

java 复制代码
@Override
public PageResult search(RequestParams params){
    SearchResponse<HotelDoc> response = null;
    try {
        String key = params.getKey();

        int page = params.getPage();
        int size = params.getSize();

        // 1 构造查询条件
        Query query = Query.of(q -> q
                .bool(b -> {
                    if (StringUtils.hasText(key)){
                        b.must(m -> m
                                .match(mm -> mm
                                        .field("all")
                                        .query(key)));
                    } else {
                        b.must(m -> m
                                .matchAll(mm -> mm));
                    }

                    if (StringUtils.hasText(params.getCity())){
                        b.filter(f -> f
                            .term(t -> t
                                .field("city")
                                .value(params.getCity())
                            )
                        );
                    }

                    if (StringUtils.hasText(params.getBrand())){
                        b.filter(f -> f
                            .term(t -> t
                                .field("brand")
                                .value(params.getBrand())
                            )
                        );
                    }

                    if (StringUtils.hasText(params.getStarName())){
                        b.filter(f -> f
                            .term(t -> t
                                .field("starName")
                                .value(params.getStarName())
                            )
                        );
                    }

                    if (params.getMaxPrice() != null && params.getMinPrice() != null){
                        b.filter(f -> f
                            .range(r -> r
                                .number(n -> n
                                    .field("price")
                                    .lte(params.getMaxPrice())
                                    .gte(params.getMinPrice())
                                )
                            )
                        );
                    }
                    return b;
                }));

        // 其余不变
}

太臃肿了,我们可以做一下改造

{% tabs serach方法重构 , 1 %}

java 复制代码
@Override
public PageResult search(RequestParams params) {
    try {
        int page = params.getPage();
        int size = params.getSize();

        Query query = buildBasicQuery(params); // 把 query 封装为方法

        SearchResponse<HotelDoc> response = esClient.search(s -> s
                        .index("hotel")
                        .query(query)
                        .from((page - 1) * size)
                        .size(size),
                HotelDoc.class
        );

        return handleResponse(response);
    } catch (IOException e) {
        throw new RuntimeException("ES 查询失败", e);
    }
}
java 复制代码
private Query buildBasicQuery(RequestParams params) {
    return Query.of(q -> q.bool(b -> {
        // 关键词查询
        if (StringUtils.hasText(params.getKey())) {
            b.must(m -> m.match(mm -> mm
                    .field("all")
                    .query(params.getKey())
            ));
        } else {
            b.must(m -> m.matchAll(ma -> ma));
        }

        // 精确过滤
        addTermFilter(b, "city", params.getCity());
        addTermFilter(b, "brand", params.getBrand());
        addTermFilter(b, "starName", params.getStarName());

        // 价格范围过滤
        addPriceRangeFilter(b, params.getMinPrice(), params.getMaxPrice());

        return b;
    }));
}

因为前面的代码我们可以看到 filter 有些重复代码,比较臃肿,可以封装为一个小方法

java 复制代码
private void addTermFilter(BoolQuery.Builder b, String field, String value) {
    if (!StringUtils.hasText(value)) {
        return;
    }

    b.filter(f -> f.term(t -> t
            .field(field)
            .value(value)
    ));
}
java 复制代码
private void addPriceRangeFilter(BoolQuery.Builder b, Integer minPrice, Integer maxPrice) {
    if (minPrice == null && maxPrice == null) {
        return;
    }

    b.filter(f -> f.range(r -> r.number(n -> {
        n.field("price");

        if (minPrice != null) {
            n.gte(minPrice.doubleValue());
        }

        if (maxPrice != null) {
            n.lte(maxPrice.doubleValue());
        }

        return n;
    })));
}

{% endtabs %}

我周边的酒店

需求:我附近的酒店

需求分析

在酒店列表页的右侧,有一个小地图,点击地图的定位按钮,地图会找到你所在的位置,将你的坐标发送到服务端

我们要做的事情就是基于这个 location 坐标,然后按照距离对周围酒店排序。

修改实体类

修改在 cn.itcast.hotel.pojo 包下的实体类 RequestParams

java 复制代码
@Data
public class RequestParams {
    private String key;
    private Integer page;
    private Integer size;
    private String sortBy;
    private String city;
    private String brand;
    private String starName;
    private Integer minPrice;
    private Integer maxPrice;
    // 我当前的地理坐标
    private String location;
}

添加距离排序

cn.itcast.hotel.service.implHotelServicesearch 方法中,添加一个排序功能

java 复制代码
SortOptions sortOptions = buildGeoDistanceSort(params.getLocation());

// 2 发起查询
response = esClient.search(s -> {
            s.index("hotel")
                .query(query)
                .from((page - 1) * size)
                .size(size);

            if (sortOptions != null){ // 非空才加入
                s.sort(sortOptions);
            }
            return s;
        },
        HotelDoc.class
);

排序方法如下

java 复制代码
private SortOptions buildGeoDistanceSort(String location){
    if (!StringUtils.hasText(location)) {
        return null;
    }
    String[] split = location.split(",");
    Double lat = Double.parseDouble(split[0].trim());
    Double lon = Double.parseDouble(split[1].trim());
    return SortOptions.of(sort -> sort
        .geoDistance(g -> g
            .field("location")
            .location(l -> l
                .latlon(ll -> ll
                    .lat(lat)
                    .lon(lon)
                )
            )
            .order(SortOrder.Asc)
            .unit(DistanceUnit.Kilometers)
        )
    );
}

排序距离显示

在结果解析阶段,除了解析 source 部分以外,还要得到 sort 部分,也就是排序的距离,然后放到响应结果中。

修改 HotelDoc 类,添加距离字段

java 复制代码
@Data
@NoArgsConstructor
public class HotelDoc {
    // ...
    // 排序时的 距离值
    private Object distance;
    // ...
}

修改 HotelService 中的 handleResponse 方法

java 复制代码
List<Hit<HotelDoc>> hits = searchHits.hits();
List<HotelDoc> list = hits.stream()
        .map(hit -> {
            HotelDoc hotelDoc = hit.source();

            if (hotelDoc == null) {
                return null;
            }

            if (hit.sort() != null && !hit.sort().isEmpty()) { // 注意 sort 的非空判断
                double distance = hit.sort().get(0).doubleValue();
                hotelDoc.setDistance(distance);
            }

            return hotelDoc;
        })
        .toList();
pageResult.setHotels(list);

return pageResult;

数据库中离黄岛区最近的 上海嘉定喜来登酒店

酒店竞价排名

需求:让指定的酒店在搜索结果中排名置顶

需求分析

要让指定酒店在搜索结果中排名置顶,效果如图

页面会给指定的酒店添加广告标记。

我们给这些酒店添加一个标记字段,这样在过滤条件中可以根据这个标记来判断,是否要提高算分

修改 HotelDoc 实体

cn.itcast.hotel.pojo 包下的 HotelDoc 类添加 isAD 字段

java 复制代码
@Data
@NoArgsConstructor
public class HotelDoc {
    // ...
    private Boolean isAD;
	// ...
}

添加广告标记

接下来挑几个酒店,添加 isAD 字段,设置为 true

json 复制代码
POST /hotel/_update/2056126831
{
    "doc": {
        "isAD": true
    }
}
POST /hotel/_update/1989806195
{
    "doc": {
        "isAD": true
    }
}
POST /hotel/_update/2056105938
{
    "doc": {
        "isAD": true
    }
}

添加算分函数查询

接下来修改查询条件了。之前是用的 boolean 查询,现在要改成 function_socre 查询。

function_score 查询结构如下

可以将之前写的 boolean 查询作为原始查询 条件放到 query 中,接下来就是添加过滤条件算分函数加权模式了。

java 复制代码
// 1 构造查询条件
Query boolQuery = buildBasicQuery(params);

Query query = Query.of(q -> q.functionScore(fs -> fs
        .query(boolQuery) // 布尔查询
        .functions(fn -> fn
            .filter(f -> f.term(t -> t
                .field("isAD")
                .value(true))) // 过滤字段
            .weight(10.0))
        // 多个 function 怎么合并
        .scoreMode(FunctionScoreMode.Sum)
        // function 分数和原始 _score 怎么合并
        .boostMode(FunctionBoostMode.Sum)
));

SortOptions sortOptions = buildGeoDistanceSort(params.getLocation());

// 2 发起查询
response = esClient.search(s -> {
            s.index("hotel")
                .query(query)
                .from((page - 1) * size)
                .size(size);

            if (sortOptions != null){
                s.sort(sortOptions);
            }
            return s;
        },
        HotelDoc.class
);

数据聚合

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

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

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

聚合的种类

聚合常见的有三类:

  • **桶(Bucket)**聚合:用来对文档做分组

    • TermAggregation:按照文档字段值分组,例如按照品牌值分组、按照国家分组
    • Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组
  • **度量(Metric)**聚合:用以计算一些值,比如:最大值、最小值、平均值等

    • Avg:求平均值
    • Max:求最大值
    • Min:求最小值
    • Stats:同时求max、min、avg、sum等
  • **管道(pipeline)**聚合:其它聚合的结果为基础做聚合

**注意:**参加聚合的字段必须是 keyword、日期、数值、布尔类型。text 可分词的不可以

DSL 实现聚合

现在,我们要统计所有数据中的酒店品牌有几种,其实就是按照品牌对数据分组。此时可以根据酒店品牌的名称做聚合,也就是Bucket聚合。

Bucket 聚合语法

json 复制代码
GET /hotel/_search
{
  "size": 0,  // 设置size为0,结果中不包含文档,只包含聚合结果
  "aggs": { // 定义聚合
    "brandAgg": { //给聚合起个名字
      "terms": { // 聚合的类型,按照品牌值聚合,所以选择term
        "field": "brand", // 参与聚合的字段
        "size": 20 // 希望获取的聚合结果数量,不指定默认为 10
      }
    }
  }
}

右侧 aggregations 代表聚合结果

聚合结果排序

默认情况下,Bucket 聚合会统计 Bucket 内的文档数量,记为 _count,并且按照 _count 降序排序。

我们可以指定 order 属性,自定义聚合的排序方式

json 复制代码
GET /hotel/_search
{
  "size": 0, 
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "order": {
          "_count": "asc" // 按照_count升序排列
        },
        "size": 20
      }
    }
  }
}

限定聚合范围

默认情况下,Bucket 聚合是对索引库的所有文档做聚合,但真实场景下,用户会输入搜索条件,因此聚合必须是对搜索结果聚合。那么聚合必须添加限定条件。

我们可以限定要聚合的文档范围,只要添加 query 条件即可

json 复制代码
GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "lte": 200 // 只对200元以下的文档聚合
      }
    }
  }, 
  "size": 0, 
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 20
      }
    }
  }
}

query 限定了 doc 查询的范围,也限定了聚合的数据来源,外层的 size 是想要获取 doc 的数量。

这样看来聚合实际上可以和查询分开来,查询即可以要结果,也可以不要结果,只给聚合限定条件。

Metric 聚合语法

现在我们需要对桶内的酒店做运算,获取每个品牌的用户评分的 min、max、avg 等值。

这就要用到 Metric 聚合了,例如 stat 聚合:就可以获取 min、max、avg 等结果。

json 复制代码
GET /hotel/_search
{
    "size": 0,
    "aggs": {
      "brandAgg": {
        "terms": {
            "field": "brand",
            "size": 10,
            "order": {
              "scoreAgg.avg": "desc" // 按照子聚合平均分对品牌做排序
            }
        },
        "aggs": { // brand 聚合的子聚合,分组后对每组分别计算
          "scoreAgg": { // 聚合名称
            "stats": { // 聚合类型
                "field": "score" // 聚合字段
            }
          }
        }
      }
    }
}

RestAPI 实现聚合

java 复制代码
@Test
void testAggregation() throws IOException {
	//  查询,并且不要 doc
    SearchResponse<Void> response = esClient.search(s -> s
            .index("hotel")
            .size(0)
            .aggregations("brand_agg", a -> a
                .terms(t -> t.field("brand")
                    .size(20)
                )
            ),
        Void.class
    );

    List<StringTermsBucket> buckets = response.aggregations()
            .get("brand_agg")
            .sterms()
            .buckets()
            .array();

    for (StringTermsBucket bucket : buckets) {
        String brand = bucket.key().stringValue();
        long count = bucket.docCount();

        System.out.println(brand + " : " + count);
    }
}

业务需求

需求:搜索页面的品牌、城市等信息不应该是在页面写死,而是通过聚合索引库中的酒店数据得来的

目前,页面的城市列表、星级列表、品牌列表都是写死的,并不会随着搜索结果的变化而变化。但是用户搜索条件改变时,搜索结果会跟着变化。

例如:用户搜索"东方明珠",那搜索的酒店肯定是在上海东方明珠附近,因此,城市只能是上海,此时城市列表中就不应该显示北京、深圳、杭州这些信息了。

也就是说,搜索结果中包含哪些城市,页面就应该列出哪些城市;搜索结果中包含哪些品牌,页面就应该列出哪些品牌。

如何得知搜索结果中包含哪些品牌?如何得知搜索结果中包含哪些城市?

使用聚合功能,利用 Bucket 聚合,对搜索结果中的文档基于品牌分组、基于城市分组,就能得知包含哪些品牌、哪些城市了。因为是对搜索结果聚合,因此聚合是限定范围的聚合,也就是说聚合的限定条件跟搜索文档的条件一致。

业务实现

cn.itcast.hotel.web 包的 HotelController 中添加一个方法,遵循下面的要求

  • 请求方式:POST
  • 请求路径:/hotel/filters
  • 请求参数:RequestParams,与搜索文档的参数一致
  • 返回值类型:Map<String, List<String>>

代码:

java 复制代码
@PostMapping("filters")
public Map<String, List<String>> getFilters(@RequestBody RequestParams params){
    return hotelService.filters(params);
}

cn.itcast.hotel.service.IHotelService 中定义新方法

java 复制代码
Map<String, List<String>> filters(RequestParams params);

cn.itcast.hotel.service.impl.HotelService 中实现该方法

java 复制代码
@Override
public Map<String, List<String>> filters(RequestParams params) {
    System.out.println("filters" + params.getStarName());
    Map<String, List<String>> map = new HashMap<>();
    final List<String> AGG_FIELDS = List.of("brand", "city", "starName");

    try {
        Query boolQuery = buildBasicQuery(params);
        SearchResponse<Void> response = esClient.search(s -> {
                    s.index("hotel").size(0);
                    s.query(boolQuery);
                    AGG_FIELDS.forEach((v) -> buildAggregation(s, v));
                    return s;
                },
            Void.class
        );

        AGG_FIELDS.forEach((v) -> {
            List<String> list = parseAggregation(response, v);
            map.put(v, list);
        });
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
    return map;
}

// 构建 aggregation
private void buildAggregation(SearchRequest.Builder s, String field){
    String aggName = field + "_agg";
    s.aggregations(aggName, a -> a
            .terms(t -> t.field(field)
                    .size(100)
            )
    );
}

// 解析 aggregation
private List<String> parseAggregation(SearchResponse<?> s, String field){
    List<StringTermsBucket> array = s.aggregations()
            .get(field + "_agg")
            .sterms()
            .buckets()
            .array();
    return array.stream().map(m -> m.key().stringValue()).toList();
}

自动补全

当用户在搜索框输入字符时,我们应该提示出与该字符有关的搜索项,如图

这种根据用户输入的字母,提示完整词条的功能,就是自动补全。因为需要根据拼音字母来推断,因此要用到拼音分词功能。

拼音分词器

要实现根据字母做补全,就必须对文档按照拼音分词。在 GitHub 上有 elasticsearch 的拼音分词插件。地址:https://github.com/medcl/elasticsearch-analysis-pinyin

安装方法

sh 复制代码
docker exec -it es /bin/bash

./bin/elasticsearch-plugin install https://get.infini.cloud/elasticsearch/analysis-pinyin/9.4.2

exit
docker restart es

测试用法如下

json 复制代码
POST /_analyze
{
  "text": "如家酒店还不错",
  "analyzer": "pinyin" // 原来是 ik_smart
}

所有字的首字母加上每个字的汉语拼音

自定义分词器

默认的拼音分词器会将每个汉字单独分为拼音,而我们希望的是每个词条形成一组拼音,需要对拼音分词器做个性化定制,形成自定义分词器。

elasticsearch 中分词器(analyzer)的组成包含三部分

  • character filters:在 tokenizer 之前对文本进行处理。例如删除字符、替换字符
  • tokenizer:将文本按照一定的规则切割成词条(term)。例如 keyword,就是不分词;还有 ik_smart
  • tokenizer filter:将 tokenizer 输出的词条做进一步处理。例如大小写转换、同义词处理、拼音处理等

文档分词时会依次由这三部分来处理文档

拼音分词器里面有很多属性我们可以用

参数 作用 默认值 备注
keep_first_letter 保留每个中文字符拼音的首字母,并合并成一个词项 true 刘德华ldh
keep_separate_first_letter 分别保留每个中文字符拼音的首字母 false 刘德华l, d, h;可能因词频影响查询模糊度
limit_first_letter_length 设置首字母结果的最大长度 16 限制首字母拼接结果长度
keep_full_pinyin 保留每个中文字符的完整拼音 true 刘德华liu, de, hua
keep_joined_full_pinyin 将每个中文字符的完整拼音拼接成一个词项 false 刘德华liudehua
keep_none_chinese 保留非中文的字母或数字 true 如保留英文、数字等
keep_none_chinese_together 将非中文字符连续保留在一起 true DJ音乐家DJ, yin, yue, jia;设为 false 时 → D, J, yin, yue, jia;需要先启用 keep_none_chinese
keep_none_chinese_in_first_letter 在首字母结果中保留非中文字符 true 刘德华AT2016ldhat2016
keep_none_chinese_in_joined_full_pinyin 在拼接完整拼音结果中保留非中文字符 false 刘德华2016liudehua2016
none_chinese_pinyin_tokenize 如果非中文字符是拼音,则将其切分为拼音词项 true liudehuaalibaba13zhuanghanliu, de, hua, a, li, ba, ba, 13, zhuang, han;需要启用 keep_none_chinesekeep_none_chinese_together
keep_original 保留原始输入内容 false 开启后原始词也会保留
lowercase 将非中文字符转为小写 true ABCabc
trim_whitespace 去除空白字符 true 去掉首尾或多余空白
remove_duplicated_term 删除重复词项,节省索引空间 false de的de;可能影响位置相关查询
ignore_pinyin_offset 忽略拼音 offset 限制,允许重叠 token true 6.0 后 offset 严格限制;开启后位置相关查询或高亮可能不准确。若需要准确 offset,应设为 false

声明自定义分词器的语法如下

json 复制代码
PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": { // 自定义分词器
        "my_analyzer": {  // 分词器名称
          "tokenizer": "ik_max_word",
          "filter": "py"
        }
      },
      "filter": { // 自定义tokenizer filter
        "py": { // 过滤器名称
          "type": "pinyin", // 过滤器类型,这里是pinyin
		  	  "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "my_analyzer",
        "search_analyzer": "ik_smart"
      }
    }
  }
}
  • 定义的分词器只针对某个索引库
  • 通过 mapping 映射给属性设定自定义分词器
  • analyzer 管理入库的时候怎么切词,search_analyzer 管查询的时候怎么分词。

拼音分词器适合在创建倒排索引的时候使用,但不能在搜索的时候使用

所以搜索的时候还是用 ik_smart,分词后只有中文 狮子 这样只会查到第一个文档。搜 shizi 那两个都可以找到了。两个加起来就可以解决某个东西 拼音或者中文 搜索都可以查询到了。

自动补全查询

elasticsearch 提供了Completion Suggester查询来实现自动补全功能。这个查询会匹配以用户输入内容开头的词条并返回。为了提高补全查询的效率,对于文档中字段的类型有一些约束

  • 参与补全查询的字段必须是 completion 类型。
  • 字段的内容一般是用来补全的多个词条形成的数组。

比如,一个这样的索引库

json 复制代码
// 创建索引库
PUT test
{
  "mappings": {
	"properties": {
	  "title": {
		"type": "completion"
	  }
	}
  }
}

然后插入下面的数据

json 复制代码
// 示例数据
POST test/_doc
{
  "title": ["Sony", "WH-1000XM3"]
}
POST test/_doc
{
  "title": ["SK-II", "PITERA"]
}
POST test/_doc
{
  "title": ["Nintendo", "switch"]
}

查询的DSL语句如下

json 复制代码
// 自动补全查询
GET /test/_search
{
  "suggest": {
	"title_suggest": {
	  "text": "s", // 关键字
	  "completion": {
		"field": "title", // 补全查询的字段
		"skip_duplicates": true, // 跳过重复的
		"size": 10 // 获取前10条结果
	  }
	}
  }
}

实现酒店搜索框自动补全

现在,hotel 索引库还没有设置拼音分词器,需要修改索引库中的配置。但是索引库是无法修改的,只能删除然后重新创建。

另外,需要添加一个字段,用来做自动补全,将 brand、suggestion、city 等都放进去,作为自动补全的提示。

因此,总结一下,需要做的事情包括

  1. 修改 hotel 索引库结构,设置自定义拼音分词器
  2. 修改索引库的 name、all 字段,使用自定义分词器
  3. 索引库添加一个新字段 suggestion,类型为 completion 类型,使用自定义的分词器
  4. 给 HotelDoc 类添加 uggestion 字段,内容包含 brand、business
  5. 重新导入数据到 hotel 库

修改酒店映射结构

json 复制代码
// 酒店数据索引库
PUT /hotel
{
  "settings": {
    "analysis": {
      "analyzer": {
        "text_anlyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        },
        "completion_analyzer": {
          "tokenizer": "keyword",
          "filter": "py"
        }
      },
      "filter": {
        "py": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "id":{
        "type": "keyword"
      },
      "name":{
        "type": "text",
        "analyzer": "text_anlyzer",
        "search_analyzer": "ik_smart",
        "copy_to": "all"
      },
      "address":{
        "type": "keyword",
        "index": false
      },
      "price":{
        "type": "integer"
      },
      "score":{
        "type": "integer"
      },
      "brand":{
        "type": "keyword",
        "copy_to": "all"
      },
      "city":{
        "type": "keyword"
      },
      "starName":{
        "type": "keyword"
      },
      "business":{
        "type": "keyword",
        "copy_to": "all"
      },
      "location":{
        "type": "geo_point"
      },
      "pic":{
        "type": "keyword",
        "index": false
      },
      "all":{
        "type": "text",
        "analyzer": "text_anlyzer",
        "search_analyzer": "ik_smart"
      },
      "suggestion":{
          "type": "completion",
          "analyzer": "completion_analyzer"
      }
    }
  }
}

这里

json 复制代码
"completion_analyzer": {
  "tokenizer": "keyword",
  "filter": "py"
}

所以,喜来 经过 keyword 后还是 喜来,经过 py 会变成如下

json 复制代码
喜来
xl
xilai

修改 HotelDoc 实体

HotelDoc 中要添加一个字段,用来做自动补全,内容可以是酒店品牌、城市、商圈等信息。按照自动补全字段的要求,最好是这些字段的数组。

因此在 HotelDoc 中添加一个 suggestion 字段,类型为 List<String>,然后将 brand、city、business 等信息放到里面。

java 复制代码
@Data
@NoArgsConstructor
public class HotelDoc {
    private Long id;
    private String name;
    private String address;
    private Integer price;
    private Integer score;
    private String brand;
    private String city;
    private String starName;
    private String business;
    private String location;
    private String pic;
    private Object distance;
    private Boolean isAD;
    private List<String> suggestion;

    public HotelDoc(Hotel hotel) {
        this.id = hotel.getId();
        this.name = hotel.getName();
        this.address = hotel.getAddress();
        this.price = hotel.getPrice();
        this.score = hotel.getScore();
        this.brand = hotel.getBrand();
        this.city = hotel.getCity();
        this.starName = hotel.getStarName();
        this.business = hotel.getBusiness();
        this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
        this.pic = hotel.getPic();
        // 组装suggestion
        if(this.business.contains("/")){
            // business有多个值,需要切割
            String[] arr = this.business.split("/");
            // 添加元素
            this.suggestion = new ArrayList<>();
            this.suggestion.add(this.brand);
            Collections.addAll(this.suggestion, arr);
        }else {
            this.suggestion = Arrays.asList(this.brand, this.business);
        }
    }
}

重新导入

重新执行之前编写的导入数据功能(之前写的 testBulkAddDocument),可以看到新的酒店数据中包含了 suggestion

自动补全查询的 JavaAPI

java 复制代码
@Test
void testCompletion() throws IOException {
    SearchResponse<Void> response = esClient.search(s -> s
        .index("hotel")
        .suggest(ss -> ss // 对应 "suggest"
            .suggesters("suggestion", i -> i // suggestion 是自己起的名字
                .prefix("s") // 用户输入的查询内容
                .completion(c -> c
                    .field("suggestion")
                    .skipDuplicates(true)
                    .size(10)
                )
            )
        )
    );
    // 解析的两种方式,stream
    System.out.println(response.suggest()
            .get("suggestion") // 对应前面自己取的名字
            .stream()
            .flatMap(suggestion -> suggestion.completion().options().stream())
            .map(option -> option.text())
            .toList()
    );

    List<Suggestion<Void>> suggestionList = response.suggest().get("suggestion");
    suggestionList.forEach(suggestion -> {
        suggestion.completion().options().forEach(option -> {
            System.out.println(option.text());
        });
    });
}

实现搜索框自动补全

当输入一个字符时,前端会发送请求

返回值是补全词条的集合,类型为 List<String>

cn.itcast.hotel.web 包下的 HotelController 中添加新接口,接收新的请求

java 复制代码
@GetMapping("suggestion")
public List<String> getSuggestions(@RequestParam("key") String prefix) {
    return hotelService.getSuggestions(prefix);
}

cn.itcast.hotel.service 包下的 IhotelService 中添加方法

java 复制代码
List<String> getSuggestions(String prefix);

cn.itcast.hotel.service.impl.HotelService 中实现该方法

java 复制代码
@Override
public List<String> getSuggestions(String prefix) {

    try {
        SearchResponse<Void> response = esClient.search(s -> s
            .index("hotel")
            .suggest(ss -> ss // 对应 "suggest"
                .suggesters("suggestion", i -> i // suggestion 是自己起的名字
                    .prefix(prefix) // 用户输入的查询内容
                    .completion(c -> c
                        .field("suggestion")
                        .skipDuplicates(true)
                        .size(10)
                    )
                )
            )
        );
        // 解析
        // 对应前面自己取的名字
        return response.suggest()
                .get("suggestion") // 对应前面自己取的名字
                .stream()
                .flatMap(suggestion -> suggestion.completion().options().stream())
                .map(CompletionSuggestOption::text)
                .toList();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }

}

数据同步

elasticsearch 中的酒店数据来自于 mysql 数据库,因此 mysql 数据发生改变时,elasticsearch 也必须跟着改变,这个就是 elasticsearch 与 mysql 之间的数据同步

redis 和 mysql 也存在数据同步问题

在微服务中,负责酒店管理 (操作 mysql) 的业务与负责酒店搜索 (操作 elasticsearch) 的业务可能在两个不同的微服务上,数据同步该如何实现呢?

思路分析

常见的数据同步方案有三种

  • 同步调用
  • 异步通知
  • 监听binlog

同步调用

基本步骤如下

  • hotel-demo 对外提供接口,用来修改 elasticsearch 中的数据
  • 酒店管理服务在完成数据库操作后,直接调用 hotel-demo 提供的接口,

异步通知

流程如下

  • hotel-admin 对 mysql 数据库数据完成增、删、改后,发送 MQ 消息
  • hotel-demo 监听 MQ,接收到消息后完成 elasticsearch 数据修改

监听 binlog

流程如下

  • 给 mysql 开启 binlog 功能
  • mysql 完成增、删、改操作都会记录在 binlog 中
  • hotel-demo 基于 canal 监听 binlog 变化,实时更新 elasticsearch 中的内容

cancel 也是一个中间件,binlog 开启后会把增删改的操作都记录下来,用来数据库同步。

选择

方式一:同步调用

  • 优点:实现简单,粗暴
  • 缺点:业务耦合度高

方式二:异步通知

  • 优点:低耦合,实现难度一般
  • 缺点:依赖 mq 的可靠性

方式三:监听 binlog

  • 优点:完全解除服务间耦合
  • 缺点:开启 binlog 增加数据库负担、实现复杂度高

实现数据同步

当酒店数据发生增、删、改时,要求对 elasticsearch 中数据也要完成相同操作。这里 hotel-admin 是真实业务,操作 MySQL 后要去发通知,hotel-demo 是操作 ES 的。

这里我们不用 rabbitmq 了,用 RocketMQ,我们在 Docker 中安装

参考 RocketMQ配置

前置准备

导入依赖

xml 复制代码
<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
    <version>2.3.6</version>
</dependency>

配置

hotel-admin: 生产者

yaml 复制代码
rocketmq:
  name-server: 127.0.0.1:9876
  producer:
    group: hotel-admin-producer-group

hotel-demo: 消费者

yaml 复制代码
rocketmq:
  name-server: 127.0.0.1:9876

公共常量,两个项目应该都抽取放在一个模块 hotel-common 里面的

java 复制代码
package cn.itcast.hotel.constants;

public final class HotelMqConstants {

    private HotelMqConstants() {
    }

    public static final String HOTEL_TOPIC = "hotel-topic";

    public static final String HOTEL_UPSERT_TAG = "hotel.upsert";

    public static final String HOTEL_DELETE_TAG = "hotel.delete";

    public static final String HOTEL_UPSERT_DESTINATION = HOTEL_TOPIC + ":" + HOTEL_UPSERT_TAG;

    public static final String HOTEL_DELETE_DESTINATION = HOTEL_TOPIC + ":" + HOTEL_DELETE_TAG;

    public static final String HOTEL_UPSERT_CONSUMER_GROUP = "hotel-upsert-consumer-group";

    public static final String HOTEL_DELETE_CONSUMER_GROUP = "hotel-delete-consumer-group";
}

对应关系就是

  • hotel-topic:hotel.upsert 新增/修改酒店
  • hotel-topic:hotel.delete 删除酒店

不建议一个裸 Long id,推荐发一个明确的消息对象

java 复制代码
package cn.itcast.hotel.mq.message;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class HotelSyncMessage implements Serializable {

    private Long hotelId;

    private Long timestamp;

    private String traceId;
}

可以表达 id、时间、traceId,后面好排查问题

定义本地 Spring 事件

java 复制代码
package cn.itcast.hotel.mq.event;

@AllArgsConstructor
@Getter
public class HotelChangedEvent {

    private final Long hotelId;
    private final HotelChangedType type;
}
java 复制代码
package cn.itcast.hotel.mq.event;

public enum HotelChangedType {
    UPSERT,
    DELETE
}

生产 MQ

在 hotel-admin 中

我们先来看一种不太好的写法

java 复制代码
@Transactional
public void updateHotel(Hotel hotel) {
    updateById(hotel);

    rocketMQTemplate.syncSend("hotel-topic:hotel.upsert", hotel.getId());
}

这个是更新完之后发送了 MQ 消息,方法结束。但是有个问题,就是事务开启之后,无论事务提交成功或者失败,MQ 都发送出去了,会导致事务不一致。

所以我们做法如下

java 复制代码
@Transactional
public void updateHotel(Hotel hotel) {
    updateById(hotel);

    eventPublisher.publishEvent(
            new HotelChangedEvent(hotel.getId(), HotelChangedType.UPSERT)
    );
}

又一个好处是,Service 不直接依赖 RocketMQ,这个时候还没有真实发送 RocketMQ,而是发送的 Spring 事件。事件类型是 HotelChangedEvent

之后我们写一个消费这个事件的

java 复制代码
package cn.itcast.hotel.mq.producer;

@Component
@RequiredArgsConstructor
public class HotelSyncMessageProducer {

    private final RocketMQTemplate rocketMQTemplate;

    @TransactionalEventListener(
            phase = TransactionPhase.AFTER_COMMIT,
            fallbackExecution = false
    )
    public void onHotelChanged(HotelChangedEvent event) {
        String destination = buildDestination(event.getType());

        HotelSyncMessage message = new HotelSyncMessage(
                event.getHotelId(),
                System.currentTimeMillis(),
                UUID.randomUUID().toString()
        );

        rocketMQTemplate.syncSend(destination, message);
    }

    private String buildDestination(HotelChangedType type) {
        if(type == HotelChangedType.DELETE) {
            return HotelMqConstants.HOTEL_DELETE_DESTINATION;
        }
        return HotelMqConstants.HOTEL_UPSERT_DESTINATION;
    }
}

几个需要注意的点

  • 加上 @Component 让 Spring 托管
  • @TransactionalEventListener 注解,普通事件监听是用 @EventListener
    • phase = TransactionPhase.AFTER_COMMIT 含义是事务提交成功后执行
    • fallbackExecution = false 含义是如果当前没有事务,要不要执行监听器。也就是说如果 updateHotel 方法上没加 @Transactional 注解是不会执行的
  • 这个方法参数是 HotelChangedEvent,所以发布事件的时候如果是这个事件类型才会执行这个监听器方法

整体流程如下

java 复制代码
1. 前端请求修改酒店
2. 进入 updateHotel()
3. Spring 开启数据库事务
4. updateById(hotel)
   修改 MySQL
5. publishEvent(...)
   发布 HotelChangedEvent 事件
6. Spring 发现有一个 @TransactionalEventListener
   监听 HotelChangedEvent
7. 但是因为 phase = AFTER_COMMIT
   所以先不执行 onHotelChanged()
8. updateHotel() 方法执行完
9. Spring 提交事务
10. 如果提交成功
    执行 onHotelChanged(event)
11. onHotelChanged() 里发送 MQ 消息

消费 MQ

在 hotel-demo 中

java 复制代码
package cn.itcast.hotel.mq.consumer;

@Component
@RocketMQMessageListener(
        topic = HotelMqConstants.HOTEL_TOPIC,
        consumerGroup = HotelMqConstants.HOTEL_DELETE_CONSUMER_GROUP,
        selectorExpression = HotelMqConstants.HOTEL_DELETE_TAG
)
public class HotelDeleteConsumer implements RocketMQListener<HotelSyncMessage> {

    private final IHotelService hotelService;

    public HotelDeleteConsumer(IHotelService hotelService) {
        this.hotelService = hotelService;
    }

    @Override
    public void onMessage(HotelSyncMessage message) {
        try {
            hotelService.deleteById(message.getHotelId()); // 删除 ES 数据
        } catch (Exception e) {
            log.error("删除 ES 酒店文档失败,message={}", message, e);
            throw e;
        }
    }
}
java 复制代码
package cn.itcast.hotel.mq.consumer;

@Component
@RocketMQMessageListener(
        topic = HotelMqConstants.HOTEL_TOPIC,
        consumerGroup = HotelMqConstants.HOTEL_UPSERT_CONSUMER_GROUP,
        selectorExpression = HotelMqConstants.HOTEL_UPSERT_TAG
)
@RequiredArgsConstructor
public class HotelUpsertConsumer implements RocketMQListener<HotelSyncMessage> {

    private final IHotelService hotelService;

    @Override
    public void onMessage(HotelSyncMessage message) {
        try {
            hotelService.upsertById(message.getHotelId());
        } catch (Exception e) {
            log.error("酒店同步 ES 失败,message={}", message, e);
            throw e;
        }
    }
}

{% hideToggle Service代码 %}

java 复制代码
@Override
public void deleteById(Long hotelId) {
    if (hotelId == null) {
        return;
    }

    try {
        esClient.delete(d -> d
                .index(HOTEL_INDEX)
                .id(hotelId.toString())
        );
    } catch (IOException e) {
        throw new RuntimeException("删除 Elasticsearch 酒店数据失败,hotelId=" + hotelId, e);
    }
}

@Override
public void upsertById(Long hotelId) {
    if (hotelId == null) {
        return;
    }

    try {
        // 真实微服务里,这里最好 Feign 调 hotel-admin 查询酒店详情
        // 当前项目里先直接查本地 MySQL
        // 这里又做了个保障,因为其实前面 hotel-admin 已经删除了 MySQL 数据
        Hotel hotel = getById(hotelId);

        if (hotel == null) {
            deleteById(hotelId);
            return;
        }

        HotelDoc hotelDoc = new HotelDoc(hotel);

        esClient.index(i -> i
                .index(HOTEL_INDEX)
                .id(hotelId.toString())
                .document(hotelDoc)
        );
    } catch (IOException e) {
        throw new RuntimeException("新增或更新 Elasticsearch 酒店数据失败,hotelId=" + hotelId, e);
    }
}

{% endhideToggle %}

这里有几个小细节

如果 ES 操作失败了,是写日志+抛异常让 ES 重试。所以我们新增/修改/删除操作要做成幂等,如果重试多次还是失败,消息会进入死信队列。

可以加一个补偿机制,比如每天凌晨或者手动接口触发,扫描 MySQL 表,重新批量写入 ES

{% note warning %}

如果是 MySQL 事务提交成功了,不是 ES 操作失败,而是 MQ 消息发送失败了呢?

生产上更稳健的做法是搞一个本地消息表,再操作完 MySQL 后,写一个消息表记录到数据库,然后提交事务,后台扫描未发送的消息,发送 MQ,发送成功后标记为已发送,失败就继续重试,但是比较复杂,现在学习先不用。

{% endnote %}

集群

单机的 elasticsearch 做数据存储,必然面临两个问题:海量数据存储问题、单点故障问题。

  • 海量数据存储问题:将索引库从逻辑上拆分为 N 个分片(shard),存储到多个节点
  • 单点故障问题:将分片数据在不同节点备份(replica )

ES集群相关概念

集群(cluster):一组拥有共同的 cluster name 的 节点。

节点(node) :集群中的一个 Elasticearch 实例

分片(shard):索引可以被拆分为不同的部分进行存储,称为分片。在集群环境下,一个索引的不同分片可以拆分到不同的节点中

解决问题:数据量太大,单点存储量有限的问题。

此处,我们把数据分成 3 片:shard0、shard1、shard2

  • 主分片(Primary shard):相对于副本分片的定义。
  • 副本分片(Replica shard)每个主分片可以有一个或者多个副本,数据和主分片一样。

数据备份可以保证高可用,但是每个分片备份一份,所需要的节点数量就会翻一倍,成本实在是太高了!

为了在高可用和成本间寻求平衡,我们可以这样做

  • 首先对数据分片,存储到不同节点
  • 然后对每个分片进行备份,放到对方节点,完成互相备份

这样可以大大减少所需要的服务节点数量,比如,每个分片都有 1 个备份,存储在 3 个节点

  • node0:保存了分片 0 和 1
  • node1:保存了分片 0 和 2
  • node2:保存了分片 1 和 2

搭建 ES 集群

使用 docker-compose 来完成,编写一个 docker-compose 文件,比如把文件放在我的 /Users/ice/Desktop/cola/environment/elasticsearch 目录下

yaml 复制代码
name: es-cluster # docker compose 项目名
services: # 定义具体容器
  es01:
    image: elasticsearch:9.4.2
    container_name: es01 # 容器名
    environment:
      - node.name=es01 # 节点名,可以和容器名不一样不一样
      - cluster.name=es-docker-cluster # 集群名,三个节点必须一样
      - discovery.seed_hosts=es02,es03 # 启动后去找XX节点,看能不能组成集群
      - cluster.initial_master_nodes=es01,es02,es03 # 第一次启动集群时,哪些节点可以当 master?
      - bootstrap.memory_lock=true
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es-data01:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
    networks:
      - elastic
  es02:
    image: elasticsearch:9.4.2
    container_name: es02
    environment:
      - node.name=es02
      - cluster.name=es-docker-cluster
      - discovery.seed_hosts=es01,es03
      - cluster.initial_master_nodes=es01,es02,es03
      - bootstrap.memory_lock=true
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es-data02:/usr/share/elasticsearch/data
    ports:
      - 9201:9200
    networks:
      - elastic
  es03:
    image: elasticsearch:9.4.2
    container_name: es03
    environment:
      - node.name=es03
      - cluster.name=es-docker-cluster
      - discovery.seed_hosts=es01,es02
      - cluster.initial_master_nodes=es01,es02,es03
      - bootstrap.memory_lock=true
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es-data03:/usr/share/elasticsearch/data
    ports:
      - 9202:9200
    networks:
      - elastic

volumes:
  es-data01:
    driver: local
  es-data02:
    driver: local
  es-data03:
    driver: local

networks:
  elastic:
    driver: bridge

进入这个目录执行命令建立集群

sh 复制代码
docker-compose up -d

集群状态监控

kibana 可以监控 es 集群,不过新版本需要依赖 es 的 x-pack 功能,配置比较复杂。这里使用 cerebro 来监控 es 集群状态,官方网址:https://github.com/lmenezes/cerebro

不过 cerebro 太老了,真正生产上还是用 kibana

macos 系统下载 tgz 压缩包就可以,然后进入 bin 目录

sh 复制代码
jdk8 # 因为我系统上是用的 jdk17,项目又比较老,所以需要换到 jdk8 环境运行
chmod +x cerebro
./cerebro

访问 http://localhost:9000

输入任何一个 es 节点的 IP 地址

可以看到三个节点都正常运行,实心的 es01 是主节点

创建索引库

指令创建

在 kibana 的 DevTools 指令创建

可以看到是在创建数据库的时候指定的

json 复制代码
PUT /itcast
{
  "settings": {
    "number_of_shards": 3, // 分片数量
    "number_of_replicas": 1 // 副本数量
  },
  "mappings": {
    "properties": {
      // mapping映射定义 ...
    }
  }
}
cerebro 创建

利用 cerebro 创建索引库,点击 more -> create index

右侧可以配置其他的东西。点击 create 创建。

现在可以看到实心的代表主的分片,虚线代表备份的分片

集群脑裂问题

集群职责划分

elasticsearch 中集群节点有不同的职责划分

compose 文件中用 node.roles: [ master, data, ingest ] 指定,但是我们没有配置

默认情况下,集群中的任何一个节点都同时具备上述四种角色。

  • master eligible,这个节点有资格被选成主节点,但是同一时间只有一个能作为真正的主节点。负责管理集群状态、分片放哪、处理节点加入/离开、处理创建/删除索引 (平时不太忙,但是非常关键,不能被查询影响,所以单独拆出来)
  • data,这个节点负责存数据、搜索、聚合、CRUD
  • ingest,数据写入前的预处理,比如去掉空格、转换字段类型、添加字段、解析日志之类的,但是如果用 Java 业务做了,就没太大用了
  • coordinating,负责接收用户请求,把请求发给 data 节点,然后收集结果(因为数据分片,可能在不同的 data 节点上),排序、分页、合并返回给用户

但是真实的集群一定要将集群职责分离

  • master 节点:对 CPU 要求高,但是内存要求低
  • data 节点:对 CPU 和内存要求都高
  • coordinating 节点:对网络带宽、CPU 要求高

职责分离可以让我们根据不同节点的需求分配不同的硬件去部署。而且避免业务之间的互相干扰。

一个典型的 es 集群职责划分如图

LB 是负载均衡器,可以是 Nginx。

这里面 ingest 就没加,因为业务一般也可以做。ingest 更适合以下几种情况

  • 数据来源很多
  • 业务系统不方便统一处理
  • 想把数据预处理集中放在 ES 入口
  • 日志类数据需要解析
  • 写入前需要统一加字段

脑裂问题

脑裂是因为集群中的节点失联导致的。

例如一个集群中,主节点与其它节点失联

此时,node2 和 node3 认为 node1 宕机,就会重新选主

当 node3 当选后,集群继续对外提供服务,node2 和 node3 自成集群,node1 自成集群,两个集群数据不同步,出现数据差异。

节点都没有宕机,这个时候网关能连到这三个节点,但是三个节点之间互相连不上了,所以网关解决不了脑裂的问题。网关转发请求的时候还是随机转发,两边都认为自己是 master,出现数据不一致

当网络恢复后,因为集群中有两个 master 节点,集群状态的不一致,出现脑裂的情况

解决脑裂的方案是,要求选票超过 ( eligible节点数量 + 1 )/ 2 才能当选为主,因此 eligible 节点数量最好是奇数。对应配置项是 discovery.zen.minimum_master_nodes,在 es7.0 以后,已经成为默认配置,因此一般不会发生脑裂问题,所以就不用管这个了

例如:3 个节点形成的集群,选票必须超过 (3 + 1) / 2 ,也就是 2 票。node3 得到 node2 和 node3 的选票,当选为主。node1 只有自己 1 票,没有当选。集群中依然只有 1 个主节点,没有出现脑裂。

集群分布式存储

当新增文档时,应该保存到不同分片,保证数据均衡,那么 coordinating node 如何确定数据该存储到哪个分片呢?

分片存储测试

用前端工具发请求,插入三条数据

这里我们插入三条,分别插入 id=1, id=3, id=5 (发三次请求哈,不啰嗦了)

注意我们是从 9200 端口插入的。

查询我们从 9201 端口查,然后我们加上一个参数 explain: true,可以查看数据所在分片位置

可以看到确实是在不同的分片上

分片存储原理

elasticsearch 会通过 hash 算法来计算文档应该存储到哪个分片

  • _routing 默认是文档的 id
  • 算法与分片数量有关,因此索引库一旦创建,分片数量不能修改!因为通过这种方式计算存到哪里,那么查询的时候也会根据这个看去哪个分片查询

新增文档的流程如下

  1. 新增一个 id=1 的文档
  2. 对 id 做 hash 运算,假如得到的是 2,则应该存储到 shard-2
  3. shard-2 的主分片在 node3 节点,将数据路由到 node3
  4. 保存文档
  5. 同步给 shard-2 的副本 replica-2,在 node2 节点
  6. 返回结果给 coordinating-node 节点

写入的时候,total 代表一共几份,咱们这个例子就是 1 个 primary 加上 1 个 replica

集群分布式查询

比如 match_all 不知道具体的文档 id

elasticsearch 的查询分成两个阶段

  • scatter phase:分散阶段,coordinating node 会把请求分发到每一个分片
  • gather phase:聚集阶段,coordinating node 汇总 data node 的搜索结果,并处理为最终结果集返回给用户

集群故障转移

集群的 master 节点会监控集群中的节点状态,如果发现有节点宕机,会立即将宕机节点的分片数据迁移到其它节点,确保数据安全,这个叫做故障转移。

例如一个集群结构如图

现在,node1 是主节点,其它两个节点是从节点。

突然,node1 发生了故障

宕机后的第一件事,需要重新选主,例如选中了 node2

node2 成为主节点后,会检测集群监控状态,发现:shard-1、shard-0 没有副本节点。因此会将 node1 上的数据迁移到 node2、node3

测试

下面测试一下,初始状态如下

然后我们执行下面的命令停掉 es01

sh 复制代码
docker-compose stop es01

这个时候整个界面是黄色的,代表不正常,es02 变为新的主节点了,稍等一会之后

可以看到又绿了,每个节点有两个分片了。我们在重启 es01

sh 复制代码
docker-compose start es01

可以看到正在同步,两个紫色的会迁移到 es01,过一会之后

还是每人一个备份分片,主节点还是 es02。

环境配置

RocketMQ 配置

这里安装 5.4 版本

bash 复制代码
mkdir -p /Users/ice/Desktop/cola/environment/rocketmq-5.4
cd /Users/ice/Desktop/cola/environment/rocketmq-5.4

再创建数据目录和日志目录

bash 复制代码
mkdir -p store logs
chmod -R 777 store logs

创建 broker.conf

bash 复制代码
cat > broker.conf <<'EOF'
brokerClusterName=DefaultCluster
brokerName=broker-a
brokerId=0

namesrvAddr=namesrv:9876
listenPort=10911

# 本机 IDEA/Spring Boot 项目连接 Docker 里的 Broker
brokerIP1=127.0.0.1

# 开发环境允许自动创建 Topic
autoCreateTopicEnable=true

deleteWhen=04
fileReservedTime=48

brokerRole=ASYNC_MASTER
flushDiskType=ASYNC_FLUSH
EOF

创建 docker-compose.yml

bash 复制代码
cat > docker-compose.yml <<'EOF'
services:
  namesrv:
    image: apache/rocketmq:5.4.0
    platform: linux/amd64
    container_name: rmqnamesrv
    ports:
      - "9876:9876"
    environment:
      - JAVA_OPT_EXT=-Xms256m -Xmx256m -Xmn128m
    command: sh mqnamesrv
    restart: unless-stopped

  broker:
    image: apache/rocketmq:5.4.0
    platform: linux/amd64
    container_name: rmqbroker
    depends_on:
      - namesrv
    ports:
      - "10909:10909"
      - "10911:10911"
    volumes:
      - ./broker.conf:/home/rocketmq/broker.conf
      - ./store:/home/rocketmq/store
      - ./logs:/home/rocketmq/logs
    environment:
      - NAMESRV_ADDR=namesrv:9876
      - JAVA_OPT_EXT=-Xms512m -Xmx512m -Xmn256m
    command: sh mqbroker -n namesrv:9876 -c /home/rocketmq/broker.conf
    restart: unless-stopped
EOF

这里是针对 macos 系统配置了个 platform: linux/amd64

配置一下 docker 镜像加速,在 setting -> docker engine 中加入并重启

json 复制代码
"registry-mirrors": [
	"https://docker.1ms.run",
	"https://docker.m.daocloud.io",
	"https://dockerproxy.com"
]

启动 RocketMQ

bash 复制代码
docker compose pull
docker compose up -d

查看容器

bash 复制代码
docker ps

之后 nameserver 和 broker 都启动了