1.初识Elasticsearch

什么是elasticsearch

elasticsearch是一款非常强大的开源搜索引擎,可以帮助我们从海量数据中快速找到需要的内容

elasticsearch结合kibana、Logstash、 Beats, 也就是elastic stack(ELK)。被广泛应用在日志数据分析、实时监控等领域。

elasticsearch可以将日志信息可视化展示出来,所以将来做日志分析时候非常方便。因此搜索引擎使用的场景非常广泛,ELK技术栈里面尽管有很多组件,核心就是elasticsearch,它复制数据的存储、计算、搜索分析。而Logstash,Beats主要负责数据抓取的, Kibana是一个数据可视化组件,用于数据展示,形成报表。但是可视化组件是否必须使用kibana?不一定,完全可以自己去实现。数据抓取也同理,完全可以自己去写java代码,抓取数据。所以,除了elasticsearch以外,都是可替代的。

elasticsearch的底层实现是一个名为Lucene的技术。

Lucene是- -个Java语言的搜索引擎类库,是Apache公司的顶级项目,由DougCutting于1999年研发
官网地址: https://lucene.apache.org/。

Lucene的优势:易扩展,高性能(基于倒排索引)

Lucene的缺点:只限于java语言开发,学习难,不支持水平扩展。不支持高并发场景,不支持集群扩展。所以要实现必须进行二次开发。

elasticsearch的发展

2004年Shay Banon基于Lucene开发了Compassp

2010年Shay Banon重写了Compass,取名为Elasticsearch。

官网地址: https://www.elastic.co/cn/

目前最新的版本是: 7.12.1

相比与lucene, elasticsearch具备下列优势:

  1. 支持分布式,可水平扩展.
  2. 提供Restful接口,可被任何语言调用

因此具备了处理海量数据和高并发场景的能力。

为什么学习elasticsearch?

搜索引擎技术排名:

  1. Elasticsearch:开源的分布式搜索引擎
  2. Splunk: 商业项目
  3. Solr: Apache的开源搜索引擎

总结

什么是elasticsearch?

一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能

什么是elastic stack (ELK) ?

是以elasticsearch为核心的技术栈 ,包括beats、Logstash.kibana、elasticsearch

什么是Lucene?

是Apache的开源搜索引擎类库, 提供了搜索引擎的核心API

2.倒排索引

倒排索引是与mysql等传统数据库的正向索引去对比得出的一个名称,因此与传统数据库的索引是有比较大的差异的。

这个差异我们通过一个案例来看一下。

正向索引和倒排索引

传统数据库( 如MySQL)采用正向索引,例如给下表(tb_ goods)中的id创建索引:

ES spring客户端_spring

一般情况下,会基于id去创建索引形成一颗B+树,检索的速度就会非常快,这种方式的索引就是一种正向索引,但是如果我现在搜索的不是id,而是一个普通的title字段,title内容比较长,你不会去给他加索引,而且即便你有索引,如果是模糊查询,索引也不会生效,这种情况下没有索引我们数据库怎么去比较和查询呢?就会采用逐条扫描的方式,如果你的表数据非常大,性能差,这就是正向索引,它进行局部内容索引的时候,效率比较差。那倒排索引又是怎么来做的呢?

elasticsearch采用倒排索引:

文档(document) : 每条数据就是一个文档

词条(term) : 文档按照语义分成的词语

比如说,小米是一个词条,手机也是一个词条,等等…

所以倒排索引,它会先把文档中的内容分成词条去存,比方说我拿到第一条数据,那么我要对标题创建倒排索引,我就要把标题做个分词,分成,小米、手机两个词,并记录它的文档id,因为是第一条数据,即文档id为1,存第二条的时候,手机已经存在词条,再记录一个文档id即可。

ES spring客户端_elasticsearch_02

将来你有更多的词条,继续往下记录即可,并且这些词条肯定有大量的重复,只记录唯一的一个词条。这样能保证倒排索引词条字段是绝对不会出现重复的。因为其唯一性,那么我们就可以给他创建索引了。数据较少的时候使用哈希法,也可以使用B+树,去给词条创建唯一索引。那么将来我们根据词条查询的速度,就非常快了。

现在比如说我搜索华为手机,elasticsearch会对用户输入的进行一段分词,分为华为、手机,拿着这两个词条去倒排索引中进行查询,这是非常快的,查到手机,1,2;查到华为2,3;两组文档ID。这个时候我就知道了包含华为手机的所有的文档了,其中2号文档的关联度更高一点,将来就可以排序,2号文档往前排,1和3往后一点。然后我拿着这三个id我就可以去查询文档了,查询id为1,2,3的正向索引,也会很快。

ES spring客户端_ES spring客户端_03

所以,我们其实经过了两次查询,但是两次查询都经过了索引,这个效率是比逐条查询效率高的。

倒排索引之所以叫倒排索引,因为我们在正向索引中,我们要找到包含手机的,我们得一行行的看,先看文档,再看是否包含词条。倒排索引是倒过来的,先去看词条,再去关联到文档。

倒排索引更擅长于基于文档的内容进行搜索,更复杂的搜索需求,这就是为什么我们的搜索引擎底层都是基于倒排索引的原因。

总结

什么是文档什么是词条?

  • 每一条数据就是一 个文档
  • 对文档中的内容分词,得到的词语就是词条

什么是正向索引?

  • 基于文档id创建索引。查询词条时必须先找到文档,而后判断是否包含词条

什么是倒排索引?

  • 对文档内容分词,对词条创建索引,并记录词条所在文档的信息。查询时先根据词条查询到文档id,而后获取到文档

