Loading

[13] IK|JavaAPI|Query&Fetch

1. 中文分词

“分词器”主要应用在中文上,在 ES 中字符串类型有 keyword 和 text 两种。keyword 默认不进行分词,而 ES 本身自带的中文分词会把 text 中每一个汉字拆开称为独立的词,这根本没有词汇的概念,就是单纯把中文一个字一个字的分开。这两种都是不适用于生产环境。

实际应用中,用户都是以词汇为条件,进行查询匹配的,如果能够把文章以词汇为单位切分开,那么与用户的查询条件能够更贴切的匹配上,查询速度也更加快速。所以我们需要有其他的分词器帮助我们完成这些事情,其中「IK 分词器」是应用最为广泛的一个分词器。

采用了特有的“正向迭代最细粒度切分算法“,具有 80w 字/秒的高速处理能力 采用了多子处理器分析模式,支持:英文字母(IP 地址、Email、URL)、数字(日期,常用中文数量词,罗马数字,科学计数法),中文词汇(姓名、地名处理)等分词处理。 优化的词典存储,更小的内存占用。

1.1 安装 IK

https://github.com/medcl/elasticsearch-analysis-ik

就是把下载的对应 Es 版本的 IK.zip 解压放到 plugins 目录下:

Windows 下安装同理:

1.2 简单使用

IK 提供了两个分词算法 ik_smartik_max_word,其中 ik_smart 为最少切分,ik_max_word 为最细粒度划分。

(1)建立 mapping

PUT movie_chn
{
    "mappings": {
      "properties": {
        "id":{
          "type": "long"
        },
        "name":{
          "type": "text",
          "analyzer": "ik_smart"  # 指定分词器
        },
        "doubanScore":{
          "type": "double"
        },
        "actorList":{
          "properties": {
            "id":{
              "type":"long"
            },
            "name":{
              "type":"keyword"
            }
          }
        }
      }
    }
}

(2)插入数据

PUT /movie_chn/1
{
  "id":1,
  "name":"红海行动",
  "doubanScore":8.5,
  "actorList":[
    {"id":1,"name":"张译"},
    {"id":2,"name":"海清"},
    {"id":3,"name":"张涵予"}
  ]
}

PUT /movie_chn/2
{
  "id":2,
  "name":"湄公河行动",
  "doubanScore":8.0,
  "actorList":[
    {"id":3,"name":"张涵予"}
  ]
}

PUT /movie_chn/3
{
  "id":3,
  "name":"红海事件",
  "doubanScore":5.0,
  "actorList":[
    {"id":4,"name":"张晨"}
  ]
}

(3)查询测试

GET /movie_chn/_search
{
  "query": {
    "match": {
      "name": "红海战役"
    }
  }
}

GET /movie_chn/_search
{
  "query": {
    "term": {
      "actorList.name": "张译"
    }
  }
}

1.3 扩展词&停用词

(1)现有「IK 分词器」无法将某些词切分成一个关键词,但又希望能把它切为一个词;

%Es_HOME%/plugins/analysis-ik/config/IKAnalyzer.cfg.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
    <comment>IK Analyzer 扩展配置</comment>
    <!--用户可以在这里配置自己的扩展字典 -->
    <entry key="ext_dict">my_ext_keyword.dic</entry>
    <!--用户可以在这里配置自己的扩展停止词字典-->
    <entry key="ext_stopwords">my_ext_stop.dic</entry>
    <!--用户可以在这里配置远程扩展字典 -->
    <!-- <entry key="remote_ext_dict">words_location</entry> -->
    <!--用户可以在这里配置远程扩展停止词字典-->
    <!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>

my_ext_keyword.dic

刘佳琦
牛逼

(2)现有「IK 分词器」将一个关键词切分成一个词,但出于某种原因这个词不能作为关键词出现(比如根据相关政策不予显示这种的);

my_ext_stop.dic

同性恋

(3)“热更新 IK 分词”使用方法

其中 location 是指一个 url,比如 http://yoursite.com/getCustomDict,该请求只需满足以下两点即可完成分词热更新。

  • 该 http 请求需要返回两个头部(header),一个是 Last-Modified,一个是 ETag,这两者都是字符串类型,只要有一个发生变化,该插件就会去抓取新的分词进而更新词库。
  • 该 http 请求返回的内容格式是一行一个分词,换行符用 \n 即可。

