之前给服务器安好了ES和Kibana一直没来得及动手试试,这篇就系统性地介绍一下ES的基本使用和特性,以及如何将其与SpringBoot整合。

        本文基于ElasticSearch 7.5.0 + Kibana 7.5.0,版本一定要一致!

目录

1 什么是ElasticSearch?

2 ElasticSearch基本概念

2.1 ElasticSearch/Kibana/分词器的安装与部署

2.2 索引 Index

2.3 映射 Mapping

2.3.1 text/keyword

2.3.2 byte/short/integer/long + half_float/float/double

2.3.3 boolean/date

2.3.4 array/object/nested/geo

2.4 文档 Document

3 ElasticSearch的使用

3.1 创建索引

3.2 插入/修改/删除文档

3.3 查询文档

3.3.1 ids 根据ID批量查询

3.3.2 match 匹配查询

3.3.3 term 精准查询

3.3.4 range 范围查询

3.3.5 bool 布尔查询

3.3.6 size/scroll 分页查询

3.4 聚合 Aggregation

3.4.1 最大值 Max

3.4.2 最小值 Min

3.4.3 平均值 Avg

3.4.4 求和 Sum

3.4.5 求文档数 Value Count

3.4.6 去重统计 Cardinality

3.4.7 基本数据统计 Stats(Statistics)

3.4.8 拓展数据统计 Extended Stats

3.4.9 百分位统计 Percentiles

3.4.10 百分位排名统计 Percentiles Ranks

3.4.11 词频聚合 Terms

3.4.12 过滤器聚合 Filter/Filters

3.4.13 范围聚合 Range

3.4.14 缺失值聚合 Missing

3.4.15 命中文档聚合 Top Hits

3.5 多余字段的删除

4 整合SpringBoot

4.1 创建索引

4.2 插入/修改/删除文档

4.3 查询文档

4.4 聚合

1 什么是ElasticSearch?

        ElasticSearch是一个由Java编写的基于Lucene框架天生支持分布式RESTful风格的开源搜索和数据分析引擎,也是Elastic Stack的核心。Elastic Stack就是整个Elastic公司包含的所有技术栈,其中包括了最著名的ELK(ES + Logstash + Kibana),以及其他毛毛多的技术,就不提了。

        搜索引擎大家应该都不陌生,打开Google输入几个关键词Google一下,就会根据相关度依次展示你搜索的内容,且会高亮标记你的搜索的关键词——ElasticSearch能够做到这件事,且不仅于此;他还提供了强大的数据分析功能:聚合,比如指标聚合中的Max、Min、Avg等典型计算功能,桶聚合中的Terms能统计指定字段的词频。

        那么说到ES就不得不提他的底层框架Lucene,市面上大部分的搜索引擎都是基于Lucene实现的。顺便提一嘴创造Lucene的大神道格·卡丁(Doug Cutting),这位也创造了Hadoop(分布式计算框架)、HDFS(高容错分布式文件系统),虽然借鉴了Google算法实现,但也同样伟大。

        Lucene是一个全文检索引擎,听起来就像是ES的核心,也确实如此。Lucene提供了关键的分词、倒排索引、匹配搜索功能。

        首先要知道“分词”是什么。打比方说我现在要搜索附近的西餐厅,你可能会输入以下的语句:

“附近哪里卖牛排?”

“离我最近的西餐厅?”

        如果你完整搜索这句话,估计啥玩意也搜不出来,除非有位和你心有灵犀的人提出了一模一样的问题。但如果把这句话分成许多个有意义的词组再搜索,“离我最近的西餐厅”分解成“最近”和“西餐厅”,就能搜出符合度较高的结果,如愿吃上近处的西餐。分词的作用就是如此,将一个句子分解成一个个有意义的词语。在英语中分词很好实现,因为每个单词间会被空格分隔开,而中文就不好说了,可能由各种词组组成。不过不用担心,咱们China有自己的“IK分词器”,后面我们就会介绍。

        那么“倒排索引”又是什么?刚刚我们通过分词,将搜索的语句分成了许多个词语,保存的记录也同样需要分词并保存。例如现在库里有这样几条记录(仅代表个人喜好):

  • 1:好吃的川菜馆
  • 2:凑合的湘菜馆
  • 3:一般的西餐厅
  • 4:好吃的陕菜馆

        这几条数据如果原封不动地放在那里也没法搜索,分词后变成了这样:

  • 1:好吃、川菜
  • 2:凑合、湘菜
  • 3:一般、西餐厅
  • 4:好吃、陕菜

        这样以后搜索“好吃”,就能对应到1、4两条记录,但这样好像效率也不高啊,每次搜索遍历每条记录的每个词语。因此我们还需要下一步,将出现过的词语和ID再关联起来,通过词语寻找ID:

  • 好吃:1、4
  • 凑合:2
  • 一般:3
  • ......

        现在再搜索“好吃”,就能直接查到这个词语对应的记录啦,再回到文档里寻找id为1和4的记录取出来即可。MySQL的非聚集索引的创建,其实就是上诉创建倒排索引的过程,根据被索引字段的值统计所有值对应的记录id,使用该索引时只需找到id再回表查询对应记录。Lucene的倒排索引不同之处在于,他会对整个文档先进行分词,再对分词的结果创建倒排索引;而MySQL只支持对列的数据创建索引,且不支持全文索引,一般全文搜索都会使用like “%abc%”,效率是令人发指的。MySQL5.7之后支持的全文索引match...against也是基于分词和倒排索引实现的!

        至此我们有了分词逻辑,也有了分词后创建得倒排索引,只需要充分用起来即可,便到了最后一步匹配搜索。还是老例子,我们搜索“离我最近的西餐厅”,分词分出了“最近”和“西餐厅”, 拿着这两个词去倒排索引里寻找。匹配到“最近”对应的记录1/2/3,“西餐厅”对应的记录2/3/4,可以看到2和3出现了两次,说明这两条记录与我们搜索内容的相关性最高,经过综合打分评估以后,根据匹配度高低返回给用户。

        介绍完Lucene后是不是觉得他很强大,ES的核心功能和思想基于Lucene构建,且做了极大地增强。再回到一开始说的天生支持分布式,部署过ES的小伙伴应该知道,配置文件中会让你指定集群名称、主节点名称、集群中子节点名称,可以看得出ES是天生支持集群化和分布式部署的,能够自动进行服务发现和主节点选举。这意味着只要你想,就可以无限上机器来水平扩展。但也不是越多越好哈,分片策略和复制策略也是需要考虑进去的。

        ES本身也是极为简单易用的,因为其提供了RESTful API,正如我们认知中,查询文档和索引是GET请求、删除是DELETE请求、修改和发送是POST和PUT。这使得ES的入门和使用变得很简单,只需要学习基本的DSL语法(类似于SQL语句),便可以畅游ES的海洋。

        说完了ElasticSearch的种种好处,我们再来总结一下为什么要使用ElasticSearch,再来和关系型数据库老大哥MySQL SOLO一下:

MySQL

ElasticSearch

存储方式

仅允许单机存储,数据量到达百万级后需要分库分表

分布式存储,使用分片 + replica冗余存储

查询效率

聚集索引查询效率高,非聚集索引查询效率较低,全文匹配效率极低

拥有精准查询、模糊匹配、范围查询等查询方式,全文搜索效率高

分布式支持

支持主从、主主等

天生支持

事务支持

支持ACID四大特性

不支持

其他特性

支持多表关联查询

