数据聚合

  • 聚合(aggregations)可以让我们极其方便的实现对数据的统计、分析、运算。例如
    1. 什么品牌的手机最受欢迎?
    2. 这些手机的平均价格、最高价格、最低价格?
    3. 这些手机每月的销售情况如何?
  • 实现这些统计功能的比数据库的SQL要方便很多,而且查询速度非常快,可以实现近实时搜索的效果

聚合的种类

常见的聚合有三类

  1. 桶(Bucket)聚合:用来对文档分组

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

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

注意:参加聚合的字段必须是非分词字段,如keyword、日期、数值、布尔类型

DSL实现聚合

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

Bucket聚合语法

  • 基本语法如下
    • 聚合的三要素: 聚合名称(自定义)、聚合类型 (terms、stats等)、聚合字段
GET /indexName/_search
{
  "aggs": {                 // 定义聚合
    "NAME": {               // 给聚合起个名字
      "AGG_TYPE": {}        // 聚合的类型
    }
  }
}

示例

GET /hotel/_search
{
  "size": 0,                // 设置size为0,结果中不包含文档,只包含聚合结果
  "aggs": {                 // 定义聚合
    "bucketAggName": {      // 给聚合起个名字
      "terms": {            // 聚合的类型,这里按照品牌值聚合,所以选择terms
        "field": "brand",   // 参与聚合的字段
        "size": 5           // 希望获取的聚合结果数量,由于品牌值可能很多,这里只获取5条看看效果
      }
    }
  }
}

img

聚合结果排序

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

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

GET /hotel/_search
{
  "size": 0, 
  "aggs": {
    "bucketAgg": {
      "terms": {
        "field": "brand",
        "order": {
          "_count": "asc"
        }, 
        "size": 5
      }
    }
  }
}

img

限定聚合范围

  • 默认情况下,Bucket聚合是对索引库的所有文档做聚合。当数据量过大时,对内存消耗特别大
  • 但真实场景下,用户会输入搜索条件
  • 因此聚合必须是对搜索结果的聚合,那么聚合就必须添加限定条件
  • 我们可以限定要聚合的文档范围,只需要添加query条件即可
  • 这里假设用户选择了1000元以上的酒店
GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 1000 // 只对1000元以上的文档做聚合
      }
    }
  }, 
  "size": 0, 
  "aggs": {
    "bucketAgg": {
      "terms": {
        "field": "brand",
        "order": {
          "_count": "desc"
        }, 
        "size": 5
      }
    }
  }
}

img

  • 从结果中看到,这次得到的酒店数量明显就减少了

Metric聚合语法

  • 现在我们需要对桶内(如品牌)文档做运算,获取每个品牌的用户频分的min、max、avg等值
  • 那么就需要用到Metric聚合了,例如stat聚合,就尅获取min、max、avg等值

  • 语法如下

GET /hotel/_search
{
  "size": 0, 
  "aggs": {
    "bucketAgg": {
      "terms": {
        "field": "brand",
        "order": {
          "_count": "desc"
        }, 
        "size": 5
      },
      "aggs": {         // 是bucketAgg聚合的子聚合,也就是分组后对每组分别进行计算
        "scoreAgg": {   // 子聚合名称
          "stats": {    // 聚合类型,stats可以计算min、max、avg等
            "field": "score"    // 聚合字段,这里计算用户评分的min、max、avg
          }
        }
      }
    }
  }
}

img

此外,我们还可以给聚合结果做排序,例如按照每个桶的酒店平均分做排序

GET /hotel/_search
{
  "size": 0, 
  "aggs": {
    "bucketAgg": {
      "terms": {
        "field": "brand",
        "order": {
          "scoreAgg.avg": "desc"    // 对scoreAgg.avg做降序排序
        }, 
        "size": 5
      },
      "aggs": {
        "scoreAgg": {
          "stats": {
            "field": "score"
          }
        }
      }
    }
  }
}

img

小结

  • aggs代表聚合,与query统计,此时query的作用是?
    • 限定聚合的文档范围
  • 聚合必须的三要素
    1. 聚合名称
    2. 聚合类型
    3. 聚合字段
  • 聚合可配置的属性有
    1. size:指定聚合结果数量
    2. order:指定聚合结果排序方式
    3. field:指定聚合字段

RestAPI实现聚合

API语法

  • 聚合条件与query统计,因此需要使用request.source()来指定聚合条件

  • 聚合条件的语法

    request.source().size(0);
    request.source().aggregation(
            AggregationBuilders
                    .terms("brandAgg")
                    .field("brand")
                    .size(20));

    img

  • 聚合的结果解析也与之前的查询结果解析不同,API比较特殊,但同样也是JSON逐层解析

    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    Aggregations aggregations = response.getAggregations();
    Terms brandTerms = aggregations.get("brandAgg");
    List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
    for (Terms.Bucket bucket : buckets) {
        String brandName = bucket.getKeyAsString();
        System.out.println(brandName);
    }

    img

  • 完整代码如下

    @Test
    void brandAggregationTest() throws IOException {
        // 1. 准备request对象
        SearchRequest request = new SearchRequest("hotel");
        // 2. 准备DSL
        // 2.1 设置size
        request.source().size(0);
        // 2.2 聚合
        request.source().aggregation(
                AggregationBuilders
                        .terms("brandAgg")
                        .field("brand")
                        .size(20));
        // 3. 发出请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        // 4. 解析结果
        Aggregations aggregations = response.getAggregations();
        // 4.1 根据聚合名称获取聚合结果
        Terms brandTerms = aggregations.get("brandAgg");
        // 4.2 获取桶
        List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
        // 4.3 遍历桶内元素
        for (Terms.Bucket bucket : buckets) {
            // 4.4 获取key,也就是品牌信息
            String brandName = bucket.getKeyAsString();
            System.out.println(brandName);
        }
    }