3.ES与Mysql的概念对比

elasticsearch是面向文档存储的,可以是数据库中的一条商品数据,一个订单信息。

文档数据会被序列化为json格式后存储在elasticsearch中。

ES spring客户端_spring cloud_04

索引(Index)、映射(mapping)

索引(index) :相同类型的文档的集合

映射(mapping) :索引中文档的字段约束信息,类似表的结构约束

ES spring客户端_spring cloud_05

elasticsearch与mysql的概念对应关系

ES spring客户端_spring_06

SQL和DSL在发送时的差别

在ES中,你写好了DSL以后,我们是基于HTTP请求发出去,因为ES对外暴露的restful接口,这种接口的好处的跟语言无关。任何的语言只要能发HTTP请求,你都可以把你的DSL发给我,我就能处理了。这样以来就彻底脱离了语言的束缚了。

是不是说有了ES以后,我们就能完全替代了我们的Mysql了呢?不是,他们两个擅长的事情是不一样的

Mysql:擅长事务类型操作,可以确保数据的安全和一-致性

Elasticsearch:擅长海量数据的搜索、分析、计算

ES没有事务的概念,它无法保证ACID,所有他们两个是各司其职的,如果说你现在做的是下单付款的业务,它对事务要求很高,数据的安全性、一致性要求很高,你就应该使用mysql去做数据的存储。但是你现在做的是商品的搜索或者页面的搜索,这个搜索比较复杂,你肯定得用ES去做。【是一种互补的关系】

将来我们的系统架构当中,两个都会存在。

比方说用户来一个商品查询的CRUD,它的请求访问到服务器以后,我们的服务器就可以作出一个判断,如果是增删写操作,就给到mysql,这样数据就比较安全了。如果你现在是查询的操作,就给到ES去做。

那么怎么确保ES和mysql都有数据呢?

mysql可以使用某种方式,将数据同步给ES,从而实现数据的双写。

ES spring客户端_spring_07

总结

ES spring客户端_spring cloud_08

针对业务量比较大, 实现需求比较复杂的时候,才会考虑两个库里都去写。但一些简单的查询(根据ID查询)还是用数据库没问题的。

合适场景选择合适的技术

4.安装elasticsearch,kibana

4.1 部署单点ES

因为我们还需要部署kibana容器,因此需要让es和kibana容器互联。这里先创建一个网络:

docker network create es-net

网络名es-net可以任意取,在xshell输入,创建全新的网络。

输入结果:

[root@hadoop100 ~]# docker network create es-net
1394876ff1c3b5e2e0c7937daeb808324ed7be9ed8d09b781e0a8ef0bc689f31

这里我们采用elasticsearch的7.12.1版本的镜像,这个镜像体积非常大,接近1G。不建议大家自己pull。

课前资料提供了镜像的tar包:

ES spring客户端_ES spring客户端_09

大家将其上传到虚拟机中,然后运行命令加载即可:

# 导入数据
docker load -i es.tar
docker load -i kibana.tar

同理还有kibana的tar包也需要这样做。

加载情况

[root@hadoop100 software]# docker load -i es.tar 
2653d992f4ef: Loading layer [==================================================>]  216.5MB/216.5MB
0ba8eff8aa04: Loading layer [==================================================>]  101.4MB/101.4MB
2a944434ad00: Loading layer [==================================================>]  314.9kB/314.9kB
ade95a7611c0: Loading layer [==================================================>]  543.9MB/543.9MB
09a575a6e776: Loading layer [==================================================>]  26.62kB/26.62kB
498ae65924d7: Loading layer [==================================================>]   7.68kB/7.68kB
36b3f8db7aaa: Loading layer [==================================================>]  490.5kB/490.5kB
Loaded image: elasticsearch:7.12.1
[root@hadoop100 software]# docker load -i kibana.tar 
d797e87ed4ce: Loading layer [==================================================>]  112.9MB/112.9MB
80ce41fc1f8a: Loading layer [==================================================>]  26.62kB/26.62kB
3345a8ffd0ea: Loading layer [==================================================>]  3.584kB/3.584kB
d736a1702974: Loading layer [==================================================>]  20.34MB/20.34MB
570575469db2: Loading layer [==================================================>]  56.83kB/56.83kB
459d502a3562: Loading layer [==================================================>]  770.7MB/770.7MB
f22a9f0649d0: Loading layer [==================================================>]  2.048kB/2.048kB
4b66f24ba0de: Loading layer [==================================================>]  4.096kB/4.096kB
0a50faa06266: Loading layer [==================================================>]  15.36kB/15.36kB
8a310ff91413: Loading layer [==================================================>]  4.096kB/4.096kB
5997553ddc84: Loading layer [==================================================>]  479.2kB/479.2kB
f87dadd7c340: Loading layer [==================================================>]  309.8kB/309.8kB
Loaded image: kibana:7.12.1

运行docker命令,部署单点es:

docker run -d \
	--name es \
    -e "ES_JAVA_OPTS=-Xms1024m -Xmx1024m" \
    -e "discovery.type=single-node" \
    -v es-data:/usr/share/elasticsearch/data \
    -v es-plugins:/usr/share/elasticsearch/plugins \
    --privileged \
    --network es-net \
    -p 9200:9200 \
    -p 9300:9300 \
elasticsearch:7.12.1