满足上面两点要求就可以实现热更新分词了,不需要重启 ES 实例。

可以将需自动更新的热词放在一个 UTF-8 编码的 .txt 文件里,放在 nginx 或其他简易 http server 下,当 .txt 文件修改时,http server 会在客户端请求该文件时自动返回相应的 Last-Modified 和 ETag。可以另外做一个工具来从业务系统提取相关词汇,并更新这个 .txt 文件。

(4)热词录入之前,已经切开的热词就还保持不变,热词仅对在它录入之后的新添的 Doc 有效,它不会去更新已有数据。

举个例子,我插了几个带有“刘佳琦”的 Doc 到某索引,IK-Analyzer 默认会把“刘佳琦”切分成“刘佳”和“琦”,后来,我把“刘佳琦”加入了远程扩展词典,再这之后录入的包含“刘佳琦”的数据,“刘佳琦”就不会被切开了,但是之前的数据不变,所以你 term 搜“刘佳琦”只能搜到设置成热词之后插入的数据。如果想要所有都被搜到,那就得重建索引。

1.4 全文搜索

文搜索两个最重要的方面是:

  • 相关性(Relevance):它是评价查询与其结果间的相关程度,并根据这种相关程度对结果排名的一种能力,这种计算方式可以是 TF/IDF 方法、地理位置邻近、模糊相似,或其他的某些算法。
  • 分词(Analysis):它是将文本块转换为有区别的、规范化的 token 的一个过程,目的是为了创建倒排索引以及查询倒排索引。

a. 单词搜索

POST /itcast/_search
{
    "query":{
        "match":{
            "hobby":"音乐"
        }
    },
    "highlight": {
        "fields": {
            "hobby": {}
        }
    }
}

过程说明:

  1. 【检查字段类型】爱好 hobby 字段是一个 text 类型( 指定了 IK 分词器),这意味着查询字符串本身也应该被分词;
  2. 【分析查询字符串】将查询的字符串 “音乐” 传入 IK 分词器中,输出的结果是单个项音乐。因为只有一个单词项,所以 match 查询执行的是单个底层 term 查询;
  3. 【查找匹配文档】用 term 查询在倒排索引中查找 “音乐” 然后获取一组包含该项的文档,本例的结果是文档:3 、5 。
  4. 【为每个文档评分】用 term 查询计算每个文档相关度评分 _score ,这是种将词频(Term Frequency,即词 “音乐” 在相关文档的 hobby 字段中出现的频率)和反向文档频率(inverse document frequency,即词 “音乐” 在所有文档的 hobby 字段中出现的频率)以及字段的长度(即字段越短相关度越高)相结合的计算方式。

b. 多词搜索

POST /itcast/_search
{
    "query":{
        "match":{
            "hobby":"音乐 篮球"
        }
    },
    "highlight": {
        "fields": {
            "hobby": {}
        }
    }
}

可以看到,包含了“音乐”、“篮球”的数据都已经被搜索到了。可是,搜索的结果并不符合我们的预期,因为我们想搜索的是既包含“音乐”又包含“篮球”的用户,显然结果返回的“或”的关系。在 ES 中,可以指定词之间的逻辑关系,如下:

POST /itcast/_search
{
    "query":{
        "match":{
            "hobby":{
                "query":"音乐 篮球",
                "operator":"and"
            }
        }
    },
    "highlight": {
        "fields": {
            "hobby": {}
        }
    }
}

前面我们测试了“OR” 和 “AND”搜索,这是两个极端,其实在实际场景中,并不会选取这 2 个极端,更有可能是选取这种,或者说只需要符合一定的相似度就可以查询到数据,在 ES 中也支持这样的查询,通过 minimum_should_match 来指定匹配度,值可以是数字也可以是百分比:

{
    "query":{
        "match":{
            "hobby":{
                "query":"游泳 羽毛球",
                "minimum_should_match":"70%"
            }
        }
    },
    "highlight": {
        "fields": {
            "hobby": {}
        }
    }
}

相似度应该多少合适,需要在实际的需求中进行反复测试,才可得到合理的值。

c. 组合搜索

在搜索时,也可以使用过滤器中讲过的 bool 组合查询,示例:

POST /itcast/_search

{
    "query":{
        "bool":{
            "must":{
                "match":{
                "hobby":"篮球"
                }
            },
            "must_not":{
                "match":{
                "hobby":"音乐"
                }
            },
            "should":[
                {
                    "match": {
                        "hobby":"游泳"
                    }
                }
            ]
        }
    },
    "highlight": {
        "fields": {
            "hobby": {}
        }
    }
}

# 搜索结果中必须包含篮球,不能包含音乐,如果包含了游泳,那么它的相似度更高。

【评分的计算规则】 bool 查询会为每个文档计算相关度评分 _score, 再将所有匹配的 must 和 should 语句的分数 _score 求和,最后除以 must 和 should 语句的总数(must_not 语句不会影响评分,它的作用只是将不相关的文档排除)。

d. 权重

有些时候,我们可能需要对某些词增加权重来影响该条数据的得分。如:搜索关键字为“游泳篮球”,如果结果中包含了“音乐”权重为 10,包含了“跑步”权重为 2。

POST /itcast/_search
{
    "query": {
        "bool": {
            "must": {
                "match": {
                    "hobby": {
                        "query": "游泳篮球",
                        "operator": "and"
                        }
                    }
                },
            "should": [
                {
                    "match": {
                        "hobby": {
                            "query": "音乐",
                            "boost": 10
                        }
                    }
                },
                {
                    "match": {
                        "hobby": {
                            "query": "跑步",
                            "boost": 2
                        }
                    }
                }
            ]
        }
    },
    "highlight": {
        "fields": {
            "hobby": {}
        }
    }
}

3.5 自定义词库

修改 /usr/share/elasticsearch/plugins/ik/config/ 中的 IKAnalyzer.cfg.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
 <comment>IK Analyzer 扩展配置</comment>
 <!--用户可以在这里配置自己的扩展字典 -->
 <entry key="ext_dict"></entry>
 <!--用户可以在这里配置自己的扩展停止词字典-->
 <entry key="ext_stopwords"></entry>
 <!--用户可以在这里配置远程扩展字典 -->
 <entry key="remote_ext_dict">http://192.168.206.129/fenci/myword.txt</entry>
 <!--用户可以在这里配置远程扩展停止词字典-->
 <!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>

按照自定义配置的远程扩展字典的路径利用 Nginx 发布静态资源(动静分离),在 nginx.conf 中配置:

server {
    listen 80;
    server_name 192.168.67.163;
        location /fenci/ {
        root es;
    }
}

/usr/local/nginx/ 下建 /es/fenci/ 目录,目录下创建 myword.txt,就写个 “雨女无瓜”;然后重启 Es 服务器,重启 Nginx。在 Kibana 中测试分词效果:

更新完成后,es 只会对新增的数据用新词分词。历史数据是不会重新分词的。如果想要历史数据重新分词。需要执行:

POST movies_index_chn/_update_by_query?conflicts=proceed

3.6 热更新词库

(1)基于 IK 分词器原生支持的热更新方案,部署一个 web 服务器,提供一个 http 接口,通过 modified 和 tag 两个 http 响应头,来提供词语的热更新;

(2)修改 IK 分词器源码,然后手动支持从 MySQL 中每隔一定时间,自动加载新的词库;推荐用这种方案,第 1 种不太稳定。

2. JavaAPI

Elasticsearch 提供了 2 种 REST 客户端,一种是低级客户端,一种是高级客户端:

  • 【Java Low Level REST Client】官方提供的低级客户端。该客户端通过 HTTP 来连接 ElasticSearch 集群。用户在使用该客户端时需要将请求数据手动拼接成 ElasticSearch 所需 JSON 格式进行发送,收到响应时同样也需要将返回的 JSON 数据手动封装成对象。虽然麻烦,不过该客户端兼容所有的 ElasticSearch 版本;
  • 【Java High Level REST Client】官方提供的高级客户端。该客户端基于低级客户端实现,它提供了很多便捷的 API 来解决低级客户端需要手动转换数据格式的问题。

2.1 LowClient

低级客户端

a. Maven

<dependencies>
    <dependency>
        <groupId>org.elasticsearch.client</groupId>
        <artifactId>elasticsearch-rest-client</artifactId>
        <version>7.8.0</version>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.12</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.9.4</version>
    </dependency>