支持mapping动态映射,支持replica复制分片保证数据完整性,支持聚合计算功能

        传统关系型数据库单表数据量达到百万条,操作效率就会大幅降低,而ES能够支持处理PB级别的数据(1PB = 1024TB)。刚刚做了个试验,1000w条记录的表查询非索引字段,搜索时间来到了恐怖的20多秒......但是需要事务或复杂关联逻辑的场景,MySQL一定是最好的,没有下位替代。

2 ElasticSearch基本概念

        聊了这么多,气氛也热起来了,该介绍振奋人心的ElasticSearch了,下面我们就来介绍一下ES的基本概念。

2.1 ElasticSearch/Kibana/分词器的安装与部署

        这篇之前讲过了哈,有兴趣的小伙伴可以看这篇 -> Linux Java常用服务安装与设置

2.2 索引 Index

        此索引非彼索引,ES中索引的概念类似于MySQL中的表,一个索引便对应着一张拥有完整字段结构约束的表。其实早期的ES索引更类似数据库,其中的Type对应着具体的表,但是这个概念并没有多大意义,反而因为一个Index中多个Type带来了许多困扰,因此在7.0版本被彻底移除。

        既然Index类似于一张表,我们肯定要先创建好Index、指定每个字段的名称和类型、然后再做其他操作。但是强大的ES为我们提供了动态映射,就是个什么意思呢,你不用建索引,也不用建字段,你直接告诉他我今天就要往索引A里插一条文档B;然后你会惊奇的发现:

ES springboot 地理位置搜索 springboot整合es常用查询_字段

         成功了!我事先是没有创建索引的,也可以看到结果的result字段显示“created”,意为索引在此时被创建并插入了文档。这便是ES动态映射带给我的自信,你只管插入,一切都由ES买单。当然,既然是人家自己动态生成的,那你用着也别挑了,咱们来看看索引的结构信息。

{
  "testindex" : {
    "aliases" : { },
    "mappings" : {
      "properties" : {
        "properties" : {
          "properties" : {
            "content" : {
              "type" : "text",
              "fields" : {
                "keyword" : {
                  "type" : "keyword",
                  "ignore_above" : 256
                }
              }
            },
            "introduction" : {
              "type" : "text",
              "fields" : {
                "keyword" : {
                  "type" : "keyword",
                  "ignore_above" : 256
                }
              }
            },
            "title" : {
              "type" : "text",
              "fields" : {
                "keyword" : {
                  "type" : "keyword",
                  "ignore_above" : 256
                }
              }
            }
          }
        }
      }
    },
    "settings" : {
      "index" : {
        "creation_date" : "1681183635630",
        "number_of_shards" : "1",
        "number_of_replicas" : "1",
        "uuid" : "FL9VycRcRIek2WiiXlsS5g",
        "version" : {
          "created" : "7050099"
        },
        "provided_name" : "testindex"
      }
    }
  }
}

        不得不说,ES的动态映射是非常智能的,我们传入的几个字段都是字符串,他便自动帮我们将字段映射设置为了text + keyword类型的复合映射;简单来说就是这个字段既支持全文搜索、又支持精准匹配,是种非常理想的状态,我们自己创建索引时一般也会这样设置。

        但还是存在一些问题,在使用text类型时是需要指定分词器的,之前说过ES对于中文的分词支持不佳,毕竟不是全世界都在说中国话;再看看分片策略,也不是很理想,主分片1复制分片1,等于说主数据都存在单节点上,对于单机存储的负载是很大的,且复制分片也只有1个,同时死两台机子这个索引就瘫痪了。因此还是建议自行创建索引,指定分片策略和字段映射等。

2.3 映射 Mapping

        映射是索引中非常重要的概念,类似于MySQL中字段的约束,例如数据类型、分词器、是否存储、是否建立索引。其中最重要的就是数据类型,为字段建立合适和数据类型会使你的搜索快上加快。下面就介绍一些常用的数据类型及可配置属性。

2.3.1 text/keyword

        text和keyword可以说是ES最核心的两种类型,在早期版本中两者被合并为String字符串类型,后来进行了拆分和优化。text和keyword最大的不同就是是否要分词,text对应需要分词,也就是text字段传入“今天星期四”,就会被分成“今天”和“星期四”两个词供匹配,搜索“今天”或“星期四”都可以匹配到该记录。而keyword字段不会进行分词,放进去什么样保存就是什么样,存“今天星期四”,查也得查“今天星期四”能找到记录。

        看起来好像是text功能全面一点,但如果需要保存用户名、手机号这种信息,明显是不需要分词也不会被模糊查询,保存成keyword肯定更合理。且keyword支持聚合而text不支持,因此如果想同时享受聚合和分词查询,就可以设置一个复合类型的字段。

2.3.2 byte/short/integer/long + half_float/float/double

        number类型,包括8/16/32/64位整型数,16位半精度/32位单精度/64位双精度浮点数。

2.3.3 boolean/date

        boolean很简单,包括true和false。date类型类似于keyword,可以通过指定format来指定日期格式,如"format": "yyyy-MM-dd HH:mm:ss"。

2.3.4 array/object/nested/geo

        俺也不会,以后再写。

        数据类型介绍完还有几个可配置属性,如index属性可以指定字段是否要建立倒排索引,如果设置为false,再使用该字段进行任何查询都会失败,有些一定不会被作为查询条件的字段可以设置为不建立索引,能够节省磁盘空间。

        store属性决定是否要单独存储该字段,一般我们取文档都是从"_source"中读取,那store是干嘛地呢?如果这条文档的字段我们都不想读取,只想看看有没有,就会将“_source”禁用掉,此时ES就只会对文档建立索引而不会保存原数据。但如果你又想要获取其中某一个字段的数据,就可以将store设置为true,在不存储整个文档的情况下,单独存储某个字段(好奇怪...但好吧...)。store属性默认为false,因为已经有source干这个活儿了。

        P.S. 但其实我还是感觉怪怪的,因为_source有includes和excludes属性来决定是否保存某些字段,意义不是很明确。

2.4 文档 Document

        建立好索引、设置完字段映射,就可以向索引中插入文档了,文档就类似于MySQL中的行数据。插入文档似乎就没什么好说的了,注意点不要写错字段名称就行,由于索引的字段添加后就无法删除,只能增加字段或者给字段追加新类型,一次插入错字段,这个字段就会跟你一辈子。错误次数多了,索引中就会多出很多莫名其妙的字段,只能通过重建索引数据迁移来强行修复,后面会介绍。

3 ElasticSearch的使用

3.1 创建索引

        先来创建个索引,如之前所说,需要指定索引名称、索引配置、字段映射,这里仅介绍我使用过的方式。ES提供了RESTful API,使得操作十分清晰,就是一堆HTTP请求,加上请求体中的DSL语句,DSL语法本身其实没什么好介绍的,就是记住然后会用就行。

//创建索引
PUT /testindex
{
  "settings" : {
    "number_of_shards": 4,
    "number_of_replicas": 1
  }
}

        执行上面的命令,先建立索引并指定主分片和副分片数量,“number_of_shards”为主分片数量(默认为5),即该索引的数据需要分成多少个分片存储,像我们就设置了4个,就是把数据分成4片放在不同的服务器上;而“number_of_replicas”是副本数量(默认为1),设置为1意为每个主分片都需要有1个复制分片。那么现在这个索引就包含4主分片 + 4副分片共8个分片,且主分片不会保存在同一个机器上,相同的主分片和副分片也不会保存在一个机器上。这点也很好理解,ES为了安全做了这样的数据冗余,如果两个主分片在同一个机器上,这台机器故障就会导致大量数据不可用;如果主分片A和他的副分片在同一台机器上,这台机器故障A分片的所有数据都会不可用。

        值得注意的是,主分片数量在设置完成后就不可再改变,而副分片数量是可以改变的,且副分片在查询时也可以被当做主分片分担查询压力。增加主分片和副分片数量固然有许多好处,比如减少单机磁盘占用量,将单机查询请求变为多线程并行请求多个分片,从而提高查询效率,但这并不意味着分片越多越好——副分片多了就意味着插入数据需要同步的分片越多,且查询请求的机器数量多了以后,网络和IO的开销会使得并行查询的效率变低。通俗地说,分片数量和查询效率的提升是对数增长关系,最开始提升分片数量确实会有效率地提升,但达到临界值后反而会降低,物极必反嘛。

