赞
踩
大数据时代系统和业务每分每秒都产生成千上万的数据,其存储一定是不能通过关系型数据库了,当然因为数据的持久性也不能存储到内存型Nosql数据库Redis中,我们通常会将这些数据存储在能够不丢失数据的非关系型数据库中,这个技术选型有很多,例如HBase、Cassandra,这里我们暂不关心其数据存储,留待日后讨论,我们关注的是另一件事,如何能在分布式的数据库中进行PB级的数据检索,目前市场上较为成熟的解决方案中间件就是ElasticSearch,本文将从使用背景开始对ElasticSearch进行全面讨论:
适合人群:不了解ElasticSearch的新手,对ElasticSearch的实现机制感兴趣的技术人员
本文的全部内容来自我个人在ElasticSearch学习过程中整理的博客,是该博客专栏的精华部分。在书写过程中过滤了流程性的上下文,例如部署环境、配置文件等,而致力于向读者讲述其中的核心部分,如果读者有意对过程性内容深入探究,可以移步MaoLinTian的Blog,在这篇索引目录里找到答案。
有因才有果,先了解下为什么使用全文检索,才能最终料到ElasticSearch。本节从以下三个方面来进行讨论:全文检索的应用场景、基本概念、实现思路。
因为我们的数据世界存在不规则的数据,而我们又需要对这些数据进行快速检索,所以和关系数据库类似,需要创建索引,这就引出了全文检索的概念。
先来了解一个前置概念:数据类型的分类,数据的来源按数据类型可分为:结构化数据、半结构化数据、非结构化数据。
对于结构化数据来说我们的查询、展示和分析很方便,强大的SQL语句和规范的表结构让这一点很容易做到,但是对于非结构化数据来说并不容易,尤其是进行查询的时候,如果给你一推文件,让你找出包含某个字符串的所有文件是很难实现的,一个两个还可以目测,多了就不行了。所以如何快速定位到满足查询条件的文档(数据)呢?
要想实现上述需求,我们可以按照非结构化数据的处理思路来看:一般将非结构化数据存放在文件系统中,数仓中记录数据的信息,如标题、摘要、创建时间等,方便进行索引查询,也就是从文件数据提取数据标签,描述这个文件是做什么的,这样我们搜索的时候只要搜索这些标签即可找到目标的数据文件。
以上非结构化查询方式抽象而言可以理解为将非结构化数据中的一部分信息提取出来,重新组织,使其变得有一定结构,然后对此有一定结构的数据进行搜索,从而达到搜索相对较快的目的。这部分从非结构化数据中提取出的然后重新组织的信息,我们称之索引,这种先建立索引,再对索引进行搜索的过程就叫全文检索(Full-text Search)。虽然创建索引的过程也是非常耗时的,但是索引一旦创建就可以多次使用,全文检索主要处理的是查询,所以耗时间创建索引是值得的。
这里说到我们将非结构化数据提取出来使其变得有结构,那么有人可能会问:如果我们把这些结构化标签存储在MySQL里不行么,还有这篇blog聊的ElasticSearch什么事儿呢?事实上MySQL也提供全文检索能力,但是没有ElasticSearch好用,这里简单解释一下为什么不用关系型数据库处理索引:
之后我们在详细聊到ElasticSearch再去讨论它是如何做到满足以上几个要求的。
对于数据量大、数据结构不固定的数据可采用全文检索方式搜索,比如百度、Google等搜索引擎、论坛站内搜索、电商网站站内搜索等,总而言之,只要用到搜索的地方,都可以使用全文检索进行搜索,
全文检索的整体实现思路如下图所示,左侧部分为创建索引,右侧部分为查询索引:
上图的执行流程说明如下:
我们来具体看下索引库的基本创建过程和查询过程。
对文档索引的过程,将用户要搜索的文档内容进行索引,索引存储在索引库(index)中,也就是左侧的绿色流程:
Lucene is a Java full-text search engine.
分析后得到的语汇单元:lucene、java、full、text、search、engine。每个单词叫做一个Term,不同的域中拆分出来的相同的单词是不同的term。term中包含两部分一部分是文档的域名,另一部分是单词的内容,例如{"FileName":"springmvc"}
就是域FileName上的一个term,这样我们在该域上进行检索的时候,只要检索springmvc就能找到当前文档。实际上以上过程就是将非结构化的一些数据进行结构化提取并索引的过程。
查询索引也是搜索的过程。搜索就是用户输入关键字,从索引(index)中进行搜索的过程。根据关键字搜索索引,根据索引找到对应的文档,从而找到要搜索的内容(这里指磁盘上的文件),也就是右侧红色的部分:
{"FileName":"springmvc"}
以上部分就是全文检索如何实现索引库的查询
按照如上的流程来模拟一遍。提出如下需求:给出一组doc文件,如下图所示,用于建立索引和搜索,找到指定的文档:要求找出所有文件名包含丑字的文件。
那么按照需求我们来看下如何实现,创建文档的相关索引,按照流程处理如下:
"FieldName":"tml超级帅"
"FieldName":"丑"
—>很丑.txt[文档id为5]–>tml介于帅和丑之间.txt[文档id为1],挂载了两篇文档查询文档过程是从索引库中搜索内容,按照流程处理过程如下:
"FieldName":"丑"
以上就是全文检索的实现逻辑,了解了实现逻辑后,其实大家就知道,一定有人会写一些工具至少是类库来加速这个过程。
熟悉全文检索概念的话知道其实早期实现方式是用Lucene的,什么是Lucene呢?Lucene是一个开放源代码的全文检索引擎工具包,但它不是一个完整的全文检索引擎,而是一个全文检索引擎的架构,提供了完整的查询引擎和索引引擎,部分文本分析引擎(英文与德文两种西方语言)。Lucene的目的是为软件开发人员提供一个简单易用的工具包,以方便的在目标系统中实现全文检索的功能,或者是以此为基础建立起完整的全文检索引擎。在Java开发环境里Lucene是一个成熟的免费开源工具。一句话概括,Lucene就是一组实现全文检索的Jar包。而ElasticSearch就是基于Lucene实现的工业级全文检索引擎
Elaticsearch,简称为es, es是一个开源的高扩展的分布式全文检索引擎,它可以近乎实时的存储、检索数据;本身扩展性很好,可以扩展到上百台服务器,处理PB[1024TB]级别的数据。es也使用Java开发并使用Lucene作为其核心来实现所有索引和搜索的功能,但是它的目的是通过简单的RESTful API来隐藏Lucene的复杂性,从而让全文搜索变得简单:
以上的所有特点都标注了Elasticsearch实质上是一款高效的分布式的全文搜索引擎
ElasticSearch是一个面向文档的搜索引擎,这意味着它可以存储整个对象或文档(document)。然而它不仅仅是存储,还会索引(index)每个文档的内容使之可以被搜索。在Elasticsearch中,你可以对文档(而非成行成列的数据)进行索引、搜索、排序、过滤。Elasticsearch比传统关系型数据库如下:
类别 | 库 | 表 | 行 | 列 |
---|---|---|---|---|
关系型数据库 | Databases | Tables | Rows | Columns |
ElasticSearch | Indices | Types | Docements | Fields |
Elasticsearch具备接近实时 NRT,Elasticsearch是一个接近实时的搜索平台。这意味着,从索引一个文档直到这个文档能够被搜索到有一个轻微的延迟,通常1秒以内,关于这个延迟后边我们会详细讨论到。 | ||||
Elasticsearch 概念 | 解释 | Lucene概念 | ||
– | :– | – | ||
索引 | 索引index就是相似特征文档的集合。例如产品目录索引、订单索引。一个索引由一个名字来标识(必须全部是小写字母),当对索引中的文档进行CRSUD的时候都需要指定索引编码。一个集群中可以定义任意多的索引 | 索引 | ||
类型 | 一个索引中可以定义一种或多种类型type。一个类型是索引的一个逻辑上的分类/分区,通常,会为具有一组共同字段的文档定义一个类型。ES7.X新版本中移除了这个属性,恒定为_doc,这么做是为了和Lucene一致 | |||
字段 | 相当于数据的字段Field,对文档数据根据不同属性进行的分类标识 | 字段域 | ||
映射 | mapping是处理数据的方式和规则的一些限制,如某个字段的**数据类型(type)、分析器(analyzer)、是否被索引(index)、是否存储(store)**等等 | 字段域属性配置集合 | ||
文档 | 一个文档是一个可被索引的基础信息单元。比如,你可以拥有某一个客户的文档,某一个产品的一个文档,当然,也可以拥有某个订单的一个文档。文档以JSON(Javascript Object Notation)格式来表示,而JSON是一个到处存在的互联网数据交互格式。在一个index/type里面,你可以存储任意多的文档。 | 文档 |
中间件可以直接从官网 ElasticSearch的官方地址下载到软件,本质上ElasticSearch就是一个服务器,服务器上存放了索引数据,看项目结构也可以看出,实质上是一个tomcat的典型服务器目录:
用户可以通过向服务器发起请求进行一系列操作, 其中9300为tcp的通讯接口【代码接口tcp调用使用】,9200为RestFul风格通讯接口【Http调用访问】。
上面提到过可以通过http请求和tcp请求来实现,创建索引也是如此,通过http请求即可创建索引并设置相关mapping,注意mapping在创建后只能新增字段,不能修改已有的字段属性,其中关于分片和复制在下一小节具体讨论:
PUT http://127.0.0.1:9200/tml-userinfo 请求体body { "settings": { "number_of_shards": 5, //分片数 "number_of_replicas": 1 //复制数 }, "mappings": { "properties": { "id": { "type": "long", "store": true, "index": "true" }, "title": { "type": "text", //数据类型 "store": true, //是否存储 "index": "true", //是否索引 "analyzer": "standard" //分词器类别 }, "content": { "type": "text", "store": true, "index": "true", "analyzer": "standard" } } } }
返回信息如下证明索引创建成功
{
"acknowledged": true,
"shards_acknowledged": true,
"index": "tml-userinfo"
}
同样的添加文档也是通过发http请求即可做到:
POST localhost:9200/tml-userinfo/_doc/1
请求体:
{
"id":1101,
"title":"我是第一个集群数据",
"content":"它提供了一个分布式多用户能力的全文搜索引擎,基于RESTful web接口"
}
返回值:
{
"_index": "tml-userinfo",
"_type": "_doc",
"_id": "1",
"_version": 1,
"result": "created",
"_shards": {
"total": 2,
"successful": 2,
"failed": 0
},
"_seq_no": 0,
"_primary_term": 1
}
在索引中的数据展示如下图所示
因为ElasticSearch是应对海量数据的检索使用,所以ES一定是以集群为基础进行访问的,上文中我们引申提到过分片和副本的概念,其实也对应于Kafka的分区和副本的概念。本节详细聊聊ElasticSearch的集群
在ElasticSearch集群中有如下的前置概念术语需要理解,分别是集群、节点、分片和复制:
其实在创建好的索引mapping信息中我们也能get到这一信息,接下来配置一个集群并创建索引来对照下我们上边提到的各种概念。
了解了集群的基本术语后,我们亲手构建一个集群,如果一个集群中有三个节点,那么这三个节点可以通过以下方式配置来相互发现:修改每个节点config\elasticsearch.yml
配置文件如下:
#节点1的配置信息: #集群名称,保证唯一 cluster.name: elasticsearch-tml #节点名称,必须不一样 node.name: node-1 #必须为本机的ip地址 network.host: 127.0.0.1 #服务端口号,在同一机器下必须不一样 http.port: 9200 #集群间通信端口号,在同一机器下必须不一样 transport.tcp.port: 9300 #设置集群自动发现机器ip集合 discovery.zen.ping.unicast.hosts: ["127.0.0.1:9300","127.0.0.1:9301","127.0.0.1:9302"] #跨域访问配置 http.cors.enabled: true http.cors.allow-origin: "*" #指定主节点 cluster.initial_master_nodes: ["node-1"]
#节点2的配置信息: #集群名称,保证唯一 cluster.name: elasticsearch-tml #节点名称,必须不一样 node.name: node-2 #必须为本机的ip地址 network.host: 127.0.0.1 #服务端口号,在同一机器下必须不一样 http.port: 9201 #集群间通信端口号,在同一机器下必须不一样 transport.tcp.port: 9301 #设置集群自动发现机器ip集合 discovery.zen.ping.unicast.hosts: ["127.0.0.1:9300","127.0.0.1:9301","127.0.0.1:9302"] #跨域访问配置 http.cors.enabled: true http.cors.allow-origin: "*"
#节点3的配置信息: #集群名称,保证唯一 cluster.name: elasticsearch-tml #节点名称,必须不一样 node.name: node-3 #必须为本机的ip地址 network.host: 127.0.0.1 #服务端口号,在同一机器下必须不一样 http.port: 9202 #集群间通信端口号,在同一机器下必须不一样 transport.tcp.port: 9302 #设置集群自动发现机器ip集合 discovery.zen.ping.unicast.hosts: ["127.0.0.1:9300","127.0.0.1:9301","127.0.0.1:9302"] #跨域访问配置 http.cors.enabled: true http.cors.allow-origin: "*"
配置集群后启动并添加上小节设置的5个分片1个复制的tml-userinfo
索引后,集群状态如下图所示:
我们我们来查看索引的信息,可以看到如下内容:
```java { "version":9, "mapping_version":2, "settings_version":1, "aliases_version":1, "routing_num_shards":640, "state":"open", "settings":{ "index":{ "routing":{ "allocation":{ "include":{ "_tier_preference":"data_content" } } }, "number_of_shards":"5", "provided_name":"tml-userinfo", "creation_date":"1610800394680", "number_of_replicas":"1", "uuid":"MbmG0jnUQ22UOfM9zhJ3kg", "version":{ "created":"7100299" } } }, "mappings":{ "_doc":{ "properties":{ "describe":{ "analyzer":"standard", "store":true, "type":"text" }, "id":{ "store":true, "type":"long" }, "title":{ "analyzer":"standard", "store":true, "type":"text" }, "content":{ "analyzer":"standard", "store":true, "type":"text" } } } }, "aliases":[ ], "primary_terms":{ "0":1, "1":1, "2":1, "3":1, "4":1 }, "in_sync_allocations":{ "0":[ "dFqgw2xGR2K2_2N5H1mg0Q", "zi7v_U9LQNCzyj6mqygowA" ], "1":[ "c36TL2OFTOWmxhs4uuVIug", "storqrL-TKCRYyneXTp5wQ" ], "2":[ "6o_AFiphQYi_IysAN6Dmnw", "veZZlgZ5Q2ihu2ALlxA2Hg" ], "3":[ "l5L-1ZNBQ5mv0JNBh9RKMw", "A8oYRyK0Qu-oL0SAndIhVA" ], "4":[ "YnNkHjeWQTSOjQFJ5MGxjw", "An8hAJP7R1Wo4uQ7IVFZJQ" ] }, "rollover_info":{ }, "system":false }
上文提到,节点分为3类,每个节点既可以是候选主节点也可以是数据节点,通过在配置文件…/config/elasticsearch.yml中设置即可,默认都为true:
node.master: true //是否候选主节点
node.data: true //是否数据节点
那么这三类节点各代表什么意义呢?
一般而言,一个节点既可以是候选主节点也可以是数据节点,但是由于数据节点对CPU、内存核I/0消耗都很大,所以如果某个节点既是数据节点又是主节点,那么可能会对主节点产生影响从而对整个集群的状态产生影响,会导致我们接下来分析的脑裂现象
NodeA是当前集群的Master,NodeB和NodeC是Master的候选节点,其中NodeA和NodeB同时也是数据节点(DataNode),此外,NodeD是一个单纯的数据节点,Node_E是一个双非节点(既非Data也非Master候选),但还是可以充当proxy节点。每个Node会跟其他所有Node建立连接,形成一张网状图
每个节点是如何连接到一起并构建成集群的呢,这个就是由集群的发现机制来实现的,上文中我们提到两个参数,在节点配置信息上必须具备:
#集群名称,保证唯一
cluster.name: elasticsearch-tml
#设置集群自动发现机器ip集合
discovery.zen.ping.unicast.hosts: ["127.0.0.1:9300","127.0.0.1:9301","127.0.0.1:9302"]
每个节点配置相同的 cluster.name 即可加入集群,那么ES内部是如何通过一个相同的设置cluster.name 就能将不同的节点连接到同一个集群的?
ES的内部使用了Zen Discovery——Elasticsearch的内置默认发现模块(发现模块的职责是发现集群中的节点以及选举master节点),发现规则为单播发现,以防止节点无意中加入集群。只有在同一台机器上运行的节点才会自动组成集群。如果集群的节点运行在不同的机器上,使用单播,可以为 Elasticsearch 提供它应该去尝试连接的节点列表。 模拟发现按照如下步骤:
discovery.zen.ping.unicast.hosts: ["host1", "host2:port"]
如果discovery.zen.ping.unicast.hosts
有设置,则 ping 设置中的 host ,否则尝试 ping localhost 的几个端口当一个节点联系到单播列表中的成员时,它就会得到整个集群所有节点的状态,然后它会联系 master 节点,并加入集群发现规则,通过这种方式节点就能发现集群,进而之后加入集群成为集群的一部分了。
集群中可能会有多个master-eligible node,此时就要进行master选举,保证只有一个当选master。如果有多个node当选为master,则集群会出现脑裂,脑裂会破坏数据的一致性,导致集群行为不可控,产生各种非预期的影响。为了避免产生脑裂,ES采用了常见的分布式系统思路,保证选举出的master被多数派(quorum)的master-eligible node认可,以此来保证只有一个master。这个quorum通过discovery.zen.minimum_master_nodes
进行配置,要求可用节点必须大于 quorum (这个属性一般设置为 eligibleNodesNum / 2 + 1),才能对外提供服务。
master选举由master-eligible节点发起,当一个master-eligible节点发现满足以下条件时发起选举:
discovery.zen.minimum_master_nodes
所设定的值,超过一半的兄弟们联系不上master即当一个master-eligible节点发现包括自己在内的多数派的master-eligible节点认为集群没有master时,就可以发起master选举。
选举规则由两个参数决定,一个是clusterStateVersion,一个是节点的ID,按照如下步骤进行选举,即每个候选主节点
选举时分两种情况,一种是当前候选主节点选自己当Master,另一种是当前候选主节点选别的节点当Master,当一个master-eligible node(我们假设为Node_A)发起一次选举时,它会按照上述排序策略选举
当前候选主节点选自己(Node_A)当Master
NodeA会等别的node来join,即等待别的node的选票,当收集到超过半数的选票时,认为自己成为master,然后变更cluster_state中的master node为自己,并向集群发布这一消息
当前候选主节点选别的节点(Node_B)当Master
以上就是整个选举的流程,如果没有特殊情况是一定能选举出一个master的。
选举时当出现多个master竞争时,主分片和副本的识别也发生了分歧,对一些分歧中的分片标识为了坏片,更新的时候造成数据混乱或其它非预期结果,也就是我们上文提到的脑裂。其实按照如上的选举规则,能选举出一个确定的master是一定的,就算clusterStateVersion一样,也不可能有两个节点id一致,总会有大有小,按照此规则,所有节点其实是能达成共识的。“脑裂”问题可能有以下几个原因造成:
为了避免脑裂现象的发生,我们可以从根源着手通过以下几个方面来做出优化措施:
discovery.zen.ping_timeout
设置节点状态的响应时间,默认为3s,可以适当调大,如果master在该响应时间的范围内没有做出响应应答,判断该节点已经挂掉了。调大参数(如6s,discovery.zen.ping_timeout:6),可适当减少误判。discovery.zen.munimum_master_nodes
的值,这个参数表示在选举主节点时需要参与选举的候选主节点的节点数,默认值是1,官方建议取值(master_eligibel_nodes/2) + 1,这样做既能防止脑裂现象的发生,也能最大限度地提升集群的高可用性,因为只要不少于discovery.zen.munimum_master_nodes个候选节点存活,选举工作就能正常进行。当小于这个值的时候,无法触发选举行为,集群无法使用,不会造成分片混乱的情况。当然这里只讨论当前master连接不上重新发起选举的情况,其实在选举过程中也存在重复投票的问题,不做深入讨论。
主分片和副本分片是如何同步的?创建索引的流程是什么样的?ES如何将索引数据分配到不同的分片上的?本节讨论以上的问题来详细解读当一个索引相关请求进入ElasticSearch集群时,经历了哪些流程。
下图描述了3个节点的集群,共拥有12个分片,包括4个主分片(S0、S1、S2、S3)和8个复制分片(R0、R1、R2、R3),每个主分片对应两个副本分片,节点1是主节点(Master节点)负责整个集群状态
需要注意,写索引是只能写在主分片上,然后同步到副本分片。这里有4个主分片,一条数据ES是根据什么规则写到特定分片上的呢?这个过程是根据下面这个公式决定的:
shard = hash(routing) % number_of_primary_shards
以上公式中涉及注意事项如下如下:
number_of_primary_shards-1
之间的余数,即文档所在分片位置公式解释了为什么要在创建索引的时候就确定好主分片的数量并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了
每个节点都有处理读写请求的能力。在一个写请求被发送到某个节点后,该节点即为上文提到的协调节点,协调节点会根据路由公式计算出需要写到哪个分片上,再将请求转发到该分片的主分片节点上。假如此时数据通过路由计算公式取余后得到的值是 shard = hash(routing) % 4 = 0,则具体流程如下:
下图为流程说明图,后续各操作流程类似,不多余画图:
同写操作一样,GET某一条数据的流程也会计算路由,当写入了某个document后,这个document会自动给你分配一个全局唯一的id,doc id,同时也是根据doc id进行hash路由到对应的primary shard上去:
以上就是通过一个具体的文档Id读数据的流程,当然其实ES我们更多用到的是它的搜索。
其实我们在ES的大多数使用场景都是检索,那么检索的原理是什么呢?这里举个例子,例如我有三条数据,分别叫:E-Node1_S3tml超级帅、E-Node1_S2tml很丑、E-Node2_S1tml很丑、E-Node3_S0tml其实挺帅的。前缀表明他们存储的主分片。我们搜索【帅】这个关键字:
这里需要注意,第一次只检索数据id返回给协调节点,并不是真正的取数据,整合之后再取数据。
删除其实是假删除,先进行标记删除,然后在段合并(这个概念后文提到)的时候再进行彻底删除,删除时给定了文档id,这样按照我们的流程,要删除文档【E-Node1_S3tml超级帅】
下面会讲到,只有在进行段合并的时候才会真正的删除文件,其它时候只是检索到后将结果集过滤了一遍.del文件。
更新其实是一次删除加一次写入,已标记删除的V1版本,在检索时可能会被检索到,然后在结果集里被过滤掉,所以.del文件还需要记录文档的版本,这样按照我们的流程,要更新文档【E-Node1_S3tml超级帅】
那么在具体的更新流程中,版本合并如何处理呢?在下边的更新版本合并策略中介绍到。
上面介绍了在ES内部索引的CRSUD处理流程,这个流程是在ES的内存中执行的,数据被分配到特定的分片和副本上之后,最终持久化到磁盘上,这样断电的时候就不会丢失数据。具体的存储路径可在配置文件../config/elasticsearch.yml
中进行设置,默认存储在安装目录的data文件夹下。建议不要使用默认值,因为若ES进行了升级,则可能导致数据全部丢失
path.data: /path/to/data //索引数据
path.logs: /path/to/logs //日志记录
索引文档以段的形式存储在磁盘上,索引文件被拆分为多个子文件,则每个子文件叫作段【其实和Kafka将Partion切分为段类似】, 每一个段本身都是一个倒排索引,并且段具有不变性,一旦索引的数据被写入硬盘,就不可再修改。在底层采用了分段的存储模式,使它在读写时几乎完全避免了锁的出现,大大提升了读写性能。
段在不同存储模式下拥有不同的读写能力,可以避免使用锁的开销,提升读写性能
为什么要有段呢?早期全文检索为整个文档集合建立了一个很大的倒排索引,并将其写入磁盘中。如果索引有更新,就需要重新全量创建一个索引来替换原来的索引。这种方式在数据量很大时效率很低,并且由于创建一次索引的成本很高,所以对数据的更新不能过于频繁,也就不能保证时效性,索引文件分段存储并且不可修改,那么新增、更新和删除如何处理呢?
段被设定为不可修改具有一定的优势也有一定的缺点。
段不变性的优势主要体现在:
总结而言就是不需要考虑读写的并发,以及高性能的读数据和压缩数据能力。
段的不变性的缺点如下:
我们说没有任何一种结构或算法是万能的,空间和时间一定有其平衡性,段其实就是牺牲了空间成就了时间。
在介绍了索引的CRSUD以及ES的存储结构后我们来看看在CRSUD过程中,结合存储,有什么策略让索引的使用更高效呢?
如果直接写入磁盘,磁盘的I/O消耗上会严重影响性能,写数据量大的时候会造成ES停顿卡死,查询也无法做到快速响应。如果真是这样ES也就不会称之为近实时全文搜索引擎了,为了提升写的性能,ES并没有每新增一条数据就增加一个段到磁盘上,而是采用延迟写的策略:
在 Elasticsearch 中,写入和打开一个新段的轻量的过程叫做 refresh (即内存刷新到文件缓存系统)。 默认情况下每个分片会每秒自动刷新一次。这就是为什么我们说 Elasticsearch 是近实时搜索,因为文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。我们也可以手动触发 refresh,POST /_refresh
刷新所有索引,POST /nba/_refresh
刷新指定的索引。
虽然通过延时写的策略可以减少数据往磁盘上写的次数提升了整体的写入能力,但是我们知道文件缓存系统也是内存空间,属于操作系统的内存,只要是内存都存在断电或异常情况下丢失数据的危险。为了避免丢失数据,Elasticsearch添加了事务日志(Translog),事务日志记录了所有还没有持久化到磁盘的数据。添加了事务日志后整个写索引的流程如下图所示:
添加了事务日志后的优化流程如下:
也可以通过es api,手动执行flush操作,手动将os cache中的数据fsync强刷到磁盘上去,记录一个commit point,清空translog日志文件。
通过这种方式当断电或需要重启时,ES不仅要根据提交点去加载已经持久化过的段,还需要查看Translog里的记录,把未持久化的数据重新持久化到磁盘上,避免了数据丢失的可能。到这里,其实ES1秒延迟的问题有两个不成熟的解决方案:
当然这肯定会牺牲性能,所以并不是完备的解决方案,但其实是解决问题的入口,值得一提的是,translog其实也是先写入os cache的,默认每隔5秒刷一次到磁盘中去,因为日志每隔5秒从文件缓存系统flush一次到磁盘,所以最多会丢5秒的数据。
由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦。 每一个段都会消耗文件句柄、内存和cpu运行周期。更重要的是,每个搜索请求都必须轮流检查每个段然后合并查询结果,所以段越多,搜索也就越慢。
Elasticsearch通过在后台定期进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。
这可以解决段不可变带来的空间占用问题
段合并时机和步骤:
段合并的计算量庞大, 而且还要吃掉大量磁盘 I/O,段合并会拖累写入速率,如果任其发展会影响搜索性能。Elasticsearch在默认情况下会对合并流程进行资源限制,所以搜索仍然有足够的资源很好地执行。整体执行流程如下图所示:
ES最核心的内容就是检索了,重头戏往往放到后边,在了解了集群的工作原理和存储机制后,正式了解ElasticSearch检索操作。
中文在stander中的切分是按照单字的,例如服务器三个字在标准分词器下会被分为服、务、器,所以我们在term查询的时候检索服务的时候检索不到数据,因为没有服务这个分词,所以我们需要使用IK分词器,IK提供了两个分词算法ik_smart 和 ik_max_word,其中 ik_smart 为最少切分,ik_max_word为最细粒度划分。
其实不光有ik、stander两种分词器,分词器种类有很多,核心是搜索的内容是如何被切分的。
按照查询条件的多少,从网上找了一些文章,看起来真的是不知所云,分类为所欲为,于是想对整个的ES查询方式做个整理,分为如下几个维度去讨论,防止大家读起来混乱。
查询大类 | 查询小类 | 细分关键词 |
---|---|---|
普通查询 | ID查询【通过ID精确匹配】 | id |
语法查询【QueryString查询】 | query_string | |
DSL查询【 ES领域专用】 | term、terms、much、much_all、match_phrase、muti_match、range、exists、prefix、wildcard、regexp、fuzzy | |
复合查询 | Boolean复合查询 | must、should、must_not、filter【四种子句关系类型】 |
聚合查询 | 桶聚合 | terms、range |
聚合查询 | 指标聚合 | min、max、avg、sum、cardinalit、stats、percentile、 percentile_rank、top hits |
以上所有的查询方式,都支持查询结果的:排序、分页【fromsize+scroll】和高亮查询 这些基本特性,所以拜托各位大佬的文章不要把这些当做分类带进去,很容易误导人。详细的查询方式见blogElasticsearch常用查询方式讨论及实践,这里只简单记录部分关键字的使用方式:
1, ID查询顾名思义就是通过ID进行查询,例如这里我们想查询ID为9的数据,通过如下的Get查询请求即可
GET:localhost:9200/tml-userinfo/_doc/9
2,语法查询相当于我的查询语句会进行分词
{
"query": {
"query_string": {
"default_field": "name", //要查询的字段
"query": "林玲"
}
}
}
3,term查询用来查询某个关键字在文档里是否存在,所以Term需要是文档切分的一个关键字
{
"query": {
"term": {
"sex": "男"
}
}
}
4,terms查询用来查询某几个个关键字在文档里是否存在,Terms可以同时对一个字段检索多个关键字
{
"query": {
"terms": {
"age": [18,28]
}
}
}
4, match查询和queryString有点类似,就是先对查询内容做分词,然后再去进行匹配
{
"query": {
"match": {
"sex": "男女"
}
}
}
5,match_all的查询方式简单粗暴,就是匹配所有,不需要传递任何参数
{
"query": {
"match_all": {
}
}
}
6,match_phrase属于短语匹配,能保证分词间的邻近关系,相当于对文档的关键词进行重组以匹配查询内容,对于匹配了短语"森 小 林"的文档,下面的条件必须为true:
我们来尝试下对姓名进行检索,请求头和上边完全一样,就不再赘述,直接看请求体,先来看一个不按顺序的
{
"query": {
"match_phrase": {
"name": "森小林"
}
}
}
7,multi_match表示多字段匹配关键词,我们试着在name和sex里找,只要包含男的我们就返回该数据
{
"query": {
"multi_match": {
"query": "男",
"fields": ["name","sex"]
}
}
}
8,range查询,顾明思意就是范围查询,例如我们这里要查询年龄在19到28的人的数据:
{
"query": {
"range": {
"age" : {
"gte" : 19,
"lt" : 29
}
}
}
}
这里需要解释下:range 查询可同时提供包含(inclusive)和不包含(exclusive)这两种范围表达式,可供组合的选项如下:
可以依据自己的需求自由进行组合。
9,exists允许你过滤文档,只查找那些在特定字段有值的文档,无论其值是多少,为了验证,需要注意,这里的有值即使是空值也算有值,只要不是null
{
"query": {
"exists": {
"field": "sex"
}
}
}
10,wildcard,通配符查询,其中【?】代表任意一个字符【*】代表任意的一个或多个字符,例如我们想查名字结尾为林的文档:
{
"query": {
"wildcard": {
"name": "*林"
}
}
}
11, prefix,前缀查询,我们为了找到所有姓名以森开头的文档,可以使用这种方式:
{
"query": {
"prefix": {
"name": "森"
}
}
}
12,regexp,正则匹配,ES兼容了正则的查询方式,例如我们想查询性别为汉字字符的文档
{
"query": {
"regexp": {
"sex": "[\u4e00-\u9fa5]"
}
}
}
13,fuzzy,纠错检索,让输入条件有容错性,例如我要检索性别为woman的数据,但是我拼错了,输入的是wman,用fuzzy照样可以检索到:
{
"query": {
"fuzzy": {
"sex": "wman"
}
}
}
复合查询通俗的说就是多个条件拼接查询,就是用Bool去拼接一系列的查询条件,来完成表达式的查询方式,其实就是将普通条件进行重新组合,常用的有四种复合类型:
用这些条件的连接词将多个查询条件连接起来就能进行复杂的复合查询了。
过滤器,文档必须匹配该过滤条件,跟must子句的唯一区别是,filter不影响查询的score,我们这里设置要查询的内容为【年龄在10-19岁之间 且 性别为男 且 姓名开头为森】的员工,查询语句为:
{"query":{
"bool": {
"filter": [
{"term": {"sex": "男"}},
{"range": {"age": { "gte" : 18, "lt" : 29}}},
{"prefix": {"name": "森"}}
]
}
}
}
返回结果为:
{ "took": 7, // 请求花了多少时间 "timed_out": false, //有没有超时 "_shards": { //执行请求时查询的分片信息 "total": 3, //查询的分片数量 "successful": 3, // 成功返回结果的分片数量 "skipped": 0, // 跳过的分片数量 "failed": 0 // 失败的分片数量 }, "hits": { "total": { "value": 2, //查询返回的文档总数 "relation": "eq" }, "max_score": 0.0, //计算所得的最高分 "hits": [ { "_index": "tml-userinfo", //索引 "_type": "_doc", //类型 "_id": "2", //标识符 "_score": 0.0, //得分 "_source": { //发送到索引的Json对象 "age": 28, "sex": "男", "name": "森小林" } }, { "_index": "tml-userinfo", "_type": "_doc", "_id": "1", "_score": 0.0, "_source": { "age": 18, "sex": "男", "name": "森小辰" } } ] } }
文档必须匹配must查询条件,我们这里设置要查询的内容为【年龄在10-19岁之间 且 性别为男 且 姓名开头为森】的员工,查询语句为:
{"query":{
"bool": {
"must": [
{"term": {"sex": "男"}},
{"range": {"age": { "gte" : 18, "lt" : 29}}},
{"prefix": {"name": "森"}}
]
}
}
}
文档应该匹配should子句查询的一个或多个,这里我们来查询【年龄在10-19岁之间 或 性别为男 或 姓名开头为森】,查询语句为:
{"query":{
"bool": {
"should": [
{"term": {"sex": "男"}},
{"range": {"age": { "gte" : 18, "lt" : 29}}},
{"prefix": {"name": "森"}}
]
}
}
}
文档不能匹配该查询条件,这里我们来查询【年龄不在10-19岁之间 且 性别不为男 且 姓名开头不为森】,查询语句为:
{"query":{
"bool": {
"must_not": [
{"term": {"sex": "男"}},
{"range": {"age": { "gte" : 18, "lt" : 29}}},
{"prefix": {"name": "森"}}
]
}
}
}
我们在真实使用复合查询的时候肯定不仅仅要查单种条件的复合关系,还需要查多种关联条件,这里我们查一个【年龄在10-19岁之间 且 性别为男 且 姓名开头为森】或【姓名以男字结尾】但【年龄不能是28岁的】,需要注意:Boolean在同时有must和should的时候,should就被过滤掉了,因为should表示有也可以没有也可以,所以我们常把must放到should字句里,确保should的子句能执行
{ "query": { "bool": { "should": [ { "wildcard": { "name": "*男" } }, { "bool": { "must": [ { "term": { "sex": "男" } }, { "range": { "age": { "gte": 18, "lt": 29 } } }, { "prefix": { "name": "森" } } ] } } ], "must_not": [ { "term": { "age": 28 } } ] } } }
聚合查询实际上是一种统计和计算,按照官方文档的解释共有四类
一般聚合的写法如下:
"aggregations" : {
"<aggregation_name>" : { <!--聚合的名字 -->
"<aggregation_type>" : { <!--聚合的类型 -->
<aggregation_body> <!--聚合体:对哪些字段进行聚合 -->
}
[,"meta" : { [<meta_data_body>] } ]? <!--元 -->
[,"aggregations" : { [<sub_aggregation>]+ } ]? <!--在聚合里面在定义子聚合 -->
}
[,"<aggregation_name_2>" : { ... } ]* <!--聚合的名字 -->
}
其中aggregations 也可简写为 aggs,虽然Elasticsearch有四种聚合方式,但在一般实际开发中,用到的比较多的就是Metric和Bucket。其实从功能上只有前两类:
剩余的两类就是程序上的辅助聚合方式,一种是管道二次处理,一种似是分桶同时处理统计计算。
简单来说桶就是满足特定条件的文档的集合。当聚合开始被执行,每个文档里面的值通过计算来决定符合哪个桶的条件,如果匹配到,文档将放入相应的桶并接着开始聚合操作。桶也可以被嵌套在其他桶里面。我们先来看一个桶聚合,例如我们想要进行如下聚合【按照年龄聚合,0-10,10-20,20-30】,查询语句如下:
{ "size": 0, "aggs": { "user_age_info": { //聚合的名字 "range": { //聚合的类型 "field": "age", "ranges": [ { "from": 0, "to": 10 }, { "from": 11, "to": 20 }, { "from": 21, "to": 30 } ] } } } }
返回结果为:
{ "took": 2, "timed_out": false, "_shards": { "total": 3, "successful": 3, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 15, "relation": "eq" }, "max_score": null, "hits": [] }, "aggregations": { "user_age_info": { "buckets": [ { "key": "0.0-10.0", "from": 0.0, "to": 10.0, "doc_count": 4 //该范围内命中4个文档 }, { "key": "11.0-20.0", "from": 11.0, "to": 20.0, "doc_count": 7 //该范围内命中7个文档 }, { "key": "21.0-30.0", "from": 21.0, "to": 30.0, "doc_count": 4 //该范围内命中4个文档 } ] } } }
指标聚合分为两种,分别是单值分析和多值分析,各自有几个查询关键字:
统计计算这里我们分别使用一下以上几种方式进行分析,最后再结合分组进行一些复合的统计计算查询。
单值分析以max举例,这里我们想查询,所有员工信息中,【年龄最大的员工】,则查询参数为:
{
"size":0,
"aggs":{
"max_age":{
"max":{
"field":"age"
}
}
}
}
返回结果为:
{ "took": 19, "timed_out": false, "_shards": { "total": 3, "successful": 3, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 15, "relation": "eq" }, "max_score": null, "hits": [] }, "aggregations": { "avg_age": { "value": 18.0 } } }
其它几种单值查询sum、min、avg、cardinality 也类似。
多值分析以stats举例,我想统计所有员工年龄的各种单值分析状态,
{
"size":0,
"aggs":{
"age_stats":{
"stats":{
"field":"age"
}
}
}
}
返回值为:
{ "took": 3, "timed_out": false, "_shards": { "total": 3, "successful": 3, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 15, "relation": "eq" }, "max_score": null, "hits": [] }, "aggregations": { "age_stats": { "count": 15, "min": 8.0, "max": 28.0, "avg": 18.0, "sum": 270.0 } } }
分页查询方式有很多种,我们就适用场景各自聊一种,也就是在浅度分页和深度分页中各自适用方式。还是3个节点的集群,共拥有12个分片,包括4个主分片(S0、S1、S2、S3)和8个复制分片(R0、R1、R2、R3),每个主分片对应两个副本分片,节点1是主节点(Master节点)负责整个集群状态
一个搜索请求到来的时候集群是如何响应的?我们举个例子来回顾下,如果我在ES集群的3个节点全部4个分片上共存储了400条人员信息数据,每个分片100条,接下来我要分页获取所有人员中【按年龄排序户籍地为乌拉特前旗】的每页10条的第3页的全部人员数据。也就是依据年龄去排序所有数据,然后取from为20,size为10的10条数据。
那么依照这样的需求我们请求发送到集群会怎么处理呢?处理流程如下【请求会被随机转发主分片或副本分片,采取随机轮询的方式,我们这里假定都是主分片处理】:
这样一个数据量在这种场景下还是可以hold的,但是如果查询量比较大呢?假设我们每个分片上存储了10万条数据,共计40万条数据,我们要取第1万页的数据,也就是from为10000,size为10,那么我们再看一遍流程如下:
以上的各个阶段可以看到,当页码很深的时候,我们拿10条数据是多么的不容易,性能损耗是多么严重,所以ES对这种获取方式的数据条数做了限制:
[root@localhost elasticsearch-5.7.4]# curl -XGET 'http://11.12.84.126:9200/_audit_0102/_log_0102/_search?size=2&from=10000&pretty=true' { "error" : { "root_cause" : [ { "type" : "query_phase_execution_exception", "reason" : "Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level parameter." } ], "type" : "search_phase_execution_exception", "reason" : "all shards failed", "phase" : "query", "grouped" : true, "failed_shards" : [ { "shard" : 0, "index" : "_audit_0102", "node" : "f_CQitYESZedx8ZbyZ6bHA", "reason" : { "type" : "query_phase_execution_exception", "reason" : "Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level parameter." } } ] }, "status" : 500 }
from+size最多限制10000,超过限制即报错,当然这个参数可以通过如下的方式调整,例如调整到50000
curl -XPUT "http://11.12.84.126:9200/_audit_0102/_settings" -d '{
"index": {
"max_result_window": 50000
}
}'
但就算调整了也只是一种临时方案,硬件极限承载能力并不是通过调整配置能解决的,需要更换策略。从报错信息也可以看出,ES推荐使用Scroll的方式:
See the scroll api for a more efficient way to request large data sets
Scroll 类似于sql中的cursor,使用scroll,每次只能获取一页的内容(按照上边的场景,每次获取10条),然后会返回一个scroll_id。根据返回的这个scroll_id可以不断地获取下一页的内容,所以scroll并不适用于有跳页的情景。Scroll的流程分为两个步骤
以下是具体操作:
初始化的时候请求接口还需要index和type【6版本后没有了】信息,初始化的作用时将所有复合条件的搜索结果缓存起来,类似于对结果集做了一个快照,之后的搜索就是游标在快照上的滚动了。
GET UserInfo/_search?scroll=5m { "query": { "bool": { "filter": [ { "term": { "home": "乌拉特前旗" } } ] } }, "size": 10, "from": 0, "sort": [ { "age": { "order": "desc" }, "_id": { "order": "desc" } } ] }
其中:scroll=5m表示设置scroll_id保留5分钟可用;使用scroll必须要将from设置为0【不允许跳页】;size决定后面每次调用_search搜索返回的数量【这里为10条】。需要注意:实际返回给协调节点的数量为:分片的数量*size,也就是每次返回40条,共进行10001次请求和返回行为
然后我们可以通过数据返回的_scroll_id读取下一页内容,每次请求将会读取下10条数据,直到数据读取完毕或者scroll_id保留时间截止,请求的接口不再使用索引名了,而是 _search/scroll,其中GET和POST方法都可以使用
GET _search/scroll
{
"scroll_id": "DnF1ZXJ5VGhlbk【全网唯一Scrollid】",
"scroll": "5m"
}
需要注意:每次都要传参数 scroll,刷新搜索结果的缓存时间,相对于流程我们再来看一下请求过程:
scroll的搜索上下文会在scroll的保留时间截止后自动清除,但是我们知道scroll是非常消耗资源的,所以一个建议就是当不需要scroll数据的时候,尽可能快的把scroll_id显式删除掉,因为每一个 scroll_id 不仅会占用大量的资源,而且会生成历史快照【在初始化请求时初始化数据快照,后续游标在快照上移动】,资源占用是很大的【5版本前scroll_id每次返回是变化的,5版本后就不变了】。又是一个经典的以空间换时间的例子
行文至此,已洋洋洒洒4万言,对ElasticSearch的原理和使用有了一个整体的认识,希望和大家共同进步。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。