文章目录

  • 1、全文搜索说明
  • 2、单机安装(非集群)
  • 3、基本概念
  • 4、基本使用
  • 5、搜索的简单使用
  • 6、分词器
  • 7、字段类型
  • 8、Kibana的简单实用
  • 9、批量导入测试数据
  • 10、高级查询
  • 11、Elasticsearch的高级使用
  • 12、springboot整合Elasticsearch
  • 13、集群
  • 14、Elasticsearch原理


1、全文搜索说明

搜索,如果是结构化数据库,那么要搜索的内容一般是某个或多个字段,如果完全匹配就是=,如果模糊匹配的话,那么就用like,完全匹配的还好弄,把这个字段设置成索引,那么下次查询的时候直接在索引里面查,查出来之后回表,速度还能接受。但是模糊搜索的话,如果要搜索的词不是在最开头,即使用的是like %xxx%,那么这个索引没法发挥作用,速度慢到怀疑人生。不信的话,可以用一个300万条数据的表试一试。

这个时候,我们就要想怎么弄了。肯定要做一些中间操作,类似于建个索引这种的中间操作。这里用的就是倒排索引,也就是说对某个字段里的内容进行倒排索引的建立,倒排索引也是索引的一种,只是和我们普通的索引原理有些不同而已,倒排索引可以实现把这个字段里面的词都提取出来然后记录下来,记录的数据可能包括是什么词、在什么这段话的位置等等。这样下次要查找的话,先去这个索引查找,这样不仅能找到哪条记录能匹配,还知道了这个词在这段内容的什么位置等信息。

以上就是针对全文索引一般采用的方式,也是Elasticsearch采用的方式。全文索引重点在于针对一大段的内容进行搜索。

所以建立索引的方式就是对已有的内容进行抽取、再重组一下、存起来。索引占用空间,所以是中牺牲空间换取时间的方式。

我们现在学的是Elasticsearch,它和Solr都是基于Lucene的。各有优缺点吧,我们现在学的是Elasticsearch,用的挺多的。

2、单机安装(非集群)

折腾了很多遍,现在捋一下,在拿到一台最新的CentOS(只分配了1G内存)后应该怎么一步步做:

# 先更新一下yum,然后下载net-tools和wget,后面要用ifconfig查看ip,wget下载Elasticsearch文件
yum update -y
yum install -y net-tools
yum install -y wget

# 新增一个用户(如果安装CentOS的时候除了root外没有其他用户的话)
# 因为Elasticsearch需要在非root用户下运行
useradd tom
passwd tom

# 先别着急切换,我们先打开防火墙的9200端口到时候访问用
firewall-cmd --add-port=9200/tcp --permanent
firewall-cmd --reload

# 我们再设置max_map_count为262144,这边你可以先不设置,等报错中让你设置max_map_count的时候再回来设置也行,但到时候注意记得切换成root做这个操作
vi /etc/sysctl.conf
# 在文件最下面增加如下设置后退出
vm.max_map_count=262144
# 然后执行生效
sysctl -p

# 我们再设置用户操作文件数
# 这是针对报错:max file descriptors [4096] for elasticsearch process is too low, increase to at least [65536]
vi /etc/security/limits.conf
# 最下面新增如下
*   soft    nofile  65536
*   hard    nofile  65536

# 我们再设置线程数
# 这是针对报错:max number of threads [3818] for user [es] is too low, increase to at least [4096]
vi /etc/security/limits.conf
# 最下面新增如下
*   soft    nproc  4096
*   hard    nproc  4096


# 切换到tom用户,然后在home目录下下载和配置
# 直接下载的话,建议地址换成国内镜像的地址,如下
wget https://elasticsearch.thans.cn/downloads/elasticsearch/elasticsearch-7.2.0-linux-x86_64.tar.gz
# 如果想直接用官方的地址,那么建议不要直接在服务器中下载,先在本地下,下载之后上传到服务器中
# 比如先下载这个https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.5.2-linux-x86_64.tar.gz
# 下载在本地之后,然后连接远程服务器上传过去,命令如下,输入tom密码即可,以下会上传到tom用户的home目录中
scp Downloads/elasticsearch-7.5.2-linux-x86_64.tar.gz tom@172.16.25.149:~

# 甭管上面是哪种方式获得的文件,解压缩这个文件(选自己的版本)
tar zvxf elasticsearch-7.2.0-linux-x86_64.tar.gz

# 然后修改配置文件,允许外网访问
vi elasticsearch-7.2.0/config/elasticsearch.yml
# 修改如下绑定ip为0.0.0.0
network.host=0.0.0.0
# 取消下面这两行的注释,不然可能出现cluster之类的报错
# 尤其是很多教程只设置了cluster那一行,node那行没有设置
# 会导致,cluster找不到node-1也找不到node-2
# 所以在操作的时候会报错master_not_discovered_exception
node.name = node-1
cluster.initial_master_nodes: ["node-1", "node-2"]


# 先用ifconfig查看一下服务器的ip,比如得到127.16.25.149
ifconfig

# 最后运行
./elasticsearch-7.2.0/bin/elasticsearch

在本机访问http://127.16.25.149:9200,出现以下就是成功了:

{
  "name" : "bogon",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "_na_",
  "version" : {
    "number" : "7.2.0",
    "build_flavor" : "default",
    "build_type" : "tar",
    "build_hash" : "508c38a",
    "build_date" : "2019-06-20T15:54:18.811730Z",
    "build_snapshot" : false,
    "lucene_version" : "8.0.0",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  },
  "tagline" : "You Know, for Search"
}

3、基本概念

索引(index)类似于数据库。

类型(type)在7.x版本中基本已经不怎么提及。原先5.x中一个索引可以有多种type,6.x中每个index只能有一种type,现在好像直接移除了这个限制。

映射(mapping)就是定义字段信息的。

文档(document)相当于一条记录。

文档中有不同的字段。

节点,相当于一台服务器或者一个Elasticsearch进程。如果很多节点以一定的规则组成的话,那就叫集群。

一个索引Index的数据可以存放在很多台机器的多个分片(shard)中,每个shard可以有主分片(primary shard)和副分片(replica shard)。

4、基本使用

Elasticsearch的接口主要是依照REST风格设计的。

# 新增一个索引
PUT http://172.16.25.149:9200/user
# 查看索引
GET http://172.16.25.149:9200/user
# 查看多个索引
GET http://172.16.25.149:9200/user,post
# 查看所有索引
GET http://172.16.25.149:9200/_all

# 查看所有索引的另一个接口
GET http://172.16.25.149:9200/_cat/indices
# 加上参数v会显示更全的信息
GET http://172.16.25.149:9200/_cat/indices?v

# 只想查看某个索引是否存在,返回200就存在,404就不存在
HEAD http://172.16.25.149:9200/user

# 关闭索引,信息中就会有这个字段"verified_before_close": "true"
POST http://172.16.25.149:9200/user/_close

# 打开索引
POST http://172.16.25.149:9200/user/_open

# 删除索引
DELETE http://172.16.25.149:9200/user

映射Mapping的基本使用。

# 给一个索引新增mapping
# text表示这个字段要全文搜索,会分词
PUT http://172.16.25.149:9200/post/_mapping
{
    "properties":{
        "title": {"type": "text"},
        "content": {"type": "text"},
        "author": {"type": "keyword"}
    }
}