</dependencies>

b. 测试用例

private static final ObjectMapper MAPPER = new ObjectMapper();
private RestClient restClient;

@Before
public void init() {
    RestClientBuilder restClientBuilder = RestClient.builder(
    new HttpHost("172.16.55.185", 9200, "http"),
    new HttpHost("172.16.55.185", 9201, "http"),
    new HttpHost("172.16.55.185", 9202, "http"));
    restClientBuilder.setFailureListener(new RestClient.FailureListener() {
        @Override
        public void onFailure(Node node) {
            System.out.println("出错了 -> " + node);
        }
    });
    this.restClient = restClientBuilder.build();
}

@After
public void after() throws IOException {
    restClient.close();
}

@Test
public void testGetInfo() throws IOException {
    Request request = new Request("GET", "/_cluster/state");
    request.addParameter("pretty","true");
    Response response = this.restClient.performRequest(request);
    System.out.println(response.getStatusLine());
    System.out.println(EntityUtils.toString(response.getEntity()));
}

@Test
public void testCreateData() throws IOException {
    Request request = new Request("POST", "/haoke/house");
    Map<String, Object> data = new HashMap<>();
    data.put("id","2001");
    data.put("title","TREE");
    data.put("price","3500");
    request.setJsonEntity(MAPPER.writeValueAsString(data));
    Response response = this.restClient.performRequest(request);
    System.out.println(response.getStatusLine());
    System.out.println(EntityUtils.toString(response.getEntity()));
}

@Test
public void testQueryData() throws IOException {
    Request request = new Request("GET", "/haoke/house/G0pfE2gBCKv8opxuRz1y");
    Response response = this.restClient.performRequest(request);
    System.out.println(response.getStatusLine());
    System.out.println(EntityUtils.toString(response.getEntity()));
}

@Test
public void testSearchData() throws IOException {
    Request request = new Request("POST", "/haoke/house/_search");
    String searchJson = "{\"query\": {\"match\": {\"title\": \"KeepGoing\"}}}";
    request.setJsonEntity(searchJson);
    request.addParameter("pretty","true");
    Response response = this.restClient.performRequest(request);
    System.out.println(response.getStatusLine());
    System.out.println(EntityUtils.toString(response.getEntity()));
}

2.2 HighClient

高级客户端

a. Maven

<dependencies>
    <dependency>
        <groupId>org.elasticsearch</groupId>
        <artifactId>elasticsearch</artifactId>
        <version>7.8.0</version>
    </dependency>
    <!-- ElasticSearch的高级客户端 -->
    <dependency>
        <groupId>org.elasticsearch.client</groupId>
        <artifactId>elasticsearch-rest-high-level-client</artifactId>
        <version>7.8.0</version>
    </dependency>
    <!-- ElasticSearch依赖2.x的log4j -->
    <dependency>
        <groupId>org.apache.logging.log4j</groupId>
        <artifactId>log4j-api</artifactId>
        <version>2.8.2</version>
    </dependency>
    <dependency>
        <groupId>org.apache.logging.log4j</groupId>
        <artifactId>log4j-core</artifactId>
        <version>2.8.2</version>
    </dependency>
	<dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.9.9</version>
	</dependency>
    <!-- Junit单元测试 -->
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.12</version>
    </dependency>
</dependencies>

b. Index

public static void createIndexTest() throws IOException {
    // 创建 ES 客户端
    RestHighLevelClient esClient = new RestHighLevelClient(
            RestClient.builder(new HttpHost("localhost", 9200, "HTTP"))
    );

    // 创建索引
    CreateIndexRequest createReq = new CreateIndexRequest("user");
    CreateIndexResponse resp = esClient.indices().create(createReq, RequestOptions.DEFAULT);
    System.out.println(resp.isAcknowledged() ? "索引创建成功" : "索引创建失败");

    // 关闭 ES 客户端
    esClient.close();
}

public static void searchIndexTest() throws IOException {
    // 创建 ES 客户端
    RestHighLevelClient esClient = new RestHighLevelClient(
            RestClient.builder(new HttpHost("localhost", 9200, "HTTP"))
    );

    // 查询索引
    GetIndexRequest searchReq = new GetIndexRequest("user");
    GetIndexResponse resp = esClient.indices().get(searchReq, RequestOptions.DEFAULT);

    // 响应状态
    System.out.println(resp.getAliases());
    System.out.println(resp.getMappings());
    System.out.println(resp.getSettings());

    // 关闭 ES 客户端
    esClient.close();
}

