1. 数据聚合
数据聚合可以让我们方便的对ES中存储的数据进行分析,统计和运算,例如:
- 每一个品牌的酒店的平均评分是多少
- 不同地区的酒店的平均分是多少?
实际上数据聚合在MySQL的时候我们已经学习过了,在MySQL中常见的数据聚合函数如, Sum, Avg, Max, Min,等。
2 ES中的数据聚合
在ES中,数据聚合分为三类
- 桶聚合:用来对文档进行分组
- TermAggregation:按照文档的字段值进行分组,例如按照酒店的品牌进行分组,按照城市进行分组等
- DateHistogram:按照日期阶梯分组,如一周为一组。
- 度量聚合:主要是以用力啊计算一些值,比如最小值,最大值和平均值等
- Avg:平均值
- Max:最大值
- Min:最小值
- Stats:同时求max,min,avg,sum
- 管道:以其他聚合方式的结果进行聚合的方式,并不常用
注意:参加聚合的字段必须是keyword, 日期,布尔类型
3. DSL实现聚合
3.1 桶聚合
语法如下:
GET /hotel/_search
{
"size": 0, // 设置size为0,结果中不包含文档,只包含聚合结果
"aggs": { // 定义聚合
"brandAgg": { //给聚合起个名字
"terms": { // 聚合的类型,按照品牌值聚合,所以选择term
"field": "brand", // 参与聚合的字段
"size": 20 // 希望获取的聚合结果数量
}
}
}
}
结果如图:
2.2 聚合结果排序
默认情况下,桶排序会根据桶内的文档数量降序排列,我们实际上也可以手动指定排序方式
GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"order": {
"_count": "asc" // 按照_count升序排列
},
"size": 20
}
}
}
}
2.3 限定聚合范围
默认情况下,桶聚合是对索引库中的所有文档做聚合。实际上,也可以根据搜索的结果,以搜索的结果为数据进行聚合,只需要添加query条件即可
我们可以限定要聚合的文档范围,只要添加query条件即可:
GET /hotel/_search
{
"query": {
"range": {
"price": {
"lte": 200 // 只对200元以下的文档聚合
}
}
},
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 20
}
}
}
}
这次,聚合得到的品牌明显变少了:
2.4 度量聚合
上一小节中我们是对酒店的品牌进行桶聚合,现在我们可以利用度量聚合统计每一个品牌评分的max,avg,min等
如果我们要计算每一个品牌用户评分的max,avg,min等,就需要先对酒店的品牌做桶聚合,然后在每一个聚合内,使用度量聚合。
语法如下:
GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 20
},
"aggs": { // 是brands聚合的子聚合,也就是分组后对每组分别计算
"score_stats": { // 聚合名称
"stats": { // 聚合类型,这里stats可以计算min、max、avg等
"field": "score" // 聚合字段,这里是score
}
}
}
}
}
}
这次的score_stats聚合是在brandAgg的聚合内部嵌套的子聚合。因为我们需要在每个桶分别计算。
另外,我们还可以给聚合结果做个排序,例如按照每个桶的酒店平均分做排序:
2.5 总结
aggs代表聚合,和query是同级关系,当同时出现aggs和query的时候,query的作用是
- 限定聚合的文档范围,如果没有query,默认对全部文档进行聚合
聚合的三要素是:
- 聚合名称
- 聚合类型
- 聚合字段
聚合可配置的字段有
- order 聚合结果的排序
- size 显示的结果条数
- field:聚合的字段
4. 使用RestAPI实现聚合
聚合条件与query条件同级别,因此需要使用request.source()来指定聚合条件。
聚合条件的语法:
聚合查询请求发送
结果解析
4.1 实例
4.1.1 需求分析
我们目前项目中过滤条件都是写死的,不会随着搜索结果的变化而变化,这样会出现以下的问题:
假设用户搜索的是“虹桥”,那么实际上城市过滤选项中就不应该再出现北京,深圳等城市,因为只有上海才有“虹桥”这个地方;再比如用户选择五星级酒店,那么诸如7天,如家就不应该再出现在品牌过滤项中。
也就是说,搜索结果中包含哪些城市,页面就应该列出哪些城市;搜索结果中包含哪些品牌,页面就应该列出哪些品牌。
我们的思路是将我们搜索到的结果使用桶聚合的方式,对搜索结果中包含的品牌,城市进行分组。
4.1.2 前端分析
通过观察前端页面可以发现,当我们设置过滤条件的时候前端发送了上面的这个请求。其返回值类型为map:
- key是字符串,城市、星级、品牌、价格。
- value是集合,例如多个城市的名称
4.1.3 代码实现
在HotelController
中添加一个方法,遵循下面的要求:
- 请求方式:
POST
- 请求路径:
/hotel/filters
- 请求参数:
RequestParams
,与搜索文档的参数一致 - 返回值类型:
Map<String, List<String>>
代码:
@PostMapping("filters")
public Map<String, List<String>> getFilters(@RequestBody RequestParams params){
return hotelService.Filters(params);
}
在HotelServiceImpl
中添加filter
方法的实现
@Override
public Map<String, List<String>> filter(SearchParam searchParam) {
try {
Map<String, List<String>> map = new HashMap<>();
SearchRequest request = new SearchRequest("hotel");
buildBasicQuery(searchParam, request);
request.source().size(0);
request.source().aggregation(
AggregationBuilders.terms("brandAgg")
.field("brand")
.size(30)
);
request.source().aggregation(
AggregationBuilders.terms("cityAgg")
.field("city")
.size(30)
);
request.source().aggregation(
AggregationBuilders.terms("starNameAgg")
.field("starName")
.size(30)
);
SearchResponse search = this.client.search(request, RequestOptions.DEFAULT);
List<String> name = parseRes(search, "brandAgg");
map.put("brand", name);
name = parseRes(search, "cityAgg");
map.put("city", name);
name = parseRes(search, "starNameAgg");
map.put("starName", name);
return map;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private List<String> parseRes(SearchResponse search, String aggName) {
Aggregations aggregations = search.getAggregations();
Terms term = aggregations.get(aggName);
List<String> list = new ArrayList<>();
List<? extends Terms.Bucket> buckets = term.getBuckets();
for (Terms.Bucket bucket : buckets) {
String name = bucket.getKeyAsString();
list.add(name);
}
return list;
}
5. 自动补全
我们发现在百度或者京东上搜索东西的时候,会根据拼音提示我们可能想要搜索的内容,这是如何实现的呢?
要实现上述的功能,需要使用一个新的分词器:拼音分词器
5.1 拼音分词器
如果要根据拼音做字母的补全,就必须对文档进行拼音分词,最常使用的拼音分词插件是https://github.com/medcl/elasticsearch-analysis-pinyin。
接下来我们测试一下拼音分词器的效果
测试用法如下:
POST /_analyze
{
"text": "如家酒店还不错",
"analyzer": "pinyin"
}
结果:
从结果中可以看出,默认的拼音分词器存在着下列的问题
- 分词结果没有进行单词的划分,比如没有rujia, jiudian等
- 分词结果中只有拼音,而没有保存中文。
我们的要求是分词结果中包含中文和拼音。如输入如家酒店
,那么分词结果应该是,rujia
, jiudian
, rujiajiudian
, rj
, jd
, rjjd
, 如家
,酒店
,如家酒店
为了解决这个问题,我们将使用自定义分词器
5.2 自定义分词器
默认的拼音分词器不能够满足我们的需求,因此我们需要对拼音分词器做定制。首先我们来了解一下ES分词器的构造
ES分词器分为三部分
- character filter:对文本进行预处理,例如删除一些特殊字符等
- tokenizer:核心组件,将文本切割成字条
- tokenizer filter:将tokenizer的结果进一步处理,比如大小写转换等
我们可以首先将输入的词使用ik分词器进行分词,然后将分词后的结果使用拼音分词器进行分词即可。
声明自定义分词器的语法如下:
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"
}
}
}
}
我们注意到,我们使用了"search_analyzer": "ik_smart"
这句代码,如果不加这个代码,会出现下面的问题
此时假设我输入的为狮子
,那么狮子这个词会经过我们自定义的分词器,分解为狮子
, shizi
, sz
;那么在进行匹配的时候,同音字虱子
也会出来,这显然是不合理的。
因此我们需要指定我们在搜索的分词器为"search_analyzer": "ik_smart"
这样我们在输入狮子
的时候,就只会根据中文分词,而不会进行拼音分词,从而避免了同音字的问题
5.3 自动补全
elasticsearch提供了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"]
}
查询的DSL语句如下:
// 自动补全查询
GET /test/_search
{
"suggest": {
"title_suggest": {
"text": "s", // 关键字
"completion": {
"field": "title", // 补全查询的字段
"skip_duplicates": true, // 跳过重复的
"size": 10 // 获取前10条结果
}
}
}
}
查询到的结果都是以s开头的所有匹配词条
5.4 使用RestAPI实现自动补全
发送自动补全查询的请求
对结果进行解析
5.5 实例:实现酒店搜索框的自动补全
完成酒店搜索框自动补全的功能,准备工作需要一下几个步骤:
- 修改hotel索引库结构,设置自定义拼音分词器
- 修改索引库的name、all字段,使用自定义分词器
- 索引库添加一个新字段suggestion,类型为completion类型,使用自定义的分词器
- 给HotelDoc类添加suggestion字段,内容包含brand、business
- 重新导入数据到hotel库
修改酒店映射结构
代码如下:
// 酒店数据索引库
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"
}
}
}
}
这里我们重点来看suggest部分
"completion_analyzer": {
"tokenizer": "keyword",
"filter": "py"
}
"suggestion":{
"type": "completion",
"analyzer": "completion_analyzer",
"search_analyzer": "keyword"
}
因为我们的suggestion里面存储的是酒店的名称和商圈,因此不需要进行分词,只需要转化成拼音即可。当用户搜索的时候,会自动根据用户输入的首个字符到suggestion里面去匹配,因此用户的输入也不需要分词或者转换成拼音。
修改实体类HotelDoc
为HotelDoc添加一个新的字段suggestion用来做自动补全,内容可以是酒店品牌、城市、商圈等信息。
@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);
}
}
}
重新导入数据
直接执行之前编写的testAutoFill
测试方法即可
分析前端页面查看前端页面,可以发现当我们在输入框键入时,前端会发起ajax请求,其返回值是一个List<String>
集合,里面保存的是所有的自动补全的词条集合
代码编写
在的HotelController
中添加新接口,接收新的请求:
@GetMapping("suggestion")
public List<String> getSuggestions(@RequestParam("key") String prefix) {
return hotelService.getSuggestions(prefix);
}
在HotelServiceImpl
中实现该方法:
@Override
public List<String> getSuggestions(String prefix) {
try {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
request.source().suggest(new SuggestBuilder().addSuggestion(
"suggestions",
SuggestBuilders.completionSuggestion("suggestion")
.prefix(prefix)
.skipDuplicates(true)
.size(10)
));
// 3.发起请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析结果
Suggest suggest = response.getSuggest();
// 4.1.根据补全查询名称,获取补全结果
CompletionSuggestion suggestions = suggest.getSuggestion("suggestions");
// 4.2.获取options
List<CompletionSuggestion.Entry.Option> options = suggestions.getOptions();
// 4.3.遍历
List<String> list = new ArrayList<>(options.size());
for (CompletionSuggestion.Entry.Option option : options) {
String text = option.getText().toString();
list.add(text);
}
return list;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
测试
6. 数据同步
我们在ES中使用的数据都是来自MySQL数据库的,因此当数据库中数据发生改变时,ES中的数据也应该同步的改变。
6.1 同步调用
当我们在hotel-admin微服务中对数据库中内容进行修改的时候,通过远程方法调用的方式调用hotel-demo中的相关代码去修改ES中的数据
- 优点:实现简单,粗暴
- 缺点:业务耦合度高,且耗时比较长
6.2 异步通知
结合我们之前学习的消息队列,当hotel-admin中有修改酒店数据相关操作时,可以将修改的信息发送到消息队列中,在hotel-demo中始终去监听这个消息队列,一旦有修改信息就读取,然后在ES中完成修改内容
- 优点:低耦合,实现难度一般
- 缺点:依赖mq的可靠性
此外,我们还可以使用数据库的Binlog功能实现异步通知流程如下:
- 给mysql开启binlog功能
- mysql完成增、删、改操作都会记录在binlog中
- hotel-demo基于canal监听binlog变化,实时更新elasticsearch中的内容
- 优点:完全解除服务间耦合
- 缺点:开启binlog增加数据库负担、实现复杂度高
6.3 使用消息队列实现异步数据同步
具体步骤如下
- 声明exchange、queue、RoutingKey
- 在hotel-admin中的增、删、改业务中完成消息发送
- 在hotel-demo中完成消息监听,并更新elasticsearch中数据
- 启动并测试数据同步功能
消息队列
我们的消息队列结构如下图,包含两个消息队列分别负责插入更新操作和删除操作。一个交换机负责分发消息。
引入依赖
我们需要在两个微服务中都引入Spring AMQP的依赖
<!--amqp-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
声明交换机,队列,以及交换机和队列的绑定
在hotel-admin和hotel-demo中的cn.itcast.hotel.constatnts
包下新建一个类MqConstants
:
package cn.itcast.hotel.constatnts;
public class MqConstants {
/**
* 交换机
*/
public final static String HOTEL_EXCHANGE = "hotel.topic";
/**
* 监听新增和修改的队列
*/
public final static String HOTEL_INSERT_QUEUE = "hotel.insert.queue";
/**
* 监听删除的队列
*/
public final static String HOTEL_DELETE_QUEUE = "hotel.delete.queue";
/**
* 新增或修改的RoutingKey
*/
public final static String HOTEL_INSERT_KEY = "hotel.insert";
/**
* 删除的RoutingKey
*/
public final static String HOTEL_DELETE_KEY = "hotel.delete";
}
在hotel-demo中,定义配置类,声明队列、交换机:
@Configuration
public class MqConfig {
@Bean
public TopicExchange topicExchange(){
return new TopicExchange(MqConstants.HOTEL_EXCHANGE, true, false);
}
@Bean
public Queue insertQueue(){
return new Queue(MqConstants.HOTEL_INSERT_QUEUE, true);
}
@Bean
public Queue deleteQueue(){
return new Queue(MqConstants.HOTEL_DELETE_QUEUE, true);
}
@Bean
public Binding insertQueueBinding(){
return BindingBuilder.bind(insertQueue()).to(topicExchange()).with(MqConstants.HOTEL_INSERT_KEY);
}
@Bean
public Binding deleteQueueBinding(){
return BindingBuilder.bind(deleteQueue()).to(topicExchange()).with(MqConstants.HOTEL_DELETE_KEY);
}
}
在hotel-admin中发送消息
@PostMapping
public void saveHotel(@RequestBody Hotel hotel){
hotelService.save(hotel);
rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_INSERT_KEY, hotel.getId());
}
@PutMapping()
public void updateById(@RequestBody Hotel hotel){
if (hotel.getId() == null) {
throw new InvalidParameterException("id不能为空");
}
hotelService.updateById(hotel);
rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_INSERT_KEY, hotel.getId());
}
@DeleteMapping("/{id}")
public void deleteById(@PathVariable("id") Long id) {
hotelService.removeById(id);
rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_DELETE_KEY, id);
}
在hotel-demo中接收消息
首先需要在hotelServiceImpl中去实现根据id在ES添加或者删除酒店的操作
@Override
public void deleteById(Long id) {
try {
DeleteRequest request = new DeleteRequest("hotel", id.toString());
this.client.delete(request, RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public void insertById(Long id) {
try {
Hotel hotel = this.getById(id);
HotelDoc hotelDoc = new HotelDoc(hotel);
IndexRequest request = new IndexRequest("hotel").id(id.toString());
request.source(JSON.toJSONString(hotel), XContentType.JSON);
this.client.index(request, RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
在hotel-demo中的cn.itcast.hotel.listener
包新增一个类,在里面编写监听器来监听两个队列:
@Component
public class MqListener {
@Resource
private IHotelService service;
@RabbitListener(queues = MqConstants.HOTEL_INSERT_QUEUE)
public void insertListener(Long id) {
service.insertById(id);
}
@RabbitListener(queues = MqConstants.HOTEL_DELETE_QUEUE)
public void deleteListener(Long id) {
service.deleteById(id);
}
}
注意:这里监听器所在的那个类一定要添加
@Component
注解,否则监听器不生效
7. ES集群
单机模式下的ES存储数据,会面临两个问题:海量数据的存储问题和单点故障问题,为了解决上述的问题,ES提供了集群模式:
- 海量数据存储:将索引库从逻辑上划分为N个分片,存储到多个ES节点
- 单点故障问题:存储多分备份分片,存储在不同的节点上
7.1 ES集群相关概念
- 集群:一组拥有相同的cluster name的ES节点
- 节点:集群中的一个ES实例
- 分片:索引库可以被拆分为不同的片存储到不同的节点上。主要用来解决索引库过大一个节点存储不下的问题
- 主分片(Primary shard):相对于副本分片的定义。
- 副本分片(Replica shard)每个主分片可以有一个或者多个副本,数据和主分片一样。
如果仅仅将数据分片而不做其他备份处理,那么一旦一个节点挂掉,那么数据就不完整了,因此还需要对分片进行备份,需要注意的是:备份分片一定不要和主分片在同一个节点上
我们在创建索引的时候,可以指定索引库划分的片数和每一片对应的副本的数量
7.2 ES节点职责划分
默认情况下,ES中的每一个节点都具备上述的四种职责
真实情况下,往往根据节点的性能划分职责:
- master节点:对CPU的要求较高,但是对内存要求低
- data节点:需要大量的运算和聚合,对CPU和内存要求都很高
- coordinating节点:对CPU和内存要求低
7.3 小结
- master eligible节点的作用:
- 参与集群选主
- 主节点可以管理集群状态,管理分片信息,处理请求
- data节点作用:
- 数据的CRUD
- coordinator节点
- 路由请求到其他节点
- 将查询结果整合后返回给用户
7.4 集群的分布式存储和查询
我们向ES集群中任意一个节点插入数据,ES会根据下面的公式计算出这一条数据应该插入到哪一个位置:
因此,索引库一旦创建,就不可以再次修改划分的片数
因此插入一条新文档的流程如下
当我们在查询的时候,可以访问任何一个ES节点,都可以查询到数据,这是因为在查询的时候,ES会将受到的查询请求发送到每一个ES集群中,然后将查询后的结果汇总,最后返回到用户。因此从用户的角度来看,访问任何一个ES节点都可以得到全部的数据。
ES的查询分成两个阶段:
- scatter phase:分散阶段,coordinating node会把请求分发到每一个分片
- gather phase:聚集阶段,coordinating node汇总data node的搜索结果,并处理为最终结果集返回给用户
7.5 集群故障转移
集群的master节点会监控集群中的节点状态,如果发现有节点宕机,会立即将宕机节点的分片数据迁移到其它节点,确保数据安全,这个叫做故障转移。
例如一个集群结构如图:
突然,node1发生了故障:
宕机后的第一件事,需要重新选主,例如选中了node2:
node2成为主节点后,会检测集群监控状态,发现:shard-1、shard-0没有副本节点。
因此需要将node1上的数据迁移到node2、node3:
这种故障转移机制在ES中自动实现,不需要人为指定