# 修改mapping的话只能新增。不能修改已有字段的类型type
PUT http://172.16.25.149:9200/post/_mapping
{
    "properties":{
        "title": {"type": "text"},
        "content": {"type": "text"},
        "author": {"type": "keyword"},
        "publish_date": {"type": "keyword"}
    }
}

# 查看某个索引的mapping
GET http://172.16.25.149:9200/post/_mapping

# 查看所有的mapping
GET http://172.16.25.149:9200/_mapping
GET http://172.16.25.149:9200/_all/_mapping

文档的增删改查。

# 指定ID新增一个文档,只能指定_doc,不能换成其他自定义的,否则报错
PUT http://172.16.25.149:9200/post/_doc/1
{
    "title": "我的Elasticsearch学习之路",
    "content": "随便什么内容随便什么内容随便什么内容随便什么内容随便什么内容",
    "author": "小张",
    "publish_date": "2020-01-02 18:09:09"
}

# 查看这个文档
GET http://172.16.25.149:9200/post/_doc/1

# 不指定ID新增。需要用POST,这个和指定ID用PUT不一样。
POST http://172.16.25.149:9200/post/_doc
{
    "title": "你在南方的艳阳里",
    "content": "哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈",
    "author": "小王",
    "publish_date": "2020-02-12 09:08:08"
}

# 查询所有文档,可以发现自动生成的id是一串字符串,比如xIexoHABoTcv0s9Mn8Qd
GET http://172.16.25.149:9200/post/_search
{
	"query":{
        "match_all": {}
    }
}

# 如果索引不存在,直接新增文档时顺便指定索引
# 这时候就看自动创建索引那个字段是否设置为true
# 如果不是true,那么就不能再没有索引时新增文档,因为自动创建索引失败
# 查看自定创建索引的字段,发现里面空空的,其实这个时候是默认会自动创建索引的
GET http://172.16.25.149:9200/_cluster/settings
# 不信的话,我们尝试新增一个文档,并且指定一个不存在的索引看看,成功了
PUT http://172.16.25.149:9200/haha/_doc/1
{
    "title": "你在南方的艳阳里",
    "content": "哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈",
    "author": "小王",
    "publish_date": "2020-02-12 09:08:08"
}
# 所以这个时候,我们应该设置自动创建索引为false,试试看
PUT http://172.16.25.149:9200/_cluster/settings
{
	"persistent": {
		"action.auto_create_index": false
	}
}
# 这个时候再新增一个不存在索引的文档试试看,报错:no such index [wow]
PUT http://172.16.25.149:9200/wow/_doc/1
{
    "title": "你在南方的艳阳里",
    "content": "哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈",
    "author": "小王",
    "publish_date": "2020-02-12 09:08:08"
}

# 执行多次这个操作,会更新原有文档,因为ID指定了
PUT http://172.16.25.149:9200/post/_doc/1
{
    "title": "我的Elasticsearch学习之路啊啊啊啊阿",
    "content": "随便什么内容随便什么内容随便什么内容随便什么内容随便什么内容",
    "author": "小张",
    "publish_date": "2020-01-02 18:09:09"
}

# 如果只想新增,不想更新,那么可以执行操作类型,这样如果id为1的已经存在就会报错,不会更新原有文档
PUT http://172.16.25.149:9200/post/_doc/1?op_type=create
{
    "title": "我的Elasticsearch学习之路啊啊啊啊阿",
    "content": "随便什么内容随便什么内容随便什么内容随便什么内容随便什么内容",
    "author": "小张",
    "publish_date": "2020-01-02 18:09:09"
}

# 以下两个_mget,用GET和POST都可以得到结果
# 查询多个文档,查询条件全部写在body中,查哪个index,哪种类型,什么条件,此处条件用了id匹配
GET http://172.16.25.149:9200/_mget
{
	"docs":[
		{
			"_index": "post",
			"_type":"_doc",
			"_id": "1"
		},
		{
			"_index": "post",
			"_type":"_doc",
			"_id": "xIexoHABoTcv0s9Mn8Qd"
		}
	]
}

# 也可以把部分查询条件放在url中,比如index,这样每个查询条件中就不用指定index了
GET http://172.16.25.149:9200/post/_mget
{
	"docs":[
		{
			"_type":"_doc",
			"_id": "1"
		},
		{
			"_type":"_doc",
			"_id": "xIexoHABoTcv0s9Mn8Qd"
		}
	]
}

# 跟进一步
GET http://172.16.25.149:9200/post/_doc/_mget
{
	"docs":[
		{
			"_id": "1"
		},
		{
			"_id": "xIexoHABoTcv0s9Mn8Qd"
		}
	]
}

# 既然上面已经更进一步这么简化了,那索性直接变一下更简单
GET http://172.16.25.149:9200/post/_doc/_mget
{
    "ids": ["1", "xIexoHABoTcv0s9Mn8Qd"]
}

# 更新修改一个文档,指定id。其中author和publish_date字段没有写,那么不会改变还是原有值
POST http://172.16.25.149:9200/post/_update/1
{
    "doc":{
        "title": "hello world",
        "content": "hahahahhaha"
    }
}

# 给文档的_source中新增一个字段
POST http://172.16.25.149:9200/post/_update/1
{
    "script":"ctx._source.gender=1"
}
# 删除这个字段
POST http://172.16.25.149:9200/post/_update/1
{
    "script":"ctx._source.remove(\"gender\")"
}

# 更新,如果不存在就新增一个文档,如果存在就更新
# 如果不存在,则新增这个文档,字段只有一个age
# 第二次执行因为已经存在,所以就按照脚本更新
POST http://172.16.25.149:9200/post/_update/2
{
    "script":{
    	"source": "ctx._source.age += params.age",
	    "params": {
	    	"age": 10
	    }
    },
    "upsert":{
    	"age": 1
    }
}

# 删除文档
DELETE http://172.16.25.149:9200/post/_doc/2

5、搜索的简单使用

先准备一些数据,比如监理一个nba的索引,然后新增几个球员的信息。

# 新增索引,顺便新增了mapping
PUT http://172.16.25.149:9200/nba
{
    "mappings":{
    	"properties": {
    		"name": {"type": "text"},
    		"team_name": {"type": "text"},
    		"position": {"type": "text"},
    		"play_year": {"type": "long"},
    		"jerse_no": {"type": "keyword"}
    	}
    }
}
# 新增几条数据,以下数据的id分别为1、2、3不再重复贴代码
PUT http://172.16.25.149:9200/nba/_doc/1
{
	"name": "哈登",
	"team_name": "火箭",
	"position": "得分后卫",
	"play_year": 10,
	"jerse_no": "13"
}
{
	"name": "库里",
	"team_name": "勇士",
	"position": "控球后卫",
	"play_year": 10,
	"jerse_no": "30"
}
{
	"name": "詹姆斯",
	"team_name": "湖人",
	"position": "小前锋",
	"play_year": 15,
	"jerse_no": "23"
}

term词条查询,这个查询时一种精确查询,不是模糊匹配,比如:

GET http://172.16.25.149:9200/nba/_search
{
	"query": {
		"term":{
			"jerse_no": "23"
		}
	}
}

# 查询多条信息的时候,term变成terms,条件可用数组
{
	"query": {
		"terms":{
			"jerse_no": ["23", "13"]
		}
	}
}

全文查询(full text),这个时候,我们的查询关键字可能会被拆分成多个,只要其中有一个匹配某一条文档,那么这条文档就会被返回。这里的全文查询的字段类型都应该是text,不能是keyword或者其他。