业务需求

利用之前做的黑马旅游网,项目继续优化

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

img

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

  • 例如:用户在搜索框输入王府井,那搜索的酒店肯定就只能在北京王府井附近,因此,城市只能是北京,此时城市列表中就不应该显示上海、深圳、杭州了

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

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

  • 使用聚合功能,利用Bucket聚合,对搜索结果中的文档,基于品牌分组、城市分组、星级分组等,就能得知包含哪些品牌、哪些城市了。

  • 因为是对搜索结果聚合,因此聚合是限定范围的聚合,且限定条件与搜索文档一致

  • 那么之前写的查询代码就可以直接用

    private void buildBasicQuery(RequestParams params, SearchRequest request) {
        // 1. 构建BoolQuery
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        String key = params.getKey();
        if (StringUtils.isEmpty(key)) {
            boolQuery.must(QueryBuilders.matchAllQuery());
        } else {
            boolQuery.must(QueryBuilders.matchQuery("all", key));
        }
        // 品牌条件
        if (params.getBrand() != null && !params.getBrand().equals("")) {
            boolQuery.filter(QueryBuilders.termsQuery("brand", params.getBrand()));
        }
        // 城市条件
        if (params.getCity() != null && !params.getCity().equals("")) {
            boolQuery.filter(QueryBuilders.termsQuery("city", params.getCity()));
        }
        // 星级条件
        if (params.getStarName() != null && !params.getStarName().equals("")) {
            boolQuery.filter(QueryBuilders.termsQuery("starName", params.getStarName()));
        }
        // 价格条件
        if (params.getMaxPrice() != null && params.getMinPrice() != null) {
            boolQuery.filter(QueryBuilders
                    .rangeQuery("price")
                    .gt(params.getMinPrice())
                    .lt(params.getMaxPrice()));
        }
    
        // 2.算分控制
        FunctionScoreQueryBuilder functionScoreQuery =
                QueryBuilders.functionScoreQuery(
                        boolQuery, new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
                                new FunctionScoreQueryBuilder.FilterFunctionBuilder(
                                        QueryBuilders.termsQuery("isAD", true),
                                        ScoreFunctionBuilders.weightFactorFunction(10))});
        request.source().query(functionScoreQuery);
    }