//设置字段映射
PUT /testindex/_mapping
{
  "properties" : {
    "datetime" : {
      "type" : "date",
      "format": "yyyy-MM-dd"
    },
    "int": {
      "type": "integer",
      "index": true
    },
    "textandkeyword": {
      "type": "text",
      "analyzer": "ik_max_word",
      "fields": {
        "keyword" : {
          "type" : "keyword",
          "ignore_above" : 256
        }
      }
    }
  }
}

        创建好索引后需要指定字段和映射,设置好字段的名称、类型、分词器。例如上面的映射,我们给testindex索引中新增了名为“datetime”、“int”、“textandkeyword”的字段,“type”属性即为该字段的类型,“datetime”字段为“date”类型且指定了保存的格式为“yyyy-MM-dd”;“int”字段类型为“integer”32位整数,还配置了“index”属性意为该字段是否创建索引,默认为true,即默认所有字段都可以参与搜索,如果设置为false该字段就不能参与搜索。

        重点想说“textandkeyword”字段,是ES中比较常见的字段类型:复合类型,可以看到他的第一个type为“text”,且指定了IK分词器,意思是这个字段会被分词存储,用于模糊查询和精确查询;但是如果只使用text,会出现精确匹配整个字段会查不到。比如我们给text类型的字段存入“今天星期四”,根据ik_max_word他会被拆分成如下的词语:

//查看分词结果
post /_analyze
{
  "analyzer": "ik_max_word",
  "text": "今天星期四"
}

{
  "tokens" : [
    {
      "token" : "今天",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "星期四",
      "start_offset" : 2,
      "end_offset" : 5,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "星期",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 2
    },
    {
      "token" : "四",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "TYPE_CNUM",
      "position" : 3
    }
  ]
}

        拆得很好,很合理,但是唯独少了这句话本身。 如果我们要精确匹配“今天星期四”,会惊奇地发现查不到,这就非常不合理了,明明是100%完全匹配的记录却查不到。

        因此,在遇到某些完全不需要分词,或者也需要精准匹配、参与聚合的字段,可以设置为keyword类型,或者像上文那样设置成复合字段,既是text又是keyword;需要精确匹配时,单独查询“textandkeyword.keyword”,也就是该字段的关键词类型。keyword可以设置一个“ignore_above”属性,因为这个字段有可能长达500字,我们搜索也不可能暴打500字,因此完全没必要对整个keyword都创建索引;这时就会用到ignore_above,意为这些位数之后的字符我就忽略了,比如上文设置的“ignore_above = 256”,就是256位之后的字符不创建索引,能够大大节省磁盘空间。

        创建好索引后,可以用GET /indexname来看看索引的信息:

{
  "testindex" : {
    "aliases" : { },
    "mappings" : {
      "properties" : {
        "datetime" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        },
        "int" : {
          "type" : "integer"
        },
        "textandkeyword" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          },
          "analyzer" : "ik_max_word"
        }
      }
    },
    "settings" : {
      "index" : {
        "creation_date" : "1681197581820",
        "number_of_shards" : "1",
        "number_of_replicas" : "2",
        "uuid" : "xcFNjShzSMqM5VwMTD-J3w",
        "version" : {
          "created" : "7050099"
        },
        "provided_name" : "testindex"
      }
    }
  }
}

        很理想,和我们设置得完全一致, 这不废话吗。

        创建完我们用GET /_cat/indices?v&pretty看看所有索引信息,这个指令也是比较常用的,_cat和Linux里的查看差不多,就是猫一下全局状态;indices是index的复数形式,再加上pretty修饰词,意思是展示得美丽一点。

health status index                    uuid                   pri rep docs.count docs.deleted store.size pri.store.size
yellow open   testindex                xcFNjShzSMqM5VwMTD-J3w   1   2          2            0      3.7kb          3.7kb
green  open   .kibana_task_manager_1   85dqt05XTf2hmQloPVxCgg   1   0          2            0     31.6kb         31.6kb
green  open   .apm-agent-configuration WKEsv_oGQhSeva9aXSw72A   1   0          0            0       283b           283b
yellow open   user                     7orJbC_KQoa-Dkax9TDLzA   1   1          7            0      5.4kb          5.4kb
green  open   .kibana_1                cB0TGuF1TMSSIJvrn436FA   1   0         14            1     46.9kb         46.9kb
yellow open   article                  1lelxTi1TxC1tyHhG0ugwg   1   1          2            0      9.7kb          9.7kb
yellow open   user_new                 cIKcpMZJThSk-5E6m4H-lQ   1   1          5            0      5.1kb          5.1kb
green  open   .tasks                   goX4wT5ETtaHFQT3KHQV8A   1   0          3            0     18.5kb         18.5kb

        看到这有人就有疑问了,为啥那个“health”字段有人是yellow有人是green呢?还有亚健康的索引库? 这得说回我们刚设置的主分片和副本分片,一般情况下会均分在集群中不同的服务器上,尴尬的是我整个集群内一台机器,就那主分片和副分片就只能都放这一台机子上了。这就会造成数据实际是没有任何冗余的,机器不可用就会导致整个索引数据不可用,所以就呈现了“yellow”的亚健康状态。细心的小伙伴也可以观察出来,pri/rep(primary主分片/replica副本分片)加起来是1的索引就是“green”完全健康状态,因为他们不需要将数据分布存储,只需存在一台机子上即可。

3.2 插入/修改/删除文档

        插入和修改实际上差不太多,就是输入对应索引结构的json字符串嘛,因此合并在一起说。

        插入文档语法是PUT /indexname/_doc/id,请求体是数据json,记住要符合字段映射,比如刚刚datetime字段指定了format为“yyyy-MM-dd”年月日,如果我们插入年月日时分秒,就会报"mapper_parsing_exception",并告诉你你插入的数据和指定的格式不同,解析失败所以插入失败了;且不要插入不存在的字段,不然时间久了你的索引会出现一堆你不认识的字段。来个正确的插入示例:

PUT /testindex/_doc/3
{
  "datetime": "2023-04-12",  //要符合format和数据类型
  "int": 11,
  "textandkeyword": "你好吗"
}

        修改文档有两种修改方式,第一种是直接覆盖,第二种是只修改某些字段。覆盖就类似于重新插入整个文档,所以和插入文档语法一样使用PUT,id换成需要覆盖的文档id即可。只修改某些字段语法不太一样但也大差不差,要用POST /indexname/_update/id,请求体只写要修改的字段和值即可,注意外面还要套一层“doc”。

POST /testindex/_update/1
{
  "doc": {
    "int": 1234
  }
}

        删除就不用多说了吧?DELETE /indexname删索引,DELETE /indexname/_doc/id删文档。

3.3 查询文档

        重头戏来了,ElasticSearch既然是搜索引擎,那查询搜索自然是他最强大的核心功能,下面我们来重点介绍各种查询文档的方法。首先要记住,ES中所有查询指令都是GET /indexname/_search,这个是基础中的基础哈。