# 这是查询全部,from和size是分页的操作
GET http://172.16.25.149:9200/nba/_search
{
	"query": {
		"match_all":{}
	},
	"from": 0,
	"size": 2
}

# 不用查询全部时,match_all需要改成match
# 下面这个仍然可以查出库里的那条记录,因为拆分成2个词,库里这个词命中了一条记录就返回了
{
	"query": {
		"match":{
			"name": "库里小孩"
		}
	},
	"from": 0,
	"size": 100
}

# 改成库就里也能查出来。
{
	"query": {
		"match":{
			"name": "库就里"
		}
	},
	"from": 0,
	"size": 100
}

# 多个字段匹配搜索,在name和team_name中匹配湖人这个关键字
{
	"query": {
		"multi_match":{
			"query": "湖人",
			"fields": ["name", "team_name"]
		}
	}
}

# 如果是match的话,因为存在拆词,所以得分后卫和控球后卫都被查出来
# 但是用match_phrase的话,只能查出得分后卫
# 这种类似于不拆词,但不是精确查询,因为用得分后还是把得分后卫这条数据查出来,就是不拆词的感觉
{
	"query": {
		"match_phrase":{
			"position": "得分后卫"
		}
	}
}

更改一下数据

POST http://172.16.25.149:9200/nba/_update/2
{
	"doc": {
		"name": "库里",
		"team_name": "勇士",
		"position": "控球后卫",
		"play_year": 10,
		"jerse_no": "30",
		"title": "the best 3-point shooter"
	}
}

# 然后查询,带前缀的,能把库里那条记录查出来
{
	"query": {
		"match_phrase_prefix":{
			"title": "the b"
		}
	}
}

6、分词器

# 标准分词器standard,大写变小写,短横前后分隔
POST http://172.16.25.149:9200/_analyze
{
	"analyzer": "standard",
	"text": "The best 3-points shooter is Curry"
}

# 简单分词器simple,相比于standard,就是去除了数字,也就是上面的3就么有了

# 空格分词器whitespace,只是通过空格分词,不转换成小写,短横也保留,只看空格

# 停止分词器stop,会转小写,但是去除了一些无意义的词,这个词有默认库,也可以自定义库

# 语言分词器,比如english,但不太准确

# 正则分词器pattern,默认的是\W+,去除非字符串的词

举例,如果我事先给某条记录增加一个"title": "the best 3-point shooter is Curry!",我们查询title为Curry或者curry或者Curry!都能查到。因为默认分词器,会分出curry这个词,我们在查询的时候也会有大小写转换,所以能匹配到。

但是如果我在创建索引的时候,自定义一个分词器,然后让这个title字段用这个分词器,就可能查不到了。比如,用whitespace分词器。
折旧意味着,我新增信息后,title中的信息是按照空格分的,那么久没有Curry了,而是Curry!在一起组成的,所以我直接搜索Curry就搜不到,但是搜索Curry!就能搜到。

PUT http://172.16.25.149:9200/_nba
{
    "settings": {
        "analysis": {
            "analyzer": {
                "my_analyzer": "whitespace"
            }
        }
    },
    "mappings": {
        "properties": {
            ...
            "title": {
                "type": "text",
                "analyzer": "my_analyzer"
            }
        }
    }
}

常用的中文分词器,这里只介绍两种,一种是smartcn,另一种是ik。因为内置的分词器对中文基本是没用的,所以我们需要另外安装,这些都是以插件的形式安装进来的。可以在解压的Elasticsearch中的plugins中查看,如果之前没有安装过任何插件,这个目录下应该是空的,或者使用http://172.16.25.149:9200/_cat/plugins,当然没安装时这个返回结果是空的。

(1)安装smartcn,在bin目录中使用安装插件的命令安装。

./bin/elasticsearch-plugin install analysis-smartcn

安装完成后,可以通过以上的方法查看是否有安装好的这个插件。然后启动Elasticsearch,如果Elasticsearch是启动着的,那么需要重启才能生效。

测试分词效果:

POST http://172.16.25.149:9200/_analyze
{
	"analyzer": "smartcn",
	"text": "我明天去一趟超市买东西"
}

(2)安装ik也一样,可以直接通过命令安装,也可以直接下载压缩包然后解压后把它直接放在plugins目录中都可以,下载的时候注意版本号要和自己的Elasticsearch版本一致。

./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.2.0/elasticsearch-analysis-ik-7.2.0.zip

同样的启动或者重启查看效果。ik有两种模式可以选择,一种是ik_max_word它会把能继续分的词继续分然后返回出来,比如有一趟也有ik_smart相当于不再继续分。

POST http://172.16.25.149:9200/_analyze
{
	"analyzer": "ik_max_word",
	"text": "我明天去一趟超市买东西"
}

7、字段类型

(1)核心数据类型

  1. 字符串。text表示用于全文搜索的,keyword表示不分词用于完整搜索匹配的。
  2. 数值型。longintegershortbytedoublefloathalf_floatscaled_float
  3. 布尔型。boolean
  4. 二进制。binary,是把文件经过base64转换,默认不存储,且不可搜索。
  5. 范围类型。是一个范围不是一个值。integer_rangefloat_rangedouble_rangelong_rangedate_range等。存储的值可以是,比如age字段存储值为{"gte": 20, "lte": 40},那么搜索时可以通过这样搜索出来{"query": {"term: {"age": 21}"}}
  6. 日期类型。date,但因为我们提交的json数据格式是没有日期类型的,所以Elasticsearch是通过格式去判断它是不是一个日期,默认的格式foramt是strict_date_optional_time||epoch_millis,比如2020-01-02这种,或者2020/01/02 12:12:12这种,或者是从1970年1月1日0点开始的毫秒数或者秒数

(2)复杂数据类型

  1. 数组类型。数组类型存储的元素类型要相同,不然报错。[1, 2]或者[{"name": "aa", "age": 20},{"name": "bb", "age": 30}]等。
  2. 对象类型。比如给一个字段存下面这个数值:
{
    ...
    "address": {
        "country": "china",
        "location": {
            "province": "beijing",
            "city": "beijing"
        }
    }
}

# 那么查询的方法如下
{
    "query":{
        "match": {
            "address.location.city": "beijing"
        }
    }
}

# 那上面这个数组或者对象怎么定义呢,也就是mapping怎么写呢,我们可以查一下就知道了,它追溯到最底一层然后定义为keyword
{
    "nba": {
        "mappings": {
            "properties": {
                "address": {
                    "properties": {
                        "location": {
                            "properties": {
                                "city": {
                                    "type": "text",
                                    "fields": {
                                        "keyword": {
                                            "type": "keyword",
                                            "ignore_above": 256
                                        }
                                    }
                                }
                            }
                        },
                        "region": {
                            "type": "text",
                            "fields": {
                                "keyword": {
                                    "type": "keyword",
                                    "ignore_above": 256
                                }
                            }
                        }
                    }
                },
                "hobbies": {
                    "type": "text",
                    "fields": {
                        "keyword": {
                            "type": "keyword",
                            "ignore_above": 256
                        }
                    }
                },
                "jerse_no": {
                    "type": "keyword"
                },
                "name": {
                    "type": "text"
                },
                "play_year": {
                    "type": "long"
                },
                "position": {
                    "type": "text"
                },
                "team_name": {
                    "type": "text"
                },
                "title": {
                    "type": "text",
                    "fields": {
                        "keyword": {
                            "type": "keyword",
                            "ignore_above": 256
                        }
                    }
                }
            }
        }
    }
}

