当前位置:   article > 正文

【ElasticSearch从入门到放弃系列 零】ElasticSearch看这一篇就够了_springmvc elasticsearch

springmvc elasticsearch

大数据时代系统和业务每分每秒都产生成千上万的数据,其存储一定是不能通过关系型数据库了,当然因为数据的持久性也不能存储到内存型Nosql数据库Redis中,我们通常会将这些数据存储在能够不丢失数据的非关系型数据库中,这个技术选型有很多,例如HBase、Cassandra,这里我们暂不关心其数据存储,留待日后讨论,我们关注的是另一件事,如何能在分布式的数据库中进行PB级的数据检索,目前市场上较为成熟的解决方案中间件就是ElasticSearch,本文将从使用背景开始对ElasticSearch进行全面讨论:

  1. 什么是全文检索:全文检索实现方式、倒排索引
  2. ElasticSearch的概念和实现:索引创建和文档添加
  3. ElasticSearch集群:节点分析、发现机制、选举机制
  4. ElasticSearch工作流程:索引CRSUD过程
  5. ElasticSearch存储原理及策略:分段存储及段不变性、索引操作策略
  6. ElasticSearch检索:分词器、查询方式以及分页查询

适合人群:不了解ElasticSearch的新手,对ElasticSearch的实现机制感兴趣的技术人员

本文的全部内容来自我个人在ElasticSearch学习过程中整理的博客,是该博客专栏的精华部分。在书写过程中过滤了流程性的上下文,例如部署环境、配置文件等,而致力于向读者讲述其中的核心部分,如果读者有意对过程性内容深入探究,可以移步MaoLinTian的Blog,在这篇索引目录里找到答案。
在这里插入图片描述

什么是全文检索

有因才有果,先了解下为什么使用全文检索,才能最终料到ElasticSearch。本节从以下三个方面来进行讨论:全文检索的应用场景、基本概念、实现思路

全文检索基本概念

因为我们的数据世界存在不规则的数据,而我们又需要对这些数据进行快速检索,所以和关系数据库类似,需要创建索引,这就引出了全文检索的概念。

数据分类

先来了解一个前置概念:数据类型的分类,数据的来源按数据类型可分为:结构化数据、半结构化数据、非结构化数据

  • 结构化数据:一般是从内部数据库和外部开放数据库接口中获得,一般存储产品业务运营数据以及用户操作的结果数据,比如注册用户数、下单量、完单量等数据。这类数据格式规范,典型代表就是关系数据库中的数据,可以用二维表来存储,有固定字段数,每个字段有固定的数据类型(数字、字符、日期等),每个字节长度相对固定。这类数据易于维护管理,同时对于查询、展示和分析而言也是最为方便的一类数据格式
  • 半结构化数据:应用的点击日志以及一些用户行为数据,通常指日志数据、xml、json等格式输出的数据,格式较为规范,一般是纯文本数据,需要对数据格式进行解析,才能用于查询或分析数据。每条记录预定义规范,但是每条记录包含信息不同,字段数不同,字段名和字段类型不同,或者还包含着嵌套的格式。
  • 非结构化数据:指非纯文本类数据,没有标准格式,无法直接解析相应值,常见的非结构化数据有富文本、图片、声音、视频等数据。一般将非结构化数据存放在文件系统中,数仓中记录数据的信息,如标题、摘要、创建时间等,方便进行索引查询

对于结构化数据来说我们的查询、展示和分析很方便,强大的SQL语句和规范的表结构让这一点很容易做到,但是对于非结构化数据来说并不容易,尤其是进行查询的时候,如果给你一推文件,让你找出包含某个字符串的所有文件是很难实现的,一个两个还可以目测,多了就不行了。所以如何快速定位到满足查询条件的文档(数据)呢?

非结构化查询方式

要想实现上述需求,我们可以按照非结构化数据的处理思路来看:一般将非结构化数据存放在文件系统中,数仓中记录数据的信息,如标题、摘要、创建时间等,方便进行索引查询,也就是从文件数据提取数据标签,描述这个文件是做什么的,这样我们搜索的时候只要搜索这些标签即可找到目标的数据文件。

全文检索

以上非结构化查询方式抽象而言可以理解为将非结构化数据中的一部分信息提取出来,重新组织,使其变得有一定结构,然后对此有一定结构的数据进行搜索,从而达到搜索相对较快的目的。这部分从非结构化数据中提取出的然后重新组织的信息,我们称之索引,这种先建立索引,再对索引进行搜索的过程就叫全文检索(Full-text Search)。虽然创建索引的过程也是非常耗时的,但是索引一旦创建就可以多次使用,全文检索主要处理的是查询,所以耗时间创建索引是值得的。

这里说到我们将非结构化数据提取出来使其变得有结构,那么有人可能会问:如果我们把这些结构化标签存储在MySQL里不行么,还有这篇blog聊的ElasticSearch什么事儿呢?事实上MySQL也提供全文检索能力,但是没有ElasticSearch好用,这里简单解释一下为什么不用关系型数据库处理索引:

  • 数据结构不固定:这个是最关键的一点,关系型数据库扩展性很差,如果需要添加一个字段,那么首先要改表,其次如果有存储过程这些的话还需要全都改一遍,不好维护,而非结构化数据的数据标签【索引】是不固定的
  • 不满足高并发读写:web2.0时代,需要依据用户个性化需要高并发读写,关系型数据库读还可以,写就很难做到了。例如论坛这样的站点, 网站的用户并发性非常高,往往达到每秒上万次读写请求,对于传统关系型数据库来说,硬盘I/O是一个很大的瓶颈
  • 不满足高效访问:海量数据高效率存储和访问, 网站每天产生的数据量是巨大的,对于关系型数据库来说,在一张包含海量数据的表中查询,效率是非常低的,因为关系型数据库导出充斥着锁和事务。
  • 不满足高可拓展性和高可用性:关系型数据库很难进行横向扩展,当一个应用系统的用户量和访问量与日俱增的时候,数据库却没有办法像web server和app server那样简单的通过添加更多的硬件和服务节点来扩展性能和负载能力。

之后我们在详细聊到ElasticSearch再去讨论它是如何做到满足以上几个要求的。

全文检索应用场景

对于数据量大、数据结构不固定的数据可采用全文检索方式搜索,比如百度、Google等搜索引擎、论坛站内搜索、电商网站站内搜索等,总而言之,只要用到搜索的地方,都可以使用全文检索进行搜索,

全文检索实现思路

全文检索的整体实现思路如下图所示,左侧部分为创建索引,右侧部分为查询索引:
在这里插入图片描述
上图的执行流程说明如下:

  • 左侧绿色部分表示索引过程,对要搜索的原始内容进行索引构建一个索引库,索引过程包括:确定原始内容即要搜索的内容—>采集文档—>创建文档—>分析文档—>索引文档
  • 右侧红色部分表示搜索过程,从索引库中搜索内容,搜索过程包括:用户通过搜索界面传参—>创建查询—>执行搜索、从索引库搜索—>渲染搜索结果

我们来具体看下索引库的基本创建过程和查询过程。

创建索引