3.3.1 ids 根据ID批量查询

        每个文档插入时都会指定或生成一个id,类似于关系型数据库的主键,最基础的就是根据id来查询;且这个查询是批量的,可以输入ids列表。

GET /testindex/_search
{
  "query": {
    "ids": {
      "values": [1, 2, 3]
    }
  }
}

        如上述指令,所有查询最外层都要包一个“query”,再往内就是我们用到的“ids”查询,指定values列表[1, 2, 3]就可以查询到id为1/2/3的文档。

3.3.2 match 匹配查询

        match查询会先将查询条件进行分词,再将分词后的词语与对应字段进行匹配,一般用于text类型的模糊查询。比如我输入“今天星期四”,就会去字段里查找含有“今天”或“星期四”的文档再返回。match大家族有许多成员,我们挨个介绍。

        基础的match查询除了可以输入字段名和字段值以外,还有几个额外的属性:

GET /testindex/_search
{
  "query": {
    "match": {
      "textandkeyword": "今天星期五"
}

GET /testindex/_search
{
  "query": {
    "match": {
      "textandkeyword": {
        "query": "今天星期五",
        "operator": "and",
        "minimum_should_match": 2  //operator为or时设置
      }
    }
  }
}

         如上述代码,第一种是基本形式,直接输入字段键值对,分词后进行匹配查询;“今天星期五”被ik_max_word神功分成了“今天”、“星期五”、“星期”、“五”,和索引中的“今天星期四”明显是可以匹配的,因此能查询到。

        再来看看第二种形式,除了字段值query属性,我们还设置了“operator”和“        minimum_should_match”,这俩是干嘛的?刚刚介绍match查询会先对查询条件进行分词,可能会被分成毛毛多的词语,默认情况下只要匹配到其中一个词语就算你匹配成功,但如果我们需要相关度很高的结果呢?再回到例子中,如果我就想搜索星期五相关的文档,默认的搜索方式却将“今天星期四”也搜索出来了,是不是不太合理呢?

        这时operator就闪亮登场了,这个属性意思是匹配操作类型,默认为or逻辑或匹配;分词结果中任何一个词语匹配上了,都会返回结果。我们将其设置为and,就成了逻辑与匹配,所有分词都能匹配到的文档才能返回。用and搜索时,就搜不到今天星期四对应的文档了。

        但这种方式又有些过于极端,用户一般不会用那么精准凝练的语言来搜索,但凡句子里带点废话就啥也搜不到了。这种情况就可以使用较为折中的minimum_should_match,意为最少应该匹配到词语,默认为1,顾名思义最少匹配到1个词语就认为是符合的,等同于逻辑或。我们将其设置为2,就又能匹配到星期四的文档了。不过要注意的是,minimum_should_match只有在operator为or时才能使用,为and时就要全部匹配上,设置这个值也没啥意义,反而会导致啥也查不到。

        match_all就是查询索引库中所有文档,只会默认返回10条,可以通过指定size来指定查询条数,也可以自定义一下排序规则;但由于ES的保护机制,单次返回不能超过10000条,可以通过配置来改变最大条数或使用滚动查询,后面我们会介绍。

GET /testindex/_search
{
  "query": {
    "match_all": {
    }
  },
  "size": 100,
  "sort": [
    {
      "datetime": {
        "order": "asc"
      }
    }
  ]
}

        multi_match为批量查询,可以同时指定多个字段,并在这些字段内进行匹配,match则只能在对应一个字段内进行匹配搜索。

GET /textindex/_search
{
  "query": {
    "multi_match": {
      "query": "今天",
      "fields": ["title", "introduction", "content"]
    }
  }
}

        match_phrase短语匹配是一种更为精准的查询方式,这种查询方式需要匹配到所有的分词,且每个词的顺序要与文档中词语顺序保持一致。如文档为“今天星期四”,如果搜索“星期四今天”就搜索不到,因为虽然所有词都能匹配上,但是一个顺序是“今天”、“星期四”,一个是“星期四”、“今天”,不满足短语匹配的条件。

GET /testindex/_search
{
  "query": {
    "match_phrase": {
      "textandkeyword": {
        "query": "星期四今天"  //顺序不同,查询不到!!!
      }
    }
  }
}

        match_phrase_prefix和match_phrase比较类似,只是会给最后一个分词加上指定数量的通配符。举个例子,“喜欢吃”被分词后为“喜欢”、“吃”,match_phrase_prefix会搜索“喜欢” + “吃*”,这个“*”是代表任意字符的通配符,那么我们就可以搜索到“喜欢吃饭”、“喜欢吃菜”。属性“max_expansions”是最后一个词后面通配符的数量,默认为1,也就是“吃*”,也可以设置为自己的幸运数字,但由于性能不佳不太常用。

GET /testindex/_search
{
  "query": {
    "match_phrase_prefix": {
      "textandkeyword": {
        "query": "今天星期",
        "max_expansions": 10
      }
    }
  }
}

        上面的查询就能查到“今天星期四”对应的文档,而match_phrase不能,因为搜索的是“今天” + “星期********”。 

3.3.3 term 精准查询

        term查询不会对查询条件进行分词,即你输入什么查询条件就是什么,更多用于keyword类型的查询,因为keyword也不会被分词,可以精确匹配到文档。还有terms查询,可以输入多个查询条件同时在字段中搜索。

//term单条件
GET /testindex/_search
{
  "query": {
    "term": {
      "textandkeyword.keyword": "今天星期四"
    }
  }
}

//terms多条件
GET /testindex/_search
{
  "query": {
    "terms": {
      "textandkeyword.keyword": ["今天星期四", "你好吗"]
    }
  }
}

        要注意的是,term查询最好使用在keyword类型的字段上,就像我们之前说的,text类型会对字段进行分词存储,不会存储字段本身;而term查询又不会对查询条件进行分词,追求的就是高精准度,text类型显然没法满足。

3.3.4 range 范围查询

        range查询用于范围查询,如查询某个日期范围内、某个价格区间内的文档,有gt/gte/lt/lte(大于/大于等于/小于/小于等于)四种逻辑符。

GET /testindex/_search
{
  "query": {
    "range": {
      "datetime": {
        "gte": "2023-04-11"
      }
    }
  }
}

3.3.5 bool 布尔查询

         在日常的搜索中条件不可能只有一个,通常是将多个条件组合起来查询,类似SQL语句中的“WHERE a AND b AND c”,这时就可以用bool查询来拼接条件。

        bool中含有must/should/must_not/filter

  • must:必须满足该条件,会进行分值计算
  • should:分含有must条件和不含must条件两种情况,在不含must条件时,只要满足should条件就会返回该文档;含有must条件时,满足should条件的文档会加分,说明相关性更高,返回的优先级也会变高
  • must_not:必须不满足该条件。
  • filter:必须满足该条件,但他不会进行分值计算,且常用filter会被缓存,非常推荐使用!能使用filter代替must的场景,尽量都使用filter。

        来一个示例,现在要查询date为2023-04-12之后的、textandkeyword为“你好吗”的文档,可以使用两种方式来拼接条件:

GET /testindex/_search
{
  "profile": "true", 
  "query": {
    "bool": {
      "must": [
        {
          "term": {
            "textandkeyword.keyword": {
              "value": "你好吗"
            }
          }
        },
        {
          "range": {
            "datetime": {
              "gte": "2023-04-12"
            }
          }
        }
      ]
    }
  }
}

GET /testindex/_search
{
  "profile": "true", 
  "query": {
    "bool": {
      "must": [
        {
          "term": {
            "textandkeyword.keyword": {
              "value": "你好吗"
            }
          }
        }
      ],
      "filter": {
        "range": {
          "datetime": {
            "gte": "2023-04-12"
          }
        }
      }
    }
  }
}

        开启profile来查看一下两种方式的执行计划和耗时,可以看到不使用filter和使用filter的耗时相差确实很大。首先是因为filter不需要计算分数,满足条件就过不满足就爬;其次是常用过滤器会被缓存,但是第一次查询可能看不出效果,甚至must查询可能快于filter,但是第二次使用该filter条件时速度就会全方位领先。

不使用:
"time_in_nanos" : 303220

使用:
"time_in_nanos" : 166729

3.3.6 size/scroll 分页查询

        分页查询是非常常用的功能,用户也不想一次性看一万条记录。ES提供了两种分页方式,一种是from + size分页查询,一种是scroll滚动查询

        最常用的是用from + size,类似于SQL中的“LIMIT offset, rows”,from是开始读取的位置,size是需要读取的条数。from默认0,size默认10,意思是返回查询到的前10条,用起来还是比较简单方便的,但是存在几个问题。

        这里就要先介绍两个知识点,深分页ES的分页机制。拿MySQL的深分页问题举例,偏移量小的时候效率还是较高的,比如“LIMIT 100, 100”取第100到第200条数据,只需要查出200条再截取后100条返回。但是这种查询方式其实埋了个大雷,如果是“LIMIT 1000000, 100”,就意味着要查出1000100条记录再取后100条,服务端CPU要持续查,再一股脑塞进内存中。

        知道深分页问题后,再了解一下ES分页机制,MySQL的查询是单机查询,一张表的记录只会从一台服务器的磁盘中读取;而ES就不一样了,ES是一个分布式搜索引擎,索引会被分片并存储在不同的服务器上。他遇到分页查询请求时,会从所有服务器的分片中获取符合查询条件的文档,再根据分页参数获取目标条数的文档,最后合并、排序、截取所需文档。

        这样说可能还是不太明晰,来模拟一下ES的分页查询过程:

  • 索引主分片数为4,分布在4台机器上。
  • 构建查询条件,分页参数,如term查询 + from 10, size 10。
  • 在4个分片中查询符合term条件的文档,并选取前10 + 10 = 20条。因为分页参数为从第10条开始向后取10条,因此需要查询20条才能满足。
  • 合并到某一主节点进行排序,再取前20条。
  • 根据分页参数,从第10条开始截取后10条文档。

        了解了整个过程以后,我们来算算一共取了多少条文档。4个节点每个取20条一共80条,排序后再取10条。现在看起来这个数字并不大,如果from是100000,就起码要获取400000条,显然内存很容易会被打满,且每个分片传这么一堆文档网络开销也是巨大的,更不用说CPU哼哧哼哧搁那查了。

        ES也深谙其道,你这分页参数大了我不得死啊?因此限制了from不能大于10000,你往10000条文档以后分页他就认为你在深分页,你别分了我不让。但万一我就要是10000条以后的数据,你总不能不让我看吧?当然也是有解决方案的。

        一种是使用索引属性“index.max_result_window”解除限制,强行提高结果窗口最大值,默认不10000吗?我就给你整个100000。但是治标不治本,看是能看到了,但你也没考虑过服务器的死活,我们肯定是要寻找一种更美丽的方式的。

        scroll滚动查询就出现了,这种查询方式和之前文章介绍的游标查询比较类似,就是指定一个类似fetchSize的值,服务端查好放那客户端分批取。scroll就更智能了,在你首次发起滚动查询时,会将所有符合条件的文档的id存放在内存中,再根据设置的size每次返回一部分给你,返回一次游标往后滚动一点,已经返回给你的文档id就被移除掉。且scroll还设置有过期时间,在一定时间没有使用该scroll且没有续期后,就会自动移除该scroll上下文来释放内存。

         那有人就会问了,那这scroll也挺占吃内存啊,听起来效率也不是很高啊?再来和from + size方式对比一下,from + size每次查询都是一次独立的查询,意味着你翻10次页,同样的查询条件会重复10次,且获取的文档数会随着页数变深指数级变大;而scroll存放在内存中的是所有符合条件的文档id,那么只要你使用的是同一个scroll且他没有过期,每次向后滚动只会拿到id去索引里查对应文档。文档和单个id占用的内存大小自然是文档占用大,每次都重复查询term这个动作scroll也省去了,而且用id查询的效率自然是极高的——想想MySQL聚集索引和非聚集索引的区别,非聚集索引要回表和聚集索引不用,直接取就是对应记录。

        这么一对比差距就很明显了吧?所以在大数据量查询场景下,我们最好是使用scroll滚动查询,一般用户的分页还是用from + size,因为scroll并不能支持指定页数的查询,只能一直滚啊滚。

        scroll的使用方法就是在第一次查询时,在查询命令后面加上“?scroll=10m”,意为这次查询需要使用滚动查询,且过期时间为10分钟;方法体内指定每次查询条数size(size不可以超过10000,也就是查询窗口最大值),发起请求后会响应相对数量的文档,并额外返回一个“_scroll_id”,下次查询直接用这个id进行查询,便可以在该scroll未过期、且数据未查询完以前一直滚动。不过记得要续期哦,不然scroll过期了而你还没查完,他就不见了。

//传统分页
GET /testindex/_search
{
  "query": {
    "match_all": {
    }
  },
  "from": 0,
  "size": 100,
  "sort": [
    {
      "datetime": {
        "order": "asc"
      }
    }
  ]
}

//滚动查询1,返回了:
//"_scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAA88Wdjl2dW9EZzlTTHVoX3BfbzdvR0NFQQ=="
GET /testindex/_search?scroll=10m
{
  "query": {
    "match_all": {
    }
  },
  "size": 1,
  "sort": [
    {
      "datetime": {
        "order": "asc"
      }
    }
  ]
}

//滚动查询2,直接使用该scroll并续期
GET /_search/scroll
{
  "scroll": "10m",
  "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAA88Wdjl2dW9EZzlTTHVoX3BfbzdvR0NFQQ=="
}

3.4 聚合 Aggregation

        基本的增删改查说完了,还记得我们最开始说ES是搜索和数据分析引擎,现在光看到搜索了没看到数据分析,而聚合就是数据分析功能。聚合类似于MySQL的GROUP BY + 各种计算函数(sum/max/min等),对符合条件文档对应字段的值、或是脚本计算后的结果(比如一条文档中两个字段的值相加、某个字段的值乘以2倍、多个字段值求平均值)进行计算和分析。但是不建议用自定义脚本,效率很低容易给自己挖坑。常用的聚合被分为四大类:指标聚合、桶聚合、矩阵聚合和管道聚合,矩阵聚合被ES官方标记为实验性功能,未来可能会被更改或删除,因此不作介绍;管道聚合是对已计算出聚合结果的增强,属于高阶应用此篇不作介绍。本文仅介绍常用的指标聚合和桶聚合

        指标聚合主要作用于Number类型字段,一般用于计算和统计数据,如求最大值、最小值、平均值等。拿求最大值举例,对索引的“price”字段求中Max聚合,对应到SQL语句就是“SELECT MAX(price) FROM `stuff_info` GROUP BY stuff_type”,简单解释一下就是根据商品种类,求每个种类价格的最大值。

        桶聚合作用就不太一样了,顾名思义桶聚合会将文档放进一个一个桶,有几个桶、每个桶放怎么样的文档,就要看使用哪种桶聚合、根据哪些查询条件了。

        先来看看聚合的基本语法,从最外层向最内层介绍:

GET /indexname/_search
{
  "aggs": {
    "custom_name1": {
      "agg_type": {
        "field": "column_name1"
      }
    },
    "custom_name2": {
      "agg_type": {
        "field": "column_name2"
      }
    }
  }
}
  • 最外层的“aggs”是必须加的,表示该键值对里面的内容为聚合计算。
  • 然后是“custom_name1”和“custom_name2”,意为自定义的聚合结果名称,因为我们可能会进行多个聚合运算,返回结果时需要显示名称。
  • “agg_type”就是ES提供得各种聚合,例如sum/min/max等,我们要告诉ES我们要使用哪种聚合功能嘛。
  • “field”内为需要做聚合的字段名称。

        上面介绍得是最基础的聚合语法,还可以在聚合结果内再次聚合,例如我们先用Term聚合把每个商户文档塞到各自的桶里,再用Sum聚合求商户总营业额。

        下面介绍常用的指标聚合和桶聚合。

3.4.1 最大值 Max

        最大值聚合,求指定字段中的最大值。

3.4.2 最小值 Min

        最小值聚合,求指定字段中的最小值。

3.4.3 平均值 Avg

        平均值聚合,求指定字段的平均值。可以通过指定“missing”属性来设置默认值,该字段没有值的文档会使用设置得默认值。

3.4.4 求和 Sum

        求和聚合,求指定字段值的总和。

3.4.5 求文档数 Value Count

        求文档数聚合,这个听起来比较抽象,其实就是求指定字段有值的文档数。比如有些文档有“sexual”字段值,有些没有,计算sexual字段的Value Count,就可以计算出有该字段的文档数量。

3.4.6 去重统计 Cardinality

        去重统计聚合,先对指定字段去重,再计算字段共有多少种值。

3.4.7 基本数据统计 Stats(Statistics)

        基本数据统计聚合,能够一次计算出该字段的max/min/avg/sum/count并返回。

3.4.8 拓展数据统计 Extended Stats

        扩展数据统计聚合,在基本数据统计的基础上增加了sum_of_squares(平方和)、variance(方差)、std_deviation(标准差)、std_deviation_bounds(平均值加/减两个标准差的区间)。

3.4.9 百分位统计 Percentiles

        百分位统计聚合,会先将字段值进行DESC排序,并计算对应百分位的数据大小。

        如统计学生成绩score字段,排序后发现记录100%处的分数为60分,50%处为80分,10%处为90分。这意味着100%的人达到了60及格线,50%的人能达到80分以上,仅有10%的人能获取90分以上。这就是百分位统计的含义,默认百分位为1.0/5.0/25.0/50.0/75.0/95.0/99.0%,也可以设置“percents”: [50, 100],来指定查看百分位。

3.4.10 百分位排名统计 Percentiles Ranks

        百分位排名统计聚合,和上面那位正好相反,上面是给出百分比,返回百分比所处的数据;这个是给出数据,返回数据所处的百分比。

        比如“您的等级已超越80%用户!”,这句话眼熟吧?我们现在的等级“Level”为80,通过Percentiles Ranks就可以计算80在Level这个字段中属于什么百分位。输入80,返回20.00,说明我们处于前20%,超越了80%用户。

        常用指标聚合到这就介绍完了,下面来介绍桶聚合

3.4.11 词频聚合 Terms

        统计对应字段词频,每个词对应一个桶,每次遇到对应的词就扔进对应的桶,最后根据桶数量从大到小返回前10个桶的词频大小。可以通过设置size控制返回桶数量的大小,还可以设置order来自定义文档排序规则,默认为“_count”从大到小排序。

3.4.12 过滤器聚合 Filter/Filters

        过滤器聚合和bool查询中的filter差不多,可以把符合条件的文档放进一个桶里,也可以设置多个过滤器对应多个查询条件,将文档放在多个桶里。

3.4.13 范围聚合 Range

        范围聚合类似于range查询,可以查询对应字段对应范围内的文档,并放在该范围的桶中,可以同时创建多个桶并设置“from + to”(注意是左闭右开哈),符合条件的文档就会放进对应的桶并返回。

3.4.14 缺失值聚合 Missing

        用于统计该字段没有值文档的数量,比如排查数据时,有些字段本不应为null却出现了没有值的异常现象,就可以通过该聚合排查这种异常现象出现的场景和频次。

3.4.15 命中文档聚合 Top Hits

        Top Hits也是个很好用的聚合,来看这样一种场景,我们需要分析销量最高商品品类的记录,那首先需要用Terms聚合,对商品品类字段进行词频统计,计算出出现频率最高的品类为“食品”。但是Terms聚合只会返回词频,不会返回其他任何信息,而我们得拿到食品类中一部分信息进行分析。

        此时就有几个方案,如再次嵌套一个其他类型聚合,或者直接拿到这个桶里的文档。那显然直接拿文档在程序中分析比较方便直观,这时就可以用到Top Hits聚合,该聚合会直接返回桶里的文档,我们可以指定返回前1000条拿出来分析,是不是很好使。

        下面这个例子为获取词频最高记录下所有文档。

GET /user/_search
{
  "aggs": {
    "agg1": {
      "terms": {
        "field": "location.keyword",
        "size": 1,
        "order": {
          "_count": "desc"
        }
      },
      "aggs": {
        "agg1inside": {
          "top_hits": {
            "size": 10
          }
        }
      }
    }
  }
}

3.5 多余字段的删除

        记得我们前面提到的ES会对字段进行动态映射,插入不存在字段时会自动创建该字段并进行动态映射,但误操作创建得字段总不能就在那放着吧,前文提到ES的索引是不支持删除字段的,只能用曲线救国的方式来删掉多余的字段。需要以下几步:

  1. 创建一个副本索引myindex_temp,使用正确的索引设置和映射。
  2. 使用脚本删除原索引myindex中,误操作新增字段所有的值。如新增了wrong_column,则需要删除所有文档wrong_column对应的数据,因为如不删除,备份索引数据时会再次插入该错误的字段。
  3. 使用reindex将myindex中的数据同步到myindex_temp中,此时myindex_temp便有着正确的映射结构和文档数据。
  4. 删除myindex,创建一个新的myindex并使用正确的索引设置和映射。
  5. 将myindex_temp中的数据同步到myindex中,此时myindex便有着正确的映射结构和文档数据,问题解决了。

        其实第4步也可以不用那么麻烦,直接给myindex_temp设置别名,即可当原来的索引库使用,可以节省一次数据同步的过程,毕竟索引库很大的话同步也是需要时间的,但这种方式仍然容易混淆,看大家如何考量了。使用这种方法也是无奈之举,ES严格要求了不允许删除字段,因此还是插入文档得时候多注意吧。

//删除多余字段的值
POST /test1/_update_by_query
{
  "script": "ctx._source.remove('{wrong_column}')",
  "query": {
      "bool": {
          "must": [
              {
                "exists": {
                    "field": "wrong_column"
                }
              }
          ]
      }
  }
}

//同步数据
POST /_reindex
{
  "source": {
    "index": "test1"
  },
  "dest": {
    "index": "test1_temp"
  }
}

4 整合SpringBoot

        上面我们已经介绍完了ElasticSearch所有的基础操作,所有的操作都是在Kibana里,最终肯定要将其整合进我们的Web应用中。ElasticSearch的集成主要使用到了ES的高阶客户端“elasticsearch-rest-high-level-client”,在Maven中引入下面的依赖即可,一定要与所使用的ES版本一致

<!-- ES的高阶的客户端API --> <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <version>7.5.0</version> </dependency>

         得益于SpringBoot的自动配置,只需在yml文件中指定ES服务的ip地址即可以集群模式连接,但我们只有一台服务器,只配置一个就好。

spring: elasticsearch: rest: uris: http://192.168.8.8:9200

        配置完就可以愉快地使用RestHighLevelClient操作ES服务器了!通过下面的使用,可以发现其实这个客户端也是在帮助大家组装DSL语句,这使得发出一个完整的命令就如同使用Kibana编写DSL语句一样丝滑。按照上文介绍的ES使用方法,我们用RestHighLevelClient重新实现一次。

4.1 创建索引

        所有操作进行之前,都要先引入RestHighLevelClient!因此我们先注入他。

@Autowired
    private RestHighLevelClient client;

        想想之前使用语句是如何创建索引的:

指定索引名称 -> 设置索引属性 -> 创建映射 -> 创建完成 

        在高级客户端中也不例外,所有的操作都是先创建请求、构建请求体、使用客户端发送请求、接收响应结果,无非是不同的操作对应不同的请求方式,下面我们便要介绍创建索引使用得请求类型“CreateIndexRequest”

        由于绝大部分操作都要落到对应的索引库上,因此请求类需要设置索引库名称,可以通过构造方法指定,也可以通过调用方法来指定。在初始化CreateIndexRequest时便指定了待创建索引的名称,接下来则需要指定索引属性,手动设置主分片数和副本分片数;ES提供了快捷构造Settings的方法,其内部实现其实就是TreeMap,放置属性对应的键值对即可,在此不作赘述。

        下面是创建映射,其实也没什么难点,整个过程就是在构建请求体的json字符串,Kibana里怎么写这里还怎么写就行,有兴趣的小伙伴可以debug一下代码,看看整个请求体构建得过程和参数。要记得告诉客户端你使用得参数类型供解析,我们这里为“XContentType.JSON”

        看看代码实现:

@PostMapping("/createIndex")
    public Result<?> insertUserDetail(@RequestBody JSONObject json) {
        if (json.isEmpty()) {
            return Result.error("请指定索引信息");
        }
        if (Strings.isNullOrEmpty(json.getString("shards"))) {
            return Result.error("请自定义分片信息");
        }
        if (Strings.isNullOrEmpty(json.getString("replica"))) {
            return Result.error("请自定义分片信息");
        }
        String indexName = json.getString("indexName");
        if (Strings.isNullOrEmpty(indexName)) {
            return Result.error("请设置索引名称");
        }

        //创建请求
        CreateIndexRequest request = new CreateIndexRequest(indexName);

        //配置分片信息
        Settings setting = Settings.builder()
                .put("index.number_of_shards", 1)
                .put("index.number_of_replicas", 1)
                //指定索引默认分词器
                //.put("index.analysis.analyzer.default.type", "ik_max_word");
                .build();
        request.settings(setting);

        //配置映射信息
        String mappingString = json.fluentRemove("shards")
                .fluentRemove("replica")
                .fluentRemove("indexName")
                .toString();
        request.mapping(mappingString, XContentType.JSON);
        //这种方法在组装映射属性时太复杂,不推荐
        //LinkedHashMap<String, Object> map = Maps.newLinkedHashMap();
        //json.entrySet().forEach(a -> {
        //    String key = a.getKey();
        //    String value = a.getValue().toString();
        //    map.put(key, value);
        //});

        try {
            CreateIndexResponse response = client.indices().create(request, RequestOptions.DEFAULT);
            boolean acknowledged = response.isAcknowledged();
            boolean shardsAcknowledged = response.isShardsAcknowledged();
            //boolean fragment = response.isFragment();
            if (acknowledged && shardsAcknowledged) {
                return Result.ok("ok");
            } else {
                return Result.error("创建索引出现异常");
            }
        } catch (IOException e) {
            log.error("创建索引 {} 出现异常: {}", indexName, e);
            return Result.error("创建索引出现异常");
        }

         上述代码还包括了入参校验等,整个过程清晰明了,就是在创建对应请求 -> 构建请求体 -> 利用客户端发送请求 -> 获取响应,最后在响应中获取一下是否创建成功即可。唯一需要注意的是使用客户端对应的操作,这个也好理解。

创建索引:client.indices().create()

插入文档:client.index()

查询文档:client.search()

         我们写好请求体利用Postman发送请求:

{
  "indexName": "test1",
  "shards": 1,
  "replica": 1,
  "properties": {
    "username": {
      "type": "keyword",
      "index": true
    },
    "sexual": {
      "type": "short",
      "index": false
    },
    "location": {
      "type": "text",
      "index": true,
      "analyzer": "ik_smart",
      "fields": {
        "keyword": {
          "type": "keyword",
          "index": true
        }
      }
    },
    "phonenumber": {
      "type": "keyword"
    }
  }
}

        发送请求后返回了个ok,再去Kibana看看索引信息,一点毛病没有。

{
  "test1" : {
    "aliases" : { },
    "mappings" : {
      "properties" : {
        "location" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword"
            }
          },
          "analyzer" : "ik_smart"
        },
        "phonenumber" : {
          "type" : "keyword"
        },
        "sexual" : {
          "type" : "short",
          "index" : false
        },
        "username" : {
          "type" : "keyword"
        }
      }
    },
    "settings" : {
      "index" : {
        "creation_date" : "1681680443532",
        "number_of_shards" : "1",
        "number_of_replicas" : "1",
        "uuid" : "EGJn10oKQGetDwrK_spIiw",
        "version" : {
          "created" : "7050099"
        },
        "provided_name" : "test1"
      }
    }
  }
}