(3)专用数据类型

  1. IP类型。本质是一个长整型。定义mapping的时候直接写"type": "ip"。存储的时候直接存储192.168.1.1之类的数值。查询的时候:
# 下面表示查询192.168.0.0~192.168.255.255之间的
{
    "query":{
        "term": {
            "ip_addr": "192.168.0.0/16"
        }
    }
}

8、Kibana的简单实用

# 下载,对应自己Elasticsearch的版本
wget https://artifacts.elastic.co/downloads/kibana/kibana-7.6.0-linux-x86_64.tar.gz
# 解压缩
tar zvxf kibana-7.6.0-linux-x86_64.tar.gz

# 先以后台方式启动Elasticsearch,不然一个控制台就被占据了,然后在启动kibana
./elasticsearch-7.2.0/bin/elasticsearch &
./kibana-7.2.0-linux-x86_64/bin/kibana

# 本机访问http://172.16.25.149:5601发现不行
# 当然不行了,5601端口防火墙没开
# 切换成root用户,开放端口,然后再切换回tom用户启动
su root
firewall-cmd --add-port=5601/tcp --permanent
firewall-cmd --reload
su tom
./kibana-7.2.0-linux-x86_64/bin/kibana

# 发现仍然不行,但是提示中有说可以通过localhost:5601访问
# 看到这,发现,肯定是绑定了localhost,没有允许访问,那么修改吧
vi kibana-7.2.0-linux-x86_64/config/kibana.yml
# 修改下面这一行,保存退出
server.host: "0.0.0.0"
# 然后重新启动,在本机访问http://172.16.25.149:5601就可以了

kibana有很多好用的功能,其中我们可以用它的console控制台,因为kibana直接连接了本机的Elasticsearch,所以我们可以在这边的控制台操作各种命令,它比postman好的地方在于,地址不用写ip地址了,而且输入命令也有智能提示。

9、批量导入测试数据

在kibana中操作,先删除我们之前创建的nba索引。然后创建换一个空的nba索引。

# 查看一下
GET /_cat/indices
# 删除
DELETE nba
# 创建一个新的索引
PUT nba

下载好我们的数据后,可以看一眼,主要格式是一行指定索引类型和id这些,下面一行数具体数据,这样排列下去,最后一行加一个回车保留一个空白行。

{"index":{"_index":"nba","_type":"_doc","_id":"1"}}
{"countryEn":"United States","teamName":"老鹰","birthDay":831182400000,"country":"美国","teamCityEn":"Atlanta","code":"jaylen_adams","displayAffiliation":"United States","displayName":"杰伦 亚当斯","schoolType":"College","teamConference":"东部","teamConferenceEn":"Eastern","weight":"86.2 公斤","teamCity":"亚特兰大","playYear":1,"jerseyNo":"10","teamNameEn":"Hawks","draft":2018,"displayNameEn":"Jaylen Adams","heightValue":1.88,"birthDayStr":"1996-05-04","position":"后卫","age":23,"playerId":"1629121"}
...

我们在本机通过bulk接口把数据批量导入到Elasticsearch中。

# player是文件名,ip换成自己的ip即可
curl -X POST "http://172.16.25.149:9200/_bulk" -H 'Content-Type: application/json' --data-binary @player

然后在kibana中查询试试:

GET nba/_search
{
  "query":{
    "match": {
      "displayName": "詹姆斯"
    }
  }
}

10、高级查询

(1)term的几种查询方式

term查询方式是针对keyword类型的字段查询的,针对text的查询一般虽然不报错但经常是查不出结果的。

# 查询球衣23号
GET nba/_search
{
  "query": {
    "term": {
      "jerseyNo": "23"
    }
  }
}
# 查询jerseyNo字段不为空的
GET nba/_search
{
  "query": {
    "exists": {
      "field": "jerseyNo"
    }
  }
}
# 查询球衣号码数据以2开头的
GET nba/_search
{
  "query": {
    "prefix": {
      "jerseyNo": "2"
    }
  }
}
# 查询球衣号码包含2,用通配符查询,*表示任意多个字符,?表示任意单个字符
GET nba/_search
{
  "query": {
    "wildcard": {
      "jerseyNo": "*2"
    }
  }
}
# 同上,只是用了正则
GET nba/_search
{
  "query": {
    "regexp": {
      "jerseyNo": ".*2"
    }
  }
}
# ids查询
GET nba/_search
{
  "query": {
    "ids": {
      "values": [1,2,3]
    }
  }
}

(2)范围查询

# 查询的是一个数值类型
GET nba/_search
{
  "query": {
    "range": {
      "playYear": {
        "gte": 1,
        "lte": 5
      }
    }
  }
}

# 查询的字段应该是一个日期date,存储的是long
# 如果不是date类型的话,查询会报错,无法用format转换
# 如果发现类型不正确,那么就获取这个索引的mapping,然后删除这个索引。重新修改一下刚刚复制的mapping在创建的时候传进去,然后再导入数据就可以了。
POST nba/_search
{
  "query": {
    "range": {
      "birthDay": {
        "gt": "10-12-1998",
        "lt": "2005",
        "format": "dd-MM-yyyy||yyyy"
      }
    }
  }
}

(3)布尔查询

  1. must必须出现在文档中
  2. must not必须不能出现在文档中
  3. filter和must一样,只是不参与打分
  4. should是应该出现在文档中,如果仅仅只有should那么和没有这个条件查出的结果一样,因为不在should条件中的文档也会被查出来
  5. 但是should设置后,再设置一个"minimum_should_match" : 1的话,相当于说should里面至少要有1个要满足条件,这个时候不满足的就不会被查出来。
POST nba/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "displayNameEn": "james"
          }
        }
      ],
      "must_not": [
        {
          "match": {
            "teamConferenceEn": "Eastern"
          }
        }
      ],
      "filter": [
        {
          "match": {
            "displayNameEn": "james"
          }
        }
      ],
      "should": [
        {
          "range": {
            "playYear": {
              "gte": 10,
              "lte": 12
            }
          }
        }
      ],
      "minimum_should_match": 1
    }
  }
}

(4)排序

POST nba/_search
{
  "query": {
    "match": {
      "teamNameEn": "Rockets"
    }
  },
  "sort": [
    {
      "playYear": {
        "order": "desc"
      },
      "heightValue": {
        "order": "asc"
      }
    }
  ], 
  "from": 0,
  "size": 10
}

(5)聚合查询之指标聚合

指标聚合就是对一个集合,比如年龄字段,查出总和、平均值等指标。聚合类型有max、min、sum和avg。还有根据字段去统计非空的数量"value_count": {"field": "playYear"}。还有一个去重计算cardinality,和max哪些用法一样。

# 求球龄的平均值
POST nba/_search
{
  "query": {
    "match": {
      "teamNameEn": "Rockets"
    }
  },
  "aggs": {
    "players_avg_number": {
      "avg": {
        "field": "playYear"
      }
    }
  },
  "size": 0
}

# 查询数量的话,也可以直接使用_count的api,下面查询条件可以任意写
POST nba/_count
{
  "query": {
    "match": {
      "teamNameEn": "Rockets"
    }
  }
}

有一个强大的类型是stats,一次在可以统计出5个指标。更强大的是extended_stats,这个甚至计算出方差这些指标。

POST nba/_search
{
  "query": {
    "match": {
      "teamNameEn": "Rockets"
    }
  },
  "aggs": {
    "players_avg_number": {
      "extended_stats": {
        "field": "playYear"
      }
    }
  },
  "size": 0
}

