本文着力于介绍ES入门级的知识,目的是让大家能懂得基本使用,能应用到工作中;并产生兴趣,进而愿意去学习ES的深层设计思想和细节。
更深的ES框架设计思想和细节,欢迎关注后续文章。
一.ES是什么
ElasticSearch(后文简称ES)是一款稳定高效的近实时的分布式搜索和分析引擎。它的底层基于Lucene(一款强大的搜索和分析引擎),在Lucene基础上提供了友好的RESTful风格的交互方式。ES开箱即用,上手容易,性能优秀。
官网给了ES的几个典型使用场景:
-
日志管理、系统指标检测和可视化
-
卓越的搜索体验,支持文档,地理数据等各种内容
-
交互式调查和自动威胁检测
目前ES在各大IT公司都有广泛的应用,比如Github拿来检索代码库,eBay拿来检索商品数据。当然数据量如果大到一定程度,ES的性能就可能不够了。
那么可能有同学就问了,mysql不是也可以做搜索吗?为啥要用es来做呢?原因是mysql的性能就满足不了需求,在模糊查询的情况下,mysql的索引发挥不了作用,查找很慢。
而ES就是专门做搜索的,它有几个突出的优点:
-
搜索速度快(近实时)
-
搜索到的数据可以进行评分,这样我们只需要返回评分高的数据给用户就行了(就像大家用搜索引擎一样,估计大部分都不会去点开第二页)。评分机制
-
关键字不需要很准确也可以搜出相关的结果
一句话,Made For Search!
二.基本概念
上面我们讲到,ES是支持分布式的,所以它也支持集群。我们先了解下ES的一些基本组件。
-
Node(节点):进行数据存储,参与搜索和排序的单个实例。每个Node都有自己的唯一标识名
-
Cluster(集群):一个或多个Node组成的集合,人多力量大。ES具有自发现的能力,会自动寻找网络上配置在相同集群中的节点共同组成一个集群
-
Document(文档):ES中信息存储和检索的最小单位,以json的形式存储。
-
Field(字段):每个文档包含多个字段,类比可以想象json文件也有多个字段。
-
Index(索引):一些具有相似特征的文档的集合。
-
Type(类型):Type是Index的逻辑类别分区,相当于一个index可以有多个type分区。不过从6.0.0之后,ES废弃了Type的概念。
-
Shard(分片):当Index存储大量数据时,可能会超过单个节点的硬件限制,ES提供了把索引垂直切分为分片的机制,这样就可以跨分片分发和并行化操作,提高性能和吞吐量。
-
Replica(副本):ES提供了将分片复制为一个或多个副本的功能,这样在复杂多变的网络环境中出现故障后也能快速地恢复。
Node和Cluster是服务相关的概念,一般接触过分布式的同学都会多少有些了解。Document、Index、Type、Field跟数据库的概念也可以对应上
三.ES集群
我们先看一个ES集群的架构图,
ES集群中的每个节点都会有两个配置项:
-
node.master:表示节点是否具有成为主节点的资格。主节点负责管理集群范围内的所有变更,例如增加、删除索引,或者增加、删除节点等,而并不需要涉及到文档级别的变更和搜索等操作。
-
node.data:表示节点是否能存储数据,处理文档变更,搜索等
针对这两个参数的配置不同,某个节点可能会有四种角色:
-
node.master为true:主节点,不处理数据
-
node.master和node.data都为true:主节点,同时也处理数据
-
node.data为true:数据节点
-
node.master和node.data都不为true:称为协调节点,不管理集群,也不维护数据,主要用作负载均衡。主节点和数据节点都拥有协调节点的能力。
ES给分片提供了Replicas机制,分片分为主分片和副分片,数据写入的时候是写到主分片,副本分片会复制主分片的数据,读取的时候主副分片都可以读。Index需要分成多少个主副分片可以通过配置设置。但是有一个强制要求:一个主分片至少有一个副分片,且主分片P0对应的副分片R0不能跟P0同时处于一个node上。当某个节点挂了,这个节点上的主分片也就挂了,那么master node就会把主分片对应的副分片提拔为主分片。
对于主分片来说,一个文档只会存在一个主分片上面,那么怎么定位这个存储了文档的分片呢?根据公式来算的。
shard = hash(routing) % number_of_primary_shards
routing是拿来定位分片的可变值,默认是文档id。routing的选择非常重要,能显著提升ES使用性能。对应到我们C端ES来说,文档id是product_id,routing是shop_id,这是因为使用场景大多是店铺相关。
number_of_primary_shards是主分片的数量,这个是创建索引的时候就要确定好的。
所以协调节点的作用,就是根据公式计算出每个请求的具体需要处理的分片。
-
对于写操作(如写入文档),协调节点会路由到对应的主分片进行写入,然后复制到副本分片中。主副节点都写入成功之后,返回给客户端结果。
-
对于读操作(如读取某个文档),通过round-robin随机轮训算法随机寻找主副分片中的一个,进行读取操作。
-
对于搜索操作(如根据条件搜索),协调节点会把命令转发到所有的主分片或副分片,每一个分片将自己搜索的结果返回给协调节点,由协调节点进行数据的合并,排序,分页等操作,产出最后的结果。
四.搜索原理
4.1 倒排索引
前面我们说到了,ES是为搜索而生,那么为什么他可以做到那么高的搜索性能呢,主要是因为他使用了Lucene的倒排索引技术。
假设我们有这样几条数据。
docId | name | age | sex | desc |
---|---|---|---|---|
1 | Ada | 18 | women | rich and smart |
2 | Carla | 20 | women | rich and tough |
3 | John | 18 | man | beautiful |
那么这样每一行都是一个document,每个document都有一个docId。
从docId去查询到这一条记录,我们称为是正排索引。而通过姓名,年龄,性别这些参数去查询到对应的docId,就叫做倒排索引。
在这个例子中,建立的倒排索引就是
name | |
---|---|
Ada | [1] |
Carla | [2] |
John | [3] |
age | |
---|---|
18 | [1,3] |
20 | [2] |
sex | |
---|---|
man | [3] |
woman | [1,2] |
这样如果我们想找男性且年龄为18的,就可以求交集得到docId为3。
而对于desc这种长端的文本,es会采用分词技术,拆解为单个有意义的词组,同样建立倒排索引
desc | |
---|---|
rich | [1,2] |
smart | [1] |
tough | [2] |
beautiful | [3] |
我们将倒排索引的查询项称为term,如Ada,man等。将对应查出来的docId list称为posting list。
但是,因为每个字段可能都有不同的取值,index规模大了以后,term的规模也会很大,这就需要ES能较快的查询到term。
4.2 ES Term查询
假设我们有很多个term,比如:
Carla,Sara,Elin,Ada,Patty,Kate,Selena
如果按照这样的顺序排列,找出某个特定的term一定很慢,因为term没有排序,需要全部过滤一遍才能找出特定的term。排序之后就变成了:
Ada,Carla,Elin,Kate,Patty,Sara,Selena
这样我们可以用二分查找的方式,比全遍历更快地找出目标的term。这个就是 term dictionary。
但是term dictionary可能会非常大,没办法放在内存中,只能放在硬盘中分块存储。于是,es采用了trie树来记录term的前缀,称为term index。
通过term index可以快速的定位到某个term dictionary的某个offset,然后从这个位置再往后查找。再加上一些压缩技术,term index的尺寸可能是所有term的尺寸的几十分之一,从而可以将term index缓存到内存中。
查询方式为:
注:数值类型的范围查找,并没有使用倒排索引,可以参考range原理
五.基本使用
5.1 搭建集群
5.1.1 搭建ES集群
接下来我们从0到1搭建一个集群,方便后续的练习。我这里使用的是公司的开发机,使用docker编排容器,es版本为7.8.1。
1.首先我们需要在开发机上安装docker,可参考 docker安装
2.安装好docker后,我们需要先拉取es的镜像。
docker pull elasticsearch:7.8.1
3.配置网络
为了模拟我们的es是独立服务器,我们可以使用docker网络IP指定隔离;docker 创建容器时默认采用的bridge网络,自行分配IP,不允许我们自己指定。而在实际部署中,我们需要指定容器IP,不允许其自行分配IP,尤其是搭建集群时,固定IP时必须的。所以我们可以创建自己的bridge网络:mynet,创建容器的时候指定网络为mynet并指定IP即可
#查看网络模式
docker network ls
#创建一个新的bridge网络-mynet
docker network create --driver bridge --subnet=172.18.12.0/16 --gateway=172.18.1.1 mynet
#查看网络详情
docker network inspect mynet
#以后使用--network=mynet --ip 172.18.12.x 指定IP
4.接下来我们可以编写两个脚本,用于部署es的master节点和data节点。
创建三个master节点
for port in $(seq 1 3); \
do \
mkdir -p ~/es/docker_es/elasticsearch/master-${port}/config
mkdir -p ~/es/docker_es/elasticsearch/master-${port}/data
chmod -R 777 ~/es/docker_es/elasticsearch/master-${port}
cat <<EOF >~/es/docker_es/elasticsearch/master-${port}/config/elasticsearch.yml
cluster.name: my-es #集群名称,同一集群该值必须设置相同
node.name: es-master-${port} #该节点的名字
node.master: true #该节点有机会成为master节点
node.data: false #该节点可以存储数据
network.host: 0.0.0.0
http.host: 0.0.0.0 #所有http均可访问
http.port: 920${port}
transport.tcp.port: 930${port}
discovery.zen.ping_timeout: 10s #设置集群中自动发现其他节点时ping连接的超时时间
discovery.seed_hosts: ["172.18.12.21:9301","172.18.12.22:9302","172.18.12.23:9303"] #设置集群中的master节点的初始化列表,可以通过这些节点来自动发现其他新加入集群的节点,es7的新增配置
cluster.initial_master_nodes: ["172.18.12.21"] # 新集群初始时的候选主节点,es7的新增配置
EOF
docker run --name es-master-${port} \
-p 920${port}:920${port} -p 930${port}:930${port} \
--network=mynet --ip 172.18.12.2${port} \
-e ES_JAVA_OPTS="-Xms300m -Xmx300m" \
-v ~/es/docker_es/elasticsearch/master-${port}/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \
-v ~/es/docker_es/elasticsearch/master-${port}/data:/usr/share/elasticsearch/data \
-v ~/es/docker_es/elasticsearch/master-${port}/plugins:/usr/share/elasticsearch/plugins \
-d elasticsearch:7.8.1
done
创建三个data节点
for port in $(seq 4 6); \
do \
mkdir -p ~/es/docker_es/elasticsearch/node-${port}/config
mkdir -p ~/es/docker_es/elasticsearch/node-${port}/data
chmod -R 777 ~/es/docker_es/elasticsearch/node-${port}
cat <<EOF >~/es/docker_es/elasticsearch/node-${port}/config/elasticsearch.yml
cluster.name: my-es #集群名称,同一集群该值必须设置相同
node.name: es-node-${port} #该节点的名字
node.master: false #该节点有机会成为master节点
node.data: true #该节点可以存储数据
network.host: 0.0.0.0
http.host: 0.0.0.0 #所有http均可访问
http.port: 920${port}
transport.tcp.port: 930${port}
discovery.zen.ping_timeout: 10s #设置集群中自动发现其他节点时ping连接的超时时间
discovery.seed_hosts: ["172.18.12.21:9301","172.18.12.22:9302","172.18.12.23:9303"] #设置集群中的master节点的初始化列表,可以通过这些节点来自动发现其他新加入集群的节点,es7的新增配置
cluster.initial_master_nodes: ["172.18.12.21"] # 新集群初始时的候选主节点,es7的新增配置
EOF
docker run --name es-node-${port} \
-p 920${port}:920${port} -p 930${port}:930${port} \
--network=mynet --ip 172.18.12.2${port} \
-e ES_JAVA_OPTS="-Xms300m -Xmx300m" \
-v ~/es/docker_es/elasticsearch/node-${port}/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \
-v ~/es/docker_es/elasticsearch/node-${port}/data:/usr/share/elasticsearch/data \
-v ~/es/docker_es/elasticsearch/node-${port}/plugins:/usr/share/elasticsearch/plugins \
-d elasticsearch:7.8.1
done
最后出来的集群就是这样
这个时候我们就可以通过http://{devbox_ip}:9201/_cluster/health?pretty 来看到集群的配置详情了。
5.1.2 搭建kibana工具
创建了es集群以后,我们再搭建一个kibana工具,便于我们使用这个es集群。
1.先拉取kibana镜像:注意kibana的版本要和es一致,不然会有问题
docker pull kibana:7.8.1
2.接下来我们可以运行这个镜像
# --link需要填写kibana连接到的es容器的名字 --net就是我们之前设定的网络名
docker run --name kibana --link=es-master-1 --net="mynet" -p 5601:5601 -d kibana:7.8.1
3.一般情况下,这样操作以后都是没办法正常运行的。我们需要修改kibana的配置项。我们先进入到当前的kibana容器
docker exec -it {container_id} /bin/bash
然后修改容器中的 config/kibana.yml 文件
将红框位置修改为你es容器的配置,然后退出来,重新restart kibana容器
docker restart {container_id}
4.这时候,我们就可以通过 http://{devbox_ip}:5601/ 访问到kibana页面了。
http://10.227.15.83:5601/app/kibana#/dev\_tools/console
5.2 settings && mapping
我们可以通过每个索引的settings和mapping来自定义索引行为。
settings定义的是索引维度的行为,比较重要的几个配置项:
-
**index.number_of_replicas:**每个主分片的副本数
-
index.refresh_interval:刷新间隔,默认为1s,数据写入以后处于不可读状态,只有刷新以后才能读到。
-
index.max_result_window:数据查询窗口。默认为1w,不建议修改。
mapping定义的是单个document的行为,类似于mysql的约束schema。
elasticsearch中一条数据对应一条document,一条document包含一个或多个字段,例如:
{
"name": "Bob",
"host": "NYC",
"date": "2020-02-11",
"balance": 5000,
...
}
不同的字段拥有不同的类型(type),不同的类型又会导致Elasticsearch在写入(index)和查询(query)中的行为方式不同。
Mapping类型大致分为以下类型:
-
字符串类型:早期为string类型,目前有text,keyword类型,text类型会进行分词,适用于全文查找,而keyword适用于精确匹配。
-
数字类型:integer,long,double等类型标识
-
日期类型:date, date_nanos等
-
地理位置信息类型:类似geo-*的信息
-
其他:包括布尔类型,ip类型,嵌套类型,范围类型,形状类型等)
详见links
{
"dynamic": "false",
"properties": {
"biz_type": {
"type": "keyword"
},
"bmp_seller_type": {
"type": "keyword"
},
"bmp_source": {
"type": "keyword"
}
}
}
这里要注意,mapping的选择会影响到查询的性能。对于不需要进行range查询,排序的数值类型,如product_id,不应该使用long类型,应该用keyword。
5.3 ES常用指令
- 索引处理
// 创建索引
PUT /customer
// 删除索引
DELETE /customer
- 文档写入删除
// 文档写入
PUT /customer/doc/1
{
"name": "john"
}
// 查看索引中的文档
GET /customer/doc/1
// 修改文档
POST /customer/doc/1/_update
{
"doc": { "name": "Jane Doe" }
}
// 删除文档
DELETE /customer/doc/1
- 搜索全部、排序、分页、指定字段
// 搜索全部
GET /bank/_search
{
"query": { "match_all": {} }
}
// 分页搜索,from表示偏移量,从0开始,size表示每页的数量
GET /bank/_search
{
"query": { "match_all": {} },
"from": 0,
"size": 10
}
// 搜索排序,使用sort表示,比如按balance降序排列
GET /bank/_search
{
"query": { "match_all": {} },
"sort": { "balance": { "order": "desc" } }
}
// 搜索并只返回指定字段内容,使用_source表示,例如只需要account_number和balance两个字段
GET /bank/_search
{
"query": { "match_all": {} },
"_source": ["account_number", "balance"]
}
- 条件查询
数值类型查询
GET /bank/_search
{
"query": {
"match": {
"account_number": 20
}
}
}
// 查询某个范围的数据。被允许的操作符有 gt大于、gte大于等于、lt小于、lte小于等于
{
"query": {
"range": {
"account_number": {
"gte": 20,
"lt": 30
}
}
}
}
文本类型查询。可以使用match,multi_match、terms等字段
// 文本类型条件搜索, 比如搜索match对于文本是模糊匹配。这里搜索含有mill的文档
GET /bank/_search
{
"query": {
"match": {
"address": "mill"
}
}
}
// 短语匹配搜索,使用match_phrase表示,这里搜索address字段同时含有mill和lane的文档
GET /bank/_search
{
"query": {
"match_phrase": {
"address": "mill lane"
}
}
}
// 使用terms查询,可以指定多值进行匹配。如果这个字段包含了指定值中的任何一个值,那么则符合条件
GET /bank/_search
{
"query": {
"terms": {
"tag": ["search","full_text","nosql"]
}
}
}
// 存在性判断。可以使用exists和missing查找指定字段中有值或无值的文档。比如我在这里可以看balance没有赋值的文档
{
"query": {
"exists": {
"field": "balance"
}
}
}
- 除了上面的单条件查询,ES还提供了多组合查询
-
must :文档 必须 匹配这些条件才能被包含进来。
-
must_not:文档 必须不 匹配这些条件才能被包含进来。
-
should:如果满足这些语句中的任意语句,将增加 _score ,否则,无任何影响。它们主要用于修正每个文档的相关性得分。
-
filter:必须 匹配,但它以不评分、过滤模式来进行。这些语句对评分没有贡献,只是根据过滤标准来排除或包含文档。
{
"bool": {
"must": { "match": { "title": "how to make millions" }},
"must_not": { "match": { "tag": "spam" }},
"should": [
{ "match": { "tag": "starred" }}
],
"filter": {
"bool": {
"must": [
{ "range": { "date": { "gte": "2014-01-01" }}},
{ "range": { "price": { "lte": 29.99 }}}
],
"must_not": [
{ "term": { "category": "ebooks" }}
]
}
}
}
}
注意:must与filter虽然都是 必须要 的语义,但是must会计算每个文档的评分,按评分顺序返回文档,而filter只是单纯做判断。所以一般情况下filter性能好于must。