之前给服务器安好了ES和Kibana一直没来得及动手试试,这篇就系统性地介绍一下ES的基本使用和特性,以及如何将其与SpringBoot整合。
本文基于ElasticSearch 7.5.0 + Kibana 7.5.0,版本一定要一致!
目录
2.1 ElasticSearch/Kibana/分词器的安装与部署
2.3.2 byte/short/integer/long + half_float/float/double
3.4.7 基本数据统计 Stats(Statistics)
3.4.10 百分位排名统计 Percentiles Ranks
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;然后你会惊奇的发现:

成功了!我事先是没有创建索引的,也可以看到结果的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
分页查询是非常常用的功能,用户也不想一次性看一万条记录。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的索引是不支持删除字段的,只能用曲线救国的方式来删掉多余的字段。需要以下几步:
- 创建一个副本索引myindex_temp,使用正确的索引设置和映射。
- 使用脚本删除原索引myindex中,误操作新增字段所有的值。如新增了wrong_column,则需要删除所有文档wrong_column对应的数据,因为如不删除,备份索引数据时会再次插入该错误的字段。
- 使用reindex将myindex中的数据同步到myindex_temp中,此时myindex_temp便有着正确的映射结构和文档数据。
- 删除myindex,创建一个新的myindex并使用正确的索引设置和映射。
- 将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);
对比一下得出了以下几点:
- GET /indexName/_search等各种索引操作类型,对应了SearchRequest的创建,创建了操作类型和操作索引。
- 最外层大括号,即整个请求体,对应了SearchSourceBuilder。
- 查询方法、分页参数、排序规则等的创建,即是在填充SearchSourceBuilder。
- 具体的查询方法,例如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暴露在编译期哈。

举个栗子,我们使用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自定义查询、算分、插入逻辑)、分片策略(多主分片和副本分片如何合理分布在集群内不同机器上,实现索引的高可用)、路由策略(新增文档时如何指定新增到哪台机器上、查询时如何根据路由实现快速查询)、性能调优等技巧,本人学艺不精就不卖弄了,以后学到了再跟大家分享。
原创文章,作者:优速盾-小U,如若转载,请注明出处:https://www.cdnb.net/bbs/archives/31003