# 截取的部分结果
{
    ...
    "aggregations" : {
        "players_avg_number" : {
          "count" : 21,
          "min" : 0.0,
          "max" : 17.0,
          "avg" : 5.333333333333333,
          "sum" : 112.0,
          "sum_of_squares" : 1140.0,
          "variance" : 25.84126984126984,
          "std_deviation" : 5.083430912412387,
          "std_deviation_bounds" : {
            "upper" : 15.500195158158107,
            "lower" : -4.833528491491442
          }
        }
    }
}

百分位上的值统计

POST nba/_search
{
  "query": {
    "match": {
      "teamNameEn": "Rockets"
    }
  },
  "aggs": {
    "players_avg_number": {
      "percentiles": {
        "field": "playYear",
        # "percents": [25, 50, 75] # 自定义百分位
      }
    }
  },
  "size": 0
}

# 部分结果截取如下
{
  ...
  "aggregations" : {
    "players_avg_number" : {
      "values" : {
        "1.0" : 0.0,
        "5.0" : 0.0,
        "25.0" : 1.0,
        "50.0" : 3.0,
        "75.0" : 8.5,
        "95.0" : 15.349999999999998,
        "99.0" : 17.0
      }
    }
  }
}

(6)聚合查询之桶聚合

桶查询就是先分组再指标聚合。

# 根据age先分组,取10组,这里是取10组,不是分成10组
# 已经自动分好了,如果取组size数量少的话,可能只能取到部分值
# 可以通过order排序,_key就是每组的年龄,_count就是每组的数量
POST nba/_search
{
  "query": {
    "match": {
      "teamNameEn": "Rockets"
    }
  },
  "aggs": {
    "custom_aggs": {
      "terms": {
        "field": "age",
        "size": 20,
        "order": {
          "_key": "desc"
        }
      }
    }
  },
  "size": 0
}

还可以使用自定义的聚合实现比如:统计每个球队的平均年龄,这里用到了下面的一个聚合,使用名字就可以引用。注意这里的field字段要是keyword类型的,不能是text的。include表示只显示这几个结果,exclude就是不显示这几个的结果,它们是对结果进行筛选。include也可以使用正则不用数组,比如"include": "Lakers|Ro.*|Warr.*"之类的。

POST nba/_search
{
  "aggs": {
    "whateverName": {
      "terms": {
        "field": "teamNameEn",
        "include": ["Lakers", "Rockets", "Warriors"],
        "exclude": ["Warriors"]
        "size": 30,
        "order": {
          "customAggName": "desc"
        }
      },
      "aggs": {
        "customAggName": {
          "avg": {
            "field": "playYear"
          }
        }
      }
    }
  },
  "size": 0
}

自定义对某个字段分组统计,用的是range类型,然后指定字段和ramges。key是别名,不然会根据范围自动默认一个分组名称。

POST nba/_search
{
  "aggs": {
    "my_custom_name": {
      "range": {
        "field": "age",
        "ranges": [
          {
            "to": 20,
            "key": "A"
          },
          {
            "from": 20,
            "to": 30
          },
          {
            "from": 30
          }
        ]
      }
    }
  },
  "size": 0
}

直方图的数据聚合结果,这个interval以后更换为fixed_interval。

POST nba/_search
{
  "aggs": {
    "birthYear": {
      "date_histogram": {
        "field": "birthDay",
        "format": "yyyy",
        "interval": "year"
      }
    }
  },
  "size": 0
}

(7)query_string查询

query_string相当于一句查询语句,Elasticsearch接收到语句后会解析,类似于我们在代码中写原生的SQL语句的感觉。关键字是AND、OR。如果要多个字段的话,就不用default_field,而是用"fields: ["", ""]这样。

POST nba/_search
{
  "query": {
    "query_string": {
      "default_field": "displayNameEn",
      "query": "james OR curry"
    }
  }
}

11、Elasticsearch的高级使用

(1)索引别名

# 所有索引的别名
GET /_alias

# 某个索引的别名
GET /nba/_alias

# 为索引nba新增一个别名nba_v1.0
POST /_aliases
{
  "actions": [
    {
      "add": {
        "index": "nba",
        "alias": "nba_v1.0"
      }
    }
  ]
}

# 删除某个别名
POST /_aliases
{
  "actions": [
    {
      "remove": {
        "index": "nba",
        "alias": "nba_v1.0"
      }
    }
  ]
}

# actions是一个数组,可以进行多个操作

# 比如为多个索引同时指定别名,或者为同一个索引同时指定多个别名等,就当做一个数组组织数据就行了

# 当一个别名同时关联多个索引时,只能有一个有写操作,通过is_write_index设置
# 删除某个别名
POST /_aliases
{
  "actions": [
    {
      "remove": {
        "index": "nba",
        "alias": "nba_v1.0",
        "is_write_index": true
      }
    }
  ]
}

# 如果别名可以写操作的话,那么可以直接用别名进行操作。但是执行为写的别名是不能查数据的。相当于读写分离了。

(2)重建索引

如果一个索引里的mapping等信息我们想要修改,比如字段类型需要修改的话,是不能直接修改的。利用别名我们可以实现这样的操作:

  1. 我们先养成一个习惯,我们大部分时候都会给索引设置别名,并且通过别名操作,也就是说索引真正的名字其实叫什么是无所谓的。
  2. 有了以上这个习惯,我们如果要重建索引的话,直接新建一个索引,然后利用_reindex把旧索引数据同步过去,然后给新的索引设置一个和旧索引相同的别名,然后删除旧索引。这里需要注意的是建立新索引的时候mapping要写成我们想修改后的索引。
  3. 比如我们之前直接导入player数据的时候,如果建立索引时候没有指定mapping它自动创建的可能不符合我们要求,比如birthDay字段它默认不是date,这个时候我们就可以重新建立索引。
# 新建了一个索引
PUT nba20200101
{
    "mappings" : {
      "properties" : {
        "age" : {
          "type" : "long"
        },
        "birthDay" : {
          "type" : "date"
        },
        "displayName" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        }
        ...
      }
    }
}

# 给新建的索引起别名,和老索引的别名一致,这样我们在使用的时候因为一直是通过别名的,所以不影响
POST _aliases
{
  "actions": [
    {
      "add": {
        "index": "nba20200101",
        "alias": "nba_latest"
      }
    }
  ]
}

# 拷贝数据过来
# 如果数据多,异步操作的话接口增加参数:/_reindex?wait_for_completion=false
POST /_reindex
{
  "source": {
    "index": "nba"
  },
  "dest": {
    "index": "nba20200101"
  }
}

(3)数据同步刷新

我们新增文档后,默认文档在缓冲区,经过一段时间才能被同步到Elasticsearch中,默认时间是1s,我们再新增文档的时候如果增加?refresh参数的话就会立即同步,如果在settings中设置为-1那么就是关闭刷新,不同步了。

# 增加这个参数立即同步
PUT nba_latest/_doc/999?refresh

# 修改刷新间隔时间,如果是-1,那么就是不刷新
PUT nba_latest/_settings
{
  "index": {
    "refresh_interval": "5s"
  }
}

(4)高亮查询

就是在查询的时候增加了highlight的设置,里面指定查哪些字段,然后这些字段中有符合文字的时候怎么对这个文字进行处理,主要就是前后增加html和各种css标签。默认是<em>xx</em>

POST nba_latest/_search
{
  "query": {
    "match": {
      "displayNameEn": "james"
    }
  },
  "highlight": {
    "fields": {
      "displayNameEn": {
        "pre_tags": ["<span class='red'>"],
        "post_tags": ["</span>"]
      }
    }
  }
}