业务实现

  • 查看浏览器,前端其实已经发出了请求

    请求网址: http://localhost:8089/hotel/filters
    请求方法: POST
    请求载荷:{key: "王府井", page: 1, size: 5, sortBy: "default"}
  • 请求参数与搜索文档的参数完全一致,其返回值类型就是页面要展示的最终结果
    img

  • 在web包下的HotelController中添加一个方法,遵循下面的要求

    1. 请求方式:POST
    2. 请求路径:/hotel/filters
    3. 请求参数:RequestParams,与搜索文档一致
    4. 返回值类型:Map(String, List<String>
@PostMapping("/filters")
public PageResult getFilters(@RequestBody RequestParams params){
    return hotelService.getFilters(params);
}
  • 在IHotelService中定义方法

    Map<String, List<String>> getFilters(RequestParams params);
  • 在HotelService中实现该方法

    @Override
    public Map<String, List<String>> getFilters(RequestParams params) {
        try {
            // 1. 准备Request对象
            SearchRequest request = new SearchRequest("hotel");
            // 2. 准备DSL
            buildBasicQuery(params, request);
            // 2.1 查询
            buildBasicQuery(params, request);
            // 2.2 设置size为0,不查询文档数据
            request.source().size(0);
            // 2.3 聚合
            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)
            );
            // 3. 发出请求
            SearchResponse response = client.search(request, RequestOptions.DEFAULT);
            HashMap<String, List<String>> result = new HashMap<>();
            Aggregations aggregations = response.getAggregations();
            // 4. 解析结果
            // 4.1 解析品牌结果
            Terms brandTerms = aggregations.get("brandAgg");
            ArrayList<String> brandList = new ArrayList<>();
            List<? extends Terms.Bucket> brandBuckets = brandTerms.getBuckets();
            for (Terms.Bucket bucket : brandBuckets) {
                String brandName = bucket.getKeyAsString();
                brandList.add(brandName);
            }
            result.put("brand", brandList);
            // 4.2 解析城市结果
            Terms cityTerms = aggregations.get("cityAgg");
            ArrayList<String> cityList = new ArrayList<>();
            List<? extends Terms.Bucket> cityBuckets = cityTerms.getBuckets();
            for (Terms.Bucket cityBucket : cityBuckets) {
                String cityName = cityBucket.getKeyAsString();
                cityList.add(cityName);
            }
            result.put("city", cityList);
            // 4.3 解析星级结果
            Terms starTerms = aggregations.get("starAgg");
            ArrayList<String> starList = new ArrayList<>();
            List<? extends Terms.Bucket> starBuckets = starTerms.getBuckets();
            for (Terms.Bucket starBucket : starBuckets) {
                String starName = starBucket.getKeyAsString();
                starList.add(starName);
            }
            result.put("starName", starList);
            return result;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
  • 这下就是动态获取的品牌、城市、星级数据了
    img

  • 当我们在搜索框输入内容时,也会根据搜索的结果来动态展示品牌、城市、星级数据
    img

  • 但是现在的代码并不是很优雅,所以我们可以把2.3的聚合操作,抽取为一个方法,IDEA中使用快捷键Ctrl + Alt + M可以快速抽取 (记得关闭网抑云的全局热键,不然会冲突)

    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)
        );
    }
  • 4.1、4.2、4.3的解析结果,也可以抽取为一个方法

    /**
     * 通过聚合名称获取对应的key的集合
     * @param aggregations 聚合结果集
     * @param aggName 聚合名称
     * @return
     */
    private ArrayList<String> getAggByName(Aggregations aggregations, String aggName) {
        Terms brandTerms = aggregations.get(aggName);
        ArrayList<String> brandList = new ArrayList<>();
        List<? extends Terms.Bucket> brandBuckets = brandTerms.getBuckets();
        for (Terms.Bucket bucket : brandBuckets) {
            String brandName = bucket.getKeyAsString();
            brandList.add(brandName);
        }
        return brandList;
    }
  • 修改完的代码就优雅多了

    @Override
    public Map<String, List<String>> getFilters(RequestParams params) {
        try {
            // 1. 准备Request对象
            SearchRequest request = new SearchRequest("hotel");
            // 2. 准备DSL
            buildBasicQuery(params, request);
            // 2.1 查询
            buildBasicQuery(params, request);
            // 2.2 设置size为0,不查询文档数据
            request.source().size(0);
            // 2.3 聚合
            buildAggregation(request);
            // 3. 发出请求
            SearchResponse response = client.search(request, RequestOptions.DEFAULT);
            HashMap<String, List<String>> result = new HashMap<>();
            Aggregations aggregations = response.getAggregations();
            // 4. 解析结果
            // 4.1 解析品牌结果,获取品牌名称集合
            ArrayList<String> brandList = getAggByName(aggregations, "brandName");
            result.put("brand", brandList);
            // 4.2 解析城市结果,获取城市名称集合
            ArrayList<String> cityList = getAggByName(aggregations, "cityAgg");
            result.put("city", cityList);
            // 4.3 解析星级结果,获取星级名称集合
            ArrayList<String> starList = getAggByName(aggregations, "starAgg");
            result.put("starName", starList);
            // 5. 返回Map集合
            return result;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
    
    /**
     * 通过聚合名称获取对应的key的集合
     * @param aggregations 聚合结果集
     * @param aggName 聚合名称
     * @return
     */
    private ArrayList<String> getAggByName(Aggregations aggregations, String aggName) {
        Terms brandTerms = aggregations.get(aggName);
        ArrayList<String> brandList = new ArrayList<>();
        List<? extends Terms.Bucket> brandBuckets = brandTerms.getBuckets();
        for (Terms.Bucket bucket : brandBuckets) {
            String brandName = bucket.getKeyAsString();
            brandList.add(brandName);
        }
        return brandList;
    }
    
    /**
     * 聚合
     * @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)
        );
    }
    
    /**
     * 查询
     * @param params 接收前端的查询参数
     * @param request
     */
    private void buildBasicQuery(RequestParams params, SearchRequest request) {
        // 1. 构建BoolQuery
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        String key = params.getKey();
        if (StringUtils.isEmpty(key)) {
            boolQuery.must(QueryBuilders.matchAllQuery());
        } else {
            boolQuery.must(QueryBuilders.matchQuery("all", key));
        }
        // 品牌条件
        if (params.getBrand() != null && !params.getBrand().equals("")) {
            boolQuery.filter(QueryBuilders.termsQuery("brand", params.getBrand()));
        }
        // 城市条件
        if (params.getCity() != null && !params.getCity().equals("")) {
            boolQuery.filter(QueryBuilders.termsQuery("city", params.getCity()));
        }
        // 星级条件
        if (params.getStarName() != null && !params.getStarName().equals("")) {
            boolQuery.filter(QueryBuilders.termsQuery("starName", params.getStarName()));
        }
        // 价格条件
        if (params.getMaxPrice() != null && params.getMinPrice() != null) {
            boolQuery.filter(QueryBuilders
                    .rangeQuery("price")
                    .gt(params.getMinPrice())
                    .lt(params.getMaxPrice()));
        }
    
        // 2.算分控制
        FunctionScoreQueryBuilder functionScoreQuery =
                QueryBuilders.functionScoreQuery(
                        boolQuery, new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
                                new FunctionScoreQueryBuilder.FilterFunctionBuilder(
                                        QueryBuilders.termsQuery("isAD", true),
                                        ScoreFunctionBuilders.weightFactorFunction(10))});
        request.source().query(functionScoreQuery);
    }

自动补全

  • 当用户在搜索框输入字符时,我们应该显示出与该字符相关的搜索项
    img
  • 这种根据用户输入的字母,提示完整词条的功能,就是自动补全
  • 因为需要根据拼音字母来推断,因此要用到拼音分词功能

拼音分词器

  • 要实现根据字母做补全,就必须对文档按照拼音分词。

  • 在GitHub上刚好有ES的拼音分词插件。

  • 地址:https://github.com/medcl/elasticsearch-analysis-pinyin

  • 这里依旧是下载7.12.1版本:https://github.com/medcl/elasticsearch-analysis-pinyin/releases/tag/v7.12.1

  • 安装方式分三步

    1. 解压

    2. 上传到虚拟机的ES的plugin目录

      BASH
      /var/lib/docker/volumes/es-plugins/_data
    3. 重启ES

      BASH
      docker restart es
  • 重启完毕之后,测试我们的分词器是否安装成功,在kibana中编写DSL代码

    JSON
    POST /_analyze
    {
      "text": ["深岩银河是真滴好玩"],
      "analyzer": "pinyin"
    }
  • 得到的分词结果(部分)如下

{
  "tokens" : [
    {
      "token" : "shen",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "syyhszdhw",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "yan",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "yin",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 2
    },
    {
      "token" : "he",
  • 暂时只是将每个字拆解成了拼音,还有一个首字母的全拼,但目前还不能满足我们的需求
  • 深岩银河是真滴好玩这句话,还没有被分词
  • 每一个字拆解成一个拼音没什么用,单独的shenyan显然没有syyh有用
  • 结果中也没有出现汉字,也就意味着只有当用户输入拼音的时候,才会补全
  • 为了满足我们的需求,我们需要来自定义分词器

自定义分词器

  • 默认的拼音分词器会将每个汉字单独分为拼音,而我们希望的是将每个词条形成一组拼音,需要对拼音分词器做个性化定制,形成自定义分词器
  • ES中分词器(analyzer)的组成包含三个部分
    1. character filters:在tokenizer之前对文本进行处理,例如删除字符、替换字符等(前导空格,末尾空格,字符表情转对应文字,:) -> 开心)
    2. tokenizer:将文本按照一定规则切割成词条(term)。例如keyword,就是不分词;ik_smart,就是最少切分
    3. tokenizer filter:将tokenizer输出的词条进一步处理。例如大小写切换、同一次处理、拼音处理等

例如:

  • image-20230413105539974
PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": {                     // 自定义分词器
        "my_analyzer": {                // 分词器名称
          "tokenizer": "ik_max_word",   // tokenizer部分
          "filter": "pinyin"            // tokenizer filter部分
      }                                 // 由于这里并不需要处理特殊字符,所以没有character filters
    }
  }
}
  • 但是默认的拼音分词器还是只能将滋蹦分解为zibengzb这三个,而没有zibeng
  • 所以我们还需要自定义tokenizer filter拼音分词器
  • 解决方案在其官方文档中给出了