命令解释:

  • -e "cluster.name=es-docker-cluster":设置集群名称
  • -e "http.host=0.0.0.0":监听的地址,可以外网访问
  • -e "ES_JAVA_OPTS=-Xms512m -Xmx512m":内存大小
  • -e "discovery.type=single-node":非集群模式
  • -v es-data:/usr/share/elasticsearch/data:挂载逻辑卷,绑定es的数据目录
  • -v es-logs:/usr/share/elasticsearch/logs:挂载逻辑卷,绑定es的日志目录
  • -v es-plugins:/usr/share/elasticsearch/plugins:挂载逻辑卷,绑定es的插件目录
  • --privileged:授予逻辑卷访问权
  • --network es-net :加入一个名为es-net的网络中
  • -p 9200:9200:端口映射配置,(暴露的Http协议的端口,将来供用户访问的)
  • -p 9300:9300:将来ES容器各个结点之间互联的端口。
  • elasticsearch:7.12.1 : 镜像名称

浏览器访问网站 http://192.168.10.100:9200/

ES spring客户端_微服务_10

ES安装就完成了!

安装Kibanna

kibana可以给我们提供一个elasticsearch的可视化界面,便于我们学习。

运行docker命令,部署kibana

docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=es-net \
-p 5601:5601  \
kibana:7.12.1
  • --network es-net :加入一个名为es-net的网络中,与elasticsearch在同一个网络中
  • -e ELASTICSEARCH_HOSTS=http://es:9200":设置elasticsearch的地址,因为kibana已经与elasticsearch在一个网络,因此可以用容器名直接访问elasticsearch
  • -p 5601:5601:端口映射配置

kibana启动一般比较慢,需要多等待一会,可以通过命令:

docker logs -f kibana

查看运行日志,当查看到下面的日志,说明成功:

ES spring客户端_elasticsearch_11

也就是这句话

{"type":"log","@timestamp":"2021-11-17T01:13:05+00:00","tags":["listening","info"],"pid":6,"message":"Server running at http://0.0.0.0:5601"}

此时,在浏览器输入地址访问:http://192.168.10.100:5601,即可看到结果

DevTools

kibana中提供了一个DevTools界面:

这个界面中可以编写DSL来操作elasticsearch。并且对DSL语句有自动补全功能。

ES spring客户端_spring cloud_12

4.2 安装IK分词器

分词器

es在创建倒排索引时需要对文档分词;在搜索时,需要对用户输入内容分词。但默认的分词规则对中文处理并不友好。我们在kibana的DevTools中测试:

英语分词还是可以的,但是中文确是逐字分词。如果要分词中文就不能使用默认分词器。一般中文分词我们会采用IK分词器。https://github.com/medcl/elasticsearch-analysis-ik

在线安装ik插件(较慢)

# 进入容器内部
docker exec -it elasticsearch /bin/bash

# 在线下载并安装
./bin/elasticsearch-plugin  install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip

#退出
exit
#重启容器
docker restart elasticsearch

离线安装ik插件(推荐)

1)查看数据卷目录

安装插件需要知道elasticsearch的plugins目录位置,而我们用了数据卷挂载,因此需要查看elasticsearch的数据卷目录,通过下面命令查看:

docker volume inspect es-plugins

显示结果:

[
    {
        "CreatedAt": "2022-05-06T10:06:34+08:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/es-plugins/_data",
        "Name": "es-plugins",
        "Options": null,
        "Scope": "local"
    }
]

说明plugins目录被挂载到了:/var/lib/docker/volumes/es-plugins/_data这个目录中。

下面我们需要把课前资料中的ik分词器解压缩,重命名为ik

传到es容器的插件数据卷中

也就是/var/lib/docker/volumes/es-plugins/_data

ES spring客户端_微服务_13

重启容器

# 4、重启容器
docker restart es
# 查看es日志
docker logs -f es

测试:

IK分词器包含两种模式:

  • ik_smart:最少切分 ( 粗粒度切分,从字数最多去到字数最少去看,比如说“程序员”是不会切分的)
  • ik_max_word:最细切分(细粒度切分,比如说“程序员”会切分成,“程序员”,“程序”,“员”)

这两种带来的后果是什么?如果搜程序,按第一种是搜不到这篇文档的,搜索的概率就比较低,好处是分的词少占用的内存空间就少了。到时内存就能缓存更多的数据,效率更高一点。这就是smart模式优势。max_word占用内存空间会更多。

底层分词的原理是什么?

字典,字典里会有各种各样的词语罗列好了。IK分词器还有其他的中文分词器,都会依赖于一个字典去做分词。它这个字典中可能会包含我们不希望的分词(如 “的”,“了”,“哦”),也有我们希望有的新潮词汇(如“奥利给”,“白嫖”)等。我们应该如何扩展呢?

4.3 IK分词器 - 扩展词库

要拓展ik分词器的词库,只需要修改一个ik分词器 目录中的config目录中的IkAnalyzer.cfg.xml文件:

ES spring客户端_spring cloud_14

要禁用某些敏感词条,只需要修改一个ik分词器目录中的config目录中的IkAnalyzer.cfg.xml文件:

ES spring客户端_spring_15

在ik分词器的config目录下修改以下3个文件

/var/lib/docker/volumes/es-plugins/_data/ik/config

ES spring客户端_spring cloud_16

其他更多的功能,参考官网文档

总结

ES spring客户端_elasticsearch_17

5.索引库操作

mapping属性

mapping是对索引库中文档的约束,常见的mapping属性包括:

官方手册:https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html

ES spring客户端_ES spring客户端_18

常用:

type

字段数据类型

字符串

text (可分词的文本)

keyword (精确值,例如:品牌、国家、ip地址)

如果字段不需要拆分,就用keyword

数值

long,integer,short,byte,double,float

