了解 ES
是一个非常强大开源搜索引擎。从海量的内容中找到所需要的内容。
elasticsearch 结合 kibana、Logstash、Beats 也就是 elastic stack(ELK)被广泛应用在日志数据分析、实时监控。
发展历程:Lucene 是一个java类库,是Apache的项目,由DougCutting 于 1999 年研发。
- 优势:易扩展
- 高性能,基于倒排索引。
- 缺点:只能是java语言开发,学习难度高,不支持水平扩展。
2004年Shay Banon基于Lucene开发了Compass
2010年Shay Banon重写了Compass,取名为Elasticsearch。
官网地址: www.elastic.co/cn/
- 支持分布式,可水平扩展
- 提供Restful接口,可被任何语言调用
倒排索引
一组数据分为文档id和词条
- 文档:每条数据就是一个文档
- 词条:文档按照语义分成的词语
步骤:
注意文档数据会被序列化成JSON数据存储。
索引
- 索引(index):相同类型的文档集合。
- 映射(mapping):索引中文档的字段约束信息,类似表结构约束。
区别
- Mysql:擅长事务类型操作,可以确保数据的安全和一致性
- Elasticsearch:擅长海量数据的搜索、分析、计算
kibana
导航栏左侧有开发工具,可以帮助我们快速发送 dsl语句给 elasticsearch
分析词语
_analyze:内置的解析词汇
POST /_analyze
{
"analyzer": "standard", // 分词器
"text": "黑马程序员学习java太棒了!" // 内容
}
使用 ik 分词器
- ik_smart:最少切分,粗粒度
- ik_max_work:最细切分,细粒度
POST /_analyze
{
"analyzer": "ik_max_word",
"text": "黑马程序员学习java太棒了!"
}
扩展字典
为什么需要扩展
- 我们希望一些网络词汇也算词语
- 我们需要过滤掉敏感词
打开 IKAnalyzer.cfg.xml 文件
创建文件就可以了!扩展名dic
<?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">my_ext_dict.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典-->
<entry key="ext_stopwords">my_ext_stopwords.dic</entry>
<!--用户可以在这里配置远程扩展字典 -->
<!-- <entry key="remote_ext_dict">words_location</entry> -->
<!--用户可以在这里配置远程扩展停止词字典-->
<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>
索引库操作
mapping 映射属性
- type:字段数据类型,常见的简单类型有:
- 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
- 数值:long、integer、short、byte、double、float、
- 布尔:boolean
- 日期:date
- 对象:object
- index:是否创建索引,默认为true(不是true,查询不到该信息)
- analyzer:使用哪种分词器(文本才需要分词)
- properties:该字段的子字段
索引库的CRUD
创建索引库
PUT /heima
{
// 映射字段
"mappings": {
// 数据
"properties": {
// 字段
"info": {
"type": "text",
// 分词器
"analyzer": "ik_smart"
},
"email": {
// 关键值不需要分词器
"type": "keyword",
// 不需要索引,默认true
"index": false
},
"name": {
"type": "object",
"properties": {
"firstName": {
"type": "keyword"
},
"lastName": {
"type": "keyword"
}
}
}
}
}
}
返回一下内容,创建成功
{
"acknowledged": true,
"shards_acknowledged": true,
"index": "heima"
}
查询
get /索引库名
修改索引库
本身不支持修改的,但是可以往里面加入新的字段
PUT /索引库名/_mapping
{
"properties": {
"新的字段名": {
"type": "integer"
}
}
}
PUT /heima/_mapping
{
"properties": {
"age": {
"type": "integer"
}
}
}
成功
{
"acknowledged": true
}
删除
DELETE /heima
文档操作
创建文档
POST /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
"字段3": {
"子属性1": "值3",
"子属性2": "值4"
},
// ...
}
POST /heima/_doc/1
{
"info": "黑马程序员Java讲师",
"email": "zy@itcast.cn",
"name": {
"firstName": "云",
"lastName": "赵"
}
}
查询删除文档
GET /索引库名/_doc/文档id
GET /heima/_doc/1
DELETE /索引库名/_doc/文档id
DELETE /heima/_doc/1
// 查询索引库所有文档内容
GET /hotel/_search
全量修改
会删除旧文档,添加新文档
PUT /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
// ... 略
}
PUT /heima/_doc/1
{
"info": "黑马程序员高级Java讲师",
"email": "zy@itcast.cn",
"name": {
"firstName": "云",
"lastName": "赵"
}
}
增量修改,修改指定字段值
POST /索引库名/_update/文档id
{
"doc": {
"字段名": "新的值",
}
}
POST /heima/_update/1
{
"doc": {
"email": "ZhaoYun@itcast.cn"
}
}
字段分析
create table tb_hotel
(
id bigint not null comment '酒店id'
primary key,
name varchar(255) not null comment '酒店名称',
address varchar(255) not null comment '酒店地址',
price int not null comment '酒店价格',
score int not null comment '酒店评分',
brand varchar(32) not null comment '酒店品牌',
city varchar(32) not null comment '所在城市',
star_name varchar(16) null comment '酒店星级,1星到5星,1钻到5钻',
business varchar(255) null comment '商圈',
latitude varchar(32) not null comment '纬度',
longitude varchar(32) not null comment '经度',
pic varchar(255) null comment '酒店图片'
)
- id:在es中id是字符串,类型是 keyword,需要查询
- name:需要分词,类型text,使用 ik_max_word,需要查询
- price:需要查询,类型 integer
- score:需要查询,类型 float
- brand:需要查询,品牌,精准。类型 keyword
- city:需要查询,城市精准。keyword
- star_name:星级,精准,keyword
- business:商圈,精准,keyword
- location:geo_point:经纬度,固定类型。
- pic:图片路径,不需要查询。keyword
很多时候,我们需要进行全字段查询,所以我们可以创建一个all的字段。
将多个关键字通过 copy_to 到all字段上。并使用分词器。
PUT hotel
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "ik_max_word",
"copy_to": "all"
},
"address": {
"type": "text",
"analyzer": "ik_max_word",
"copy_to": "all"
},
"price": {
"type": "integer"
},
"score": {
"type": "float"
},
"brand": {
"type": "keyword",
"copy_to": "all"
},
"city": {
"type": "keyword",
"copy_to": "all"
},
"star_name": {
"type": "keyword"
},
"business": {
"type": "keyword"
},
"location": {
"type": "geo_point"
},
"pic": {
"type": "keyword",
"index": false
},
"all": {
"type": "text",
"analyzer": "ik_max_word",
}
}
}
}
使用 java 操作
依赖
<!-- 需要 jackson-databind 正常 spring提供了 -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.17.3</version>
</dependency>
<!-- JSON 解析需要 -->
<dependency>
<groupId>jakarta.json</groupId>
<artifactId>jakarta.json-api</artifactId>
<version>2.0.1</version>
</dependency>
<!-- SpringBootParent 2.7.14 不需要这个 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.12.5</version>
</dependency>
<!-- 8.17.3 内置的版本低 -->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
<version>8.17.3</version>
</dependency>
初始化
// 创建低级客户端实例
RestClient restClient = RestClient.builder(HttpHost.create("http://127.0.0.1:19200")).build();
// 传输层解析器,设置JSON传输
RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
// 封装了低级客户端实例,更高效
ElasticsearchClient esClient = new ElasticsearchClient(transport);
// 不使用记得销毁
esClient.close();
创建索引库
CreateIndexRequest indexRequest = new CreateIndexRequest.Builder()
.index("hotel").mappings(m ->
// id
m.properties("id", p -> p.keyword(k -> k))
// 名称
.properties("name", p -> p.text(t -> t.analyzer("ik_max_word").copyTo(List.of("all"))))
// 地址
.properties("address", p -> p.text(t -> t.analyzer("ik_max_word").copyTo(List.of("all"))))
// 价格
.properties("price", p -> p.integer(i -> i))
// 评分
.properties("score", p -> p.float_(f -> f))
// 品牌
.properties("brand", p -> p.keyword(k -> k.copyTo(List.of("all"))))
// 城市
.properties("city", p -> p.keyword(k -> k.copyTo(List.of("all"))))
// 星级
.properties("star_name", p -> p.keyword(k -> k))
// 商圈
.properties("business", p -> p.keyword(k -> k))
// 位置
.properties("location", p -> p.geoPoint(g -> g))
// 图片
.properties("pic", p -> p.keyword(k -> k.index(false)))
.properties("all", p -> p.text(t -> t.analyzer("ik_max_word")))
).build();
// 创建索引获取结果
CreateIndexResponse createIndexResponse = client.indices().create(indexRequest);
// true 创建成功
System.out.println(createIndexResponse.acknowledged());
删除索引库 / 查询是否存在
是否存在
注意是:co.elastic.clients.elasticsearch.indices.ExistsRequest
// 创建一个请求对象,检查是否存在名为 "hotel" 的索引
ExistsRequest request = new ExistsRequest.Builder()
.index("hotel") // 设置索引名为 "hotel"
.build(); // 构建请求对象
// 执行索引是否存在的检查,返回结果为 BooleanResponse 对象
BooleanResponse response = client.indices().exists(request);
// 输出检查结果,response.value() 返回的是布尔值,表示索引是否存在
System.out.println(response.value());
删除索引库
// 创建一个请求对象,准备删除名为 "hotel" 的索引
DeleteIndexRequest request = new DeleteIndexRequest.Builder()
.index("hotel").build();
// 执行删除索引的操作
DeleteIndexResponse response = client.indices().delete(request);
// 输出删除操作是否成功,response.acknowledged() 返回一个布尔值
System.out.println(response.acknowledged());
新增文档数据
Hotel hotel = hotelService.getById(36934);
// 注意:this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
HotelDoc hotelDoc = new HotelDoc(hotel);
// 设置索引,和文档数据
IndexRequest<Object> request = IndexRequest.of(i -> i.index("hotel").id(String.valueOf(hotelDoc.getId())).document(hotelDoc));
// 执行新增文档
IndexResponse response = client.index(request);
// 获取结果
System.out.println(response);
查询文档数据
// 执行查询文档
GetRequest request = GetRequest.of(g -> g.index("hotel").id("36934"));
// 执行查询
GetResponse<HotelDoc> response = client.get(request, HotelDoc.class);
// 判断是否存在
if (response.found()) {
// 获取文档
HotelDoc hotelDoc = response.source();
System.out.println(hotelDoc);
} else {
System.out.println("没有找到");
}
修改文档
// 创建修改的结构
Map<String, Object> map = Map.of(
"price", 999,
"starName", "四钻"
);
UpdateRequest<Object, Object> request = UpdateRequest.of(
// 设置索引库、文档ID和修改数据
u -> u.index("hotel").id("36934").doc(map));
// 执行修改
UpdateResponse<Object> response = client.update(request, Map.class);
// 如果是更新,则更新成功
if (response.result() == Result.Updated) {
System.out.println("更新成功");
} else {
System.out.println("更新失败");
}
删除文档
// 创建删除对象,补全索引和库
DeleteRequest deleteRequest = DeleteRequest.of(builder -> builder.index("hotel").id("36934"));
// 执行删除
DeleteResponse response = client.delete(deleteRequest);
System.out.println(response.id());
批量新增
// 获取数据
List<Hotel> list = hotelService.list();
// 将数据转换成指定格式,批量插入
List<BulkOperation> list1 = list.stream().map(hotel -> {
// 创建数据对象,指定id和库名,以及文档数据
IndexOperation<Object> operation = IndexOperation.of(idx -> idx.index("hotel")
.id(String.valueOf(hotel.getId())).document(hotel));
return BulkOperation.of(op -> op.index(operation));
}).toList();
// 注意包名:co.elastic.clients.elasticsearch.core
BulkRequest bulkRequest = BulkRequest.of(b -> b.operations(list1));
BulkResponse response = client.bulk(bulkRequest);
// 处理响应
if (response.errors()) {
System.err.println("批量插入时发生错误!");
response.items().forEach(item -> {
if (item.error() != null) {
System.err.println("错误:" + item.error().reason());
}
});
} else {
System.out.println("批量插入成功!");
}
数据处理 / 查询
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
查询
基本语法
GET /索引库/_search
{
"query": {
"查询类型": {
"查询条件": "条件值"
}
}
}
GET /hotel/_search
{
"query": {
// 查询所有
"match_all": {}
}
}
// 返回值
{
// 时间
"took": 1,
// 是否超时
"timed_out": false,
"_shards": {},
// 命中数据
"hits": {
"total": {
"value": 202, // 总数
"relation": "eq"
},
"max_score": 1,
"hits": [] // 数据
}
}
java 实现
// 创建搜索类
SearchRequest.Builder builder = new SearchRequest.Builder();
// 设置索引库和条件,这里设置的是所有
builder.index("hotel").query(q -> q.matchAll(m -> m));
SearchRequest build = builder.build();
// 执行查询
SearchResponse<HotelDoc> search = client.search(build, HotelDoc.class);
System.out.println(search);
全文检索查询
会对用户的内容进行分词。all 词是咱们自己定义的,不在文档内。
GET /hotel/_search
{
"query": {
// 条件
"match": {
// 字段 all
"all": "如家外滩"
}
}
}
多字段查询(效率低)
GET /hotel/_search
{
"query": {
"multi_match": {
"query": "如家外滩",
// 多字段
"fields": ["brand", "name", "business"]
}
}
}
精确 / 范围
精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。
精确
GET /hotel/_search
{
"query": {
// 精确
"term": {
// 字段
"city": {
"value": "上海"
}
}
}
}
范围
GET /hotel/_search
{
"query": {
"range": {
// 字段
"price": {
// gt 大于 gte 大于等于,lte 小于等于
"gte": 100,
"lte": 300
}
}
}
}
经纬度查询
根据经纬度查询。常见的使用场景包括:
- 携程:搜索我附近的酒店
- 滴滴:搜索我附近的出租车
- 微信:搜索我附近的人
geo_bounding_box:查询geo_point值落在某个矩形范围的所有文档
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:查询到指定中心点小于某个距离值的所有文档
GET /hotel/_search
{
"query": {
"geo_distance": {
"distance": "15km",
"location": "31.21, 121.5"
}
}
}
相关性算法
复合(compound)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑,例如:
-
fuction score:算分函数查询,可以控制文档相关性算分,控制文档排名。例如百度竞价
-
TF-IDF:在elasticsearch5.0之前,会随着词频增加而越来越大
-
BM25:在elasticsearch5.0之后,会随着词频增加而增大,但增长曲线会趋于水平
如何使用
使用 function score query,可以修改文档的相关性算分(query score),根据新得到的算分排序。
特点:会先进行查询、再算分
GET /hotel/_search
{
"query": {
"function_score": {
"query": {
"match": {
"all": "外滩"
}
},
"functions": [
{
"filter": {
"term": {
"brand": "如家"
}
},
"weight": 10 // 默认加分是乘法
}
],
"boost_mode": "sum" // 修改为相加
}
}
}
复合查询
布尔查询是一个或多个查询子句的组合。子查询的组合方式有:
-
must:必须匹配每个子查询,类似“与”
-
should:选择性匹配子查询,类似“或”
-
must_not:必须不匹配,不参与算分,类似“非”
-
filter:必须匹配,不参与算分
主词条要算法,其他的不要,影响性能
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
}
}
}
]
}
}
}
java 实现操作
SearchRequest.Builder builder = new SearchRequest.Builder();
// match 查询
// builder.index("hotel").query(q -> q.match(m -> m.field("all").query("如家")));
// 多字段查询
// builder.index("hotel").query(q -> q.multiMatch(m -> m.query("如家")
// .fields(List.of("brand", "name"))));
// 精确查询
builder.index("hotel").query(q -> q.term(m -> m.field("city").value("深圳")));
// 范围查询
// builder.index("hotel").query(q -> q.range(r -> r.number(n ->
// n.field("price").gte(100.00).lte(150.00))));
// 创建布尔查询
// builder.index("hotel").query(q -> q.bool(b ->
// // must 条件
// b.must(m -> m.term(t -> t.field("city").value("杭州")))
// // filter 条件
// .filter(f -> f.range(r -> r.number(n -> n.field("price").lte(250.00))))));
SearchRequest build = builder.build();
// 执行查询
SearchResponse<HotelDoc> search = client.search(build, HotelDoc.class);
HitsMetadata<HotelDoc> hits = search.hits();
long total = hits.total().value();
System.out.println(total);
hits.hits().forEach(hit -> {
HotelDoc source = hit.source();
System.out.println(source);
});
System.out.println(search);
结果操作
排序,注意排序后ES会放弃打分
GET /hotel/_search
{
"query": {
"match_all": {}
},
// 排序
"sort": [
{
// 字段一
"score": {
"order": "desc"
},
// 字段二
"price": {
"order": "asc"
}
}
]
}
按照地理位置排序
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
// 地理
"_geo_distance": {
"location": {
"lat": 31.034661,
"lon": 121.612282
},
"order": "asc",
"unit": "km"
}
}
]
}
分页
elasticsearch 默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。
elasticsearch中通过修改from、size参数来控制要返回的分页结果:
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 990, // 分页开始的位置,默认为0
"size": 10, // 期望获取的文档总数
"sort": [
{"price": "asc"}
]
}
ES是分布式的,所以会面临深度分页问题。例如按price排序后,获取from = 990,size =10的数据:
- 首先在每个数据分片上都排序并查询前1000条文档。
- 然后将所有节点的结果聚合,在内存中重新排序选出前1000条文档
- 最后从这1000条中,选取从990开始的10条文档
如果搜索页数过深,或者结果集(from + size)越大,对内存和CPU的消耗也越高。因此ES设定结果集查询的上限是10000
针对深度分页,ES提供了两种解决方案,官方文档:
- search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。
- scroll:原理将排序数据形成快照,保存在内存。官方已经不推荐使用。
from + size
- 优点:支持随机翻页
- 缺点:深度分页问题,默认查询上限(from + size)是10000
- 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索
after search
- 优点:没有查询上限(单次查询的size不超过10000)
- 缺点:只能向后逐页查询,不支持随机翻页
- 场景:没有随机翻页需求的搜索,例如手机向下滚动翻页
scroll
- 优点:没有查询上限(单次查询的size不超过10000)
- 缺点:会有额外内存消耗,并且搜索结果是非实时的
- 场景:海量数据的获取和迁移。从ES7.1开始不推荐,建议用 after search方案。
java 实现
SearchRequest.Builder builder = new SearchRequest.Builder();
// match 查询
builder.index("hotel").query(q -> q.matchAll(m -> m))
// 分页
.from(0).size(5)
// 排序
.sort(s -> s.field(f -> f.field("price").order(SortOrder.Asc)));
// 可以继续加
// .sort();
SearchRequest build = builder.build();
SearchResponse<HotelDoc> search = client.search(build, HotelDoc.class);
HitsMetadata<HotelDoc> hits = search.hits();
long total = hits.total().value();
System.out.println(total);
hits.hits().forEach(hit -> {
HotelDoc source = hit.source();
System.out.println(source);
});
System.out.println(search);
高亮
高亮:就是在搜索结果中把搜索关键字突出显示。
原理是这样的:
将搜索结果中的关键字用标签标记出来 在页面中给标签添加css样式,默认是em
GET /hotel/_search
{
"query": {
"match": {
"all": "如家"
}
},
"highlight": {
"fields": {
"name": {
// 前缀
"pre_tags": "<em>",
// 后缀
"post_tags": "</em>",
// 是否需要字段匹配,默认true
"require_field_match": "false"
}
}
}
}
java 实现
SearchRequest.Builder builder = new SearchRequest.Builder();
// match 查询
builder.index("hotel").query(q -> q.match(m -> m.field("all").query("如家")))
// 高亮
.highlight(h ->
// 高亮字段,可以多个
h.fields("name", f -> f)
// 是否与查询字段匹配
.requireFieldMatch(false)
// 设置高亮标签
.preTags("<em>").postTags("</em>"));
SearchRequest build = builder.build();
SearchResponse<HotelDoc> search = client.search(build, HotelDoc.class);
HitsMetadata<HotelDoc> hits = search.hits();
long total = hits.total().value();
System.out.println(total);
hits.hits().forEach(hit -> {
// 获取高亮
// Map<String, List<String>> highlight = hit.highlight();
HotelDoc source = hit.source();
System.out.println(source);
});
System.out.println(search);
实战
搜素查询
@Override
public PageResult search(RequestParam param) {
SearchRequest.Builder builder = new SearchRequest.Builder();
// 查询
builder.index("hotel").query(q -> StringUtils.isNotBlank(param.getKey()) ?
q.match(m -> m.field("all").query(param.getKey())) : q.matchAll(m -> m));
// 分页
builder.from((param.getPage() - 1) * param.getSize()).size(param.getSize());
try {
// 发起请求
SearchResponse<HotelDoc> response = elasticsearchClient.search(builder.build(), HotelDoc.class);
HitsMetadata<HotelDoc> hits = response.hits();
// 整合数据
Assert.isTrue(hits.total() != null, "总数为空");
long total = hits.total().value();
List<HotelDoc> hotelDocs = hits.hits().stream().map(Hit::source).toList();
return new PageResult(total, hotelDocs);
} catch (IOException e) {
throw new RuntimeException("请求ES数据失败", e);
}
}
位置排序
@Override
public PageResult search(RequestParam param) {
SearchRequest.Builder builder = new SearchRequest.Builder();
// 这里使用 query 来更好的阅读代码
BoolQuery.Builder boolQuery = new BoolQuery.Builder();
// 过滤关键字:需要算分
boolQuery.must(mu -> StringUtils.isNotBlank(param.getKey()) ?
mu.match(m -> m.field("all").query(param.getKey())) : mu.matchAll(a -> a));
// 条件查询
if (StringUtils.isNotBlank(param.getCity())) {
boolQuery.filter(f -> f.term(t -> t.field("city").value(param.getCity())));
}
if (StringUtils.isNotBlank(param.getBrand())) {
boolQuery.filter(f -> f.term(t -> t.field("brand").value(param.getBrand())));
}
if (param.getMinPrice() != null && param.getMaxPrice() != null) {
boolQuery.filter(f -> f.range(t -> t.number(n ->
n.field("price").gte(param.getMinPrice()).lte(param.getMaxPrice()))));
}
// 查询
builder.index("hotel").query(q -> q.bool(boolQuery.build()));
// 分页
builder.from((param.getPage() - 1) * param.getSize()).size(param.getSize());
// 排序
if (StringUtils.isNotBlank(param.getLocation())) {
builder.sort(s -> s.geoDistance(geo -> geo.field("location")
// 使用集合的方式---ES 推荐
// .location(l -> l.coords(List.of()))))
.location(l -> l.text(param.getLocation()))
.order(SortOrder.Asc) // 排序
.unit(DistanceUnit.Kilometers) // 单位km
));
}
try {
// 发起请求
SearchResponse<HotelDoc> response = elasticsearchClient.search(builder.build(), HotelDoc.class);
HitsMetadata<HotelDoc> hits = response.hits();
// 整合数据
Assert.isTrue(hits.total() != null, "总数为空");
long total = hits.total().value();
List<HotelDoc> hotelDocs = hits.hits().stream().map(hotelDocHit -> {
if (hotelDocHit.sort().isEmpty()) {
return hotelDocHit.source();
}
double value = hotelDocHit.sort().get(0).doubleValue();
HotelDoc source = hotelDocHit.source();
assert source != null;
source.setDistance(value);
return source;
}).toList();
return new PageResult(total, hotelDocs);
} catch (IOException e) {
throw new RuntimeException("请求ES数据失败", e);
}
}
算分
@Override
public PageResult search(RequestParam param) {
SearchRequest.Builder builder = new SearchRequest.Builder();
// 这里使用 query 来更好的阅读代码
BoolQuery.Builder boolQuery = new BoolQuery.Builder();
// 过滤关键字:需要算分
boolQuery.must(mu -> StringUtils.isNotBlank(param.getKey()) ?
mu.match(m -> m.field("all").query(param.getKey())) : mu.matchAll(a -> a));
// 条件查询
if (StringUtils.isNotBlank(param.getCity())) {
boolQuery.filter(f -> f.term(t -> t.field("city").value(param.getCity())));
}
if (StringUtils.isNotBlank(param.getBrand())) {
boolQuery.filter(f -> f.term(t -> t.field("brand").value(param.getBrand())));
}
if (param.getMinPrice() != null && param.getMaxPrice() != null) {
boolQuery.filter(f -> f.range(t -> t.number(n ->
n.field("price").gte(param.getMinPrice()).lte(param.getMaxPrice()))));
}
// 查询
builder.index("hotel").query(q -> q.functionScore(f ->
// 使用 bool 查询
f.query(fq -> fq.bool(boolQuery.build()))
// 过滤出 isAD 字段等于 true 的进行加分
.functions(fun -> fun.filter(fq -> fq.term(t -> t.field("isAD").value(true)))
// 分值
.weight(10.0))
));
// 分页
builder.from((param.getPage() - 1) * param.getSize()).size(param.getSize());
// 排序
if (StringUtils.isNotBlank(param.getLocation())) {
builder.sort(s -> s.geoDistance(geo -> geo.field("location")
// 使用集合的方式---ES 推荐
// .location(l -> l.coords(List.of()))))
.location(l -> l.text(param.getLocation()))
.order(SortOrder.Asc) // 排序
.unit(DistanceUnit.Kilometers) // 单位km
));
}
try {
// 发起请求
SearchResponse<HotelDoc> response = elasticsearchClient.search(builder.build(), HotelDoc.class);
HitsMetadata<HotelDoc> hits = response.hits();
// 整合数据
Assert.isTrue(hits.total() != null, "总数为空");
long total = hits.total().value();
List<HotelDoc> hotelDocs = hits.hits().stream().map(hotelDocHit -> {
if (hotelDocHit.sort().isEmpty()) {
return hotelDocHit.source();
}
double value = hotelDocHit.sort().get(0).doubleValue();
HotelDoc source = hotelDocHit.source();
assert source != null;
source.setDistance(value);
return source;
}).toList();
return new PageResult(total, hotelDocs);
} catch (IOException e) {
throw new RuntimeException("请求ES数据失败", e);
}
}
聚合
聚合(aggregations)可以实现对文档数据的统计、分析、运算。聚合常见的有三类:
桶(Bucket)聚合:用来对文档做分组
- TermAggregation:按照文档字段值分组
- Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组
度量(Metric)聚合:用以计算一些值,比如:最大值、最小值、平均值等
- Avg:求平均值
- Max:求最大值
- Min:求最小值
- Stats:同时求max、min、avg、sum等
管道(pipeline)聚合:其它聚合的结果为基础做聚合
参与聚合的字段类型必须是:
- keyword
- 数值
- 日期
- 布尔
GET /hotel/_search
{
"query": { // 条件过滤
"range": {
"price": {
"gte": 0,
"lte": 200
}
}
},
"size": 0, // 文档不需要返回
"aggs": {
"brandagg": { // 自定义函数名
"terms": { // 查找
"field": "brand", // 聚合字段
"size": 10, // 返回个数
"order": {
"_count": "asc" // 排序
}
}
}
}
}
Metrics 聚合函数
GET /hotel/_search
{
"query": {
"range": {
"price": {
"gte": 0,
"lte": 200
}
}
},
"size": 0,
"aggs": {
"brandagg": {
"terms": {
"field": "brand",
"size": 10,
"order": {
"scoreAgg.avg": "desc" // 排序
}
},
// 在分组的基础上进行函数
"aggs": {
"scoreAgg": {
"stats": {
"field": "score" // 对分数
}
}
}
}
}
}
Java 实现
SearchRequest.Builder builder = new SearchRequest.Builder();
builder.index("hotel")
.size(0)
// 聚合器,聚合名称
.aggregations("brandAgg",
ag -> ag.terms(t -> t.field("brand").size(10)
.order(List.of(new NamedValue<>("scoreAgg.avg", SortOrder.Desc))))
// 聚合后再聚合
.aggregations("scoreAgg", a -> a.stats(s -> s.field("score"))));
// 发起请求
SearchResponse<HotelDoc> response = client.search(builder.build(), HotelDoc.class);
for (StringTermsBucket brandAgg : response.aggregations().get("brandAgg").sterms().buckets().array()) {
System.out.println("品牌:" + brandAgg.key().stringValue());
brandAgg.aggregations().forEach((name, agg) -> {
StatsAggregate stats = agg.stats(); // 还可以 sterms().buckets().array()
System.out.println("总数:" + stats.count());
System.out.println("平均分:" + stats.avg());
System.out.println("最高分:" + stats.max());
System.out.println("最低分:" + stats.min());
System.out.println("总分:" + stats.sum());
});
}
拼音分词器
下载解压到插件目录即可。
POST /_analyze
{
"text": ["如家酒店还不错"],
"analyzer": "pinyin"
}
自定义分词器
elasticsearch中分词器(analyzer)的组成包含三部分:
- character filters:在tokenizer之前对文本进行处理。例如删除字符、替换字符
- tokenizer:将文本按照一定的规则切割成词条(term)。例如keyword,就是不分词;还有ik_smart
- tokenizer filter:将tokenizer输出的词条做进一步处理。例如大小写转换、同义词处理、拼音处理等
如何实现呢?
首先创建了一个索引库,就可以用 my_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,
"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"
}
}
}
}
但是我们做个测试,会发现狮子和师资都出来了,同音字。
POST /test/_doc/1
{
"id": 1,
"name": "狮子"
}
POST /test/_doc/2
{
"id": 2,
"name": "师资"
}
GET /test/_search
{
"query": {
"match": {
"name": "掉入狮子笼怎么办"
}
}
}
拼音分词器适合在创建倒排索引的时候使用,但不能在搜索的时候使用。
创建倒排索引时,默认我们搜汉字,会转换成拼音去搜索,所以出来的结果是不准确的。
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "my_analyzer",
"search_analyzer": "ik_smart" // 加上这行,可以实现汉字搜索
}
}
}
自动补全
Completion Suggester 来实现自动补全功能
- 参与补全查询的字段必须是completion类型。
- 字段的内容一般是用来补全的多个词条形成的数组。
// 创建索引库 PUT test
{
"mappings": {
"properties": {
"title":{
"type": "completion"
}
}
}
}
// 示例数据
POST test/_doc
{
"title": ["Sony", "WH-1000XM3"]
}
POST test/_doc
{
"title": ["SK-II", "PITERA"]
}
POST test/_doc
{
"title": ["Nintendo", "switch"]
}
查询
// 自动补全查询
GET /test2/_search
{
"suggest": {
"titleSuggest": {
"text": "s", // 关键字
"completion": {
"field": "title", // 补全查询的字段
"skip_duplicates": true, // 跳过重复的
"size": 10 // 获取前10条结果
}
}
}
}
实战演示
// 酒店数据索引库
PUT /hotel
{
"settings": {
// 分词器配置
"analysis": {
// 分词器
"analyzer": {
"text_anlyzer": {
"tokenizer": "ik_max_word", // 将文本拆分成多个词语
"filter": "py" // 结合 py 过滤器
},
"completion_analyzer": {
"tokenizer": "keyword", // 不拆分关键字
"filter": "py" // 结合 py 使其支持拼音搜索
}
},
"filter": {
// py 过滤器
"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": {
"name":{
"type": "text",
"analyzer": "text_anlyzer", // 使用过滤器
"search_analyzer": "ik_smart", // 查询时中文分词
"copy_to": "all"
},
// 配备 all
"all":{
"type": "text",
"analyzer": "text_anlyzer", // 使用过滤器
"search_analyzer": "ik_smart" // 查询时中文分词
},
// 自动补全字段,使用 completion_analyzer(拼音支持),用于搜索联想
"suggestion":{
"type": "completion",
"analyzer": "completion_analyzer" // 对应JAVA字段是 List<String>
}
}
}
}
Java 代码实现
@Test
void Suggest() throws IOException {
SearchRequest.Builder builder = new SearchRequest.Builder();
Suggester.Builder sugg = new Suggester.Builder();
// 方式一:不好记
// CompletionSuggester suggestion = new CompletionSuggester.Builder().field("suggestion").skipDuplicates(true).size(10).build();
// sugg.text("sd").suggesters("titleSuggest",
// new FieldSuggester.Builder().completion(suggestion).build());
// 设置搜索的词
Suggester suggester = sugg.text("sd")
// 起一个key
.suggesters("titleSuggest", field ->
// 设置字段
field.completion(c -> c.field("suggestion")
// 跳过重复的
.skipDuplicates(true)
// 返回条数
.size(10))).build();
builder.suggest(suggester);
SearchResponse<HotelDoc> response = client.search(builder.build(), HotelDoc.class);
// 遍历标签
response.suggest().forEach((k, v) -> {
// 遍历该数组
v.forEach(suggestion -> {
// 获取多个结果
List<CompletionSuggestOption<HotelDoc>> options = suggestion.completion().options();
// 打印结果
options.forEach(o -> {
System.out.println(o.text());
});
});
});
}
数据同步
elasticsearch中的酒店数据来自于mysql数据库,因此mysql数据发生改变时,elasticsearch也必须跟着改变,这个就是elasticsearch与mysql之间的数据同步。
- 业务耦合,影响性能
方式二:使用MQ(推荐)
方式三:监听 binlog
方式一:同步调用
- 优点:实现简单,粗暴
- 缺点:业务耦合度高
方式二:异步通知
- 优点:低耦合,实现难度一般
- 缺点:依赖mq的可靠性
方式三:监听binlog
- 优点:完全解除服务间耦合
- 缺点:开启binlog增加数据库负担、实现复杂度高
ES 集群
单机的elasticsearch做数据存储,必然面临两个问题:海量数据存储问题、单点故障问题。
海量数据存储问题:将索引库从逻辑上拆分为N个分片(shard),存储到多个节点
单点故障问题:将分片数据在不同节点备份(replica )
- 建立多节点,保证存储海量数据
- 解决单节点故障,方式:每个节点有个备份,将备份放在不同的节点,比如节点0 的机器存储的是节点2 的备份。
集群搭建
docker-compose文件
version: '2.2' # Docker Compose 版本
services:
# Elasticsearch 第一个节点(主节点候选)
es01:
image: elasticsearch:8.14.3 # 使用 Elasticsearch 8.14.3 版本
container_name: es01 # 容器名称
environment:
- node.name=es01 # 节点名称
- cluster.name=es-docker-cluster # 集群名称,所有节点必须相同
- discovery.seed_hosts=es02,es03 # 其他节点的地址,用于自动发现
- cluster.initial_master_nodes=es01,es02,es03 # 指定初始主节点
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" # 限制 Elasticsearch 的 JVM 堆内存为 512MB
- xpack.security.enabled=false # 关闭安全认证(可选,默认需要用户名密码)
volumes:
- data01:/usr/share/elasticsearch/data # 将数据存储到本地,防止数据丢失
ports:
- 9200:9200 # 映射端口,宿主机 9200 -> 容器 9200(Elasticsearch API 访问)
networks:
- elastic # 连接到 elastic 网络,与其他节点通信
# Elasticsearch 第二个节点(数据节点)
es02:
image: elasticsearch:8.14.3
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"
- xpack.security.enabled=false
volumes:
- data02:/usr/share/elasticsearch/data
ports:
- 9201:9200 # 这里映射为 9201 避免端口冲突
networks:
- elastic
# Elasticsearch 第三个节点(数据节点)
es03:
image: elasticsearch:8.14.3
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"
- xpack.security.enabled=false
volumes:
- data03:/usr/share/elasticsearch/data
networks:
- elastic
ports:
- 9202:9200 # 这里映射为 9202 避免端口冲突
# 定义存储卷,持久化 Elasticsearch 数据,避免容器删除后数据丢失
volumes:
data01:
driver: local
data02:
driver: local
data03:
driver: local
# 自定义 Docker 网络,保证 Elasticsearch 各节点可以互相通信
networks:
elastic:
driver: bridge
修改配置
配置 Elasticsearch 的 vm.max_map_count
1. 背景
Elasticsearch 运行时,会使用大量的内存映射(memory-mapped files)来处理索引、数据存储等任务。vm.max_map_count 配置项用于限制单个进程可以创建的 memory-mapped 文件的数量。
Linux 系统默认的 vm.max_map_count 值通常较低(65530),Elasticsearch 官方推荐至少设置为 262144,否则可能会遇到 Elasticsearch 运行异常或性能问题。
2. 为什么需要修改 vm.max_map_count
- 避免 "out of memory" 或 "too many open files" 错误
如果 vm.max_map_count 过低,Elasticsearch 在创建或访问索引时,可能会因为映射文件数超过上限而崩溃,日志可能会出现类似的错误:
- 提升 Elasticsearch 处理大索引的能力
Elasticsearch 依赖 Lucene 进行索引存储,Lucene 使用 memory-mapped files 访问索引数据。较高的 vm.max_map_count 值可以确保 Elasticsearch 在处理大量索引时不会受到限制。
- 提高查询性能
由于 Elasticsearch 采用 memory-mapped file 方式访问数据,设置 vm.max_map_count=262144 可以让更多的索引数据存放在内存中,减少磁盘 I/O,提升查询性能。
3. 修改 vm.max_map_count
- 永久修改(推荐)
编辑 sysctl.conf 文件
添加或修改以下内容
让配置生效
- 临时修改(重启后失效)
如果只是临时调整,可以执行以下命令:
此方法在系统重启后会恢复默认值。
- 验证修改是否生效
执行以下命令,检查当前 vm.max_map_count 的值:
如果输出:
说明修改成功。
4. 结论
| 配置项 | 作用 |
|---|---|
| vm.max_map_count=262144 | 允许 Elasticsearch 进程打开更多 memory-mapped 文件,避免 too many open files 错误,提高索引性能。 |
这个配置是 Elasticsearch 官方推荐 的,特别是在 生产环境中 运行时必须修改!
集群监控状态
使用 Cerebro 监控 Elasticsearch 集群
1. 背景
Kibana 可以用于监控 Elasticsearch 集群,但新版本需要依赖 Elasticsearch 的 x-pack 功能,配置较为复杂。因此,推荐使用 Cerebro 来监控 Elasticsearch 集群状态。
2. Cerebro 介绍
Cerebro 是一个轻量级的 Elasticsearch 可视化监控工具,安装和使用都非常简单。
3. 安装 Cerebro
获取 Cerebro 安装包
- 官方地址:github.com/lmenezes/ce…
- 下载并解压提供的安装包。
目录结构
解压后的目录结构如下:
cerebro/
├── bin/
├── conf/
├── lib/
├── logs/
└── public/
启动 Cerebro
- 进入
bin目录。 - Windows 环境:双击
cerebro.bat启动。 - Linux/Mac 环境:执行以下命令:
./bin/cerebro
访问 Cerebro
- 打开浏览器,访问
http://localhost:9000。 - 在输入框中填写 Elasticsearch 任意节点的地址(例如
http://192.168.1.100:9200)。 - 点击
Connect按钮连接到 Elasticsearch。
监控集群状态
连接成功后,可以查看 Elasticsearch 集群的状态:
| 状态颜色 | 说明 |
|---|---|
| 🟢 绿色(Green) | 集群运行正常,所有主分片和副本分片均已分配。 |
| 🟡 黄色(Yellow) | 部分副本分片未分配,但主分片正常,不影响查询。 |
| 🔴 红色(Red) | 部分主分片未分配,可能影响查询和索引。 |
结论 Cerebro 是一个轻量级的 Elasticsearch 可视化监控工具,推荐用于集群状态监测。相比 Kibana,它的配置简单,适用于不想启用 x-pack 但又需要集群监控的场景。🚀
ES集群的脑裂
PUT /itcast
{
"settings": {
"number_of_shards": 3, // 分片数量
"number_of_replicas": 1 // 副本数量
},
"mappings": {
"properties": {
// mapping映射定义 ...
}
}
}
elasticsearch中集群节点有不同的职责划分:
- master eligible:可以参与选主节点
- ingest:不常用
- coordinating:路由 + 负载均衡
- 协调节点不能控制,但是把其他默认值都改成false
不用我们选择这些节点职责,节点都是身兼数职,但是实际开发需要配置,不能这样做
不同的职责,不同的硬件要求。
| 节点类型 | 配置参数 | 默认值 | 节点职责 |
|---|---|---|---|
| master eligible | node.master | true | 备选主节点:主节点可以管理和记录集群状态、决定分片在哪个节点、处理创建和删除索引库的请求 |
| data | node.data | true | 数据节点:存储数据、搜索、聚合、CRUD |
| ingest | node.ingest | true | 数据存储之前的预处理 |
| coordinating | 上面3个参数都为false则为coordinating节点 | 无 | 路由请求到其它节点合并其它节点处理的结果,返回给用户 |
elasticsearch中的每个节点角色都有自己不同的职责,因此建议集群部署时,每个节点都有独立的角色。
默认情况下,每个节点都是master eligible节点,因此一旦master节点宕机,其它候选节点会选举一个成为主节点。当主节点与其他节点网络故障时,可能发生脑裂问题。
为了避免脑裂,需要要求选票超过 ( eligible节点数量 + 1 )/ 2 才能当选为主,因此eligible节点数量最好是奇数。对应配置项是discovery.zen.minimum_master_nodes,在es7.0以后,已经成为默认配置,因此一般不会发生脑裂问题
1. 网络阻塞
选举出两个主节点:所以脑裂。
(目前不会发生,因为eligible 节点总数 + 1 / 2 才会)
分布式新增
GET /itcast/_search
{
"explain": true, // 可以查看存储位置
"query": {
"match_all": {}
}
}
结果
{
"hits": {
"total": 3,
"hits": [
{
"_shard": "[itcast][0]", // 表示 0 号片
"_node": "node-2",
"_index": "itcast",
"_type": "_doc",
"_id": "5",
"_score": 1.0,
"_source": {
"title": "试着插入一条 id = 5"
},
"_explanation": {
"value": 1.0,
"description": "Explanation of score",
"details": []
}
}
]
}
}
那么存储位置是怎么计算的呢?
shard = hash(id) % 分片数量
用 hash 对 ID 做运算,用结果对于 分片数量做取余。
- _routing默认是文档的id
- 算法与分片数量有关,因此索引库一旦创建,分片数量不能修改!
新增修改删除流程
查询全部流程
elasticsearch的查询分成两个阶段:
- scatter phase:分散阶段,coordinating node会把请求分发到每一个分片
- gather phase:聚集阶段,coordinating node汇总data node的搜索结果,并处理为最终结果集返回给用户
故障转移
集群的master节点会监控集群中的节点状态,如果发现有节点宕机,会立即将宕机节点的分片数据迁移到其它节点,确保数据安全,这个叫做故障转移。
当 Master 节点检测到某个节点 离线(宕机) 时,会执行以下步骤:
- 你有 3 台服务器(节点),数据被分成 3 份,分别存放在它们上面,同时每份数据都有一个“备份”放在另一台服务器上。
- 现在假设 A 服务器宕机了,它上面的数据暂时无法访问。
- 主节点会将 A 服务器的备份调取进行重新分配到健康的节点上
故障转移:
- master宕机后,EligibleMaster选举为新的主节点。
- master节点监控分片、节点状态,将故障节点上的分片转移到正常节点,确保数据安全。