The plugin includes analyzer: pinyin , tokenizer: pinyin and token-filter: pinyin.

Optional Parameters

keep_first_letter when this option enabled, eg: 刘德华>ldh, default: true
keep_separate_first_letter when this option enabled, will keep first letters separately, eg: 刘德华>l,d,h, default: false, NOTE: query result maybe too fuzziness due to term too frequency
limit_first_letter_length set max length of the first_letter result, default: 16
keep_full_pinyin when this option enabled, eg: 刘德华> [liu,de,hua], default: true
keep_joined_full_pinyin when this option enabled, eg: 刘德华> [liudehua], default: false
keep_none_chinese keep non chinese letter or number in result, default: true
keep_none_chinese_together keep non chinese letter together, default: true, eg: DJ音乐家 -> DJ,yin,yue,jia, when set to false, eg: DJ音乐家 -> D,J,yin,yue,jia, NOTE: keep_none_chinese should be enabled first
keep_none_chinese_in_first_letter keep non Chinese letters in first letter, eg: 刘德华AT2016->ldhat2016, default: true
keep_none_chinese_in_joined_full_pinyin keep non Chinese letters in joined full pinyin, eg: 刘德华2016->liudehua2016, default: false
none_chinese_pinyin_tokenize break non chinese letters into separate pinyin term if they are pinyin, default: true, eg: liudehuaalibaba13zhuanghan -> liu,de,hua,a,li,ba,ba,13,zhuang,han, NOTE: keep_none_chinese and keep_none_chinese_together should be enabled first
keep_original when this option enabled, will keep original input as well, default: false
lowercase lowercase non Chinese letters, default: true
trim_whitespace default: true
remove_duplicated_term when this option enabled, duplicated term will be removed to save index, eg: de的>de, default: false, NOTE: position related query maybe influenced
ignore_pinyin_offset after 6.0, offset is strictly constrained, overlapped tokens are not allowed, with this parameter, overlapped token will allowed by ignore offset, please note, all position related query or highlight will become incorrect, you should use multi fields and specify different settings for different query purpose. if you need offset, please set it to false. default: true.

  • 可以看到,默认情况下是keep_full_pinyin,会将滋蹦拆成zibeng,我们要将这个选项设置为false
  • 同时将keep_joined_full_pinyin设置为true,就会正常解析为zibeng
  • 我们还需要保留原始结果,故也需要将keep_original设置为true
  • 我们在实际应用中,根据自己的需求来配置就好了,那么这里声明的自定义分词器如下
PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        }
      },
      "filter": {
        "py": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  }
}

image-20230413110114922

  • 我们自定义的分词器肯定是在mapping映射中用的,也就是在我们定义索引库的字段的时候用的
  • 这样对字段创建倒排索引的时候,除了会创建中文的倒排索引,也会创建拼音的倒排索引
  • 那我们建立一个索引库,定义一个name字段,其分词器不再使用之前的ik_max_word,而是使用我们的自定义分词器
PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        }
      },
      "filter": {
        "py": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "my_analyzer"
      }
    }
  }
}
  • 那我们现在来测试一下

测试1