布尔

boolean

日期

date

对象

object

index

是否创建索引

默认为true,主要取决于某个字段是否参与搜索

analyzer

使用哪种分词器

使用的较少,因为只有text需要分词,其他类型都不需要分词。

它的值就是分词器的名称(ik_smart,ik_max_word)

properties

该字段的子字段

处理"name":{ "firstName":"xx","lastName":"yy" }这种情况

总结:

ES spring客户端_elasticsearch_19

创建索引库

ES spring客户端_微服务_20

DSL

PUT /heima
{
  "mappings": {
    "properties": {
      "info":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "email":{
        "type": "keyword",
        "index": false
      },
      "name":{
        "properties": {
          "firstName":{
            "type":"keyword"
          },
          "lastName":{
            "type":"keyword"
          }
        }
      }
    }
  }
}

结果

{
  "acknowledged" : true,
  "shards_acknowledged" : true,
  "index" : "heima"
}

查询、删除索引库

ES spring客户端_spring cloud_21

修改索引库

事实上在ES中是不允许 修改的,因为索引库创建完成了以后,它的数据结构也就是mapping映射都已经定义好了,我们ES会基于这些mapping去创建倒排索引,那么如果说你要去修改一个字段,就会导致原有的倒排索引彻底失效。

【在ES里禁止修改索引库】

ES虽然禁止修改原有字段,但允许你添加新字段。

ES spring客户端_spring_22

# 查询
GET /heima

# 修改索引库,添加新字段
PUT /heima/_mapping
{
  "properties":{
    "age":{
      "type":"integer"
    }
  }
}

# 删除
DELETE /heima

总结:

ES spring客户端_spring cloud_23

6.文档的CRUD

添加文档

ES spring客户端_spring cloud_24

查看、删除文档

ES spring客户端_ES spring客户端_25

修改文档

ES spring客户端_spring cloud_26

所有DSL

# 插入文档
POST /heima/_doc/1
{
  "info":"黑马程序员java讲师",
  "email":"zy@123.com",
  "name":{
    "firstName":"y",
    "lastName":"z"
  }
}

# 查询文档
GET /heima/_doc/1

# 删除文档
DELETE /heima/_doc/1

# 修改文档方式1,文档存在的情况:updated
PUT /heima/_doc/1
{
  "info":"黑马程序员java讲师",
  "email":"zhaoyun@123.com",
  "name":{
    "firstName":"y",
    "lastName":"z"
  }
}

# 修改文档方式1,文档不存在的情况:created
PUT /heima/_doc/3
{
  "info":"黑马程序员java讲师",
  "email":"12312321313@123.com",
  "name":{
    "firstName":"y",
    "lastName":"z"
  }
}

# 修改文档方式2-局部修改 updated
POST /heima/_update/1
{
  "doc":{
      "email":"zzyy@123.com"
  }
}

总结:

ES spring客户端_spring cloud_27

7.RestClient操作索引库

什么是RestClient

ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。官方文档地址: https://www.elastic.co/guide/en/elasticsearch/client/index.html

案例

ES spring客户端_spring_28

ES spring客户端_elasticsearch_29

这个hotel-demo的application.yml默认的mysql地址 是 mysql:3306意味着连接的是虚拟机上docker的mysql,所以如果你是在windows机器上导入的sql文件,你应该改成localhost

分析数据结构

ES spring客户端_spring_30