public static void DeleteIndexTest() throws IOException {
    // 创建 ES 客户端
    RestHighLevelClient esClient = new RestHighLevelClient(
            RestClient.builder(new HttpHost("localhost", 9200, "HTTP"))
    );

    // 删除索引
    DeleteIndexRequest delReq = new DeleteIndexRequest("user");
    AcknowledgedResponse resp = esClient.indices().delete(delReq, RequestOptions.DEFAULT);
    System.out.println(resp.isAcknowledged());

    // 关闭 ES 客户端
    esClient.close();
}

c. Doc

public static void InsertDocTest() throws IOException {
    RestHighLevelClient esClient = new RestHighLevelClient(
            RestClient.builder(new HttpHost("localhost", 9200, "HTTP")));

    IndexRequest request = new IndexRequest();
    request.index("user").id("1001");

    User user = new User();
    user.setName("张三");
    user.setAge(15);
    user.setSex("女");

    ObjectMapper mapper = new ObjectMapper();
    String reqBody = mapper.writeValueAsString(user);
    request.source(reqBody, XContentType.JSON);

    IndexResponse resp = esClient.index(request, RequestOptions.DEFAULT);

    System.out.println(resp.getResult());

    esClient.close();

/*
 [user] creating index, cause [auto(bulk api)], templates [], shards [1]/[1], mappings []
 [user/DhRas-mmTKqY0SL_AF-yiA] create_mapping [_doc]
 */
}

public static void DeleteDocTest() throws IOException {
    try (RestHighLevelClient esClient = new RestHighLevelClient(
            RestClient.builder(new HttpHost("localhost", 9200, "HTTP")))) {
        DeleteRequest request = new DeleteRequest();
        request.index("user").id("1001");

        DeleteResponse response = esClient.delete(request, RequestOptions.DEFAULT);
        System.out.println(response.status());
    }
}

public static void QueryDocTest() throws IOException {
    try (RestHighLevelClient esClient = new RestHighLevelClient(
            RestClient.builder(new HttpHost("localhost", 9200, "HTTP")))) {
        // 查询数据
        GetRequest request = new GetRequest();
        request.index("user").id("1001");
        GetResponse response = esClient.get(request, RequestOptions.DEFAULT);
        System.out.println(response.getSourceAsString());

    }
}

public static void UpdateDocTest() throws IOException {
    RestHighLevelClient esClient = new RestHighLevelClient(
            RestClient.builder(new HttpHost("localhost", 9200, "HTTP")));

    // 修改数据
    UpdateRequest request = new UpdateRequest();
    request.index("user").id("1001");
    request.doc(XContentType.JSON, "sex", "男");

    UpdateResponse response = esClient.update(request, RequestOptions.DEFAULT);
    System.out.println(response.getResult());

    esClient.close();
}

public static void BatchInsertDocTest() throws IOException {
    try (RestHighLevelClient esClient = new RestHighLevelClient(
            RestClient.builder(new HttpHost("localhost", 9200, "HTTP")))) {
        BulkRequest bulkRequest = new BulkRequest();
        for (int i = 1001; i < 1010; i++) {
            bulkRequest.add(new IndexRequest().index("user")
                    .id(i+"").source(XContentType.JSON, "name", "张三"));
        }
        BulkResponse response = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
        System.out.println(response.getTook());
        System.out.println(response.getItems());
    }
}

2.3 SearchSourceBuilder

// TODO

3. Doc Value

搜索的时候,要依靠倒排索引;排序的时候,需要依靠正排索引,看到每个 doc 的每个 field,然后进行排序,所谓的正排索引,其实就是 Doc Value。

在建立索引的时候,一方面会建立倒排索引,以供搜索用;一方面会建立正排索引,也就是 Doc Value,以供排序、聚合、过滤等操作使用。

Doc Value 是被保存在磁盘上的,此时如果内存足够,OS 会自动将其缓存在内存中,性能还是会很高;如果内存不足够,OS 会将其写入磁盘上。