POST /test/_analyze
{
  "text": "实现自定义分词器",
  "analyzer": "my_analyzer"
}
  • 得到的结果(部分)如下,符合我们的需求了
{
  "tokens" : [
    {
      "token" : "实现",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "shixian",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "sx",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "自定义",
      "start_offset" : 2,
      "end_offset" : 5,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "zidingyi",
      "start_offset" : 2,
      "end_offset" : 5,
      "type" : "CN_WORD",
      "position" : 1
    },

测试2(同音字)

但是现在还存在一点小问题,这里举个例子说明

  • 我们在创建的索引库中添加两个同音字,狮子柿子
POST /test/_doc/1
{
  "id": 1,
  "name": "柿子"
}

POST /test/_doc/2
{
  "id": 2,
  "name": "狮子"
}
  • 那我们现在查询掉进狮子笼怎么办,结果中出现了狮子柿子,这显然不是我们想要的
GET /test/_search
{
  "query": {
    "match": {
      "name": "掉进狮子笼怎么办?在线等,挺急的"
    }
  }
}

img

  • 那么为什么会出现这种问题呢?
    • 拼音分词器适合在创建倒排索引的时候使用,但是不能在搜索的时候使用
      • 例如我们的狮子分词之后变成狮子shizisz,柿子分词后变成柿子shizisz

image-20230413111455677

  • 因为这两个次的拼音是一样的,所以创建倒排索引的时候,shizi对应1、2这两条文档,如果对shizi查询,则会把狮子柿子都查询出来
  • 用户搜索掉进狮子笼,如果用我们自定义的分词器,最终也会分出shizi这个词条,进行搜索的时候,当然会查询出两条数据
  • 那么怎么解决这个问题呢?
    • 我们在搜索的时候,使用ik_smart分词器,在mapping映射时,指定两个分词器(系统有这个两个分词器属性,我们仅需设置用什么即可)analyzersearch_analyzer
      • 其中analyzer是创建索引时使用的(使用拼音分词器,使用自定义的分词器)
      • search_analyzer是搜索时使用的(不使用拼音分词器就使用ik分词器即可)
    • 步骤:
    • 删掉原来的分词器(删掉原来的索引库) delete /test
    • 新建索引库和分词器,
PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        }
      },
      "filter": {
        "py": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "my_analyzer",
        "search_analyzer": "ik_smart"
      }
    }
  }
}
  • 再次插入文档,查询就会发现只有狮子这一种选项了!

小结

  • 如何使用拼音分词器?
    1. 下载pinyin分词器
    2. 解压到ES的plugin目录
    3. 重启ES
  • 如何自定义分词器?
    • 创建索引库时,在settings中配置analysis,可以包含三部分
      1. character filter
      2. tokenizer
      3. filter
  • 拼音分词器的注意事项?
    • 为了避免搜索到同音字,搜索时不要使用拼音分词器,添加search_analyzer属性

自动补全查询

  • ES提供额

    Completion Suggester

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

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

例如这个索引库

PUT /test2
{
  "mappings": {
    "properties": {
      "title": {
        "type": "completion"
      }
    }
  }
}

然后插入一些测试数据

POST /test2/_doc/1
{
  "title": ["Sony", "PS5"]
}


POST /test2/_doc/2
{
  "title": ["SK-II", "PITERA"]
}


POST /test2/_doc/3
{
  "title": ["Nitendo", "Switch"]
}


POST /test2/_doc/4
{
  "title": ["Sony", "WF-1000XM4"]
}

补全查询的DSL语句如下

GET /test2/_search
{
  "suggest": {
    "title_suggest": {              // 给suggest取名
      "text": "P",                  // 补全查询关键字
      "completion": {       
        "field": "title",           // 补全查询的字段
        "skip_duplicates": true,    // 跳过重复的
        "size": 10                  // 获取前10条结果
      }
    }
  }
}

结果中成功查询到PS5PITERA

{
  "took" : 42,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "suggest" : {
    "title_suggest" : [
      {
        "text" : "P",
        "offset" : 0,
        "length" : 1,
        "options" : [
          {
            "text" : "PITERA",
            "_index" : "test2",
            "_type" : "_doc",
            "_id" : "2",
            "_score" : 1.0,
            "_source" : {
              "title" : [
                "SK-II",
                "PITERA"
              ]
            }
          },
          {
            "text" : "PS5",
            "_index" : "test2",
            "_type" : "_doc",
            "_id" : "1",
            "_score" : 1.0,
            "_source" : {
              "title" : [
                "Sony",
                "PS5"
              ]
            }
          }
        ]
      }
    ]
  }
}

实现酒店搜索框自动补全

  • 现在我们的hotel索引库还没有设置拼音分词器,需要修改索引库中的配置。但是索引库是无法修改的,只能先删掉再重新创建
  • 另外,我们需要添加一个字段,用来做自动补全,将brandsuggestioncity等都放进去,作为自动补全的提示
  • 那我们现在总结一下需要做的事
    1. 修改hotel的索引结构,设置自定义拼音分词器
    2. 修改索引库的name、all字段,使用自定义分词器(其他字段已经是keyword类型的词条了,这两个text类型的还需要自定义分词)
    3. 索引库添加一个新字段suggestion,类型为completion类型,使用自定义的分词器
    4. 给HotelDoc类添加suggestion字段,内容包含brandbusiness
    5. 重新导入数据到hotel库

修改hotel映射结构