# 查询得到的结果,除了之前我们得到的结果外,还有一个字段额外标注了高亮值
"hits" : [
  {
    "_index" : "nba20200101",
    "_type" : "_doc",
    "_id" : "214",
    "_score" : 4.699642,
    "_source" : {
      "countryEn" : "United States",
      "teamName" : "火箭",
      "birthDay" : 620107200000,
      "country" : "美国",
      "teamCityEn" : "Houston",
      "code" : "james_harden",
      "displayAffiliation" : "Arizona State/United States",
      "displayName" : "詹姆斯 哈登",
      "schoolType" : "College",
      "teamConference" : "西部",
      "teamConferenceEn" : "Western",
      "weight" : "99.8 公斤",
      "teamCity" : "休斯顿",
      "playYear" : 10,
      "jerseyNo" : "13",
      "teamNameEn" : "Rockets",
      "draft" : 2009,
      "displayNameEn" : "James Harden",
      "heightValue" : 1.96,
      "birthDayStr" : "1989-08-26",
      "position" : "后卫",
      "age" : 30,
      "playerId" : "201935"
    },
    "highlight" : {
      "displayNameEn" : [
        "<span class='red'>James</span> Harden"
      ]
    }
  },
  ...
]

(5)查询建议

第一种是词条的建议,只分词单个词条,如果输入多个单词,也是被分成多个词条一个个提供意见的。

# 自定义一个名字,输入文本,匹配哪个字段。最核心的是模式,默认是missing,就是如果没有完全匹配的才给意见。
POST nba_latest/_search
{
  "suggest": {
    "CUSTOM_SUGGESTER": {
      "text": "jamse harden",
      "term": {
        "suggest_mode": "missing",
        "field": "displayNameEn"
      }
    }
  }
}

# 可以看到以下结果有options,harden就没有options,如果我们的james也写对了,那么也没有options
{
  ...
  "suggest" : {
    "CUSTOM_SUGGESTER" : [
      {
        "text" : "jamse",
        "offset" : 0,
        "length" : 5,
        "options" : [
          {
            "text" : "james",
            "score" : 0.8,
            "freq" : 5
          },
          {
            "text" : "jamal",
            "score" : 0.6,
            "freq" : 2
          }
          ...
        ]
      },
      {
        "text" : "harden",
        "offset" : 6,
        "length" : 6,
        "options" : [ ]
      }
    ]
  }
}

# 以上是默认的missing模式,还有popular(仅提供哪些文档频率比搜索词项高的建议)和always(总是提供建议)

第二种就是词组的建议了。

POST nba_latest/_search
{
  "suggest": {
    "CUSTOM_SUGGESTER": {
      "text": "jamse harden",
      "phrase": {
        "field": "displayNameEn"
      }
    }
  }
}

# 给的建议是以词组为整体的,而不是一个单词给一组options了
# 它会考虑我们输入词之间的关系
{
  ...
  "suggest" : {
    "CUSTOM_SUGGESTER" : [
      {
        "text" : "jamse harden",
        "offset" : 0,
        "length" : 12,
        "options" : [
          {
            "text" : "james harden",
            "score" : 0.0034703047
          },
          {
            "text" : "jamal harden",
            "score" : 0.0022665835
          },
          ...
        ]
      }
    ]
  }
}

第三种是完成建议。

# 我们直接查询的话,可能会报错
# 因为要求我们的字段类型是completion
POST nba_latest/_search
{
  "suggest": {
    "CUSTOM_SUGGESTER": {
      "text": "Miam",
      "completion": {
        "field": "teamCityEn"
      }
    }
  }
}

# 我们可以利用重建索引修改字段类型type为completion
# 查出的结果options中是整个文档的数据

所以查询建议主要为了用户输入搜索关键字的时候给出的查询建议,或者在搜索结果页面,给出一些搜索关键字建议。

12、springboot整合Elasticsearch

(1)假设我们正常的业务数据都在mysql中,然后增加了Elasticsearch,所以我们先本机mysql新建一个nba的数据库名,然后通过脚本把数据导入到数据库中。

(2)使用idea搭建项目。注意使用的依赖,其中Elasticsearch的jar版本尽量与服务器端的保持一致。核心的依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.1</version>
</dependency>
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
    <version>7.2.0</version>
</dependency>
<dependency>
    <groupId>org.elasticsearch</groupId>
    <artifactId>elasticsearch</artifactId>
    <version>7.2.0</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.66</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.21</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>

(3)配置application.yml。

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/nba?useUnicode=true&characterEncoding=utf-8&erverTimeZone=GMT%2B8
    username: root
    password: xxx
    druid:
      initial-size: 5
      min-idle: 10
      max-active: 20
      web-stat-filter:
        exclusions: "*.js,*.jpg,*.png,*.gif,*.css,*.ico,/druid/*" # 不统计这些请求数据
      stat-view-servlet:
        login-username: druid
        login-password: druid
elasticsearch:
  host: 172.16.25.149
  port: 9200

(4)配置一个Elasticsearch的实例,主要是获取Elasticsearch实例,后续都基于这个去调用各个接口。

# config/ESConfig.java
@Configuration
public class ESConfig {
    @Value("${elasticsearch.host}")
    private String host;
    @Value("${elasticsearch.port}")
    private Integer port;

    @Bean
    public RestHighLevelClient client(){
        return new RestHighLevelClient(RestClient.builder(HttpHost.create("http://" + host + ":" + port)));
    }

    # getter和setter
}

(5)配置一下Mybatis。主要是扫描包。

# config/MybatisConfig.java
@Configuration
@MapperScan("com.example.esdemo.dao")
public class MybatisConfig {
}

(6)弄一个实例,model/NBAPlayer.java。

(7)弄一个dao,dao/NBAPlayerDao.java。

@Mapper
@Repository
public interface NBAPlayerDao {
    @Select("select * from nba_player")
    public List<NBAPlayer> selectAll();
}

(8)弄个Controller测试,controller/NBAPlayerController.java。

@RestController
@RequestMapping("/nba")
public class NBAPlayerController {
    @Autowired
    NBAPlayerDao nbaPlayerDao;

    @GetMapping("all")
    public List<NBAPlayer> getAllPlayers(){
        return nbaPlayerDao.selectAll();
    }
}

浏览器访问localhost:8080/nba/all可见结果。

(9)下面主要贴出在controller和service实现类中的代码,怎么使用es的接口实现增删改查的。核心接口文档官方都有详细的说明。

# controller
@RestController
@RequestMapping("/nba")
public class NBAPlayerController {
    @Autowired
    NBAPlayerService nbaPlayerService;

    @GetMapping("importAll")
    public boolean importAllPlayers(){
        return nbaPlayerService.importAllPlayers();
    }

    @GetMapping("deleteAll")
    public boolean deleteAllPlayers(){
        return nbaPlayerService.deleteAllPlayers();
    }

    @GetMapping("getPlayer")
    public Map<String, Object> getPlayer(String id){
        return nbaPlayerService.getPlayer(id);
    }

}
# service的实现类
package com.example.esdemo.service.impl;

@Service
public class NBAPlayerServiceImpl implements NBAPlayerService {

    private static final String NBA_INDEX = "nba_latest";

    @Autowired
    RestHighLevelClient client;

    @Autowired
    NBAPlayerDao nbaPlayerDao;