对文档索引的过程,将用户要搜索的文档内容进行索引,索引存储在索引库(index)中,也就是左侧的绿色流程:

  1. 确定原始内容即要搜索的内容:确定我们的需求也即对什么数据进行分析,这里就是这个存放了诸多文档的文件夹
  2. 采集文档:手段有很多,网络爬虫,数据读取,文件IO读取,我们这里用的就是文件IO读取
  3. 创建文档:获取原始内容的目的是为了索引,在索引前需要将原始内容创建成文档(Document),文档中包括一个一个的域(Field),域中存储内容。这里我们可以将磁盘上的一个文件当成一个document,Document中包括一些Field(file_name文件名称、file_path文件路径、file_size文件大小、file_content文件内容)
    在这里插入图片描述
    每个文档都有一个唯一的编号,就是文档id
  4. 分析文档:将原始内容创建为包含域(Field)的文档(document)后,需要再对域中的内容进行分析,分析的过程是经过对原始文档提取单词、将字母转为小写、去除标点符号、去除停用词等过程生成最终的语汇单元,可以将语汇单元理解为一个一个的单词,例如:Lucene is a Java full-text search engine. 分析后得到的语汇单元:lucene、java、full、text、search、engine。每个单词叫做一个Term,不同的域中拆分出来的相同的单词是不同的term。term中包含两部分一部分是文档的域名,另一部分是单词的内容,例如{"FileName":"springmvc"}就是域FileName上的一个term,这样我们在该域上进行检索的时候,只要检索springmvc就能找到当前文档。
  5. 索引文档:对所有文档分析得出的语汇单元进行索引,索引的目的是为了搜索,最终要实现只搜索被索引的语汇单元从而找到Document(文档),注意:创建索引是对语汇单元索引,通过词语找文档,这种索引的结构叫倒排索引结构。传统方法是根据文件找到该文件的内容,在文件内容中匹配搜索关键字,这种方法是顺序扫描方法,数据量大、搜索慢。倒排索引结构是根据内容(词语)找文档,如下图
    在这里插入图片描述
    这里的词典就是term的集合,每个term【域和关键词的组合】会索引一连串满足条件的文档id,检索时通过term检索可以找到这串id,进而找到满足条件的文档集合。

实际上以上过程就是将非结构化的一些数据进行结构化提取并索引的过程。

查询索引

查询索引也是搜索的过程。搜索就是用户输入关键字,从索引(index)中进行搜索的过程。根据关键字搜索索引,根据索引找到对应的文档,从而找到要搜索的内容(这里指磁盘上的文件),也就是右侧红色的部分:

  1. 用户通过搜索界面键入关键词:全文检索系统提供用户搜索的界面供用户提交搜索的关键字,搜索完成展示搜索结果,例如我们常用的百度就是这个原理。
  2. 创建查询:用户输入查询关键字执行搜索之前需要先构建一个查询对象,查询对象中可以指定查询要搜索的Field文档域、查询关键字等,查询对象会生成具体的查询语法,例如term为{"FileName":"springmvc"}
  3. 执行搜索:根据查询语法在倒排索引词典表中分别找出对应搜索词的索引,从而找到索引所链接的文档链表
  4. 渲染搜索结果:以一个友好的界面将查询结果展示给用户,用户根据搜索结果找自己想要的信息,为了帮助用户很快找到自己的结果,提供了很多展示的效果,比如搜索结果中将关键字高亮显示,百度提供的快照等

以上部分就是全文检索如何实现索引库的查询

全流程举例

按照如上的流程来模拟一遍。提出如下需求:给出一组doc文件,如下图所示,用于建立索引和搜索,找到指定的文档:要求找出所有文件名包含字的文件。
在这里插入图片描述

那么按照需求我们来看下如何实现,创建文档的相关索引,按照流程处理如下:

  1. 确定原始内容即要搜索的内容:确认要搜索的是文档的文件名,所以只给文件名进行索引即可。需要说明的是当前这个例子比较简单,基本通过目测就可以实现,就是举个例子,实际上文档成千上万的时候,索引非常有用。
  2. 采集文档:我们这里可以通过将文件通过IO读取到磁盘即可。
  3. 创建文档:创建一个文档对象,在文档对象下我们可以创建一个FieldName为Name的域,Value值即为文件名,例如以上六个文档对象里都会包含一个文件名域,拿第一个文档对象举例:"FieldName":"tml超级帅"
  4. 分析文档:将文件名进行处理,如果按照标准的分词方法可以举例如下:tml超级帅分词时分为**:tml、有、点、帅** ,标准的中文是单字切分的。
  5. 索引文档:索引文档就是建立term【某个域下的某个关键词】和文档的关系,倒排索引结构的创建,例如这里就是:例如term:"FieldName":"丑"—>很丑.txt[文档id为5]–>tml介于帅和丑之间.txt[文档id为1],挂载了两篇文档

查询文档过程是从索引库中搜索内容,按照流程处理过程如下:

  • 用户通过搜索界面传参:选定好域Name:FieldName,关键词value:丑
  • 创建查询:创构造一个term查询结构:"FieldName":"丑"
  • 执行搜索:找到所有包含关键词的文档,共两篇,返回文档id:1、5
  • 渲染搜索结果:通过文档id找到对应文档,返回结果,呈现给用户

以上就是全文检索的实现逻辑,了解了实现逻辑后,其实大家就知道,一定有人会写一些工具至少是类库来加速这个过程。

ElasticSearch的概念和实现

熟悉全文检索概念的话知道其实早期实现方式是用Lucene的,什么是Lucene呢?Lucene是一个开放源代码的全文检索引擎工具包,但它不是一个完整的全文检索引擎,而是一个全文检索引擎的架构,提供了完整的查询引擎和索引引擎,部分文本分析引擎(英文与德文两种西方语言)。Lucene的目的是为软件开发人员提供一个简单易用的工具包,以方便的在目标系统中实现全文检索的功能,或者是以此为基础建立起完整的全文检索引擎。在Java开发环境里Lucene是一个成熟的免费开源工具。一句话概括,Lucene就是一组实现全文检索的Jar包。而ElasticSearch就是基于Lucene实现的工业级全文检索引擎

什么是ElasticSearch

Elaticsearch,简称为es, es是一个开源的高扩展的分布式全文检索引擎,它可以近乎实时的存储、检索数据;本身扩展性很好,可以扩展到上百台服务器,处理PB[1024TB]级别的数据。es也使用Java开发并使用Lucene作为其核心来实现所有索引和搜索的功能,但是它的目的是通过简单的RESTful API来隐藏Lucene的复杂性,从而让全文搜索变得简单:

  • Elasticsearch 自身带有分布式协调管理功能仅支持json文件格式,Elasticsearch处理实时搜索应用时效率很高

以上的所有特点都标注了Elasticsearch实质上是一款高效的分布式的全文搜索引擎

ElasticSearch基本术语

ElasticSearch是一个面向文档的搜索引擎,这意味着它可以存储整个对象或文档(document)。然而它不仅仅是存储,还会索引(index)每个文档的内容使之可以被搜索。在Elasticsearch中,你可以对文档(而非成行成列的数据)进行索引、搜索、排序、过滤。Elasticsearch比传统关系型数据库如下:

类别
关系型数据库DatabasesTablesRowsColumns
ElasticSearchIndicesTypesDocementsFields
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的官方地址下载到软件,本质上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"
            }
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

返回信息如下证明索引创建成功

{
    "acknowledged": true,
    "shards_acknowledged": true,
    "index": "tml-userinfo"
}
  • 1
  • 2
  • 3
  • 4
  • 5

添加文档

同样的添加文档也是通过发http请求即可做到:

POST localhost:9200/tml-userinfo/_doc/1
请求体:
{
	"id":1101,
	"title":"我是第一个集群数据",
	"content":"它提供了一个分布式多用户能力的全文搜索引擎,基于RESTful web接口"
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

返回值:

{
    "_index": "tml-userinfo",
    "_type": "_doc",
    "_id": "1",
    "_version": 1,
    "result": "created",
    "_shards": {
        "total": 2,
        "successful": 2,
        "failed": 0
    },
    "_seq_no": 0,
    "_primary_term": 1
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

在索引中的数据展示如下图所示
在这里插入图片描述

ElasticSearch集群

因为ElasticSearch是应对海量数据的检索使用,所以ES一定是以集群为基础进行访问的,上文中我们引申提到过分片和副本的概念,其实也对应于Kafka的分区和副本的概念。本节详细聊聊ElasticSearch的集群

集群术语

在ElasticSearch集群中有如下的前置概念术语需要理解,分别是集群、节点、分片和复制:

  • 集群 cluster:一个集群由一个或多个节点组织在一起,它们共同持有整个的数据,并一起提供索引和搜索功能。一个集群由一个唯一的名字标识,节点只能通过指定某个集群的名字加入集群,也即cluster.name必须一致通过discovery.zen.ping.unicast.hosts可以相互关联起来,discovery.zen.ping.unicast.hosts 列表中的IP列表称为种子节点,整个集群提供索引和搜索的服务。
  • 节点 node一个节点是集群中的一个服务器,作为集群的一部分,它存储数据,参与集群的索引和搜索功能,节点分为三类:主节点、数据节点\候选主节点、协调节点,当然并不意味着一个节点只能是一种角色
  • 分片 shard:一个索引可以存储超出单个结点硬件限制的大量数据。比如一个具有10亿文档的索引占据1TB的磁盘空间,而任一节点都没有这样大的磁盘空间或单个节点处理搜索请求响应太慢。为此Elasticsearch提供了将索引划分成多份的能力,这些份就叫做分片。每个分片本身也是一个功能完善并且独立的“索引”,这个“索引”可以被放置到集群中的任何节点上,允许水平分割/扩展内容容量,在分片之上进行分布式的、并行的操作来提高性能/吞吐量,提供了高可扩展及高并发能力。
  • 复制 replica:分片故障时故障转移机制非常必要。Elasticsearch允许创建分片的一份或多份拷贝,这些拷贝叫做复制分片。在分片/节点失败的情况下,提供了高可用性。因为这个原因,注意到复制分片从不与原/主要(original/primary)分片置于同一节点上是非常重要的,同时复制分片还能提提高并发量。所以复制分片的作用是高可用\高并发,副本越多消耗越大,也越保险,集群的可用性就越高,但是由于每个分片物理上相当于一个Lucene的索引文件,会占用一定的文件句柄、内存及CPU,并且分片间的数据同步也会占用一定的网络带宽,所以索引的分片数和副本数也不是越多越好

其实在创建好的索引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"]
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
#节点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: "*"
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
#节点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: "*"
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

配置集群后启动并添加上小节设置的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
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90

节点分析

上文提到,节点分为3类,每个节点既可以是候选主节点也可以是数据节点,通过在配置文件…/config/elasticsearch.yml中设置即可,默认都为true

node.master: true  //是否候选主节点
node.data: true    //是否数据节点
  • 1
  • 2

那么这三类节点各代表什么意义呢?

  • 数据节点(data)【物理配置】:负责数据的存储和相关的操作,例如对数据进行增、删、改、查和聚合等操作,所以数据节点(data节点)对机器配置要求比较高,对CPU、内存和I/O的消耗很大。通常随着集群的扩大,需要增加更多的数据节点来提高性能和可用性。可以存放数据的节点
  • 候选主节点(master-eligible node)【物理配置】可以被选举为主节点(master节点),集群中只有候选主节点才有选举权和被选举权,其他节点不参与选举的工作。可以参与主节点竞选的节点
  • 主节点(master)【动态概念】:负责创建索引、删除索引、跟踪哪些节点是集群的一部分,并决定哪些分片分配给相关的节点、追踪集群中节点的状态等,稳定的主节点对集群的健康是非常重要的。管理整个集群中的节点、索引等的节点,master
  • 协调节点(proxy)【动态概念】:虽然对节点做了角色区分,但是用户的请求可以发往任何一个节点,并由该节点负责分发请求、收集结果等操作,而不需要主节点转发,这种节点可称之为协调节点,协调节点是不需要指定和配置的,集群中的任何节点都可以充当协调节点的角色,接收用户请求并反馈结果给用户的节点

一般而言,一个节点既可以是候选主节点也可以是数据节点,但是由于数据节点对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"]
  • 1
  • 2
  • 3
  • 4

每个节点配置相同的 cluster.name 即可加入集群,那么ES内部是如何通过一个相同的设置cluster.name 就能将不同的节点连接到同一个集群的?

ES的内部使用了Zen Discovery——Elasticsearch的内置默认发现模块(发现模块的职责是发现集群中的节点以及选举master节点),发现规则为单播发现,以防止节点无意中加入集群。只有在同一台机器上运行的节点才会自动组成集群。如果集群的节点运行在不同的机器上,使用单播,可以为 Elasticsearch 提供它应该去尝试连接的节点列表。 模拟发现按照如下步骤:

  1. 每个节点的配置文件都维护一个初始节点主机列表,单播列表不需要包含集群中的所有节点, 它只是需要足够的节点,当一个新节点联系上其中一个并且说上话就可以了,discovery.zen.ping.unicast.hosts: ["host1", "host2:port"]
  2. 节点使用发现机制通过Ping的方式查找其他节点,节点启动后先 ping, 如果discovery.zen.ping.unicast.hosts 有设置,则 ping 设置中的 host ,否则尝试 ping localhost 的几个端口
  3. 节点检测cluster.name是否一致,如果一致,就联系上了这个集群,之后会联系这个集群的master,来通知集群它已经准备好加入到集群中了

当一个节点联系到单播列表中的成员时,它就会得到整个集群所有节点的状态,然后它会联系 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节点发现满足以下条件时发起选举:

  1. 当前master eligible节点不是master,我不是master
  2. 当前master eligible节点与其它的节点通信无法发现master,我联系不上master
  3. 集群中无法连接到master的master eligible节点数量已达到 discovery.zen.minimum_master_nodes 所设定的值,超过一半的兄弟们联系不上master

即当一个master-eligible节点发现包括自己在内的多数派的master-eligible节点认为集群没有master时,就可以发起master选举

选举规则

选举规则由两个参数决定,一个是clusterStateVersion,一个是节点的ID,按照如下步骤进行选举,即每个候选主节点

  1. clusterStateVersion越大,优先级越高。这是为了保证新Master拥有最新的clusterState(即集群的meta),避免已经commit的meta变更丢失。因为Master当选后,就会以这个版本的clusterState为基础进行更新。候选主节点寻找clusterStateVersion比自己高的master eligible的节点,向其发送选票
  2. 当clusterStateVersion相同时,节点的Id越小,优先级越高。即总是倾向于选择Id小的Node,这个Id是节点第一次启动时生成的一个随机字符串。之所以这么设计,应该是为了让选举结果尽可能稳定,不要出现都想当master而选不出来的情况。如果clusterStatrVersion一样,则计算自己能找到的master eligible节点(包括自己)中节点id最小的一个节点,向该节点发送选举投票
  3. 如果一个节点收到足够多的投票(即 minimum_master_nodes 的设置),并且它也向自己投票了,那么该节点成为master开始发布集群状态

选举时分两种情况,一种是当前候选主节点选自己当Master,另一种是当前候选主节点选别的节点当Master,当一个master-eligible node(我们假设为Node_A)发起一次选举时,它会按照上述排序策略选举

当前候选主节点选自己(Node_A)当Master

NodeA会等别的node来join,即等待别的node的选票,当收集到超过半数的选票时,认为自己成为master,然后变更cluster_state中的master node为自己,并向集群发布这一消息

当前候选主节点选别的节点(Node_B)当Master

  1. 如果Node_B已经成为Master,Node_B就会把Node_A加入到集群中,然后发布最新的cluster_state, 最新的cluster_state就会包含Node_A的信息。相当于一次正常情况的新节点加入。对于Node_A,等新的cluster_state发布到Node_A的时候,Node_A也就完成join了
  2. 如果Node_B在竞选Master,并且选了自己为Master,那么Node_B会把这次join当作一张选票。对于这种情况,Node_A会等待一段时间,看Node_B是否能成为真正的Master,直到超时或者有别的Master选成功。
  3. 如果Node_B认为自己不是Master(现在不是,将来也选不上),那么Node_B会拒绝这次join。对于这种情况,Node_A会开启下一轮选举。

以上就是整个选举的流程,如果没有特殊情况是一定能选举出一个master的。

脑裂现象和避免

选举时当出现多个master竞争时,主分片和副本的识别也发生了分歧,对一些分歧中的分片标识为了坏片,更新的时候造成数据混乱或其它非预期结果,也就是我们上文提到的脑裂。其实按照如上的选举规则,能选举出一个确定的master是一定的,就算clusterStateVersion一样,也不可能有两个节点id一致,总会有大有小,按照此规则,所有节点其实是能达成共识的。“脑裂”问题可能有以下几个原因造成:

  • 网络问题:集群间的网络延迟导致一些节点访问不到master,认为master挂掉了从而选举出新的master,并对master上的分片和副本标红,分配新的主分片
  • 节点负载:主节点的角色既为master又为data,访问量较大时可能会导致ES停止响应(假死状态)造成大面积延迟,此时其他节点得不到主节点的响应认为主节点挂掉了,会重新选取主节点。
  • 内存回收:主节点的角色既为master又为data,当data节点上的ES进程占用的内存较大,引发JVM的大规模内存回收,造成ES进程失去响应。

为了避免脑裂现象的发生,我们可以从根源着手通过以下几个方面来做出优化措施:

  • 适当调大响应时间,减少误判:通过参数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连接不上重新发起选举的情况,其实在选举过程中也存在重复投票的问题,不做深入讨论。

ElasticSearch工作流程

主分片和副本分片是如何同步的?创建索引的流程是什么样的?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
  • 1

以上公式中涉及注意事项如下如下:

  • routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值【例如租户ID】。 routing 通过 hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards (主分片的数量)后得到余数 。这个在 0 到 number_of_primary_shards-1 之间的余数,即文档所在分片位置
  • 如果是自定义的routing,在查询时,一定要指定routing进行查询,否则是查询不到文档的。这并不是局限性,恰恰相反,指定routing的查询,性能上会好很多,因为指定_routing意味着直接去存储数据的shard上搜索,而不会搜索所有shard

公式解释了为什么要在创建索引的时候就确定好主分片的数量并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了

写操作流程

每个节点都有处理读写请求的能力。在一个写请求被发送到某个节点后,该节点即为上文提到的协调节点,协调节点会根据路由公式计算出需要写到哪个分片上,再将请求转发到该分片的主分片节点上。假如此时数据通过路由计算公式取余后得到的值是 shard = hash(routing) % 4 = 0,则具体流程如下:

  1. 客户端向E-Node2节点(协调节点)发送写请求,通过路由计算公式得到值为0,则当前数据应被写到主分片S0上。
  2. E-Node2节点将请求转发到S0主分片所在的节点E-Node3,E-Node3接受请求并写入到磁盘S0。
  3. 并发将数据复制到两个副本分片R0上,其中通过乐观并发控制数据的冲突。
  4. 一旦所有的副本分片都报告成功,则节点ES3将向协调节点报告成功,协调节点向客户端报告成功。

下图为流程说明图,后续各操作流程类似,不多余画图:
在这里插入图片描述

读操作流程

同写操作一样,GET某一条数据的流程也会计算路由,当写入了某个document后,这个document会自动给你分配一个全局唯一的id,doc id,同时也是根据doc id进行hash路由到对应的primary shard上去:

  1. 客户端发送请求到E-Node2节点,该节点成为协调节点
  2. 协调节点对document进行路由,路由规则同上,将请求转发到路由主分片E-Node3节点,此时会使用round-robin【这个轮询算法在我的Kafka相关blog介绍过】随机轮询算法,在E-Node3_S0以及其所有replica【E-Node1_R0、E-Node2_R0】中随机选择一个,让读请求负载均衡
  3. 接收请求的节点【E-Node3_S0、E-Node1_R0、E-Node2_R0中随机的一个】返回document给coordinate node【E-Node2】
  4. 协调节点向客户端报告成功,返回document给客户端

以上就是通过一个具体的文档Id读数据的流程,当然其实ES我们更多用到的是它的搜索。

搜索操作流程

其实我们在ES的大多数使用场景都是检索,那么检索的原理是什么呢?这里举个例子,例如我有三条数据,分别叫:E-Node1_S3tml超级帅、E-Node1_S2tml很丑、E-Node2_S1tml很丑、E-Node3_S0tml其实挺帅的。前缀表明他们存储的主分片。我们搜索【帅】这个关键字:

  1. 客户端发送请求到E-Node2节点,该节点成为协调节点
  2. 协调节点将请求转发到所有分片上【主分片或副本分片,采取随机轮询的方式】,假设这里都是主分片处理请求,那么请求被转发到了E-Node1、E-Node1和E-Node3两个节点
  3. query phase:每个分片将自己的搜索结果(其实就是一些doc id),返回给协调节点,由协调节点进行数据的合并、排序、分页等操作,产出最终结果,这里【帅】命中了两条数据:E-Node1的S3分片上【E-Node1_S3tml超级帅】,E-Node3的S0分片上【E-Node3_S0tml其实挺帅的】,这两条数据的doc id被返回给协调节点E-Node2
  4. fetch phase:协调节点E-Node2根据doc id去各个节点上拉取实际的document数据,最终返回给客户端

这里需要注意,第一次只检索数据id返回给协调节点,并不是真正的取数据,整合之后再取数据。

删除操作流程

删除其实是假删除,先进行标记删除,然后在段合并(这个概念后文提到)的时候再进行彻底删除,删除时给定了文档id,这样按照我们的流程,要删除文档【E-Node1_S3tml超级帅】

  1. 客户端发送请求到E-Node2节点,该节点成为协调节点
  2. 协调节点对document id进行路由,路由规则同上,将请求转发到路由主分片E-Node1_S3的节点E-Node1,此时不直接删除文档,而是把文档id标记到.del的删除文件
  3. 将删除请求路由到所有副本节点【E-Node2_R3、E-Node3_R3】执行同样的操作
  4. 协调节点向客户端报告成功

下面会讲到,只有在进行段合并的时候才会真正的删除文件,其它时候只是检索到后将结果集过滤了一遍.del文件。

更新操作流程

更新其实是一次删除加一次写入,已标记删除的V1版本,在检索时可能会被检索到,然后在结果集里被过滤掉,所以.del文件还需要记录文档的版本,这样按照我们的流程,要更新文档【E-Node1_S3tml超级帅】

  1. 客户端发送请求到E-Node2节点,该节点成为协调节点
  2. 协调节点对document id进行路由,路由规则同上,将请求转发到路由主分片E-Node1_S3的节点E-Node1,此时不直接删除文档,而是把文档id标记到.del的删除文件,然后索引一个文档的新版本
  3. 将更新请求路由到所有副本节点对分片【E-Node2_R3、E-Node3_R3】执行同样的操作
  4. 协调节点向客户端报告成功

那么在具体的更新流程中,版本合并如何处理呢?在下边的更新版本合并策略中介绍到。

ElasticSearch存储原理及策略

上面介绍了在ES内部索引的CRSUD处理流程,这个流程是在ES的内存中执行的,数据被分配到特定的分片和副本上之后,最终持久化到磁盘上,这样断电的时候就不会丢失数据。具体的存储路径可在配置文件../config/elasticsearch.yml中进行设置,默认存储在安装目录的data文件夹下。建议不要使用默认值,因为若ES进行了升级,则可能导致数据全部丢失

path.data: /path/to/data  //索引数据
path.logs: /path/to/logs  //日志记录
  • 1
  • 2

分段存储

索引文档以段的形式存储在磁盘上,索引文件被拆分为多个子文件,则每个子文件叫作段【其实和Kafka将Partion切分为段类似】, 每一个段本身都是一个倒排索引,并且段具有不变性,一旦索引的数据被写入硬盘,就不可再修改。在底层采用了分段的存储模式,使它在读写时几乎完全避免了锁的出现,大大提升了读写性能。
在这里插入图片描述

段在不同存储模式下拥有不同的读写能力,可以避免使用锁的开销,提升读写性能

  1. 当索引在内存中时,就只有写的权限,而不具备读数据的权限,意味着不能被检索【只能写不能读】
  2. 当索引被刷到文件缓存系统里成为段之后才能被检索【只能读不能写】
  3. 段被写入到磁盘后会生成一个提交点(checkpoint),提交点是一个用来记录所有提交后段信息的文件。一个段一旦拥有了提交点,就说明这个段的数据在断电时也不会丢失了

为什么要有段呢?早期全文检索为整个文档集合建立了一个很大的倒排索引,并将其写入磁盘中。如果索引有更新,就需要重新全量创建一个索引来替换原来的索引。这种方式在数据量很大时效率很低,并且由于创建一次索引的成本很高,所以对数据的更新不能过于频繁,也就不能保证时效性,索引文件分段存储并且不可修改,那么新增、更新和删除如何处理呢?

  • 新增,新增很好处理,由于数据是新的,所以只需要对当前文档新增一个段就可以了。
  • 删除,由于不可修改,所以对于删除操作,不会把文档从旧的段中移除而是通过新增一个.del文件,文件中会列出这些被删除文档的段信息。这个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除
  • 更新,不能修改旧的段来进行反映文档的更新,其实更新相当于是删除和新增这两个动作组成。会将旧的文档在.del文件中标记删除,然后文档的新版本被索引到一个新的段中。可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就会被移除

段被设定为不可修改具有一定的优势也有一定的缺点。

段不变性的优势

段不变性的优势主要体现在:

  1. 不需要锁。如果不更新索引,就不需要担心多进程同时修改数据的问题,也就是单纯读写时不会有锁的问题
  2. 一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求文件系统缓存【内存】,而不会命中磁盘。这提供了很大的性能提升。
  3. filter缓存在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。
  4. 写入单个大的倒排索引允许数据被压缩,减少磁盘 I/O 和 需要被缓存到内存的索引的使用量。

总结而言就是不需要考虑读写的并发,以及高性能的读数据和压缩数据能力。

段不变性的缺点

段的不变性的缺点如下:

  1. 新增时:每次新增数据时都需要新增一个段来存储数据。当段的数量太多时,对服务器的资源例如文件句柄的消耗会非常大。
  2. 删除时:当对旧数据进行删除时,旧数据不会马上被删除,而是在.del文件中被标记为删除。而旧数据只能等到段更新时才能被移除,这样会造成大量的空间浪费。
  3. 更新时:若有一条数据频繁的更新,每次更新都是新增新的标记旧的,则会有大量的空间浪费。
  4. 查询时:在查询的结果中包含所有的结果集,需要排除被标记删除的旧数据,这增加了查询的负担

我们说没有任何一种结构或算法是万能的,空间和时间一定有其平衡性,段其实就是牺牲了空间成就了时间。

索引操作策略

在介绍了索引的CRSUD以及ES的存储结构后我们来看看在CRSUD过程中,结合存储,有什么策略让索引的使用更高效呢?

延迟写策略

如果直接写入磁盘,磁盘的I/O消耗上会严重影响性能,写数据量大的时候会造成ES停顿卡死,查询也无法做到快速响应。如果真是这样ES也就不会称之为近实时全文搜索引擎了,为了提升写的性能,ES并没有每新增一条数据就增加一个段到磁盘上,而是采用延迟写的策略:

  1. 写入内存:每当有新增的数据时,就将其先写入到内存中,在内存和磁盘之间是文件系统缓存。这里的内存使用的是ES的JVM内存,新的数据会继续的被写入内存,但内存中的数据并不是以段的形式存储的,因此不能提供检索功能
  2. 写入文件系统缓存:当达到默认的时间(1秒钟)或者内存的数据达到一定量时,会触发一次刷新(Refresh),将内存中的数据生成到一个新的段上并缓存到文件缓存系统 上,文件缓存系统使用的是操作系统的内存,由内存刷新到文件缓存系统的时候会生成了新的段,并将段打开以供搜索使用,而不需要等到被刷新到磁盘
  3. 写入磁盘:稍后再被刷新到磁盘中并生成提交点。

在 Elasticsearch 中,写入和打开一个新段的轻量的过程叫做 refresh (即内存刷新到文件缓存系统)。 默认情况下每个分片会每秒自动刷新一次。这就是为什么我们说 Elasticsearch 是近实时搜索,因为文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。我们也可以手动触发 refresh,POST /_refresh 刷新所有索引,POST /nba/_refresh刷新指定的索引。

虽然通过延时写的策略可以减少数据往磁盘上写的次数提升了整体的写入能力,但是我们知道文件缓存系统也是内存空间,属于操作系统的内存,只要是内存都存在断电或异常情况下丢失数据的危险。为了避免丢失数据,Elasticsearch添加了事务日志(Translog),事务日志记录了所有还没有持久化到磁盘的数据。添加了事务日志后整个写索引的流程如下图所示:
在这里插入图片描述
添加了事务日志后的优化流程如下:

  1. 写入内存及事务日志,先将数据写入buffer【ES的内存】,在buffer里的时候数据是搜索不到的;同时将数据写入translog日志文件,这时新数据还不能被检索和查询。需要注意的是:当Elasticsearch作为NoSQL数据库时,查询方式是GetById,这种查询可以直接从TransLog中查询,这时候就成了RT(Real Time)实时系统
  2. 写入文件系统缓存(os cache), 如果buffer快满了,或者到一定时间(每隔1s),就会将buffer数据refresh到一个新的segment file中(此时数据不是直接进入segment file的磁盘文件的,而是先进入os cache)并且将buffer清空。只要数据进入os cache,此时就可以让这个segment file的数据对外提供搜索了,为什么叫es是准实时的?NRT,near real-time,准实时。默认是每隔1秒refresh一次的,所以es是准实时的,因为写入的数据1秒之后才能被看到。
  3. 重复1~2步骤,新的数据不断进入buffer和translog,不断将buffer数据写入一个又一个新的segment file中去,每次refresh完buffer清空,translog保留。随着这个过程推进,translog会变得越来越大。当translog达到一定长度的时候,就会触发commit操作。
  4. 写入磁盘并删除事务日志,当日志数据大小超过512M或者时间超过30分钟时,会触发一次 flush,也就是 commit操作分为如下几步:
    1. 就是将buffer中现有数据refresh到os cache中去,清空buffer
    2. 将一个commit point写入磁盘文件,里面标识着这个commit point对应的所有segment file
    3. 强行将os cache中目前所有的数据都fsync到磁盘文件中去
    4. 清空translog日志文件然后再次重启启用一个translog

也可以通过es api,手动执行flush操作,手动将os cache中的数据fsync强刷到磁盘上去,记录一个commit point,清空translog日志文件。

通过这种方式当断电或需要重启时,ES不仅要根据提交点去加载已经持久化过的段,还需要查看Translog里的记录,把未持久化的数据重新持久化到磁盘上,避免了数据丢失的可能。到这里,其实ES1秒延迟的问题有两个不成熟的解决方案

  1. 可以通过es的restful api或者java api,在写入内存后,手动执行一次refresh操作,就是手动将buffer中的数据刷入os cache中,让数据立马就可以被搜索到
  2. 查询方式是GetById时,直接从TransLog中查询

当然这肯定会牺牲性能,所以并不是完备的解决方案,但其实是解决问题的入口,值得一提的是,translog其实也是先写入os cache的,默认每隔5秒刷一次到磁盘中去,因为日志每隔5秒从文件缓存系统flush一次到磁盘,所以最多会丢5秒的数据。

段合并策略

由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦。 每一个段都会消耗文件句柄、内存和cpu运行周期。更重要的是,每个搜索请求都必须轮流检查每个段然后合并查询结果,所以段越多,搜索也就越慢。

Elasticsearch通过在后台定期进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段

  • 段合并的时候会将那些旧的已删除文档从文件系统中清除。被删除的文档不会被拷贝到新的大段中
  • 合并的过程中不会中断索引和搜索

这可以解决段不可变带来的空间占用问题
在这里插入图片描述
段合并时机和步骤:

  1. 段合并在进行索引和搜索时会自动进行,合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中
  2. 待合并的段既可以是未提交的也可以是已提交【文件操作系统缓存里的或磁盘里的】的。合并结束后老的段会被删除,新的段被 flush 到磁盘,同时写入一个包含新段(已排除旧的被合并的段)的新提交点
  3. 新的段被打开可以用来搜索

段合并的计算量庞大, 而且还要吃掉大量磁盘 I/O,段合并会拖累写入速率,如果任其发展会影响搜索性能。Elasticsearch在默认情况下会对合并流程进行资源限制,所以搜索仍然有足够的资源很好地执行。整体执行流程如下图所示:
在这里插入图片描述

ElasticSearch检索

ES最核心的内容就是检索了,重头戏往往放到后边,在了解了集群的工作原理和存储机制后,正式了解ElasticSearch检索操作。

分词器

中文在stander中的切分是按照单字的,例如服务器三个字在标准分词器下会被分为服、务、器,所以我们在term查询的时候检索服务的时候检索不到数据,因为没有服务这个分词,所以我们需要使用IK分词器,IK提供了两个分词算法ik_smart 和 ik_max_word,其中 ik_smart 为最少切分,ik_max_word为最细粒度划分。

  • 最小切分不切分到单字,这样querstring搜【服务】就不再能搜到了,因为querstring切分的关键词不包含,term搜【服务器】就可以搜到了。
    在浏览器地址栏输入地址
  • 最大切分的情况下,有可能被单字切分,粒度更细一些。此时term搜【服务】就可以搜到了
    在这里插入图片描述

其实不光有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
  • 1

2,语法查询相当于我的查询语句会进行分词

{
    "query": {
        "query_string": {
            "default_field": "name",  //要查询的字段
            "query": "林玲"
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

3,term查询用来查询某个关键字在文档里是否存在,所以Term需要是文档切分的一个关键字

{
    "query": {
        "term": {
            "sex": "男"
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

4,terms查询用来查询某几个个关键字在文档里是否存在,Terms可以同时对一个字段检索多个关键字

{
    "query": {
        "terms": {
            "age": [18,28]
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

4, match查询和queryString有点类似,就是先对查询内容做分词,然后再去进行匹配

{
    "query": {
        "match": {
            "sex": "男女"
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

5,match_all的查询方式简单粗暴,就是匹配所有,不需要传递任何参数

{
    "query": {
        "match_all": {
            
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

6,match_phrase属于短语匹配,能保证分词间的邻近关系,相当于对文档的关键词进行重组以匹配查询内容,对于匹配了短语"森 小 林"的文档,下面的条件必须为true:

  • 森 、小、 林必须全部出现在某个字段中
  • 的位置必须比的位置大1
  • 的位置必须比的位置大2

我们来尝试下对姓名进行检索,请求头和上边完全一样,就不再赘述,直接看请求体,先来看一个不按顺序的

{
    "query": {
        "match_phrase": {
            "name": "森小林"
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

7,multi_match表示多字段匹配关键词,我们试着在name和sex里找,只要包含的我们就返回该数据

{
    "query": {
        "multi_match": {
             "query": "男",
             "fields":  ["name","sex"]  
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

8,range查询,顾明思意就是范围查询,例如我们这里要查询年龄在19到28的人的数据:

{
    "query": {
        "range": {
             "age" : {
                        "gte" : 19,
                        "lt"  : 29
                    }
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

这里需要解释下:range 查询可同时提供包含(inclusive)和不包含(exclusive)这两种范围表达式,可供组合的选项如下:

  • gt: > 大于(greater than)
  • lt: < 小于(less than)
  • gte: >= 大于或等于(greater than or equal to)
  • lte: <= 小于或等于(less than or equal to)

可以依据自己的需求自由进行组合。

9,exists允许你过滤文档,只查找那些在特定字段有值的文档,无论其值是多少,为了验证,需要注意,这里的有值即使是空值也算有值,只要不是null

{
    "query": {
         "exists": {
            "field": "sex"
          }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

10,wildcard,通配符查询,其中【?】代表任意一个字符【*】代表任意的一个或多个字符,例如我们想查名字结尾为的文档:

{
    "query": {
         "wildcard": {
            "name": "*林"
          }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

11, prefix,前缀查询,我们为了找到所有姓名以开头的文档,可以使用这种方式:

{
    "query": {
         "prefix": {
            "name": "森"
          }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

12,regexp,正则匹配,ES兼容了正则的查询方式,例如我们想查询性别为汉字字符的文档

{
    "query": {
         "regexp": {
            "sex": "[\u4e00-\u9fa5]"
          }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

13,fuzzy,纠错检索,让输入条件有容错性,例如我要检索性别为woman的数据,但是我拼错了,输入的是wman,用fuzzy照样可以检索到:

{
    "query": {
         "fuzzy": {
            "sex": "wman"
          }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

复合查询

复合查询通俗的说就是多个条件拼接查询,就是用Bool去拼接一系列的查询条件,来完成表达式的查询方式,其实就是将普通条件进行重新组合,常用的有四种复合类型:

  • filter:只过滤符合条件的文档,与must唯一的区别是:不计算相关系得分,但因为有缓存,所以性能高
  • must:用must连接的多个条件必须都满足,是and的关系,逻辑&与的关系。
  • should:用should连接的多个条件只要满足一个即可,是or的关系,逻辑||或的关系
  • must_not:用must_not绑定的条件表示一定不能满足该条件,是not的关系,逻辑^非的关系。

用这些条件的连接词将多个查询条件连接起来就能进行复杂的复合查询了。

filter使用

过滤器,文档必须匹配该过滤条件,跟must子句的唯一区别是,filter不影响查询的score,我们这里设置要查询的内容为【年龄在10-19岁之间 且 性别为男 且 姓名开头为森】的员工,查询语句为:

{"query":{
    "bool": {
      "filter": [
        {"term": {"sex": "男"}},
        {"range": {"age": { "gte" : 18, "lt"  : 29}}},
        {"prefix": {"name": "森"}}
      ]
    }
  }
}
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

返回结果为:

{
    "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": "森小辰"
                }
            }
        ]
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
must使用

文档必须匹配must查询条件,我们这里设置要查询的内容为【年龄在10-19岁之间 且 性别为男 且 姓名开头为森】的员工,查询语句为:

{"query":{
    "bool": {
      "must": [
        {"term": {"sex": "男"}},
        {"range": {"age": { "gte" : 18, "lt"  : 29}}},
        {"prefix": {"name": "森"}}
      ]
    }
  }
}
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
should使用

文档应该匹配should子句查询的一个或多个,这里我们来查询【年龄在10-19岁之间 或 性别为男 或 姓名开头为森】,查询语句为:

{"query":{
    "bool": {
      "should": [
        {"term": {"sex": "男"}},
        {"range": {"age": { "gte" : 18, "lt"  : 29}}},
        {"prefix": {"name": "森"}}
      ]
    }
  }
}
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
must_not使用

文档不能匹配该查询条件,这里我们来查询【年龄不在10-19岁之间 且 性别不为男 且 姓名开头不为森】,查询语句为:

{"query":{
    "bool": {
      "must_not": [
        {"term": {"sex": "男"}},
        {"range": {"age": { "gte" : 18, "lt"  : 29}}},
        {"prefix": {"name": "森"}}
      ]
    }
  }
}
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
复合条件查询

我们在真实使用复合查询的时候肯定不仅仅要查单种条件的复合关系,还需要查多种关联条件,这里我们查一个【年龄在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
                    }
                }
            ]
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44

聚合查询

聚合查询实际上是一种统计和计算,按照官方文档的解释共有四类

  • Metric(指标): 指标分析类型,如计算最大值、最小值、平均值等等 (对桶内的文档进行聚合分析的操作)
  • Bucket(桶): 分桶类型,类似SQL中的GROUP BY语法 (满足特定条件的文档的集合)
  • Pipeline(管道): 管道分析类型,基于上一级的聚合分析结果进行在分析
  • Matrix(矩阵): 矩阵分析类型(聚合是一种面向数值型的聚合,用于计算一组文档字段中的统计信息)

一般聚合的写法如下:

"aggregations" : {
    "<aggregation_name>" : {                                 <!--聚合的名字 -->
        "<aggregation_type>" : {                               <!--聚合的类型 -->
            <aggregation_body>                                 <!--聚合体:对哪些字段进行聚合 -->
        }
        [,"meta" : {  [<meta_data_body>] } ]?               <!---->
        [,"aggregations" : { [<sub_aggregation>]+ } ]?   <!--在聚合里面在定义子聚合 -->
    }
    [,"<aggregation_name_2>" : { ... } ]*                     <!--聚合的名字 -->
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

其中aggregations 也可简写为 aggs,虽然Elasticsearch有四种聚合方式,但在一般实际开发中,用到的比较多的就是Metric和Bucket。其实从功能上只有前两类:

  • 一类是进行数学计算聚合使用的【max、min、avg、sum、stats 】,类似sql里的函数,也就是ES里的统计聚合
  • 另一类则是进行分组聚合使用的,类似sql里的group,也就是ES里的桶聚合。

剩余的两类就是程序上的辅助聚合方式,一种是管道二次处理,一种似是分桶同时处理统计计算。

桶聚合

简单来说桶就是满足特定条件的文档的集合。当聚合开始被执行,每个文档里面的值通过计算来决定符合哪个桶的条件,如果匹配到,文档将放入相应的桶并接着开始聚合操作。桶也可以被嵌套在其他桶里面。我们先来看一个桶聚合,例如我们想要进行如下聚合【按照年龄聚合,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
          }
        ]
      }
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

返回结果为:

{
    "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个文档
                }
            ]
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
指标聚合

指标聚合分为两种,分别是单值分析和多值分析,各自有几个查询关键字:

  • 单值分析只输出一个分析结果:min,max,avg,sum,cardinality,cardinality表示求唯一值,即不重复的字段有多少(相当于mysql中的distinct)
  • 多值分析输出多个分析结果:stats, extended_stats, percentile, percentile_rank, top hitsPercentiles表示对指定字段的值按从小到大累计每个值对应的文档数的占比,返回指定占比比例对应的值。默认按照[ 1, 5, 25, 50, 75, 95, 99 ]来统计,例如我们想统计各个年龄百分区间内员工的数量会用到,Percentile Ranks通过文档值求百分比,例如我们想统计某个年龄在整个年龄list占比百分区间

统计计算这里我们分别使用一下以上几种方式进行分析,最后再结合分组进行一些复合的统计计算查询。

单值分析

单值分析以max举例,这里我们想查询,所有员工信息中,【年龄最大的员工】,则查询参数为:

{
    "size":0,
    "aggs":{
        "max_age":{
            "max":{
                "field":"age"
            }
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

返回结果为:

{
    "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
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

其它几种单值查询sum、min、avg、cardinality 也类似。

多值分析

多值分析以stats举例,我想统计所有员工年龄的各种单值分析状态,

{
    "size":0,
    "aggs":{
        "age_stats":{
            "stats":{
                "field":"age"
            }
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

返回值为:

{
    "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
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

分页查询

分页查询方式有很多种,我们就适用场景各自聊一种,也就是在浅度分页和深度分页中各自适用方式。还是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条数据。

  • size:显示应该返回的结果数量,默认是10。
  • from:显示查询数据的偏移量,即应该跳过的初始结果数量,默认是0,我们这里取第三页的数据,则from应该设置为20

那么依照这样的需求我们请求发送到集群会怎么处理呢?处理流程如下【请求会被随机转发主分片或副本分片,采取随机轮询的方式,我们这里假定都是主分片处理】:

  1. 客户端发送请求到E-Node1节点,该节点成为协调节点,E-Node1上的【S2、S3】各建立一个大小为from+size(30)的优先级队列来存放查询结果;
  2. 协调节点将请求转发到所有分片上【主分片或副本分片,采取随机轮询的方式】,假设这里都是主分片处理请求,那么请求被转发到了E-Node2上的【S1】和E-Node3上的【S0】,它们各建立一个大小为from+size(30)的优先级队列来存放查询结果
  3. 每个shards在内部执行查询(搜索户籍地为乌拉特前旗,且按照年龄进行排序),把from+size(30)条记录存到内部的优先级队列(top N表)中
  4. 每个分片将自己的搜索结果(其实就是一些doc id),返回给协调节点
  5. query phase:E-Node1获取到各个shards数据后,进行合并排序,选择30*4共120条记录里的前 from + size 30条数据以及用于排序的 _score 存到优先级队列即可,以便 fetch 阶段使用。
  6. fetch phase:协调节点E-Node2获取到整体的top30后,取其中的第20-30条也就是第三页的数据中的10个doc id,根据doc id去各个节点上拉取实际的document数据,最终返回给客户端

这样一个数据量在这种场景下还是可以hold的,但是如果查询量比较大呢?假设我们每个分片上存储了10万条数据,共计40万条数据,我们要取第1万页的数据,也就是from为10000,size为10,那么我们再看一遍流程如下:

  1. client发送分页查询请求到E-Node1(coordinating node)上,E-Node1上的【S2、S3】各建立一个大小为from+size(10010)的优先级队列来存放查询结果;内存、IO损耗
  2. 协调节点将请求转发到E-Node2【S1】和E-Node3【S0】上,它们各建立一个大小为from+size(10010)的优先级队列来存放查询结果;内存、IO损耗
  3. 每个shards在内部执行查询(按照年龄进行排序),把from+size(10010)条记录存到内部的优先级队列(top N表)中;CPU损耗
  4. 每个shards把缓存的from+size(10010)条记录返回给ES1;网络带宽损耗
  5. query phase:E-Node1获取到各个shards数据后,进行合并排序,选择10010*4共40040条记录里的前 from + size 10010条数据以及用于排序的 _score 存到优先级队列即可,以便 fetch 阶段使用。CPU损耗
  6. fetch phase:协调节点E-Node1获取到整体的top10010后,取其中的第10001-10010条也就是第10000页的数据中的10个doc id,根据doc id去各个节点上拉取实际的document数据,最终返回给客户端

以上的各个阶段可以看到,当页码很深的时候,我们拿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
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

from+size最多限制10000,超过限制即报错,当然这个参数可以通过如下的方式调整,例如调整到50000

curl -XPUT "http://11.12.84.126:9200/_audit_0102/_settings" -d '{
        "index": {
            "max_result_window": 50000
        }
    }'

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

但就算调整了也只是一种临时方案,硬件极限承载能力并不是通过调整配置能解决的,需要更换策略。从报错信息也可以看出,ES推荐使用Scroll的方式:

See the scroll api for a more efficient way to request large data sets
  • 1
深度分页适用场景

Scroll 类似于sql中的cursor,使用scroll,每次只能获取一页的内容(按照上边的场景,每次获取10条),然后会返回一个scroll_id。根据返回的这个scroll_id可以不断地获取下一页的内容,所以scroll并不适用于有跳页的情景。Scroll的流程分为两个步骤

  • 第一次搜索完成之后,将所有复合条件的搜索结果缓存起来,类似于对结果集做了一个快照;
  • 在需要返回数据时,从该快照中按照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"
      }
    }
  ]
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

其中: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"
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

需要注意:每次都要传参数 scroll,刷新搜索结果的缓存时间,相对于流程我们再来看一下请求过程:

  1. client发送分页查询请求到E-Node1(coordinating node)上E-Node1上的【S2、S3】各建立一个大小为from+size(10)的优先级队列来存放查询结果;内存无损耗、IO无损耗
  2. 协调节点将请求转发到ES2【S1】和ES3【S0】上,它们各建立一个大小为from+size(10)的优先级队列来存放查询结果;内存无损耗、IO无损耗
  3. 每个shards在内部执行查询(按照年龄进行排序),把from+size(10)条记录存到内部的优先级队列(top N表)中;CPU无损耗
  4. 每个shards把缓存的from+size(10)条记录返回给E-Node1;网络带宽无损耗
  5. query phase:E-Node1获取到各个shards数据后,发起下一次请求,直到轮询发起10001次请求,共获得40040条数据后执行合并排序,进行合并排序,选择10010*4共40040条记录里的前 from + size 10010条数据以及用于排序的 _score 存到优先级队列即可,以便 fetch 阶段使用。CPU无损耗、快照堆积
  6. fetch phase:协调节点E-Node1获取到整体的top100010后,取其中的第10001-10010条也就是第10000页的数据中的10个doc id,根据doc id去各个节点上拉取实际的document数据,最终返回给客户端
删除Scroll上下文快照

scroll的搜索上下文会在scroll的保留时间截止后自动清除,但是我们知道scroll是非常消耗资源的,所以一个建议就是当不需要scroll数据的时候,尽可能快的把scroll_id显式删除掉,因为每一个 scroll_id 不仅会占用大量的资源,而且会生成历史快照【在初始化请求时初始化数据快照,后续游标在快照上移动】,资源占用是很大的【5版本前scroll_id每次返回是变化的,5版本后就不变了】。又是一个经典的以空间换时间的例子

行文至此,已洋洋洒洒4万言,对ElasticSearch的原理和使用有了一个整体的认识,希望和大家共同进步。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/羊村懒王/article/detail/363264
推荐阅读
相关标签
  

闽ICP备14008679号