倒排索引:

doc1: hello world you and me
doc2: hi, world, how are you
term doc1 doc2
hello *
world * *
you * *
and *
me *
hi *
how *
are *

正排索引:

doc1: { "name": "jack", "age": 27 }
doc2: { "name": "tom", "age": 30 }
document name age
doc1 jack 27
doc2 tom 30

【补充】Es 的聚合,统计查询时报错 fielddata=true 问题

在 Es 中,text 类型的字段使用一种叫做 fielddata 的查询时内存数据结构。当字段被排序,聚合或者通过脚本访问时这种数据结构会被创建。它是通过从磁盘读取每个段的整个反向索引来构建的,然后存存储在 Java 的堆内存中。

fileddata 默认是不开启的。fielddata 可能会消耗大量的堆空间,尤其是在加载高基数文本字段时。一旦 fielddata 已加载到堆中,它将在该段的生命周期内保留。此外,加载 fielddata 是一个昂贵的过程,可能会导致用户遇到延迟命中。这就是默认情况下禁用 fielddata 的原因。如果尝试对文本字段进行排序,聚合或脚本访问,将看到以下异常:

"Fielddata is disabled on text fields by default. Set fielddata=true on [your_field_name]
in order to load fielddata in memory by uninverting the inverted index.
Note that this can however use significant memory."

在启用 fielddata 之前,请考虑使用文本字段进行聚合、排序或脚本的原因。这样做通常没有意义。text 字段在索引例如 "New York" 这样的词会被分词,会被拆成 "new","york"。在此字段上面来一个 terms 的聚合会返回一个 "new" 的 bucket 和一个 "york" 的 bucket,当你想只返回一个 "New York" 的 bucket 的时候就会出现问题。

4. Query/Fetch Phase

Es 的搜索过程,目标是符合搜索条件的文档,这些文档可能散落在各个 node,各个 shard 中。

Es 的搜索,需要找到匹配的文档,并且把从各个 node,各个 shard 返回的结果进行汇总、排序,组成一个最终的结果排序列表,才算完成一个搜索过程。

一次搜索请求在两个阶段中执行(query 和 fetch),这两个阶段由接收客户端请求的节点 (协调节点)协调。

  • 在请求 query 阶段,协调节点将请求转发到保存数据的数据节点。 每个数据节点在本地执行请求并将其结果返回给协调节点。
  • 在收集 fetch 阶段,协调节点将每个数据节点的结果汇集为单个全局结果集。

4.1 Query Phase

  1. 搜索请求发送到某一个 Coordinate Node,该节点会构建一个 Priority Queue,长度以 paging 操作 from 和 size 为准,不加默认为 10;
  2. Coordinate Node 将请求转发到所有 shard,每个 shard 本地搜索,并构建一个本地的同样大小的 Priority Queue,用于存储该 shard 执行查询的结果;
  3. 各个 shard 将自己的 Priority Queue { id, _score } 返回给 Coordinate Node,并构建一个全局的 Coordinate Node。

  • 哪个 Node 接收客户端的请求,该 Node 就会成为 Coordinate Node;
  • Coordinate Node 转发请求时,会根据负载均衡算法分配到同一分片的 Primary shard 或 Replica shard 上,注意这里是或不是与。
  • 为什么说 Replica 值设置得大一些, 可以增加系统吞吐量呢? Coordinate Node 的查询请求负载均衡算法会轮询所有的可用 shard,如果每个 shard 都有多个 Replica,那么同时并发过来的搜索请求可以同时打到其他的 Replica 上去。也就是说在并发场景时就会有更多的硬件资源(CPU、内存、IO)会参与其中,系统整体的吞吐量就能提升。

4.2 Fetch Phase

  1. Coordinate Node 收集到各个 shard 数据从而构建出最终的 Priority Queue { id, _score } 之后,就发送 mget 请求去所有 shard 上获取对应的 document 的详细信息;
  2. 各个 shard 将 document 返回给 Coordinate Node;
  3. Coordinate Node 将合并后的 document 结果返回给 client 客户端(一般搜索,如果不加 from 和 size,就默认搜索前 10 条,按照 _score 排序)。