4.2 插入/修改/删除文档

         插入文档对应的是IndexRequest,指定索引库名称,构建插入文档json,想指定文档id就调用id()方法传入,不想就让客户端自动生成。

        修改和删除文档的请求是UpdateRequest和DeleteRequest,就是这么简单。修改指的是增量修改,覆盖修改和插入文档操作一致,指定被覆盖的文档id即可;指定id和需要修改的字段doc,这个doc就是在构建IndexRequest,其中包含了需要修改字段的键值对。删除则只需传入文档id即可。

@PostMapping("/insertUserDetail")
    public Result<?> insertUserDetail(@RequestBody EsUser user) {
        //指定索引库名称进行操作
        IndexRequest indexRequest = new IndexRequest("user");
        indexRequest.source(JSONObject.toJSONString(user), XContentType.JSON);

        //更新文档
        //UpdateRequest updateRequest = new UpdateRequest();
        //updateRequest.id(id);
        //updateRequest.doc();
        //client.update(updateRequest, RequestOptions.DEFAULT);

        try {
            client.index(indexRequest, RequestOptions.DEFAULT);
        } catch (IOException e) {
            log.error("插入user索引出现异常: {}", e);
            return Result.error("插入user索引出现异常");
        }

        return Result.ok("ok");
    }

4.3 查询文档

        查询文档使用SearchRequest,和其他操作唯一的不同点就是要构建查询条件,说到底其实也是用ES API来组装条件json。整个过程就是创建查询请求、构建查询条件、发送请求、获取响应,是不是很眼熟?所以说RestHighLevelClient的使用很丝滑便捷,所有请求的结构都是一致的。

        用match_all查询一下索引中所有的文档,先创建查询文档请求并指定索引、构建查询源、构建match_all查询条件、将查询条件传入查询源、将查询源传入查询请求、使用客户端发送请求。整个过程用代码实现一下:

@PostMapping("/queryAllUser")
    public Result<?> queryAllUser() {
        //指定索引库名称进行操作
        SearchRequest searchRequest = new SearchRequest("user");

        //组装查询条件并赋值
        SearchSourceBuilder search = new SearchSourceBuilder();
        //match_all
        //MatchAllQueryBuilder builder1 = QueryBuilders.matchAllQuery();
        MatchAllQueryBuilder builder = new MatchAllQueryBuilder();
        search.query(builder);
        searchRequest.source(search);

        try {
            SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
            SearchHits hits = response.getHits();
            HashMap<String, Object> resMap = Maps.newHashMapWithExpectedSize(2);
            LinkedList<EsUser> resList = Lists.newLinkedList();

            for (SearchHit hit : hits.getHits()) {
                String str = hit.getSourceAsString();
                log.info("hit: {}", str);
                EsUser esUser = JSONObject.parseObject(str, EsUser.class);
                resList.add(esUser);
            }

            resMap.put("count", hits.getTotalHits());
            resMap.put("data", resList);
            return Result.ok(resMap);
        } catch (IOException e) {
            log.error("查询user索引出现异常: {}", e);
            return Result.error("查询user索引出现异常");
        }
    }

        match_all方法的构建可以使用QueryBuilders建造器或者直接new,建造器里集成了所有查询方法,使用起来方便好记一些。构建好MatchAllQueryBuilder后传入SearchSourceBuilder,查询条件便组装好了,将组装后的查询条件传给查询请求,就可以获取到相应结果。

        response中我们可以获取很多有用的信息,最重要的便是查询命中文档SearchHits,从其中可以拿到命中文档列表SearchHit[],遍历该列表就可以获取命中文档。获取json字符串形式的文档source并解析成实体类,再想怎么使用就怎么使用吧。

        再来构建个复杂一点的查询方法,我们构建个bool查询方法,创建terms查询并作为过滤器传入bool。但是看到代码大家会发现,整个逻辑也就那么回事,就是把json转换成调用API。

