初识 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做模糊查询,只能是逐行扫描数据,流程如下
- 用户搜索数据,条件是 title 符合
"%手机%" - 逐行获取数据,比如 id 为 1 的数据
- 判断数据中的 title 是否符合用户搜索条件
- 如果符合则放入结果集,不符合则丢弃。回到步骤 1
逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。
倒排索引
倒排索引中有两个非常重要的概念
- 文档(
Document):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息 - 词条(
Term):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条
创建倒排索引是对正向索引的一种特殊处理,流程如下
- 将每一个文档的数据利用算法分词,得到一个个词条
- 创建表,每行数据包括词条、词条所在文档 id、位置等信息
- 因为词条唯一性,可以给词条创建索引,例如 hash 表结构索引

倒排索引的搜索流程如下(以搜索"华为手机"为例)
- 用户输入条件
"华为手机"进行搜索 - 对用户输入内容分词 ,得到词条:
华为、手机 - 拿着词条在倒排索引中查找,可以得到包含词条的文档id:1、2、3
- 拿着文档 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:端口映射配置
安装 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 %}
分词器
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 的运行流程如下
- 根据原始条件 查询搜索文档,并且计算相关性算分,称为原始算分(query score)
- 根据过滤条件,过滤文档
- 符合过滤条件 的文档,基于算分函数 运算,得到函数算分(function score)
- 将原始算分 (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" // 简化写法
}
]
}

对查询的文档不算分数了 _score 为 null
地理坐标排序
地理坐标排序略有不同。
语法
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 方案。
高亮
高亮原理
我们在百度,京东搜索时,关键字会变成红色,比较醒目,这叫高亮显示

高亮显示的实现分为两步
- 给文档中的所有关键字都添加一个标签,例如
<em>标签 - 页面给
<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 可以选择在代码外层包裹东西,比如 if,try 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.impl 的 HotelService 的 search 方法中,添加一个排序功能
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 |
刘德华AT2016 → ldhat2016 |
keep_none_chinese_in_joined_full_pinyin |
在拼接完整拼音结果中保留非中文字符 | false |
刘德华2016 → liudehua2016 |
none_chinese_pinyin_tokenize |
如果非中文字符是拼音,则将其切分为拼音词项 | true |
liudehuaalibaba13zhuanghan → liu, de, hua, a, li, ba, ba, 13, zhuang, han;需要启用 keep_none_chinese 和 keep_none_chinese_together |
keep_original |
保留原始输入内容 | false |
开启后原始词也会保留 |
lowercase |
将非中文字符转为小写 | true |
ABC → abc |
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 等都放进去,作为自动补全的提示。
因此,总结一下,需要做的事情包括
- 修改 hotel 索引库结构,设置自定义拼音分词器
- 修改索引库的 name、all 字段,使用自定义分词器
- 索引库添加一个新字段 suggestion,类型为 completion 类型,使用自定义的分词器
- 给 HotelDoc 类添加 uggestion 字段,内容包含 brand、business
- 重新导入数据到 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注解,普通事件监听是用@EventListenerphase = 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
输入任何一个 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,这个节点负责存数据、搜索、聚合、CRUDingest,数据写入前的预处理,比如去掉空格、转换字段类型、添加字段、解析日志之类的,但是如果用 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- 算法与分片数量有关,因此索引库一旦创建,分片数量不能修改!因为通过这种方式计算存到哪里,那么查询的时候也会根据这个看去哪个分片查询
新增文档的流程如下

- 新增一个 id=1 的文档
- 对 id 做 hash 运算,假如得到的是 2,则应该存储到 shard-2
- shard-2 的主分片在 node3 节点,将数据路由到 node3
- 保存文档
- 同步给 shard-2 的副本 replica-2,在 node2 节点
- 返回结果给 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 都启动了