# 酒店的mapping
PUT /hotel
{
  "mappings": {
    "properties": {
      "id":{
        "type": "keyword"
      },
      "name":{
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "address":{
        "type": "keyword",
        "index": false
      },
      "price":{
        "type":"integer"
      },
      "score":{
        "type":"integer"
      },
      "brand":{
        "type": "keyword"
      },
      "city":{
        "type": "keyword"
      },
      "starName":{
        "type": "keyword"
      },
      "business":{
        "type": "keyword"
      },
      "location":{
        "type":"geo_point"
      },
      "pic":{
        "type": "keyword",
        "index": false
      }
    }
  }
}

我是根据多个字段搜索效率高,还是只根据一个字段搜索效率高?显然是一个字段效率高。我现在需求是用户搜名称、搜品牌、搜地址都能搜到,而且我还希望性能好怎么办?

字段拷贝可以使用copy_to属性将当前字段拷贝到指定字段。示例:

你可以在一个字段里搜到多个字段的内容了,而且这种拷贝还做了优化,并不是把文档拷贝进去了,而只是基于它创建倒排索引,所有你将来查询是查不到这个字段的,但搜却可以根据其搜。

即:"copy_to": "all"

# 酒店的mapping
PUT /hotel
{
  "mappings": {
    "properties": {
      "id":{
        "type": "keyword"
      },
      "name":{
        "type": "text",
        "analyzer": "ik_max_word",
        "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": "ik_max_word"
      }
    }
  }
}

初始化RestClient

ES spring客户端_spring cloud_31

引入依赖:

<!--elasticsearch-->
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
    <version>7.12.1</version>
</dependency>

为什么有些依赖是7.6.2版本?

ES spring客户端_微服务_32

因为依赖被Springboot管理,所以想要覆盖Springboot的版本定义,找到自己的pom文件,在properties标签下定义

<elasticsearch.version>7.12.1</elasticsearch.version>

所以用springboot管理时,一定要去properties里指明版本。

那么这里的版本信息不写也行

<!--elasticsearch-->
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>

在test包下,创建测试类,以下是基本代码,包含了初始化与最终销毁的基本代码。(后面测试的代码都写在这个类中,并省略下面这段代码。)

package cn.itcast.hotel;

import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;

import java.io.IOException;

/**
 * 酒店索引测试
 * @author:whd
 * @createTime: 2021/11/17
 */
public class HotelIndexTest {
    private RestHighLevelClient client;

    /**
     * 在一开始就完成client的初始化
     */
    @BeforeEach
    void setUp() {
        //如果是集群,这里的HttpHost.create("http://192.168.10.100:9200")可以用逗号分割
        this.client = new RestHighLevelClient(RestClient.builder(
                HttpHost.create("http://192.168.10.100:9200")
        ));
    }

    /**
     * 用完后销毁
     */
    @AfterEach
    void tearDown() throws IOException {
        this.client.close();
    }
}

测试初始化

@Test
void testInit() {
    System.out.println("client = " + client);
}

ES spring客户端_elasticsearch_33

创建索引库

ES spring客户端_elasticsearch_34

这里将DSL语句定义在一个常量类里面

package cn.itcast.hotel.constants;

/**
 * @author:whd
 * @createTime: 2021/11/17
 */
public class HotelConstants {
    public static final String MAPPING_TEMPLATE = "{\n" +
            "  \"mappings\": {\n" +
            "    \"properties\": {\n" +
            "      \"id\":{\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"name\":{\n" +
            "        \"type\": \"text\",\n" +
            "        \"analyzer\": \"ik_max_word\",\n" +
            "        \"copy_to\": \"all\"\n" +
            "      },\n" +
            "      \"address\":{\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"index\": false\n" +
            "      },\n" +
            "      \"price\":{\n" +
            "        \"type\":\"integer\"\n" +
            "      },\n" +
            "      \"score\":{\n" +
            "        \"type\":\"integer\"\n" +
            "      },\n" +
            "      \"brand\":{\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"copy_to\": \"all\"\n" +
            "      },\n" +
            "      \"city\":{\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"starName\":{\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"business\":{\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"copy_to\": \"all\"\n" +
            "      },\n" +
            "      \"location\":{\n" +
            "        \"type\":\"geo_point\"\n" +
            "      },\n" +
            "      \"pic\":{\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"index\": false\n" +
            "      },\n" +
            "      \"all\":{\n" +
            "        \"type\": \"text\",\n" +
            "        \"analyzer\": \"ik_max_word\"\n" +
            "      }\n" +
            "    }\n" +
            "  }\n" +
            "}";
}

创建酒店索引

/**
* 创建酒店索引
*/
@Test
void createHotelIndex() throws IOException {
    //1.创建Request对象
    CreateIndexRequest request = new CreateIndexRequest("hotel");
    //2.准备请求的参数,DSL语句
    request.source(MAPPING_TEMPLATE, XContentType.JSON);
    //3.发送请求
    client.indices().create(request, RequestOptions.DEFAULT);

}

然后查询

GET /hotel

没问题,成功建立

删除、判断索引库是否存在

ES spring客户端_spring cloud_35

/**
 * 删除索引
 */
@Test
void testDeleteHotelIndex() throws IOException {
    DeleteIndexRequest request = new DeleteIndexRequest("hotel");
    client.indices().delete(request,RequestOptions.DEFAULT);
    System.out.println("删除成功!");
}

/**
 * 是否存在索引
 */
@Test
void testExistsHotelIndex() throws IOException {
    GetIndexRequest request = new GetIndexRequest("hotel");
    boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
    System.err.println(exists ? "索引库已经存在!" : "索引库不存在!");
}

总结

索引库操作的基本步骤:

  • 初始化RestHighLevelClient
  • 创建XxxIndexRequest。XXX是Create、Get、Delete
  • 准备DSL( Create时需要)
  • 发送请求。调用RestHighLevelClient.indices().xxx()方法,xxx是create、exists、delete

8.RestClient操作文档

ES spring客户端_spring_36

ES spring客户端_ES spring客户端_37

添加

ES spring客户端_ES spring客户端_38

@SpringBootTest
public class HotelDocumentTest {
    @Autowired
    private IHotelService iHotelService;

    private RestHighLevelClient client;

    /**
     * 添加文档
     */
    @Test
    void testAddDocument() throws IOException {
        //根据ID查询酒店数据
        Hotel hotel = iHotelService.getById(36934L);
        //转换为文档类型(以处理location与经纬度不一致)
        HotelDoc hotelDoc = new HotelDoc(hotel);

        //1.准备request对象
        IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString());
        //2.准备JSON文档
        request.source(JSON.toJSONString(hotelDoc),XContentType.JSON);
        //3.发送请求
        client.index(request,RequestOptions.DEFAULT);
    }

    /**
     * 在一开始就完成client的初始化
     */
    @BeforeEach
    void setUp() {
        //如果是集群,这里的HttpHost.create("http://192.168.10.100:9200")可以用逗号分割
        this.client = new RestHighLevelClient(RestClient.builder(
                HttpHost.create("http://192.168.10.100:9200")
        ));
    }

    /**
     * 用完后销毁
     */
    @AfterEach
    void tearDown() throws IOException {
        this.client.close();
    }
}

回到浏览器

发送GET /hotel/_doc/36934查询成功!

{
  "_index" : "hotel",
  "_type" : "_doc",
  "_id" : "36934",
  "_version" : 1,
  "_seq_no" : 0,
  "_primary_term" : 1,
  "found" : true,
  "_source" : {
    "address" : "静安交通路40号",
    "brand" : "7天酒店",
    "business" : "四川北路商业区",
    "city" : "上海",
    "id" : 36934,
    "location" : "31.251433, 121.47522",
    "name" : "7天连锁酒店(上海宝山路地铁站店)",
    "pic" : "https://m.tuniucdn.com/fb2/t1/G1/M00/3E/40/Cii9EVkyLrKIXo1vAAHgrxo_pUcAALcKQLD688AAeDH564_w200_h200_c1_t0.jpg",
    "price" : 336,
    "score" : 37,
    "starName" : "二钻"
  }
}