PUT /hotel
{
  "settings": {
    "analysis": {
      "analyzer": {
        "text_analyzer": {
          "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,
          "remove_deplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  }, 
  "mappings": {
    "properties": {
      "id": {
        "type": "keyword"
      },
      "name": {
        "type": "text",
        "analyzer": "text_analyzer",
        "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_analyzer",
        "search_analyzer": "ik_smart"
      },
      "suggestion": {
        "type": "completion",
        "analyzer": "completion_analyzer",
        "search_analyzer": "ik_smart"
      }
    }
  }
}

修改HotelDoc实体

  • HotelDoc中要添加一个字段,用来做数组补全,内容可以是酒店品牌、城市、商圈、名称等信息。按照自动补全的要求,最好是这些字段的数组
  • 因此我们可以在HotelDoc中添加一个suggestion字段,类型为List<String>,然后将brand、city、business等信息放到里面
  • 由于某些business的信息是包含多个关键字,所以我们需要对其切分
"business" : "天安门/王府井地区"
"business" : "永定门、南站、大红门、南苑地区"
@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;
    // 新增suggestion属性
    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("/") || this.business.contains("、")) {
            // business有多个值、需要切分,根据数据库中的数据,这里按照/和、来切分
            String[] splits = this.business.split("/|、");
            this.suggestion = new ArrayList<>();
            // 添加元素
            this.suggestion.add(this.brand);
            this.suggestion.add(this.city);
            // 添加切分business后的结果
            Collections.addAll(this.suggestion, splits);
        } else {
            this.suggestion = Arrays.asList(brand, city, business);
        }
    }
}

重新导入

  • 运行之前编写的批量导入数据功能,可以看到新的酒店数据中包含了suggestion,且切分了business,后面我们就根据suggestion这个字段来自动补全
{
  "_index" : "hotel",
  "_type" : "_doc",
  "_id" : "413460",
  "_score" : 1.8905408,
  "_source" : {
    "address" : "东城天坛东里甲48号",
    "brand" : "7天酒店",
    "business" : "前门、崇文门商贸区",
    "city" : "北京",
    "id" : 413460,
    "location" : "39.875786, 116.421987",
    "name" : "7天连锁酒店(北京天坛店)",
    "pic" : "https://m.tuniucdn.com/fb2/t1/G2/M00/C7/D8/Cii-T1knCK6IWTtxAAI0plLButMAAKYTAJu-woAAjS-422_w200_h200_c1_t0.jpg",
    "price" : 753,
    "score" : 38,
    "starName" : "二钻",
    "suggestion" : [
      "7天酒店",
      "北京",
      "前门",
      "崇文门商贸区"
    ]
  }
}

自动补全查询的API

  • 前面我们只是学了自动补全查询的DSL,那现在我们来学习对应的JavaAPI
SearchRequest request = new SearchRequest("test2");
request.source()
        .suggest(new SuggestBuilder().addSuggestion(
                "title_suggest",
                SuggestBuilders.completionSuggestion("title")
                        .prefix("p")
                        .skipDuplicates(true)
                        .size(10)
        ));

img

自动补全的结果也比较特殊,解析的代码如下

SearchResponse response = client.search(request, RequestOptions.DEFAULT);
Suggest suggest = response.getSuggest();
CompletionSuggestion suggestion = suggest.getSuggestion("title_suggest");
List<CompletionSuggestion.Entry.Option> options = suggestion.getOptions();
for (CompletionSuggestion.Entry.Option option : options) {
    String text = option.getText().toString();
    System.out.println(text);
}

img

实现搜索框自动补全

  • 当我们在搜索框输入s时,会看到请求
请求网址: http://localhost:8089/hotel/suggestion?key=s
请求方法: GET
请求载荷:key: s
  • 需求:当我们在搜索框输入s时,会显示以s为首字母的词条
  • 那我们在HotelController中定义方法,遵循以下要求
    1. 请求方式:GET
    2. 请求路径:/hotel/suggestion
    3. 请求参数:key
    4. 返回值类型: List<String>
@Override
public List<String> getSuggestion(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 遍历 这里可以提前声明集合的大小,只能是options.size()
        ArrayList<String> list = new ArrayList<>(options.size());
        for (CompletionSuggestion.Entry.Option option : options) {
            // 将每条补全结果都加入到集合中
            String text = option.getText().toString();
            list.add(text);
        }
        // 5. 返回补全结果集合
        return list;
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

重启服务,自动补全功能已经实现了

img

无论用户输入中文还是拼音都能正确显示补全结果

img

如果搜中文不显示补全,那肯定是创建索引库的时候,没指定suggestion的search_analyzer为ik_smart

数据同步

  • ES中的酒店数据来自于MySQL的数据库,因此MySQL数据发生改变时,ES也必须跟着改变,这就是ES与MySQL之间的数据同步问题
  • 但是在微服务中,负责酒店管理(操作MySQL)的业务,与负责酒店搜索(操作ES)的业务可能在两个不同的微服务上,那么数据同步又该如何实现呢?
    • 两个服务只能访问MySQL 或者 ES中的一个,这样才符合微服务业务隔离的效果

思路分析

  • 常见的数据沟通过不方案有三种
    1. 同步调用
    2. 异步通知
    3. 监听binlog

同步调用

img

  • 流程如下
    1. hotel-demo对外提供接口,用来更新ES中的数据
    2. 酒店管理服务在完成数据库操作后,直接调用hotel-demo提供的接口
  • 但是这样存在一些问题
    1. 耦合度太高,hotel-admin原本只需要将数据写入数据库,但现在写入数据库之后,还要调用hotel-demo提供的更新ES的接口,形成了业务耦合
    2. 性能差,例如原本的写入数据库只需要50ms,调用hotel-demo提供的更新ES的接口耗时150ms,那么总耗时就达到了0.2s,性能自然就下降了。且如果1.21.3发生了异常,那么整个业务也都出问题了

异步通知

img

  • 流程如下
    1. hotel-admin对MySQL数库数据完成增删改后,发送MQ消息
    2. hotel-demo监听MQ,接收到消息后完成ES数据修改
  • 这样就解除了业务间的耦合,也提高了性能,但是比较依赖于MQ的可靠性,并且引入了新的中间件,实现起来的复杂度有所上升

监听binlog

img

  • 流程如下
    1. 给mysql开启binlog功能
    2. mysql完成增删改操作,都会记录在binlog中
    3. hotel-demo基于canal监听binlog变化,实时更新ES中的内容
  • 相比较于异步通知来说,此种方式完全接触了耦合,既不用给MQ发消息,也不用调用hotel-demo提供的接口。但是由于要开启mysql的binlog功能,所以对mysql的压力很大,并且要引入新的中间件canal,实现起来比较复杂

小结

  • 同步调用
    • 优点:实现简单
    • 缺点:业务耦合度高
  • 异步通知
    • 优点:低耦合,实现难度一般
    • 缺点:依赖MQ的可靠性
  • 监听binlog
    • 优点:完全解除服务间的耦合
    • 缺点:开启binlog增加数据库负担、实现复杂度高

实现数据同步

思路

  • 使用黑马提供的hotel-admin项目作为酒店管理的微服务。

  • 当酒店数据发生增删改时,要求对ES中的数据也完成相同的操作

  • 需要注意的一点是,ES中新增数据和修改数据是同一个操作,因为在RestClient的API中,全量修改与新增的API完全一致,判断的依据是ID

    • 若新增时,ID已经存在,则修改(删除再新增)
    • 若新增时,ID不存在,则新增
  • 实现思路如下

    1. 导入黑马提供的hotel-admin,启动并测试酒店数据的CRUD
    2. 声明exchange、queue、RountingKey
    3. 在hotel-admin中的增删改业务中,完成消息发送
    4. 在hotel-demo中完成消息监听,并更新ES中数据
    5. 启动并测试数据同步功能

导入demo

  • 导入黑马提供的hotel-admin,修改application.yml配置文件,将数据库连接信息改成自己的,访问http://localhost:8099/ 就能看到管理页面了

img

其中已经包含了酒店的CRUD功能

@PostMapping
public void saveHotel(@RequestBody Hotel hotel){
    hotelService.save(hotel);
}

@PutMapping()
public void updateById(@RequestBody Hotel hotel){
    if (hotel.getId() == null) {
        throw new InvalidParameterException("id不能为空");
    }
    hotelService.updateById(hotel);
}

@DeleteMapping("/{id}")
public void deleteById(@PathVariable("id") Long id) {
    hotelService.removeById(id);
}

声明交换机、队列

  • MQ结构如图

img

  • 引入依赖
    • 使用RabbitMQ,我们首先要在hotel-demo和hotel-admin引入SpringAMQP的依赖
<!--amqp-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
  • 配置RabbitMQ的连接信息
    • 在hotel-demo和hotel-admin的application.yml文件中添加配置
spring:
  rabbitmq:
    host: 192.168.96.128 # 主机名
    port: 5672 #端口
    username: root # 用户名
    password: root # 密码
    virtual-host: / # 虚拟主机
  • 声明交换机和队列名称
    • 在hotel-demo和hotel-admin的constants包下新建一个MqConstants类
public class MqConstants {
    /**
    * 交换机
    */
    public static final String HOTEL_EXCHANGE = "hotel.topic";
    /**
    * 监听新增和修改的队列
    */
    public static final String HOTEL_INSERT_QUEUE = "hotel.insert.queue";
    /**
    * 监听删除的队列
    */
    public static final String HOTEL_DELETE_QUEUE = "hotel.delete.queue";
    /**
    * 新增和修改的RoutingKey
    */
    public static final String HOTEL_INSERT_KEY = "hotel.insert";
    /**
    * 删除的RoutingKey
    */
    public static final String HOTEL_DELETE_KEY = "hotel.delete";
}
  • 声明交换机和队列
    • 在hotel-demo中,定义配置类,声明队列、交换机
@Configuration
public class MqConfig {
    @Bean
    public TopicExchange topicExchange() {
        return new TopicExchange(MqConstants.HOTEL_EXCHANGE);
    }

    @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);
    }
}