使用 from 和 size 进行分页时,传递信息给 Coordinate Node 的每个 shard,都创建了一个 from + size 长度的队列,并且 Coordinate Node 需要对所有传过来的数据进行排序,工作量为 number_of_shards * (from + size),然后从里面挑出 size 数量的文档,如果 from 值特别大,那么会带来极大的硬件资源浪费,鉴于此原因,强烈建议不要使用深度分页。

4.3 搜索参数小总结

a. preference

决定了哪些 shard 会被用来执行搜索操作。

Bouncing Results 问题:

两个 document 排序,其 field 值相同;不同的 shard 上,可能排序不同;每次请求轮询打到不同的 Replica shard 上;每次页面上看到的搜索结果的排序都不一样。这就是 Bouncing Results,也就是跳跃的结果。

比如当你使用一个 timestamp 字段对结果进行排序,因为 Es 中时间格式为 %Y-%m-%d,那么同样时间的数据会有很多。Es 如果不做任何设置,将会按 Round-Robined 的方式从 Primary 和 Replica 里取了再排序,这样结果就不能保证每次都一样的。毕竟 Primary 有的 Replica 里不一定有,尤其是在不停往 Es 里存放数据的情况。

如果有两份文档拥有相同的 timestamp。因为搜索请求是以一种循环(Round-Robin)的方式被可用的分片拷贝进行处理的,因此这两份文档的返回顺序可能因为处理的分片不一样而不同,比如主分片处理的顺序和副本分片处理的顺序就可能不一样。

这就是结果跳跃问题:每次用户刷新页面都会发现结果的顺序不一样。

解决方案:将 preference 设置为一个字符串,比如说 user_id,让每个 user 每次搜索的时候,都使用同一个 Replica shard 去执行,就不会看到 Bouncing Results 了。

_primary, _primary_first, _local, _only_node:xyz, _prefer_node:xyz, _shards:2,3

b. timeout

已经讲解过原理了,主要就是限定在一定时间内,将部分获取到的数据直接返回,避免查询耗时过长。

c. routing

document 文档路由,_id 路由,routing=user_id,这样的话可以让同一个 user 对应的数据到一个 shard 上去。

d. search_type

场景:一个在我理解应该排第一的结果被放在了后面,而且评分相差接近两倍之多。

逆向文件频率(inverse document frequency,idf)是一个词语普遍重要性的度量。某一特定词语的 idf,可以由总文件数目除以包含该词语之文件的数目,再将得到的商取以 10 为底的对数得到。

从定义可知,idf 仅仅与搜索关键词有关,与文档无关。所以同一输入来说,所有的文档应该是共享同一 idf 的。但事实上并非如此。原因就在 Es 的分布式机制。Es 的索引(index)会被分片(shard),而每一个分片相当于一个独立的搜索引擎。每一次搜索任务会被分配到不同的 shard 去执行,然后将各个 shard 的结果汇总起来得到最终我们看到的结果。而评分的过程会在 shard 完成,因此不同分片下,会得到不同的 idf。这里需要有个前提假设是文档数量足够多的时候各个分片的词频会趋近,因此 idf 的差异也就不大。但是如果文档数量不够多的时候启用分片,可能词频在不同分片会有较大的差异,我遇到的情况就是这样的。这时候就需要我们了解一下 search_type。

  • query_then_fetch(默认值):它对词频的计算方式和所在的问题如上文所述;
  • dfs_query_then_fetch:当 search_type 设置为它的时候,词频的计算方法是整个索引(index)而不是单个分片(shard),这样会得到更准确的 tf-idf 评分(比第一种方式多了一个初始化散发 initial scatter 步骤)。

想必大家可以想到另一个解决方案那就是在创建索引的时候设置只有一个分片,这样也不需要 search_type 了。其实如果数据的确不多的话,用一个分片足矣。

4.4 定位非法搜索及原因

在开发的时候,我们可能会写到上百行的查询语句,如果出错的话,找起来很麻烦,Es 提供了帮助开发人员定位不合法的查询的 api:_validate

GET /book/_validate/query?explain
{
    "query": {
        "match1": {
            "name": "test"
        }
    }
}

返回结果:

{
    "valid":false,
    "error":"org.elasticsearch.common.ParsingException: no [query] registered for [match1]"
}
posted @ 2022-01-13 23:26  tree6x7  阅读(154)  评论(0编辑  收藏  举报