SearchRequest request = new SearchRequest();
        request.indices("user");

        SearchSourceBuilder builder = new SearchSourceBuilder();
        //filter条件构建
        BoolQueryBuilder bool = new BoolQueryBuilder();
        TermsQueryBuilder terms = new TermsQueryBuilder("location", locations);
        bool.filter(terms);
        builder.query(bool);
        request.source(builder);

         整了这么些我们把Kibana请求写法和API写法对比一下,先写一个完整的请求体,包括了查询方法、分页参数、排序方法。

GET /testindex/_search
{
  "query": {
    "match_all": {
    }
  },
  "from": 0,
  "size": 100,
  "sort": [
    {
      "datetime":{
        "order": "asc"
      }
    }
  ]
}

        再对应到高阶客户端API。

//指定索引库名称进行操作
        SearchRequest searchRequest = new SearchRequest("user");

        //组装查询条件并赋值
        SearchSourceBuilder search = new SearchSourceBuilder();

        MatchAllQueryBuilder builder = new MatchAllQueryBuilder();
        search.query(builder);
        //分页参数
        Integer pageSize = json.getInteger("pageSize");
        Integer pageNo = (json.getInteger("pageNo") - 1) * pageSize;
        search.from(pageNo).size(pageSize);
        search.sort("datetime", SortOrder.DESC);
        searchRequest.source(search);

         对比一下得出了以下几点:

  1. GET /indexName/_search等各种索引操作类型,对应了SearchRequest的创建,创建了操作类型和操作索引。
  2. 最外层大括号,即整个请求体,对应了SearchSourceBuilder。
  3. 查询方法、分页参数、排序规则等的创建,即是在填充SearchSourceBuilder。
  4. 具体的查询方法,例如match_all,对应了MatchAllQueryBuilder等一众查询方法。

        你能想到ElasticSearch的所有操作,都能用RestHighLevelClient实现!以后再构建请求时只需要记住,把该加的东西加在正确的地方。那我们再举一反三一下,用滚动查询时应该如何构建?Kibana里指定滚动查询是在GET方法后面加上?scroll=10m,刚刚说指定请求是使用SearchRequest,其中也确实有个scroll()方法来指定滚动查询生效时间,可以说是一通百通了。