发送MQ消息

  • 在hotel-admin中的增删改业务中,分别发送MQ消息
  • 但是发送的消息MQ是会保存的,而MQ又是基于内存的,所以我们要发送的内容要尽可能的小
    • 因此不建议直接把整个hotel对象发送,太消耗内存了
    • 只发送一个酒店id就足以满足需求了
 @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);
    }

接收MQ消息

  • hotel-demo接收到MQ消息要做的事情包括
    1. 新增消息:根据传递的hotel的id查询hotel信息,然后新增一条数据到索引库(修改同理)
    2. 删除消息:根据传递的hotel的id删除索引库中的一条数据
  • 我们在mq包下新增一个类HotelListener
@Component
public class HotelListener {
    @Autowired
    private IHotelService hotelService;

    /**
     * 监听酒店新增/修改业务
     * @param id 酒店的id
     */
    @RabbitListener(queues = MqConstants.HOTEL_INSERT_QUEUE)
    public void listenHotelInsertQueue(Long id) {
        hotelService.insertById(id);
    }

    /**
     * 监听酒店删除业务
     * @param id 酒店的id
     */
    @RabbitListener(queues = MqConstants.HOTEL_DELETE_QUEUE)
    public void listenHotelDeleteQueue(Long id){
        hotelService.deleteById(id);
    }
}
  • 然后在IHotelService中创建这两个方法
public interface IHotelService extends IService<Hotel> {

    void insertById(Long id);

    void deleteById(Long id);
}
  • 并在HotelService中实现业务逻辑