查询

ES spring客户端_spring_39

/**
 * 查询文档
 */
@Test
void testGetDocumentById() throws IOException {
    //1.准备request对象
    GetRequest request = new GetRequest("hotel","36934");
    //2.发送请求得到响应
    GetResponse response = client.get(request, RequestOptions.DEFAULT);
    //3.解析响应结果
    String json = response.getSourceAsString();
    HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
    System.out.println("hotelDoc = " + hotelDoc);
}

ES spring客户端_spring cloud_40

更新

ES spring客户端_ES spring客户端_41

/**
 * 更新文档
 */
@Test
void testUpdateDocument() throws IOException {
    //1.准备Request
    UpdateRequest request = new UpdateRequest("hotel","36934");
    //2.准备请求参数
    request.doc(
            "price","337",
            "starName","五星"
    );
    //3.发送请求
    client.update(request,RequestOptions.DEFAULT);
}

删除

ES spring客户端_spring cloud_42

/**
 * 删除文档
 */
@Test
void testDeleteDocument() throws IOException {
    //1.准备Request
    DeleteRequest request = new DeleteRequest("hotel","36934");
    //2.发送请求
    client.delete(request,RequestOptions.DEFAULT);
}

总结

文档操作的基本步骤:

  1. 初始化RestHighLevelClient
  2. 创建XxxRequest。XXX是Index、Get、Update、Delete
  3. 准备参数(Index和Update时需要)
  4. 发送请求。调用RestHighLevelClient#.xxx()方法,xxx是index、get、update、delete
  5. 解析结果(Get时需要)

批量处理文档

ES spring客户端_spring cloud_43

/**
     * 批量导入数据到文档
     */
    @Test
    void testBulkRequest() throws IOException {
        List<Hotel> hotelList = iHotelService.list();
        BulkRequest request = new BulkRequest();
        for (Hotel hotel : hotelList) {
            HotelDoc hotelDoc = new HotelDoc(hotel);
            //创建文档request对象
            request.add(new IndexRequest("hotel")
                    .id(hotelDoc.getId().toString())
                    .source(JSON.toJSONString(hotelDoc),XContentType.JSON)
            );
        }
        //发送请求
        client.bulk(request,RequestOptions.DEFAULT);
    }

在ES的DEVtools输入以下查询指令,即可得到结果

GET /hotel/_search

9.分布式搜索引擎

1.DSL查询语法

DSL Query的分类

Elasticsearch提供了基于JSON的DSL(Domain Specific Language)来定义查询。常见的查询类型包括:

  • 查询所有:查询出所有数据,一般测试用。
    例如:match_all
  • 全文检索(full text)查询:利用分词器对用户输入内容分词,然后去倒排索引库中匹配。
    例如:
    match_query
    multi_match_query
  • 精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。
    例如:
    ids
    range
    term
  • 地理(geo)查询:根据经纬度查询。
    例如:
    geo_distance
    geo_bounding_box
  • 复合(compound)查询:复合查询可以将上述各种查询条件组合起来,合并查询条件。
    例如:
    bool
    function_score

DSL Query - Match查询

ES spring客户端_spring_44

ES spring客户端_微服务_45

默认只查询出来10条展示。

全文检索查询

ES spring客户端_elasticsearch_46

# match查询
GET /hotel/_search
{
  "query": {
    "match": {
      "all": "外滩"
    }
  }
}


# multi_match查询
# "brand","name","business"只要有一个满足就行
GET /hotel/_search
{
  "query": {
    "multi_match": {
      "query": "外滩如家",
      "fields": ["brand","name","business"]
    }
  }
}

搜索字段越多,查询的效率越低,因此要想办法把多个字段弄到一个字段里去查,比如说all字段

这个all字段实际上是我们索引库操作时"copy_to": "all"的结果

ES spring客户端_spring_47

精确查询

ES spring客户端_elasticsearch_48

ES spring客户端_微服务_49

# term查询
GET /hotel/_search
{
  "query": {
    "term": {
      "city": {
        "value": "上海"
      }
    }
  }
}


# range查询
GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 100,
        "lte": 200
      }
    }
  }
}

gte 大于等于,gt大于, lte同理

精确查询常见的有哪些?

  1. term查询:根据词条精确匹配,一般搜索keyword类型、数值类型、布尔类型、日期类型字段
  2. range查询:根据数值范围查询,可以是数值、日期的范围

地理查询

第一种

ES spring客户端_spring cloud_50

第二种

ES spring客户端_elasticsearch_51

# distance 查询
GET /hotel/_search
{
  "query": {
    "geo_distance":{
      "distance": "5km",
      "location" : "31.21,121.5"
    }
  }
}

复合查询

ES spring客户端_elasticsearch_52

相关性算法

当我们利用match查询时,文档结果会根据与搜索词条的关联度打分(_ score) ,返回结果时按照分值降序排列。
例如,我们搜索"虹桥如家",结果如下:

ES spring客户端_spring cloud_53

ES spring客户端_spring cloud_54

可见词条出现次数越多,TF越高,相关性就越高。所以早期我们计算文档得分就是计算TF。因为【虹桥,如家】两个分词,就先计算虹桥,然后计算如家的TF,相加就可以了。