//创建游标查询,指定存活时间
searchRequest.scroll(new Scroll(new TimeValue(10, TimeUnit.MINUTES)));

4.4 聚合

        最后来介绍聚合在高阶客户端中的实现,和查询流程其实差不多,也是构建聚合再传入,主要讲讲如何获取聚合结果。我们先构建个Terms聚合,从response中获取聚合结果集合看看。

//bucket聚合构建,词频统计
        TermsAggregationBuilder agg = AggregationBuilders.terms("location").field("location.keyword");
        builder.aggregation(agg);
        request.source(builder);

        //获取聚合结果
        List<Aggregation> aggregations = response.getAggregations().asList();

        试着遍历这个集合,你会发现没法从里面的Aggregation获取任何有用的信息。这是为啥?由于我们可能会创建很多个聚合,而聚合又有毛毛多的种类,ES显然不愿意每个聚合类型都提供一个GET方法,而是鼓励大家获取每个聚合结果后自行作类型转换——首先是因为Aggregation是所有聚合类型的父类,直接转换不会出现编译错误。其次是这种方式胜在操作者心知肚明,根据自定义的聚合名称获取聚合,再转换成使用得聚合类型,风险是相对较小的;假定你使用了Terms聚合,却不小心使用了比如getSum()(不存在这个方法!!!是虚构的!!!)获取了Sum聚合,编译期不会出现问题,在获取结果时因为我们使用的聚合根本不是Sum,运行时就可能会引起bug,最好还是将bug暴露在编译期哈。

ES springboot 地理位置搜索 springboot整合es常用查询_搜索引擎_02

         举个栗子,我们使用Terms聚合来统计地区词频,如果传入location参数统计指定地区、不传入则统计所有地区。即下面这段代码逻辑,如果指定地区就构建filter并传入,先查询再对查询结果进行聚合。

//如指定地区则返回指定地区
        //未指定则返回所有地区
        String location = json.getString("location");
        if (!Strings.isNullOrEmpty(location)) {
            log.info("查询指定地区");
            BoolQueryBuilder bool = new BoolQueryBuilder();
            TermQueryBuilder term = new TermQueryBuilder("location.keyword", location);
            bool.filter(term);
            builder.query(bool);
        }

        Terms聚合结果里有许多个桶Bucket,Bucket里存放了字段名和词频,我们构建Terms聚合并获取结果组装返回。

//bucket聚合构建,词频统计
        TermsAggregationBuilder agg = AggregationBuilders.terms("location").field("location.keyword");
        builder.aggregation(agg);
        request.source(builder);


//获取结果
        Terms terms = (Terms) aggregations.get("location");
        bucketMap = Maps.newHashMapWithExpectedSize(10);
        for (Terms.Bucket bucket : terms.getBuckets()) {
            bucketMap.put(bucket.getKeyAsString(), bucket.getDocCount());
        }
        aggList.put(terms.getName(), bucketMap);

        最后的返回值如下:

"aggs": {
            "location": {
                "陕西省西安市": "2",
                "广州省深圳市": "1",
                "天津市": "1",
                "北京市": "1",
                "影分身": "1",
                "湖北省武汉市": "1"
            }
        }

        

        至此ES基础、高级特性及整合SpringBoot圆满完结了,相信你已经掌握了ES的基本原理、基本操作、SpringBoot高级客户端的整合,其实还有很多知识点可以讲,包括强大的脚本功能(脚本确实是一把双刃剑,效率低下但用起来很爽,可以突破ES提供的基础DSL语法,利用Groovy自定义查询、算分、插入逻辑)、分片策略(多主分片和副本分片如何合理分布在集群内不同机器上,实现索引的高可用)、路由策略(新增文档时如何指定新增到哪台机器上、查询时如何根据路由实现快速查询)、性能调优等技巧,本人学艺不精就不卖弄了,以后学到了再跟大家分享。