@Override
public void insertById(Long id) {
    try {
        // 1. 根据id查询酒店数据
        Hotel hotel = getById(id);
        // 2. 转换为文档类型
        HotelDoc hotelDoc = new HotelDoc(hotel);
        // 3. 准备Request对象
        IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString());
        // 4. 准备文档
        request.source(JSON.toJSON(hotelDoc), XContentType.JSON);
        // 5. 发送请求
        client.index(request,RequestOptions.DEFAULT);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

@Override
public void deleteById(Long id) {
    try {
        // 1. 准备Request对象
        DeleteRequest request = new DeleteRequest("hotel",id.toString());
        // 2. 发送请求
        client.delete(request,RequestOptions.DEFAULT);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}
  • 重启这两个服务,并测试

集群

  • 单机的ES做数据存储,必然会面临两个问题
    1. 海量数据存储问题:将索引库从逻辑上拆分为N个分片(shard),存储到多个节点
    2. 单点故障问题:将分片数据在不同节点备份(replica)
  • ES集群相关概念
    • 集群(cluster):一组拥有共同cluster name的节点
    • 节点(node):集群中的一个ES示例
    • 分片(shard):索引可以被拆分为不同的部分进行存储,称为分片。
      • 在集群环境下,一个索引的不同分片可以拆分到不同的节点中。
      • 解决问题:数据量太大,单点存储有限的问题
    • 主分片(Primary shard):相对于副本分片的定义
    • 副本分片(Replica shard):每个主分片都可以有一个或多个副本,数据与主分片一样
  • 数据备份可以保证高可用,但是每个分片备份一份,所需要的节点数量就会翻一倍,成本太高了
  • 为了在高可用和成本间寻求平衡,我们可以这样做
    1. 首先对数据分片,存储到不同节点
    2. 然后对每个分片进行备份,放到对方节点,完成互相备份

img

  • 现在,每个分片都有1个备份,存储在3个节点:
    • node0:保存了分片0和1
    • node1:保存了分片0和2
    • node2:保存了分片1和2

搭建ES集群

  • 部署es集群可以直接使用docker-compose来完成,不过虚拟机至少要有4G的内存空间(额我就不整了)
  • 首先编写一个docker-compose文件
version: '2.2'
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
	ports:
      - 9201:9200
    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
	ports:
      - 9202:9200
    networks:
      - elastic

volumes:
  data01:
    driver: local
  data02:
    driver: local
  data03:
    driver: local

networks:
  elastic:
    driver: bridge
  • 运行docker-compose文件集群部署
docker-compose up
  • 索引库分片
  • cerobro

集群脑裂问题

集群职责划分

  • ES中集群节点有不同的职责划分
节点类型 配置参数 默认值 节点职责
master eligible(备选/主节点) node.master true 备选主节点:主节点可以管理和记录集群状态、决定分片在哪个节点、处理创建和删除索引库的请求
data node.data true 数据节点:存储数据、搜索、聚合、CRUD
ingest node.ingest true 数据存储之前的预处理
coordinating(不做业务,路由加负载均衡加合并数据) 上面3个参数都为false则为coordinating节点 路由请求到其它节点合并其它节点处理的结果,返回给用户
  • 默认情况下,急群众的任何一个节点都同时具备上述四种角色
  • 但真实的集群一定要将集群职责分离
    • master节点:对CPU要求高,但是对内存要求低
    • data节点:对CPU和内存要求都高
    • coordinating节点:对网络带宽、CPU要求高
  • 职责分离可以让我们根据不同节点的需求分配不同的硬件去部署。而且可以避免业务之间的互相干扰
  • 一个典型的ES集群职责划分如下图
    img

脑裂问题

  • 脑裂是因为集群中的节点失联导致的。
  • 例如一个集群中,主节点与其他节点失联
    img
  • 此时node2和node3会认为node1宕机,就会重新选主
    img
  • 当node3当选后,集群继续对外提供服务,node2和node3自成一个集群,node1自成一个集群。这两个集群数据不同步,出现数据差异
  • 当网络恢复后,因为急群众有两个master节点,集群状态的不一致,会出现脑裂的情况
    img
  • 解决脑裂的方案是,要求选品超过( eligible节点数量 + 1 )/ 2才能当选为主,因此eligible节点数量最好是奇数。对应配置项是discovery.zen.minimum_master_nodes,在es7.0以后,已经成为默认配置,因此一般不会发生脑裂问题
  • 例如:3个节点形成的集群,选票必须超过 (3 + 1) / 2 ,也就是2票。node3得到node2和node3的选票,当选为主。node1只有自己1票,没有当选。集群中依然只有1个主节点,没有出现脑裂。

小结

  • master eligible节点的作用是什么?
    • 参与集群选主
    • 主节点可以管理集群状态、管理分片信息、处理创建和删除索引库的请求
  • data节点的作用是什么?
    • 数据的CRUD
  • coordinating节点的作用是什么?
    • 路由请求到其他节点
    • 合并查询到的结果,返回给用户

集群分布式存储

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

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

    shard = hash(_routing) % number_of_shards
  • 说明:_routing默认是文档的id,算法与分片数量有关,因此索引库一旦创建,分片数量不能修改

  • 新增文档的流程如下
    img

  • 解读

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

集群分布式查询

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

集群故障转移

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

    img

    • 现在,node1是主节点,其它两个节点是从节点。
  2. 突然,node1发生了故障:

    img

    • 宕机后的第一件事,需要重新选主,例如选中了node2:
      img
    • node2成为主节点后,会检测集群监控状态,发现:shard-1、shard-0没有副本节点。因此需要将node1上的数据迁移到node2、node3:
      img