但是这种算法有一种问题,【如家】这个词在三篇文档中都有出现,再去把【如家】进行累加毫无意义。后面为了避免这种在每个文档中都出现这个词的情况,这种词的权重比较低,所以我们引入新的算法:

ES spring客户端_spring_55

逆文档频率

文档总数/ 包含词条的文档总数。比方说我们拿【如家】为例,包含【如家】的文档有3个,而文档总数也是3个,3除3为1,因此Log1 = 0,代表这个如家的权重就是0。

相反如果是【虹桥】呢?包含【虹桥】的文档有1个,而文档总和是3个,3除1=3,Log 3 = 0.477,所以【虹桥】的权重就比较高,因为这个词在文档中出现的次数越少,权重则越高。将来得分也就越高。

最终得分就是TF乘IDF,再累加。

这就是业界常用的 TF-IDF算法。

但是在我们ES中并没有使用这种算法,(早期ES是有使用过),从ES的5.1开始就已经没有再使用这种算法。而采用了新的算法:

ES spring客户端_spring cloud_56

这种算法不会受词频影响较大,在传统TF算法中,词频越高,将来得分会无限增加。但是BM25算法最终得分趋于一种水平。

ES spring客户端_spring_57

Function Score Query

使用function score query,可以修改文档的相关性算分(query score) ,根据新得到的算分排序。

ES spring客户端_微服务_58

ES spring客户端_elasticsearch_59

# 算分函数
GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "all": "外滩"
        }
      },
      "functions": [
        {
          "filter": {
            "term": {
              "brand": "如家"
            }
          },
          "weight": 10
        }
      ]
    }
  }
}

其他使用的案例查阅官方文档

官方文档

复合查询 Boolean Query

ES spring客户端_spring_60

修改前

# 复合查询
GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "name": "如家"
          }
        },
        {
          "geo_distance":{
            "distance":"10km",
            "location":{
              "lat":31.21,
              "lon":121.5
            }
          }
        }
        
      ],
      "must_not": [
        {
          "range":{
            "price": {
              "gt": 400
            }
          }
        }
      ]
     
    }
  }
}

修改后:(将geo_distance 这种不需要参与算法的,放入filter里,可以提高查询效率)

# 复合查询
GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "name": "如家"
          }
        }
        
      ],
      "must_not": [
        {
          "range":{
            "price": {
              "gt": 400
            }
          }
        }
      ],
      "filter": [
        {
          "geo_distance":{
            "distance":"10km",
            "location":{
              "lat":31.21,
              "lon":121.5
            }
          }
        }
      ]
    }
  }
}

ES spring客户端_elasticsearch_61

一般情况下,关键字搜索放到must里,其他的尽量放到must_not和filter里

2.搜索结果处理

1.排序

ES spring客户端_微服务_62

# 按用户评价降序,评价相同按照价格升序排序
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "score": {
        "order": "desc"
      },
      "price": {
        "order": "asc"
      }
    }
  ]
}

获取经纬度的方式 - 高德开放平台

# 找到121.393598,31.316488周围的酒店,距离升序排序
# 113.260791,23.128016 广州 (查询结果都是深圳的)
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance": {
        "order": "asc",
        "unit": "km",
        "location": {
          "lat": 23.128016,
          "lon": 113.260791
        }
      }
    }
  ]
}

你一旦做了排序,相关性打分就没有意义了。所以这时候ES会放弃打分, 效率提高。

2.分页

elasticsearch 默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。

elasticsearch中通过修改from、size参数来控制要返回的分页结果:

ES spring客户端_spring_63

ES使用的倒排索引,所以它是不适合分页的,它其实是逻辑上的分页,

比方说我现在要查询990到1000这10条数据,对于ES来说只能是查出从0到1000的所有数据,然后再去截取990到1000的这一部分,这是因为其数据结构决定的。这种在单点查询是没有问题的,在生产环境为了让ES存储更多的数据,一定会做集群,而且ES天生就是支持集群的。一旦做了集群,ES就会把数据做拆分。

ES spring客户端_spring_64

放到不同的机器上,拆分出的每一份我们叫做分片,每一片上的数据是不一样的。现在我要按照价格做排序,集群ES就不知道是找哪个分片上的前1000条。而是把所有分片上的前1000名,都取出来合并重新作个排序,才是前1000条。

首先在每个数据分片上都排序并查询前1000条文档。

然后将所有节点的结果聚合,在内存中重新排序选出前1000条文档

最后从这1000条中,选取从990开始的10条文档

在生成环境下,像百度,ES集群达到数千台,意味着要在每一台上截取1000个,至少百万级别的截取量。还要排序五百万条记录。内存消耗非常大。

如果搜索页数过深,或者结果集(from + size)越大,对内存和CPU的消耗也越高。因此ES设定结果集查询的上限是10000

面临深度分页问题,要在业务上杜绝。

如果有这样的需求怎么办?

针对深度分页,ES提供了两种解决方案,官方文档

•search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。

•scroll:原理将排序数据形成快照,保存在内存。官方已经不推荐使用。

from + size:

•优点:支持随机翻页

•缺点:深度分页问题,默认查询上限(from + size)是10000

•场景:百度、京东、谷歌、淘宝这样的随机翻页搜索

after search:

•优点:没有查询上限(单次查询的size不超过10000)

•缺点:只能向后逐页查询,不支持随机翻页

•场景:没有随机翻页需求的搜索,例如手机向下滚动翻页

scroll:

•优点:没有查询上限(单次查询的size不超过10000)

