# 基础介绍
ES(Elasticsearch)是一个分布式全文搜索引擎,中文说明 (opens new window)
# Elastic Stack
Elastic Stack(以前称为 ELK Stack)是一套开源的日志管理和分析解决方案,主要由四个组件组成:
Elasticsearch # 是elastic stack的核心,负责存储、搜索、分析数据
Logstash # 负责收集、处理,然后将数据发送到 Elasticsearch 或其他目标系统
Kibana # 是一个数据可视化和管理工具,用于与 Elasticsearch 交互并展示数据
Beats # 轻量级的数据采集器,用于从各种数据源收集数据并发送到 Elasticsearch 或 Logstash
# Elasticsearch
- elasticsearch是开源的分布式搜索引擎,擅长海量数据的搜索、分析、计算
- elasticsearch是面向文档存储的,可以是数据库中的一条商品数据,一个订单信息
- 文档数据会被序列化为json格式后存储在elasticsearch中
# 操作过程
- 将被查询的字段的数据全部文本信息进行查分,分成若干个词
分词 # 例如"中华人民共和国"会被拆分成三个词,分别是"中华"、"人民"、"共和国"
分词器 # 分词的策略不同,分出的效果不一样,不同的分词策略称为分词器
- 将分词得到的结果存储起来,对应每条数据的id
# 如id为1的"中华人民共和国,分词后,就会出现"中华"对应id为1,"人民"对应id为1,"共和国"对应id为1
# 如id为2的"人民代表大会",分词后,就会出现"人民"对应id为2,"代表"对应id为2,"大会"对应id为2
- 当查询"人民"时,通过上述表格数据进行比对,得到id值 1,2 ,然后再根据id值得到查询的结果数据
# 倒排索引
# 全文搜索中的根据分词结果查询后得到的并不是整条的数据,而是数据的id,要想获得具体数据还要再次查询
# 因此这里为这种分词结果关键字起了一个全新的名称,叫做倒排索引
# Mysql与Elasticsearch对比
MySQL | Elasticsearch | 说明 |
---|---|---|
Table | Index | 索引(index),就是文档的集合,类似数据库的表(table) |
Row | Document | 文档(Document),就是一条条的数据,类似数据库中的行,文档都是JSON格式 |
Column | Field | 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column) |
Schema | Mapping | Mapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema) |
SQL | DSL | DSL是elasticsearch提供的JSON风格的请求语句,用来操作elasticsearch,实现CRUD |
# 安装Elasticsearch
Windows 下载地址 (opens new window)
# 部署单点es
# 创建网络,让es和kibana容器互联
docker network create es-net
# 采用7.12.1版本的镜像,将其上传到虚拟机中,然后运行命令加载
docker load -i es.tar
# 运行docker命令,部署单点es
docker run -d \
--name es \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \ # 最小内存,最大内存都配置成了512MB
-e "cluster.name=es-docker-cluster" \ # 设置集群名称
-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 \
-p 9300:9300 \
elasticsearch:7.12.1
在浏览器中输入:http://192.168.153.131:9200/ 即可看到elasticsearch的响应结果:
{
"name" : "SYLONE",
"cluster_name" : "elasticsearch",
"cluster_uuid" : "FRC3KaaURamXTtdgTFkWQQ",
"version" : {
"number" : "7.12.1",
"build_flavor" : "default",
"build_type" : "zip",
"build_hash" : "b26557f585b7d95c71a5549e571a6bcd2667697d",
"build_date" : "2024-04-08T08:34:31.070382898Z",
"build_snapshot" : false,
"lucene_version" : "8.11.3",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}
# 部署kibana
kibana可以给我们提供一个elasticsearch的可视化界面,Windows下载地址 (opens new window)
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \ # 设置elasticsearch的地址
--network=es-net \
-p 5601:5601 \
kibana:7.12.1
# kibana启动一般比较慢,需要多等待一会,可以通过命令查看运行日志,说明成功
docker logs -f kibana
# 在浏览器输入地址访问:http://127.0.0.1:5601,即可看到结果
# DevTools
kibana中提供了一个DevTools界面,可以编写DSL来操作elasticsearch。并且对DSL语句有自动补全功能
POST # 请求方式
/_analyze # 请求路径,这里省略了虚拟机IP地址:9200,有kibana帮我们补充
analyzer # 分词器类型,这里是默认的standard分词器
text # 要分词的内容
# 安装IK分词器
不同的es有不同的ik版本对应
# 查看数据卷elasticsearch的plugins目录位置
docker volume inspect es-plugins
# 把课前资料中的ik分词器解压缩,重命名为ik,上传到es容器的插件数据卷中
/var/lib/docker/volumes/es-plugins/_data
# 重启容器
docker restart es
IK分词器包含两种模式类型:
ik_smart # 最少切分
ik_max_word # 最细切分
# 扩展词词典
# 打开IK分词器config目录
/var/lib/docker/volumes/es-plugins/ data/ik/config
# 在IKAnalyzer.cfg.xml配置文件内容添加
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 *** 添加扩展词典-->
<entry key="ext_dict">ext.dic</entry>
</properties>
# 新建一个 ext.dic,可以参考config目录下复制一个配置文件进行修改
传智播客
奥力给
# 重启elasticsearch
docker restart elasticsearch
docker restart kibana
注意
注意当前文件的编码必须是 UTF-8 格式,严禁使用Windows记事本编辑
# 停用词词典
# IKAnalyzer.cfg.xml配置文件内容添加
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典-->
<entry key="ext_dict">ext.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典 *** 添加停用词词典-->
<entry key="ext_stopwords">stopword.dic</entry>
</properties>
# 在 stopword.dic 添加停用词
停用词
# 重启elasticsearch
docker restart elasticsearch
docker restart kibana
# 部署es集群
# 首先编写一个docker-compose文件,内容如下:
services:
es01:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
container_name: es01
environment:
- node.name=es01
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es02,es03
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data01:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
- elastic
es02:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
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
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data02:/usr/share/elasticsearch/data
networks:
- elastic
es03:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
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
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data03:/usr/share/elasticsearch/data
networks:
- elastic
volumes:
data01:
driver: local
data02:
driver: local
data03:
driver: local
networks:
elastic:
driver: bridge
# 再运行docker-compose
docker-compose up
# DSL操作索引库
索引库就类似数据库表,创建索引库,最关键的是mapping映射,mapping映射就类似表的结构
# mapping映射属性
mapping是对索引库中文档的约束,常见的mapping属性包括:
type # 字段数据类型,常见的简单类型有:
# 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
# 数值:long、integer、short、byte、double、float、
# 布尔:boolean
# 日期:date
# 对象:object
index # 是否创建索引,默认为true,参与搜索
analyzer # 使用哪种分词器
properties # 该字段的子字段
例如下面的数据库表结构:
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;
对应的mapping映射索引库结构:
"mappings": {
"properties": {
"id": {
"type": "long"
"index": true # 默认为true,参与搜索
},
"name":{
"type": "text", # 类型为字符串,需要分词,因此是text
"analyzer": "ik_max_word", # 分词器是ik_max_word
"copy_to": "all"
},
"address":{
"type": "keyword", # 类型为字符串,但是不需要分词,因此是keyword
"index": false # 不参与搜索,因此需要index为false
},
"price":{
"type": "integer"
},
"score":{
"type": "integer"
},
"brand":{
"type": "keyword", # 类型为字符串,但是不需要分词,因此是keyword
"copy_to": "all"
},
"city":{
"type": "keyword", # 类型为字符串,但是不需要分词,因此是keyword
"copy_to": "all"
},
"starName":{
"type": "keyword" # 类型为字符串,但是不需要分词,因此是keyword
},
"business":{
"type": "keyword" # 类型为字符串,但是不需要分词,因此是keyword
},
"location":{
"type": "geo_point" # longitude和latitude需要合并为location
},
"pic":{
"type": "keyword", # 类型为字符串,但是不需要分词,因此是keyword
"index": false
},
"all":{
"type": "text", # 类型为字符串,需要分词,因此是text
"analyzer": "ik_max_word" # 分词器是ik_max_word
}
}
}
copy_to属性
如果对 all 字段进行搜索,实际上会同时搜索 brand 和 city 的内容,而不必担心搜索的哪个字段
ES中支持两种地理坐标数据类型:
geo_point
# 由纬度 latitude 和经度 longitude 确定的一个点
# 例如:"32.8752345,120.2981576"
geo_shape
# 有多个geo_point组成的复杂几何图形
# 例如直线:"LINESTRING(-77.03653 38.897676,-77.009051 38.889939)
# 索引库的CRUD
ES中通过Restful请求操作索引库、文档。请求内容用DSL语句来表示。
- 新增索引库
# 请求方式:PUT
# 请求路径:/索引库名,可以自定义
# 请求参数:mapping映射
PUT /索引库名称
{
"mappings": {
"properties": {
"字段名":{
"type": "text",
"analyzer": "ik_smart"
},
"字段名2":{
"type": "keyword",
"index": "false"
},
"字段名3":{
"properties": {
"子字段": {
"type": "keyword"
}
}
},
# ...略
}
}
}
- 修改索引库,索引库和mapping一旦创建无法修改,但是可以添加新的字段
PUT /索引库名/_mapping
{
"properties": {
"新字段名":{
"type": "integer"
}
}
}
- 查询索引库
# 请求方式:GET
# 请求路径:/索引库名
# 请求参数:无
GET /索引库名
- 删除索引库
# 请求方式:DELETE
# 请求路径:/索引库名
# 请求参数:无
DELETE /索引库名
# DSL操作文档
- 新增文档
POST /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
"字段3": {
"子属性1": "值3",
"子属性2": "值4"
},
# ...
}
- 修改文档
# 全量修改:先根据id删除文档,再新增一个相同id的文档
PUT /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
# ... 略
}
# 增量修改:只修改指定id匹配的文档中的部分字段
POST /索引库名/_update/文档id
{
"doc": {
"字段名": "新的值",
}
}
注意
如果全量修改根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了
- 查询文档
GET /索引库名称/_doc/{id}
- 删除文档
DELETE /索引库名称/_doc/{id}
# RestClient操作索引库
ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。官方文档地址 (opens new window)
# 初始化RestClient
在elasticsearch提供的API中,与elasticsearch一切交互都封装在一个名为RestHighLevelClient的类中,必须先完成这个对象的初始化,建立与elasticsearch的连接。
- 引入es的RestHighLevelClient依赖
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.12.1</version>
</dependency>
- 因为SpringBoot默认的ES版本是7.6.2,所以我们需要覆盖默认的ES版
<properties>
<java.version>1.8</java.version>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
- 初始化RestHighLevelClient
RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://127.0.0.1:9200")
));
# 新增索引库
public class HotelIndex {
private RestHighLevelClient client;
@BeforeEach
void setUp() {
// 初始化RestHighLevelclient
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://127.0.0.1:9200")
));
}
@Test
void testCreateHotelIndex() throws IOException {
// 1.创建Request对象,因为是创建索引库的操作,因此Request是CreateIndexRequest
CreateIndexRequest request = new CreateIndexRequest("hotel");
// 2.添加请求参数,其实就是DSL的JSON参数部分。这里是定义了静态字符串常量MAPPING_TEMPLATE
request.source(MAPPING_TEMPLATE, XContentType.JSON);
// 3.发起请求,client.indices()方法的返回值是IndicesClient类型,包含索引库操作的所有方法
client.indices().create(request, RequestOptions.DEFAULT);
}
@AfterEach
void tearDown() throws IOException {
this.client.close();
}
}
public class HotelConstants {
// 静态字符串常量MAPPING_TEMPLATE
public static final String MAPPING_TEMPLATE="{\n" +
" \"mappings\": {\n" +
" \"properties\": {\n" +
" \"id\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"name\":{\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"address\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"price\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"score\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"brand\":{\n" +
" \"type\": \"keyword\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"city\":{\n" +
" \"type\": \"keyword\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"starName\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"business\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"location\":{\n" +
" \"type\": \"geo_point\"\n" +
" },\n" +
" \"pic\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"all\":{\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\"\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
}
# 删除索引库
@Test
void testCreateHotelIndex() throws IOException {
// 1.创建Request对象,这次是DeleteIndexRequest对象
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
// 3.发起请求,改用delete方法
client.indices().delete(request, RequestOptions.DEFAULT);
}
# 判断索引库是否存在
@Test
void testExistsHotelIndex() throws IOException {
// 1.创建Request对象,这次是GetIndexRequest对象
GetIndexRequest request = new GetIndexRequest("hotel");
// 2.发送请求,改用exists方法
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
// 3.输出
System.err.println(exists ? "索引库已经存在!" : "索引库不存在!");
}
# RestClient操作文档
# 新增文档
- 索引库实体类
// 由于Hotel类型的对象与我们的索引库结构存在差异(longitude和latitude需要合并为location)
// 我们需要定义一个新的类型,与索引库结构吻合
@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;
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();
}
}
- 代码整体步骤如下:
@Test
void testAddDocument() throws IOException {
// 1.根据id查询酒店数据
Hotel hotel = hotelService.getById(61083L);
// 2.转换为文档类型
HotelDoc hotelDoc = new HotelDoc(hotel);
// 3.将HotelDoc转json
String json = JSON.toJSONString(hotelDoc);
// 1.创建IndexRequest,指定索引库名和id
IndexRequest request = new IndexRequest("hotel").id(hotelDoc.getId().toString());
// 2.准备Json文档
request.source(json, XContentType.JSON);
// 3.发送请求
client.index(request, RequestOptions.DEFAULT);
}
# 修改文档
- 全量修改:本质是先根据id删除,再新增,
# 在RestClient的API中,全量修改与新增的API完全一致,判断依据是ID:
# 如果新增时,ID已经存在,则修改
# 如果新增时,ID不存在,则新增
- 增量修改:修改文档中的指定字段值
@Test
void testUpdateDocument() throws IOException {
// 1.准备Request,这次是修改,所以是UpdateRequest
UpdateRequest request = new UpdateRequest("hotel", "61083");
// 2.准备请求参数,也就是JSON文档,里面包含要修改的字段
request.doc(
"price", "952",
"starName", "四钻"
);
// 3.发送请求,这里调用client.update()方法
client.update(request, RequestOptions.DEFAULT);
}
# 查询文档
@Test
void testGetDocumentById() throws IOException {
// 1.准备Request,这次是查询,所以是GetRequest。要指定索引库名和id
GetRequest request = new GetRequest("hotel", "61083");
// 2.发送请求,因为是查询,这里调用client.get()方法
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3.得到响应结果json,数据是放在一个_source属性中,因此getSourceAsString()
String json = response.getSourceAsString();
// 4.对JSON做反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
# 删除文档
@Test
void testDeleteDocument() throws IOException {
// 1.准备Request,因为是删除,这次是DeleteRequest对象。要指定索引库名和id
DeleteRequest request = new DeleteRequest("hotel", "61083");
// 2.发送请求,因为是删除,所以是client.delete()方法
client.delete(request, RequestOptions.DEFAULT);
}
# 批量导入文档
利用JavaRestClient中的BulkRequest批处理,实现批量新增文档
@Test
void testBulkRequest() throws IOException {
// 批量查询酒店数据
List<Hotel> hotels = hotelService.list();
// 1.创建Request,这里是BulkRequest
BulkRequest request = new BulkRequest();
// 2.准备参数,添加多个新增的IndexRequest
for (Hotel hotel : hotels) {
// 2.1.转换为文档类型HotelDoc
HotelDoc hotelDoc = new HotelDoc(hotel);
// 2.2.创建新增文档的Request对象
request.add(new IndexRequest("hotel")
.id(hotelDoc.getId().toString())
.source(JSON.toJSONString(hotelDoc), XContentType.JSON));
}
// 3.发送请求,调用的方法为client.bulk()方法
client.bulk(request, RequestOptions.DEFAULT);
}
BulkRequest本质就是将多个普通的CRUD请求组合在一起发送,能添加的请求包括:
IndexRequest # 新增 request.add(new IndexRequest("hotel").id(hotelDoc.getId().toString()))
UpdateRequest # 修改 request.add(new UpdateRequest("hotel", "61083"))
DeleteRequest # 删除 request.add(new DeleteRequest("hotel", "61083"))
# DSL 查询文档
Elasticsearch提供了基于JSON的DSL(Domain Specific Lanquage)来定义查询
# DSL查询分类
查询所有 # 查询出所有数据,一般测试用。例如:match_all
全文检索(full text)查询 # 利用分词器对用户输入内容分词,然后去倒排索引库中匹配
# 例如:match(单字段查询)、multi_match(多字段查询)
精确查询 # 根据值查找数据,不需要分词,一般是查找keyword、数值、日期、boolean等类型
# 例如:ids、range(根据值的范围查询)、term(根据词条精确值查询)
地理(geo)查询 # 根据经纬度查询。例如:geo_distance、geo_bounding_box
复合(compound)查询 # 将上述各种查询条件组合起来,合并查询条件
# 例如:bool(布尔查询)、function_score(算分函数查询)
# 查询所有
GET /indexName/_search
{
"query": {
# 没有查询条件
"match_all": {
}
}
}
# 全文检索
以下两种查询结果是一样的,因为我们将brand、name、city值都利用copy_to复制到了all字段中,搜索字段越多,对查询性能影响越大,因此建议采用copy_to
# 单字段查询
GET /indexName/_search
{
"query": {
"match": {
"a11": "如家"
}
}
}
# 多字段查询,参与查询字段越多,查询性能越差
GET /indexName/_search
{
"query": {
"multi_match": {
"query": "如家",
"fields": ["brand","name","city"]
}
}
}
# 精确查询
# term查询
GET /indexName/_search
{
"query": {
"term": {
"city": {
"value": "上海"
}
}
}
}
# range查询
GET /indexName/_search
{
"query": {
"range": {
"price": {
"gte": 10, # 这里的gte代表大于等于,gt则代表大于
"lte": 20 # lte代表小于等于,lt则代表小于
}
}
}
}
# 地理查询
# 附近查询 geo_distance
GET /indexName/_search
{
"query": {
"geo_distance": {
"distance": "15km", # 半径
"FIELD": "31.21,121.5" # 圆心
}
}
}
# 矩形范围查询 geo_bounding_box
GET /indexName/_search
{
"query": {
"geo_bounding_box": {
"location": {
# 左上点,经度在前面,维度在后面
"top_left": {
"lat": 31.1,
"lon": 121.5
},
# 右下点,经度在前面,维度在后面
"bottom_right": {
"lat": 30.9,
"lon": 121.7
}
}
}
}
}
# 多边形范围查询 geo_polygon
GET /indexName/_search
{
"query": {
"geo_polygon": {
"location": {
"points": [
# 经度在前面,维度在后面
{ "lat": 40, "lon": -70 },
{ "lat": 30, "lon": -80 },
{ "lat": 20, "lon": -90 }
]
}
}
}
}
# 多边形范围查询 geo_polygon
GET /indexName/_search
{
"query": {
"geo_shape": {
"location": {
"shape": {
"type": "envelope",
# 经度在前面,维度在后面
"coordinates": [ [ 13.0, 53.0 ], [ 14.0, 52.0 ] ]
},
# 类型有:intersects、contained、within、disjoint
"relation": "within"
}
}
}
}
# 多边形范围查询 geo_shape,可以制定空间关系
GET /indexName/_search
{
"query": {
"geo_shape": {
"location": {
"shape": {
"type": "envelope",
# 经度在前面,维度在后面
"coordinates": [ [ 13.0, 53.0 ], [ 14.0, 52.0 ] ]
},
# 类型有:intersects、contained、within、disjoint
"relation": "within"
}
}
}
}
# 复合查询
复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑。常见的有两种:
function_score # 算分函数查询,可以控制文档相关性算分,控制文档排名
bool query # 布尔查询,利用逻辑关系组合多个其它的查询,实现复杂搜索
- 算分函数查询
// 当我们利用match查询时,文档结果会根据与搜索词条的关联度打分,返回结果时按照分值降序排列
[
{
"_score" : 17.850193,
"_source" : {
"name" : "虹桥如家酒店真不错",
}
},
{
"_score" : 12.259849,
"_source" : {
"name" : "外滩如家酒店真不错",
}
},
{
"_score" : 11.91091,
"_source" : {
"name" : "迪士尼如家酒店真不错",
}
}
]
elasticsearch早期使用的打分算法是 TF-IDF 算法,在后来的5.1版本升级中,改为 BM25 算法
TF-IDF算法 # 缺点是词条频率越高,文档得分也会越高,单个词条对文档影响较大
BM25算法 # 则会让单个词条的算分有一个上限,曲线更加平滑
要想人为控制相关性算分,就需要利用elasticsearch中的function_score 查询了
GET /hotel/_search
{
"query": {
"function_score": {
"query": { .... }, # 原始查询,可以是任意条件
"functions": [ # 算分函数
{
"filter": { # 满足的条件,品牌必须是如家
"term": {
"brand": "如家"
}
},
"weight": 2 # 算分权重为2
}
],
"boost_mode": "sum" # 加权模式,求和
}
}
}
- 布尔查询
must # 必须匹配每个子查询,类似"与"
should # 选择性匹配子查询,类似"或"
must_not # 必须不匹配,不参与算分,类似"非"
filter # 必须匹配,不参与算分
GET /hotel/_search
{
"query": {
"bool": {
"must": [
{"term": {"city": "上海" }}
],
"should": [
{"term": {"brand": "皇冠假日" }},
{"term": {"brand": "华美达" }}
],
"must_not": [
{ "range": { "price": { "lte": 500 } }}
],
"filter": [
{ "range": {"score": { "gte": 45 } }}
]
}
}
}
# DSL 查询结果处理
# 排序
elasticsearch默认是根据相关度算分(_score)来排序,但是也支持自定义方式对搜索结果排序
- 普通字段排序
# keyword、数值、日期类型排序的语法基本一致
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
# 排序字段:排序方式ASC、DESC
"score": "desc"
},
{
# 排序条件是一个数组,可以写多个排序条件
"price": "desc"
}
]
}
- 地理坐标排序
# 假设我的位置是:31.034661,121.612282,寻找我周围距离最近的酒店
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance" : {
# 文档中geo_point类型的字段名:目标坐标点(维度在前,经度在后)
"location" : "31.034661, 121.612282",
"order" : "asc", # 排序方式
"unit" : "km" # 排序的距离单位
}
}
]
}
# 分页
elasticsearch 默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了
from # 从第几个文档开始
size # 总共查询几个文档
- 基本的分页
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 0, # 分页开始的位置,默认为0
"size": 10, # 期望获取的文档总数
"sort": [
{"price": "asc"}
]
}
- 深度分页问题
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 990, # 分页开始的位置,默认为0
"size": 10, # 期望获取的文档总数
"sort": [
{"price": "asc"}
]
}
# 这里是查询990开始的数据,也就是第990 ~ 1000条数据
# 不过 elasticsearch 内部分页时,必须先查询0 ~ 1000条,然后截取其中的990 ~ 1000的这10条
# 如果在 elasticsearch 集群中,要查询TOP1000的数据,并不是每个节点查询200条就可以了
# 因为节点A的TOP200,在另一个节点可能排到10000名以外了
# 要想获取整个集群的TOP1000,必须先查询出每个节点的TOP1000,汇总结果后,重新排名,重新截取TOP1000
# 对内存和CPU会产生非常大的压力,因此elasticsearch会禁止from+ size 超过10000的请求
ES提供了两种解决方案,官方文档 (opens new window)
search after # 原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式
scroll # 原理将排序后的文档id形成快照,保存在内存。官方已经不推荐使用
- 分页查询的常见实现方案以及优缺点:
from + size:
优点 # 支持随机翻页
缺点 # 深度分页问题,默认查询上限(from + size)是10000
场景 # 百度、京东、谷歌、淘宝这样的随机翻页搜索
after search:
优点 # 没有查询上限(单次查询的size不超过10000)
缺点 # 只能向后逐页查询,不支持随机翻页
场景 # 没有随机翻页需求的搜索,例如手机向下滚动翻页
scroll:
优点 # 没有查询上限(单次查询的size不超过10000)
缺点 # 会有额外内存消耗,并且搜索结果是非实时的
场景 # 海量数据的获取和迁移。从ES7.1开始不推荐,建议用 after search方案
# 高亮
# 高亮是对关键字高亮,因此搜索条件必须带有关键字,而不能是范围这样的查询
# 默认情况下,高亮的字段,必须与搜索指定的字段一致,否则无法高亮
# 如果要对非搜索字段高亮,则需要添加一个属性:required_field_match=false
GET /hotel/_search
{
"query": {
"match": {
"all": "如家" # 查询条件,高亮一定要使用全文检索查询
}
},
"highlight": {
"fields": { # 指定要高亮的字段
"name": {
"required_field_match": false # 需要添加属性
"pre_tags": "<em>", # 用来标记高亮字段的前置标签
"post_tags": "</em>" # 用来标记高亮字段的后置标签
}
}
}
}
# RestClient查询文档
# 查询所有
@Test
void testMatchAll() throws IOException {
// 1.创建SearchRequest对象,指定索引库名
SearchRequest request = new SearchRequest("hotel");
// 2.利用request.source()构建DSL,DSL中可以包含查询、分页、排序、高亮等
request.source()
// 利用QueryBuilders.matchAllQuery()构建一个match_all查询的DSL
// 其中包含match、term、function_score、bool等各种查询
.query(QueryBuilders.matchAllQuery());
// 3.利用client.search()发送请求,得到响应
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
private void handleResponse(SearchResponse response) {
// 4.解析响应,hits:命中的结果
SearchHits searchHits = response.getHits();
// 4.1.获取总条数
long total = searchHits.getTotalHits().value;
// 4.1.所有结果中得分最高的文档的相关性算分
long score = searchHits.getMaxScore().value;
System.out.println("共搜索到" + total + "条数据");
// 4.2.搜索结果的文档数组,其中的每个文档都是一个json对象
SearchHit[] hits = searchHits.getHits();
// 4.3.遍历
for (SearchHit hit : hits) {
// 获取文档source,文档中的原始数据,也是json对象
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println("hotelDoc = " + hotelDoc);
}
}
# 全文检索
@Test
void testMatch() throws IOException {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
request.source()
// 单字段查询
.query(QueryBuilders.matchQuery("all", "如家"));
request.source()
// 多字段查询
.query(QueryBuilders.multiMatchQuery("如家","name","business"));
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
# 精确查询
@Test
void testMatch() throws IOException {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
request.source()
// term:词条精确匹配
.query(QueryBuilders.termQuery("city", "上海"));
request.source()
// range:范围查询
.query(QueryBuilders.rangeQuery("price").gte(100).lte(500));
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
# 布尔查询
@Test
void testBool() throws IOException {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1.准备BooleanQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 2.2.添加term
boolQuery.must(QueryBuilders.termQuery("city", "杭州"));
// 2.3.添加range
boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250));
request.source().query(boolQuery);
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
# 算分函数查询
@Test
void testBool() throws IOException {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1.准备BooleanQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
boolQuery.must(QueryBuilders.termQuery("city", "杭州"));
// 2.2.准备functionScoreQuery
FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery(
boolQuery, // 原始查询,相关性算分的查询
// function score的数组
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
// 其中的一个function score 元素
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
// 过滤条件
QueryBuilders.termQuery("isAD", true),
// 算分函数
ScoreFunctionBuilders.weightFactorFunction(10)
)
});
);
request.source().query(functionScoreQuery);
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
# RestClient查询结果处理
# 排序、分页
@Test
void testPageAndSort() throws IOException {
// 页码,每页大小
int page = 1, size = 5;
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1.query
request.source().query(QueryBuilders.matchAllQuery());
// 2.2.排序 sort
request.source().sort("price", SortOrder.ASC);
// 2.2.按照距离排序 sort
String location = params.getLocation();
request.source().sort(SortBuilders
.geoDistanceSort("location", new GeoPoint(location))
.order(SortOrder.ASC)
.unit(DistanceUnit.KILOMETERS)
);
// 2.3.分页 from、size
request.source().from((page - 1) * size).size(5);
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
# 高亮
@Test
void testHighlight() throws IOException {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1.query
request.source().query(QueryBuilders.matchQuery("all", "如家"));
// 2.2.高亮,requireFieldMatch 是否需要与查询字段匹配
request.source()
.highlighter(new HighlightBuilder().field("name").requireFieldMatch(false));
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
private void handleResponse(SearchResponse response) {
// 4.解析响应
SearchHits searchHits = response.getHits();
// 4.1.获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到" + total + "条数据");
// 4.2.文档数组
SearchHit[] hits = searchHits.getHits();
// 4.3.遍历
for (SearchHit hit : hits) {
// 获取文档source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 获取高亮结果
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
if (!CollectionUtils.isEmpty(highlightFields)) {
// 根据字段名获取高亮结果
HighlightField highlightField = highlightFields.get("name");
if (highlightField != null) {
// 获取高亮值
String name = highlightField.getFragments()[0].string();
// 覆盖非高亮结果
hotelDoc.setName(name);
}
}
System.out.println("hotelDoc = " + hotelDoc);
}
}
注意
高亮查询必须使用全文检索查询,并且要有搜索关键字,将来才可以对关键字高亮
# DSL 数据聚合
# 聚合的种类
聚合(aggregations)可以实现对文档数据的统计、分析、运算。聚合常见的有三类:
桶(Bucket)聚合 # 用来对文档做分组,桶聚合类似Group by
# Term Aggregation:按照文档字段值分组
# Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组
度量(Metric)聚合 # 用以计算一些值,比如:最大值、最小值、平均值等
# Avg:求平均值
# Max:求最大值
# Min:求最小值
# Stats:同时求max、min、avg、sum等
管道(pipeline)聚合 # 聚合的结果为基础做聚合
# 参与聚合的字段类型必须是:keyword、数值、日期、布尔
网格(geo_grid)聚合 # 三种网格 geohash、geotile、geo_hex
# geohash 和 geotile 网格,查询可用于 geo_point 和 geo_shape 字段
# geo_hex 网格,它只能用于 geo_point 字段
# DSL实现Bucket聚合
# 要统计所有数据中的酒店品牌有几种,此时可以根据酒店品牌的名称做聚合。类型为term类型
GET /hotel/_search
{
# 可以限定要聚合的文档范围,只要添加query条件即可
"query": {
"range": {
"price": {
"lte": 200 # 只对200元以下的文档聚合
}
}
},
"size": 0, # 设置为0,结果不包含文档,所以hits是空的,只包含聚合结果
"aggs": { # 定义聚合,aggressions的缩写,aggs里面可以定义好多的聚合的,所以是一个数组
"bradAgg": { # 给聚合起的名字
"terms": { # 聚合的类型,按照品牌值聚合,所以选择terms
"field": "brand", # 参与聚合的字段
"size": 20 # 结果显示的条数
"order": {
"_count": "asc" # 按照_count升序排列
},
}
}
}
}
doc_count # 文档数量,每个桶里面有几条文档,默认是倒叙排序,统计出来越多的排名越靠前
buckets # 这个桶是品牌的桶,品牌一样的放在一个桶里面,所以是一个数组,里面的size是显示20
# DSL实现Metrics聚合
# 我们要求获取每个品牌的用户评分的min、max、avg等值。我们可以利用stats聚合:
GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 10,
"order": {
"score_stats.avg": "desc" # 先聚合然后根据品牌的平均评分进行降序排序排序
}
},
"aggs": { # 是brands聚合的子聚合,也就是分组后对每组分别计算
"score_stats": { # 聚合的名称,自己定义即可
"stats": { # 聚合的类型,这里stats可以计算min、max、avg等
"field": "score" # 聚合字段,这里是score
}
}
}
}
}
}
# DSL基于geo_grid实现聚合
- geohash_grid 聚合,可以根据文档的 geohash 值对文档进行分组
GET /my_locations/_search
{
"size" : 0,
"aggs" : {
"grouped" : {
"geohash_grid" : {
"field" : "location",
"precision" : 2 # 精度为 2
}
}
}
}
# 上面返回的结果为,数据被分成两个组。它们分别位于不同的 geohash 的网格中
{
"aggregations": {
"grouped": {
"buckets": [
{
"key": "u1",
"doc_count": 2
},
{
"key": "u0",
"doc_count": 1
}
]
}
}
}
- geotile_grid 聚合,可以根据文档的 geotile 值对文档进行分组
GET /my_locations/_search
{
"size" : 0,
"aggs" : {
"grouped" : {
"geotile_grid" : {
"field" : "location",
"precision" : 6
}
}
}
}
# 上面的命令返回的结果为:
{
"aggregations": {
"grouped": {
"buckets": [
{
"key": "6/32/21", # 在图层 https://a.tile.openstreetmap.org/6/32/21.png 中有2个
"doc_count": 2
},
{
"key": "6/32/22", # 在图层 https://a.tile.openstreetmap.org/6/32/22.png 中有1个
"doc_count": 1
}
]
}
}
}
# 使用 geotile_grid 聚合,可以根据其 geotile 值对文档进行分组:
GET /my_locations/_search
{
"query": {
"geo_grid" :{
"location" : {
"geotile" : "6/32/22"
}
}
}
}
# 上面是使用上面返回的 geotile 值 6/32/22 来进行查询的文档
{
"hits": {
"hits": [
{
"_index": "my_locations",
"_id": "3",
"_score": 1,
"_source": {
"location": "POINT(2.336389 48.861111)",
"city": "Paris",
"name": "Musée du Louvre"
}
}
]
}
}
- geohex_grid 聚合,可以根据其 geohex 值对文档进行分组。geo-hex-agg 是一个需要版权的功能
GET /my_locations/_search
{
"size" : 0,
"aggs" : {
"grouped" : {
"geohex_grid" : {
"field" : "location",
"precision" : 1
}
}
}
}
# 上面的命令返回的结果为:
{
"aggregations": {
"grouped": {
"buckets": [
{
"key": "81197ffffffffff",
"doc_count": 2
},
{
"key": "811fbffffffffff",
"doc_count": 1
}
]
}
}
}
# 可以通过使用具有以下语法的存储桶键执行 geo_grid 查询来提取其中一个存储桶上的文档:
GET /my_locations/_search?filter_path=**.hits
{
"query": {
"geo_grid" :{
"location" : {
"geohex" : "811fbffffffffff"
}
}
}
}
# 上面的命令返回的结果为:
{
"hits": {
"hits": [
{
"_index": "my_locations",
"_id": "3",
"_score": 1,
"_source": {
"location": "POINT(2.336389 48.861111)",
"city": "Paris",
"name": "Musée du Louvre"
}
}
]
}
}
- 当请求详细的存储桶(通常用于显示“放大”的地图)时,应添加 geo_bounding_box 之类的过滤器来缩小主题范围,否则可能会创建并返回数百万个存储桶
POST /museums/_search?size=0
{
"aggregations": {
"zoomed-in": {
"filter": {
"geo_bounding_box": {
"location": {
"top_left": "52.4, 4.9",
"bottom_right": "52.3, 5.0"
}
}
},
"aggregations": {
"zoom1": {
"geohash_grid": {
"field": "location",
"precision": 8
}
}
}
}
}
}
# 或者是指定 top_left 和 bottom_right 为一个 geohash u17
POST /museums/_search?size=0
{
"aggregations": {
"zoomed-in": {
"filter": {
"geo_bounding_box": {
"location": {
"top_left": "u17",
"bottom_right": "u17"
}
}
},
"aggregations": {
"zoom1": {
"geohash_grid": {
"field": "location",
"precision": 8
}
}
}
}
}
}
# 返回为结果为
{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 6,
"relation" : "eq"
},
"max_score" : null,
"hits" : [ ]
},
"aggregations" : {
"zoomed-in" : {
"doc_count" : 3,
"zoom1" : {
"buckets" : [
{
"key" : "u173zy3j",
"doc_count" : 1
},
{
"key" : "u173zvfz",
"doc_count" : 1
},
{
"key" : "u173zt90",
"doc_count" : 1
}
]
}
}
}
}
# RestClient数据聚合
# Bucket聚合
@Test
void testAggregation() throws IOException {
//1.准备request
SearchRequest request = new SearchRequest("hotel");
//2.准备DSL
//2.1.设置size
request.source().size(0); ////设置为0,结果不包含文档,只包含聚合结果
//2.2.聚合
request.source().aggregation(AggregationBuilders
.terms("brandAgg") ///聚合的类型,按照品牌值聚合,所以选择terms,名字自定义即可
.field("brand") //参与聚合的字段
.size(10)); //希望获取的聚合结果数量
//3.发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.解析结果
Aggregations aggregations = response.getAggregations();
//4.1.根据聚合的名称获取结果
Terms bradAgg = aggregations.get("brandAgg");
System.out.println(bradAgg);
//4.2.获取buckets 结果是一个集合,里面存储了聚合后的信息
List<? extends Terms.Bucket> buckets = bradAgg.getBuckets();
//4.3.遍历
for (Terms.Bucket bucket : buckets) {
//4.4.获取key
String key = bucket.getKeyAsString();
System.out.println(key);
}
}
// 搜索页面的品牌、城市等信息不应该是在页面写死,而是根据数据库中的信息展示
@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
@Autowired
private RestHighLevelClient client;
@Override
public Map<String, List<String>> filters() {
try {
//1.准备request
SearchRequest request = new SearchRequest("hotel");
//2.准备DSL
//2.1.设置size
request.source().size(0);
//2.2.聚合
buildAggregation(request);
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.解析结果
Aggregations aggregations = response.getAggregations();
Map<String, List<String>> result = new HashMap<>();
//有三个解析,所以需要解析3次
//1.解析 品牌
List<String> brandList = getAggByName(aggregations, "brandAgg");
result.put("brand", brandList);
//2.解析 城市
List<String> cityList = getAggByName(aggregations, "cityAgg");
result.put("city", cityList);
//3.解析 星级
List<String> starList = getAggByName(aggregations, "starAgg");
result.put("starName", starList);
return result;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* 准备DSL
* @param request
*/
private void buildAggregation(SearchRequest request){
request.source().aggregation(AggregationBuilders
.terms("brandAgg")
.field("brand")
.size(100));
request.source().aggregation(AggregationBuilders
.terms("cityAgg")
.field("city")
.size(100));
request.source().aggregation(AggregationBuilders
.terms("starAgg")
.field("starName")
.size(100));
}
/**
* 解析响应,将结果保存到List集合中
* @param aggregations 获取Aggregations
* @param aggName 聚合的名称
* @return
*/
private List<String> getAggByName(Aggregations aggregations, String aggName){
//4.1.根据聚合的名称获取结果
Terms bradAgg = aggregations.get(aggName);
System.out.println(bradAgg);
//4.2.获取buckets 结果是一个集合,里面存储了聚合后的信息
List<? extends Terms.Bucket> buckets = bradAgg.getBuckets();
//4.3.遍历
List<String> brandList = new ArrayList<>();
for (Terms.Bucket bucket : buckets) {
//4.4.获取key
String key = bucket.getKeyAsString();
brandList.add(key);
}
return brandList;
}
}
# 自动补全
这种根据用户输入的字母,提示完整词条的功能,就是自动补全了
# 使用拼音分词
要实现根据字母做补全,就必须对文档按照拼音分词,拼音分词插件 (opens new window),安装方式与IK分词器一样:
# 1、解压
# 2、上传到 Elasticsearch 的 plugins/py 目录
# 3、重启 Elasticsearch
# 4、测试拼音分词
POST /_analyze
{
"text": "如家酒店还不错",
"analyzer": "pinyin"
}
从该查询结果可以看出拼音分词器存在的一些问题:
# 第一个问题是拼音分词器它不会分词
# 第二个问题是它把一句话里面的每一个字都形成了拼音,这对我们来说不仅没什么用,而且还会占用空间
# 第三个问题是拼音分词结果中没有汉字只剩下了拼音,实际上用拼音搜索的情况是占少数的,汉字也得保留
# 自定义分词器
Elasticsearch中分词器(analyzer)的组成包含三部分:
character filters # 在tokenizer之前对文本进行处理。例如:删除字符、替换字符
tokenizer # 将文本按照一定的规则切割成词条(term)。例如:keyword 就是不分词、还有ik_smart
tokenizer filter # 对tokenizer输出的词条做进一步的处理。例如:大小写转换、同义词处理、拼音处理等
我们可以在创建索引库时,通过settings来配置自定义的analyzer(分词器):
PUT /test
{
"settings": {
"analysis": {
"analyzer": { # 自定义分词器
"my_analyzer": { # 自定义分词器名称
"tokenizer": "ik_max_word",
"filter": "py" # 过滤器名称,可以是自定义的过滤器
}
},
"filter": { # 自定义tokenizer filter
"py": { # 自定义过滤器的名称,可随意取
"type": "pinyin", # 过滤器类型,这里是pinyin
"keep_full_pinyin": false, # 修改可选参数,具体可参考拼音分词器GitHub官网
"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" # 使用自定义分词器
}
}
}
}
拼音分词器更多可选参数可参考拼音分词插件官网 (opens new window),test索引库创建完成后,下面我们来测试下:
POST /test/_analyze
{
"text": [
"如家酒店还不错"
],
"analyzer": "my_analyzer"
}
注意
- 在test索引库中自定义的分词器也只能在test索引库中使用
- 拼音分词器适合在创建倒排索引的时候使用,不适合在搜索的时候使用,在字段搜索时应该使用ik_smart分词器
PUT /test
{
"settings": {
"analysis": {
"analyzer": { # 自定义分词器
"my_analyzer": { # 自定义分词器名称
"tokenizer": "ik_max_word",
"filter": "py" # 过滤器名称,可以是自定义的过滤器
}
},
"filter": { # 自定义tokenizer filter
"py": { # 自定义过滤器的名称,可随意取
"type": "pinyin", # 过滤器类型,这里是pinyin
"keep_full_pinyin": false, # 修改可选参数,具体可参考拼音分词器GitHub官网
"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" # 搜索时应该使用ik_smart分词器
}
}
}
}
# DSL实现自动补全查询
Elasticsearch 提供了 Completion Suggester 查询来实现自动补全功能。官方文档地址 (opens new window)。 这个查询会匹配以用户输入内容开头的词条并返回。为了提高补全查询的效率,对于文档中字段的类型有一些约束:
# 参与补全查询的字段必须是completion类型
# 字段的内容一般是用来补全的多个词条形成的数组
# 1、创建索引库
PUT test
{
"mappings": {
"properties": {
"title": {
"type": "completion" # 字段必须是completion类型
}
}
}
}
# 2、添加示例数据
POST test/_doc
{
"title": [ # 多个词条形成的数组
"Sony",
"WH-1000XM3"
]
}
POST test/_doc
{
"title": [ # 多个词条形成的数组
"SK-II",
"PITERA"
]
}
POST test/_doc
{
"title": [ # 多个词条形成的数组
"Nintendo",
"switch"
]
}
# 3、自动补全查询
GET /test/_search
{
"suggest": {
"title_suggest": { # 自动补全查询的名称(自定义的名称)
"text": "s", # 搜索关键字
"completion": {
"field": "title", # 自动补全查询的字段
"skip_duplicates": true, # 跳过重复的
"size": 10 # 获取前10条结果
}
}
}
}
# RestClient实现自动补全
@Test
public void testSuggestion() throws IOException {
SearchRequest searchRequest = new SearchRequest("hotel");
searchRequest.source().suggest(new SuggestBuilder().addSuggestion("mySuggestion",
SuggestBuilders.completionSuggestion("suggestion")
.prefix("hl")
.skipDuplicates(true)
.size(10)));
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
//解析响应结果
Suggest suggest = response.getSuggest();
CompletionSuggestion completionSuggestion = suggest.getSuggestion("mySuggestion");
List<CompletionSuggestion.Entry.Option> options = completionSuggestion.getOptions();
options.stream().map(CompletionSuggestion.Entry.Option::getText)
.forEach(System.out::println);
}
# 酒店数据自动补全(案例)
实现hotel索引库的自动补全、拼音搜索功能,实现思路如下:
# 1、修改hotel索引库结构,设置自定义拼音分词器
# 2、修改索引库的name、all字段,使用自定义分词器
# 3、索引库添加一个新字段suggestion,类型为completion类型,使用自定义的分词器
# 4、给HotelDoc类添加suggestion字段,内容包含brand、business
# 5、重新导入数据到hotel索引库
注意
name、all是可分词的,自动补全的brand、business是不可分词的,要使用不同的分词器组合
# 创建酒店数据索引库
PUT /hotel
{
"settings": {
"analysis": {
"analyzer": {
"text_anlyzer": { # 自定义分词器,在创建倒排索引时使用
"tokenizer": "ik_max_word",
"filter": "py" # 自定义过滤器py
},
"completion_analyzer": { # 自定义分词器,用于实现自动补全
"tokenizer": "keyword", # 不分词
"filter": "py" # 自定义过滤器py
}
},
"filter": { # 自定义tokenizer filter
"py": { # 自定义过滤器的名称,可随意取
"type": "pinyin",
"keep_full_pinyin": false, # 可选参数配置,具体可参考拼音分词器Github官网
"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", # 在创建倒排索引时使用自定义分词器text_anlyzer
"search_analyzer": "ik_smart", # 在搜索时使用ik_smart
"copy_to": "all" # 拷贝到all字段
},
"address": {
"type": "keyword",
"index": false # 不创建倒排索引,不参与搜索
},
"price": {
"type": "integer"
},
"score": {
"type": "integer"
},
"brand": {
"type": "keyword",
"copy_to": "all" # 拷贝到all字段
},
"city": {
"type": "keyword"
},
"starName": {
"type": "keyword"
},
"business": {
"type": "keyword",
"copy_to": "all" # 拷贝到all字段
},
"location": {
"type": "geo_point" # geo_point地理坐标类型
},
"pic": {
"type": "keyword",
"index": false # 不创建倒排索引,不参与搜索
},
"all": { # 该字段主要用于搜索,没有实际意义,且在搜索结果的原始文档中你是看不到该字段的
"type": "text",
"analyzer": "text_anlyzer", # 在创建倒排索引时使用自定义分词器text_anlyzer
"search_analyzer": "ik_smart" # 在搜索时使用ik_smart
},
"suggestion": { # 自动补全搜索字段
"type": "completion", # completion为自动补全类型
"analyzer": "completion_analyzer" # 自动补全使用自定义分词器completion_analyzer
}
}
}
}
# 数据同步
# 数据同步问题
# Elasticsearch中的酒店数据发生改变时,Elasticsearch也必须跟着改变,这个就是数据同步
# 在微服务中,负责酒店管理的业务与负责酒店搜索的业务可能在两个不同的微服务上,数据同步该如何实现呢
# 数据同步方案
- 同步调用
# 优点:实现简单,粗暴
# 缺点:业务耦合度高
- 异步通知
# 优点:低耦合,实现难度一般
# 缺点:依赖mq的可靠性
- 监听binlog
# 优点:完全解除服务间耦合
# 缺点:开启binlog增加数据库负担、实现复杂度高
# 集群部署
ES 集群中的数据备份使用了错位备份的原理,即当前节点的数据将会备份在其他节点上。这样做保证了当某一节点宕机后,数据仍然完整
# 集群搭建方法
- 创建 docker-compose 文件
version: '2.2'
services:
es01:
image: elasticsearch:7.12.1
container_name: es01
environment:
- node.name=es01
- cluster.name=es-docker-cluster # 集群名称一样,ES会自动把它们组成集群
- discovery.seed_hosts=es02,es03 # 集群中另两个点的IP地址,正常的话写ip地址
- cluster.initial_master_nodes=es01,es02,es03 # 初始化主节点,候选主节点
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" # 最小内存,最大内存都配置成了512MB
volumes:
- data01:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
- elastic
es02:
image: elasticsearch:7.12.1
container_name: es02
environment:
- node.name=es02
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es03
- cluster.initial_master_nodes=es01,es02,es03
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data02:/usr/share/elasticsearch/data
ports:
- 9201:9200
networks:
- elastic
es03:
image: elasticsearch:7.12.1
container_name: es03
environment:
- node.name=es03
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es02
- cluster.initial_master_nodes=es01,es02,es03
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data03:/usr/share/elasticsearch/data
networks:
- elastic
ports:
- 9202:9200
volumes:
data01:
driver: local
data02:
driver: local
data03:
driver: local
networks:
elastic:
driver: bridge
- 执行 docker-compose 文件之前,需要修改部分文件权限
# 1.进入文件进行编辑
vim /etc/sysctl.conf
# 2.添加如下内容
vm.max_map_count=262144
# 3.然后执行,让其生效
sysctl -p
- 通过docker-compose启动集群
docker-compose up -d
# 集群状态监控
这里不再使用 kibana 来监控集群状态(kibana 的配置较为复杂),而是通过 cerebro (opens new window) 来监控
# 使用 docker 部署 cerebro
# 1.拉取镜像
docker pull lmenezes/cerebro
# 2.运行镜像
docker run --name renebro -p 9000:9000 -d lmenezes/cerebro
# 3.浏览器访问 9000 端口即可
# 4.在主界面输入任一ES集群的地址,即可进入管理界面,例如:http://127.0.0.1:9200
集群列表中的 实心星星 表示为主节点,其余为 候选节点
# 创建索引库时设置分片信息
PUT /test
{
"settings": {
"number_of_shards": 3, # 分片数量
"number_of_replicas": 1 # 副本树量
},
"mappings": {
"properties": {
"firstName": {
"type": "keyword"
},
"lastName": {
"type": "keyword"
}
}
}
}
# 节点角色划分
Elasticsearch中集群节点有不同的职责划分:
节点类型 | 配置参数 | 默认值 | 节点职责 |
---|---|---|---|
master eligible | node.master | true | 备选主节点:主节点可以管理和记录集群状态、决定分片在哪个节点、处理创建和删除索引库的请求。 |
data | node.data | true | 数据节点:存储数据、搜索、聚合、CRUD |
ingest | node.ingest | true | 数据存储之前的预处理 |
coordinating | 上面3个参数都为false则为coordinating节点 | 无 | 协调节点:路由请求到其它节点,合并其它节点处理的结果,返回给用户 |
ES 中的每一个节点都有着属于自己的不同职责,因此建议集群部署时,每个节点都有独立的角色
# ES集群的脑裂
# 默认情况下每个节点都是master eligible节点,一旦master节点宕机,其它候选节点会选举一个成为主节点
# 当主节点与其他节点网络故障时,可能发生脑裂问题
# 为了避免脑裂,需要选票超过 (eligible节点数量 + 1)/2 才能当选为主,因此eligible节点数最好是奇数
# 对应配置项是discovery.zen.minimum_master_nodes
# 在es7.0以后,已经成为默认配置,因此一般不会发生脑裂问题
主从结构脑裂问题示意图:
# ES集群的分布式存储
- 查看数据位置
GET /test/_search
{
"query":{
"match_all":{}
},
"explain":true # 通过 explain 命令,可以看到插入的数据具体在集群上的哪一个分片中
}
- 分布式存储算法
# 新增文档时,应保存到不同分片,保证数据均衡,coordinating node如何确定数据该存储到哪个分片呢?
# Elasticsearch会通过hash算法来计算文档应该存储到哪个分片:
shard = hash(_routing) % number_of_shards
# _routing默认是文档的id,number_of_shards:表示分片数量
# 算法与分片数量有关,因此索引库一旦创建,分片数量不能修改!
# ES集群的分布式查询
Elasticsearch的查询分成两个阶段:
scatter phase # 分散阶段,coordinating node会把请求分发到每一个分片
gather phase # 聚集阶段,coordinating node汇总data node的搜索结果,并处理为最终结果集返回给用户
# ES集群的故障转移
集群中的 master 节点会监控集群中的节点状态,如果发现宕机,会立即将宕机节点的分片数据迁移到其他节点,确保数据安全。这个机制称之为故障转移
# master宕机后,EligibleMaster选举为新的主节点
# master节点监控分片、节点状态,将故障节点上的分片转移到正常节点,确保数据安全
# 基本操作
ES中保存有我们要查询的数据,只不过格式和数据库存储数据格式不同而已。在ES中我们要先创建倒排索引,这个索引的功能又点类似于数据库的表,然后将数据添加到倒排索引中,添加的数据称为文档。所以要进行ES的操作要先创建索引,再添加文档,这样才能进行后续的查询操作。
要操作ES可以通过Rest风格的请求来进行,也就是说发送一个请求就可以执行一个操作。比如新建索引,删除索引这些操作都可以使用发送请求的形式来进行。
# 创建索引
// PUT请求(books是索引名称) http://localhost:9200/books
//发送请求后,看到如下信息即索引创建成功
{
"acknowledged": true,
"shards_acknowledged": true,
"index": "books"
}
重复创建已经存在的索引会出现错误信息,reason属性中描述错误原因
"reason": "index [books/VgC_XMVAQmedaiBNSgO2-w] already exists"
# 查询索引
// GET请求 http://localhost:9200/books
// 查询索引得到索引相关信息,如下
{
"book": {
"aliases": {},
"mappings": {},
"settings": {
"index": {
"routing": {
"allocation": {
"include": {
"_tier_preference": "data_content"
}
}
},
"number_of_shards": "1",
"provided_name": "books",
"creation_date": "1645768584849",
"number_of_replicas": "1",
"uuid": "VgC_XMVAQmedaiBNSgO2-w",
"version": {
"created": "7160299"
}
}
}
}
}
如果查询了不存在的索引,会返回错误信息
reason": "no such index [book]"
# 删除索引
// DELETE请求 http://localhost:9200/books
//删除所有后,给出删除结果
{
"acknowledged": true
}
如果重复删除,会给出错误信息,同样在reason属性中描述具体的错误原因
"reason": "no such index [books]",
# 创建索引并指定分词器
可以在创建索引时添加请求参数,设置分词器。目前较为流行的是IK分词器 IK分词器下载地址 (opens new window)
分词器下载后解压到ES安装目录的plugins目录中即可,安装分词器后需要重新启动ES服务器
// PUT请求 http://localhost:9200/books
// 请求参数如下(注意是json格式的参数)
{
"mappings":{ #定义mappings属性内容
"properties":{ #定义索引中包含的属性设置
"id":{ #设置索引中包含id属性
"type":"keyword" #当前属性可以被直接搜索
},
"name":{ #设置索引中包含name属性
"type":"text", #当前属性是文本信息,参与分词
"analyzer":"ik_max_word", #使用IK分词器进行分词
"copy_to":"all" #分词结果拷贝到all属性中
},
"type":{
"type":"keyword"
},
"description":{
"type":"text",
"analyzer":"ik_max_word",
"copy_to":"all"
},
"all":{ #定义属性,用来描述多个字段的分词结果集合,当前属性可以参与查询
"type":"text",
"analyzer":"ik_max_word"
}
}
}
}
# 添加文档,有三种方式
POST请求 http://localhost:9200/books/_doc #使用系统生成id
POST请求 http://localhost:9200/books/_create/1 #使用指定id
POST请求 http://localhost:9200/books/_doc/1 #使用指定id,不存在创建,存在更新(版本递增)
//文档通过请求参数传递,数据格式json
{
"name":"springboot",
"type":"springboot",
"description":"springboot"
}
# 查询文档
GET请求 http://localhost:9200/books/_doc/1 #查询单个文档
GET请求 http://localhost:9200/books/_search #查询全部文档
# 条件查询
GET请求 http://localhost:9200/books/_search?q=name:springboot # q=查询属性名:查询属性值
# 删除文档
DELETE请求 http://localhost:9200/books/_doc/1
# 修改文档(全量更新)
PUT请求 http://localhost:9200/books/_doc/1
//文档通过请求参数传递,数据格式json
{
"name":"springboot",
"type":"springboot",
"description":"springboot"
}
# 修改文档(部分更新)
POST请求 http://localhost:9200/books/_update/1
//文档通过请求参数传递,数据格式json
{
"doc":{
"name":"springboot"
}
}
# springboot整合
# 早期的操作方式
- 导入springboot整合ES的starter坐标
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
- 进行基础配置
spring:
elasticsearch:
rest:
uris: http://localhost:9200
- 使用springboot整合ES的专用客户端接口ElasticsearchRestTemplate来进行操作
@SpringBootTest
class Springboot18EsApplicationTests {
@Autowired
private ElasticsearchRestTemplate template;
}
# 高级别的操作方式
- 导入springboot整合ES高级别客户端的坐标,此种形式目前没有对应的starter
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
- 使用编程的形式设置连接的ES服务器,并获取客户端对象
@SpringBootTest
class Springboot18EsApplicationTests {
//在测试类中每个操作运行前运行的方法
@BeforeEach
void setUp() {
HttpHost host = HttpHost.create("http://localhost:9200");
RestClientBuilder builder = RestClient.builder(host);
client = new RestHighLevelClient(builder);
}
//在测试类中每个操作运行后运行的方法
@AfterEach
void tearDown() throws IOException {
client.close();
}
private RestHighLevelClient client;
@Test
void testCreateIndex() throws IOException {
CreateIndexRequest request = new CreateIndexRequest("books");
client.indices().create(request, RequestOptions.DEFAULT);
}
}
- 创建索引(IK分词器)
@Test
void testCreateIndexByIK() throws IOException {
CreateIndexRequest request = new CreateIndexRequest("books");
String json = "{\n" +
" \"mappings\":{\n" +
" \"properties\":{\n" +
" \"id\":{\n" +
" \"type\":\"keyword\"\n" +
" },\n" +
" \"name\":{\n" +
" \"type\":\"text\",\n" +
" \"analyzer\":\"ik_max_word\",\n" +
" \"copy_to\":\"all\"\n" +
" },\n" +
" \"type\":{\n" +
" \"type\":\"keyword\"\n" +
" },\n" +
" \"description\":{\n" +
" \"type\":\"text\",\n" +
" \"analyzer\":\"ik_max_word\",\n" +
" \"copy_to\":\"all\"\n" +
" },\n" +
" \"all\":{\n" +
" \"type\":\"text\",\n" +
" \"analyzer\":\"ik_max_word\"\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
//IK分词器是通过请求参数的形式进行设置的,设置请求参数使用request对象中的source方法进行设置
request.source(json, XContentType.JSON);
client.indices().create(request, RequestOptions.DEFAULT);
}
- 添加文档:请求对象是IndexRequest,与创建索引使用的请求对象不同
@Test
//批量添加文档
void testCreateDocAll() throws IOException {
List<Book> bookList = bookDao.selectList(null);
BulkRequest bulk = new BulkRequest();
for (Book book : bookList) {
IndexRequest request = new IndexRequest("books").id(book.getId().toString());
String json = JSON.toJSONString(book);
request.source(json,XContentType.JSON);
bulk.add(request);
}
client.bulk(bulk,RequestOptions.DEFAULT);
}
- 批量添加文档:先创建一个BulkRequest的对象,可以将该对象理解为是一个保存request对象的容器,将所有的请求都初始化好后,添加到BulkRequest对象中,再使用BulkRequest对象的bulk方法,一次性执行完毕
@Test
//批量添加文档
void testCreateDocAll() throws IOException {
List<Book> bookList = bookDao.selectList(null);
BulkRequest bulk = new BulkRequest();
for (Book book : bookList) {
IndexRequest request = new IndexRequest("books").id(book.getId().toString());
String json = JSON.toJSONString(book);
request.source(json,XContentType.JSON);
bulk.add(request);
}
client.bulk(bulk,RequestOptions.DEFAULT);
}
- 按id查询文档:使用的请求对象是GetRequest
@Test
//按id查询
void testGet() throws IOException {
GetRequest request = new GetRequest("books","1");
GetResponse response = client.get(request, RequestOptions.DEFAULT);
String json = response.getSourceAsString();
System.out.println(json);
}
- 按条件查询文档:使用的请求对象是SearchRequest,查询时调用SearchRequest对象的termQuery方法,需要给出查询属性名,此处支持使用合并字段,也就是前面定义索引属性时添加的all属性
@Test
//按条件查询
void testSearch() throws IOException {
SearchRequest request = new SearchRequest("books");
SearchSourceBuilder builder = new SearchSourceBuilder();
builder.query(QueryBuilders.termQuery("all","spring"));
request.source(builder);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
SearchHits hits = response.getHits();
for (SearchHit hit : hits) {
String source = hit.getSourceAsString();
//System.out.println(source);
Book book = JSON.parseObject(source, Book.class);
System.out.println(book);
}
}