    @Override
    public boolean addPlayer(NBAPlayer player, String id) {
        IndexRequest request = new IndexRequest(NBA_INDEX).id(id).source(beanToMap(player));
        try {
            IndexResponse response = client.index(request, RequestOptions.DEFAULT);
            System.out.println(JSONObject.toJSON(response));
        }catch (IOException e){
            return false;
        }
        return true;
    }

    @Override
    public Map<String, Object> getPlayer(String id) {
        GetRequest request = new GetRequest(NBA_INDEX, id);
        try {
            GetResponse response = client.get(request, RequestOptions.DEFAULT);
            System.out.println(JSONObject.toJSON(response.getSource()));
            return response.getSource();
        }catch (IOException e){
            return null;
        }
    }

    @Override
    public boolean updatePlayer(String id) {
        UpdateRequest request = new UpdateRequest(NBA_INDEX, id);
        try {
            UpdateResponse response = client.update(request, RequestOptions.DEFAULT);
            System.out.println(response);
        }catch (IOException e){
            return false;
        }
        return true;
    }

    @Override
    public boolean deletePlayer(String id) {
        DeleteRequest request = new DeleteRequest(NBA_INDEX, id);
        try {
            DeleteResponse response = client.delete(request, RequestOptions.DEFAULT);
            System.out.println(JSONObject.toJSON(response));
        }catch (IOException e){
            return false;
        }
        return true;
    }

    @Override
    public boolean deleteAllPlayers() {
        DeleteByQueryRequest request = new DeleteByQueryRequest(NBA_INDEX);
        try {
            # 需要有个查询条件,但是我们要删除所有,所以查所有
            request.setQuery(QueryBuilders.matchAllQuery());
            BulkByScrollResponse response = client.deleteByQuery(request, RequestOptions.DEFAULT);
        }catch (IOException e){
            return false;
        }
        return true;
    }

    @Override
    public boolean importAllPlayers() {
        List<NBAPlayer> nbaPlayerList = nbaPlayerDao.selectAll();
        for (NBAPlayer nbaPlayer : nbaPlayerList){
            addPlayer(nbaPlayer, String.valueOf(nbaPlayer.getId()));
        }
        return true;
    }

    public static <T>Map<String, Object> beanToMap(T bean){
        Map<String, Object> map = new HashMap<>();
        if (bean != null){
            BeanMap beanMap = BeanMap.create(bean);
            for (Object key : beanMap.keySet()){
                if (beanMap.get(key) != null){
                    map.put(key + "", beanMap.get(key));
                }
            }
        }
        return map;
    }
}

我们先清空Elasticsearch中的数据,我们代码中用了nba_latest这个索引,索引要保证我们有这个索引,如果没有的话,在kibana中执行PUT nba_latest即可。

然后访问localhost:8080/nba/importAll导入所有数据到ES中。

# kibana中查一下,数据都有了
POST nba_latest/_search
{
  "query": {
    "match_all": {}
  }
}

访问http://localhost:8080/nba/getPlayer?id=1可以查到这个数据。

接下来删除这个数据http://localhost:8080/nba/deleteAll再去kibana中查看发现没有数据了。

(10)模拟搜索

搜索主要用的是Search开头的系列接口,具体文档见官方。下面只列出主要部分。

# controller
@GetMapping("searchMatch")
public List<NBAPlayer> searchMatchNamePlayer(@RequestParam String displayNameEn){
    return nbaPlayerService.searchMatch("displayNameEn", displayNameEn);
}

@GetMapping("searchTerm")
public List<NBAPlayer> searchTerPlayer(@RequestParam(required = false) String country, @RequestParam(required = false) String teamName){
    if (!StringUtils.isEmpty(country)){
        return nbaPlayerService.searchTerm("country", country);
    }else {
        return nbaPlayerService.searchTerm("teamName", teamName);
    }
}
# 实现类
@Override
public List<NBAPlayer> searchMatch(String key, String value) {
    SearchRequest request = new SearchRequest(NBA_INDEX);
    SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
    sourceBuilder.query(QueryBuilders.matchQuery(key, value));
    sourceBuilder.from(0);
    sourceBuilder.size(100);
    request.source(sourceBuilder);
    try {
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        SearchHit[] hits = response.getHits().getHits();
        List<NBAPlayer> nbaPlayers = new ArrayList<>();
        for (SearchHit hit : hits){
            nbaPlayers.add(JSONObject.parseObject(hit.getSourceAsString(), NBAPlayer.class));
        }
        return nbaPlayers;
    }catch (IOException e){
        return null;
    }
}

@Override
public List<NBAPlayer> searchTerm(String key, String value) {
    # 其他部分和上面的matchQuery都一样
    ...
    sourceBuilder.query(QueryBuilders.termQuery(key, value));
    ...
}

访问http://localhost:8080/nba/searchMatch?displayNameEn=james%20harden模拟搜索球员名字。

访问http://localhost:8080/nba/searchTerm?country=%E8%A5%BF%E7%8F%AD%E7%89%99或者http://localhost:8080/nba/searchTerm?teamName=%E7%81%AB%E7%AE%AD是精确搜索球员国家或者球员所在球队的。

首字母匹配的,比如点击索引J可以列出名字首字母是J的球员,这个和上面的类似,只是又换了一个接口,接口名字叫做prefixQuery(key, value),这里就不展示了。

13、集群

(1)基础概念

集群的好处就不展开说了,提高可用性,还能顺便实现负载均衡,因为多台机器还实现了高性能。

集群(cluster)由多个节点组成。一个节点其实就是一个Elasticsearch实例,我们测试的时候可以在一台机器上开很多个Elasticsearch实例,但是实际生产环境中建议每台服务器部署一个实例,也就是一台服务器上一个节点。

每个节点都设置相同的集群名称,这就意味着它们是一个集群。所以在不在集群中,是看每个节点它们设置的集群名字是否一致。

每个节点可以通过配置文档声明是否准备竞选主节点(通过node.master设置true和false控制)或是否存储数据(通过node.data设置true和false控制)。

# 竞选主节点+存储数据
node.matser: true
node.data: true
# 只存储数据
node.matser: false
node.data: true
# 不竞选也不存储,那干嘛的?主要是用于负载均衡
node.matser: false
node.data: false

每个索引可以设置多个分片(shard),每个分片又有主分片(primary shard)和副分片(replica shard)。副分片作用可以用于读写分离达到负载均衡。在settings中设置,主分片不能动态调整,所以设置前要三思,副分片可以动态调整:

{
    "settings":{
        "idnex": {
            "number_of_shards": 1,
            "number_of_replicas": 1
        }
    }
}

(2)搭建集群

新建3个虚拟主机,CentOS7的。我们之前一直使用的就当做是1号,然后我们还需要安装2台。每台的安装过程参见文档前面,主要说明的是因为这里设置的是集群,所以在elasticsearch.yml上有一些说明:

# 集群名称,都设置一样的就行
cluster.name: hello-word
# 节点名称,三台依次编号即可 
node.name: node-1
# 都设置成网关地址
network.host: 0.0.0.0
# 都设置成端⼝
http.port: 9200
# 各节点ip,用于被服务发现的
discovery.seed_hosts:["172.16.25.149:9300","172.16.25.150:9300","172.16.25.151:9300"]
# 要成为主节点的节点名字
cluster.initial_master_nodes: ["node-1","node-2","node-3"]
# 数据和存储路路径,根据实际服务器设置
path.data: /home/tom/es/data
path.logs: /home/tom/es/logs