•缺点:会有额外内存消耗,并且搜索结果是非实时的

•场景:海量数据的获取和迁移。从ES7.1开始不推荐,建议用 after search方案。

3.高亮

ES spring客户端_微服务_65

# 高亮查询 默认情况下,ES搜索字段必须与高亮字段一致
GET /hotel/_search
{
  "query": {
    "match": {
      "all": "如家"
    }
  },
  "highlight": {
    "fields": {
      "name": {
        "require_field_match": "false"
      }
    }
  }
}

4.总结

ES spring客户端_elasticsearch_66

3.RestClient查询文档

match_all

ES spring客户端_微服务_67

ES spring客户端_ES spring客户端_68

/**
     * 测试matchall
     */
    @Test
    void MatchAll() throws IOException {
        SearchRequest request = new SearchRequest("hotel");
        request.source().query(QueryBuilders.matchAllQuery());
        SearchResponse response = client.search(request,RequestOptions.DEFAULT);
        SearchHits searchHits = response.getHits();
        long total = searchHits.getTotalHits().value;
        SearchHit[] hits = searchHits.getHits();
        for (SearchHit hit : hits) {
            String json = hit.getSourceAsString();
            System.out.println("json = " + json);
        }
    }

match和multi_match

ES spring客户端_微服务_69

/**
     * 测试match和multimatch
     */
    @Test
    void Match() throws IOException {
        SearchRequest request = new SearchRequest("hotel");
        //单字段查询
//        QueryBuilders builder1 = QueryBuilders.matchQuery("all", "如家");
        //多字段查询
        MultiMatchQueryBuilder builder2 = QueryBuilders.multiMatchQuery("如家","name","business");

        request.source().query(builder2);

        SearchResponse response = client.search(request,RequestOptions.DEFAULT);
        SearchHits searchHits = response.getHits();
        long total = searchHits.getTotalHits().value;
        SearchHit[] hits = searchHits.getHits();
        for (SearchHit hit : hits) {
            String json = hit.getSourceAsString();
            System.out.println("json = " + json);
        }
    }

term和range

ES spring客户端_elasticsearch_70

/**
 * 测试termAndRange
 */
@Test
void termAndRange() throws IOException {
    SearchRequest request = new SearchRequest("hotel");
    //TermQueryBuilder termQueryBuilder = QueryBuilders.termQuery("city", "深圳");

    RangeQueryBuilder price = QueryBuilders.rangeQuery("price").gte(100).lte(150);

    request.source().query(price);

    SearchResponse response = client.search(request,RequestOptions.DEFAULT);
    SearchHits searchHits = response.getHits();
    long total = searchHits.getTotalHits().value;
    SearchHit[] hits = searchHits.getHits();
    for (SearchHit hit : hits) {
        String json = hit.getSourceAsString();
        System.out.println("json = " + json);
    }
}

bool查询

ES spring客户端_spring cloud_71

/**
 * boolQuery
 */
@Test
void boolQuery() throws IOException {
    SearchRequest request = new SearchRequest("hotel");

    BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
    boolQueryBuilder.must(QueryBuilders.termQuery("city","深圳"));
    boolQueryBuilder.filter(QueryBuilders.rangeQuery("price").lte(250));

    request.source().query(boolQueryBuilder);

    SearchResponse response = client.search(request,RequestOptions.DEFAULT);
    SearchHits searchHits = response.getHits();
    long total = searchHits.getTotalHits().value;
    SearchHit[] hits = searchHits.getHits();
    for (SearchHit hit : hits) {
        String json = hit.getSourceAsString();
        System.out.println("json = " + json);
    }
}

分页排序

ES spring客户端_spring cloud_72

/**
 * fenyeANDpaixu
 */
@Test
void fenyeANDpaixu() throws IOException {
    SearchRequest request = new SearchRequest("hotel");
    request.source().query(QueryBuilders.matchAllQuery());

    //分页
    request.source().from(0).size(5);
    //价格排序
    request.source().sort("price", SortOrder.ASC);
    
    SearchResponse response = client.search(request,RequestOptions.DEFAULT);
    SearchHits searchHits = response.getHits();
    long total = searchHits.getTotalHits().value;
    SearchHit[] hits = searchHits.getHits();
    for (SearchHit hit : hits) {
        String json = hit.getSourceAsString();
        System.out.println("json = " + json);
    }
}

高亮

/**
 * 高亮
 */
@Test
void highlight() throws IOException {
    SearchRequest request = new SearchRequest("hotel");
    request.source().highlighter(
            new HighlightBuilder().field("name").requireFieldMatch(false));

    //搜索如家,把如家高亮
    MatchQueryBuilder builder1 = QueryBuilders.matchQuery("all", "如家");
    request.source().query(builder1);


    SearchResponse response = client.search(request,RequestOptions.DEFAULT);
    SearchHits searchHits = response.getHits();
    long total = searchHits.getTotalHits().value;
    SearchHit[] hits = searchHits.getHits();
    for (SearchHit hit : hits) {

        HotelDoc hotelDoc = JSON.parseObject(hit.getSourceAsString(),HotelDoc.class);
        //处理高亮
        Map<String, HighlightField> highlightFields = hit.getHighlightFields();
        if (!CollectionUtils.isEmpty(highlightFields)) {
            //高亮字段
            HighlightField highlightField = highlightFields.get("name");
            if(highlightField != null){
                //取出高亮字段第一个
                String name = highlightField.getFragments()[0].string();
                hotelDoc.setName(name);
            }
        }

        System.out.println("hotelDoc = " + hotelDoc);

    }
}