# 以下几个配置,因为配置文件中是默认没有的,我也没有设置
# 部分的是否开启或者内部沟通端口应该是采用了默认值
# 都设置成有资格竞选主节点
node.master: true
# 都能设置成存储数据
node.data: true
# 都设置成最⼤集群节点数
node.max_local_storage_nodes: 3
# 都设置成内部节点之间沟通端⼝
transport.tcp.port: 9300

记得防火墙开通9300端口,不然节点之间没有联系,所以集群是组成不了的。看集群是否生效,可以直接访问http://172.16.25.149:9200/或者其他几台看看,返回的是否有cluster uuid。或者访问http://172.16.25.150:9200/_cat/health?v看看具体情况,其中有节点信息。

但是,这个样子没法直观管理集群。我们在第一台上有一个kibana,我们配置一下kibana.yml,让它连接到我们的几台节点。

elasticsearch.hosts: ["http://172.16.25.149:9200", "http://172.16.25.150:9200", "http://172.16.25.151:9200"]

启动kibana,访问172.16.25.149:5601看看,倒数第二个菜单stack monitoring,进到界面中可以发现有集群信息了。标星的是主节点,我们停掉这个主节点所在的实例后刷新试试看看主节点以及节点信息是否有变化:我们3台变成2台后,整个集群状态从绿色变成黄色了,这是直观地监测集群健康状态的。

(3)集群索引管理

7.x版本之前,ES默认为每个索引创建5个主分片,但是在7.x中默认是1个主分片和1个副分片。

我们尝试在kibana的控制台中新建一个索引,设置主分片和副分片。

PUT nba_latest
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1
  }
}

kibana中可以查看所有Indices的分片情况,总共有几个分片(应该是6),在每个节点上的分布情况等等都可以看到。如果其中一台出故障,分片会重新分配,故障修改也会重新分配。

  1. 分片是ES自动管理的。
  2. 单机上设置副分片没有意义,起不到备份作用,一起挂掉了。
  3. 在集群中,我们也可以看到主分片和副分片是被自动分在不同的节点中的。因为如果把他们放在同一个节点上,那起不到备份作用啊。
  4. 可以手动移动主分片的位置,比如把分片2从node-1移动到node-3,移动过去之后,整个分片会自动被调整,以保证主分片和副分片不在一个节点中。
POST _cluster/reroute
{
  "commands": [
    {
      "move": {
        "index": "nba_latest",
        "shard": 2,
        "from_node": "node-1",
        "to_node": "node-3"
      }
    }
  ]
}
  1. 主分片设置后不能修改,要么就重建索引,所以一开始尽量多设置一些。副分片可以手动调整数量,设置多个副分片相当于设置多个备份。

(4)集群健康管理
我们之前说了,可以通过接口/_cat/health?v查看,green对应kibana里面的健康颜色,我们之前关掉只剩2个节点时变成yellow表示可用但是不可靠了,如果变成red就表示集群不可用了。还有总节点数,数据节点数量、主分片数量等。

epoch      timestamp cluster     status node.total node.data shards pri relo init unassign pending_tasks max_task_wait_time active_shards_percent
1583335854 15:30:54  hello-world green           3         3     14   7    0    0        0             0                  -                100.0%

我们也可以通过这个接口/_cat/indices?v查看索引的分片分布情况等等。

health status index                           uuid                   pri rep docs.count docs.deleted store.size pri.store.size
green  open   nba_latest                      J18qZz3qQ4uNVB-R4aCFVw   3   1          0            0      1.6kb           849b

接口/_cat/allocation?v查看每个节点的磁盘分配情况。

接口/_cat/nodes?v查看节点信息。

_cat下还有其他接口,可以查看官方文档了解。

14、Elasticsearch原理

(1)分布式原理

ES的集群实现还是相对简单的,我们只要启动多个节点,节点设置统一的集群名称,然后给索引设置分片数,里面的分片管理这些都是ES帮我们自动实现的。

master主节点和主分片双重作用原理如下:

  1. master主节点负责管理集群状态和管理分片的分配(比如有节点增减时需要重新分配分片)。
  2. 当一个写操作来了,是直接找到主分片,注意这个主分片不一定在主节点上,主分片写完再同步到副分片上。
  3. 读写的请求可以被任意一个节点接收到,接收到之后如果不在这个节点那么它会转到目标节点上去。
  4. 具体到写,举个例子,如果请求到主节点1,节点1发现主分片在节点3,那么请求转到节点3,节点3写完主分片后,分别同步到节点1和节点2的副分片上。副分片都同步成功后,节点3向节点1报告成功,节点1再成功返回给客户端。
  5. 具体到读,举个例子,如果请求到主节点1,节点1会通过轮询各个副分片达到负载均衡。所以节点多副分片多的情况下,相当于自建了一个负载均衡。

(2)路由原理

当一个索引有多个分片的时候,新增文档时到底放在哪个分片呢?这就是由路由算法决定的。默认的routing是文档的_id。这个公式也说明了为什么主分片数量设置好之后不能修改,因为修改后,之前路由算出来的文档可能永远找不到具体在哪个分片上了。

shard = hash(routing) % number_of_primary_shard

可以通过接口查看某个文档的分片存储情况,可以看到存在哪个分片上,在哪个节点上等等。

# 这个1就是文档id
GET nba/_search_shards?routing=1

(3)乐观锁

因为ES使用场景仍然是读多写少,所以采用乐观锁可以提高性能。乐观锁是通过文档中的_version字段实现的。我们再对文档执行一些操作时,_version字段会自动加1。关于乐观锁和悲观锁的说明,这里不再赘述。

(4)倒排索引

倒排索引也是一种索引。本质上有两个过程:

  1. 建立索引。当一个文档新增过来了,我们要分析它里面的内容,把里面的内容分词后,放在一个地方(就是索引),这个地方记录的是这个单词出现在哪个文档中、哪个位置、出现的频率等等信息,如果这个单词之前已经有记录过了,那么就会和之前的那个记录合并在一起。
  2. 查询索引。到了使用的时候,查某个单词,先查索引,发现这个单词有,并且在哪些文档中,哪些位置,出现频率等等一清二楚,那么可以直接根据文档id把整个文档数据都返回,这就是倒排索引。

(5)ES的分词原理

我们可以对text类型的字段指定分词器,因为默认的是standard对中文不太友好,所以我们可能使用到ik_max_word之类的分词器。

分词器的设置在设置mapping的时候指定,而且不能修改。这样的话,我们新增文档的时候,这个字段就会使用设置的分词器去分词监理索引,这就是写时分词。

那么我们查询这个字段的时候,比如写了一段话或者一个词,也会默认先使用和写时分词同样的分词器分词,分完之后再匹配查询,读写使用相同的分词器可以保证查询匹配最好,举个例子,一段话乔丹很厉害设置了中文分词器,那么监理索引的时候其实只有乔丹,没有,这样我们在用关键字乔丹查询的时候,如果故意设置了分词器为standard,那么就会被分成导致在索引中找不到,返回空结果。

分词器(analyzer)一般分为3部分:

  1. 字符过滤器(char filter)。可以有0个或多个。
  2. 分词器(tokenizer)。拆分成多个词。有且只能有1个。
  3. token过滤器(token filter)。对拆出来的再过滤一遍,比如在stop中词就删除掉。或者这种过滤是转换的作用,比如转换小写等等。

举例,standard标准分词器的组成:

  1. tokenizer:Standard Tokenizer。
  2. token filter: Standard Token Filter和Lower Case Token Filter。