赞
踩
Elasticsearch是实时的分布式搜索分析引擎,内部使用Lucene做索引与搜索。
何谓实时?新增到 ES 中的数据在1秒后就可以被检索到,这种新增数据对搜索的可见性称为“准实时搜索”。分布式意味着可以动态调整集群规模,弹性扩容,而这一切操作起来都非常简便,用户甚至不必了解集群原理就可以实现。按官方的描述,集群规模支持“上百”个节点,相比HDFS等上千台的集群,这个规模“小了点”。影响集群规模上限的原因将在后续的章节中分析。因此,目前我们认为ES适合中等数据量的业务,不适合存储海量数据。
Lucene是Java语言编写的全文搜索框架,用于处理纯文本的数据,但它只是一个库,提供建立索引、执行搜索等接口,但不包含分布式服务,这些正是 ES 做的。什么是全文?对全部的文本内容进行分析,建立索引,使之可以被搜索,因此称为全文。
基于ES,你可以很容易地搭建自己的搜索引擎,用于分析日志,或者配合开源爬虫建立某个垂直领域的搜索引擎。ES 易用的产品设计使得它很容易上手。除了搜索,ES 还提供了大量的聚合功能,所以它不单单是一个搜索引擎,还可以进行数据分析、统计,生成指标数据。而这些功能都在快速迭代,目前每2周左右就会发布新版本。
ES是面向文档的。各种文本内容以文档的形式存储到ES中,文档可以是一封邮件、一条日志,或者一个网页的内容。一般使用 JSON 作为文档的序列化格式,文档可以有很多字段,在创建索引的时候,我们需要描述文档中每个字段的数据类型,并且可能需要指定不同的分析器,就像在关系型数据中“CREATE TABLE”一样。
在存储结构上,由_index、_type 和_id唯一标识一个文档。
_index指向一个或多个物理分片的逻辑命名空间,_type类型用于区分同一个集合中的不同细分,在不同的细分中,数据的整体模式是相同或相似的,不适合完全不同类型的数据。多个_type可以在相同的索引中存在,只要它们的字段不冲突即可(对于整个索引,映射在本质上被“扁平化”成一个单一的、全局的模式)。_id文档标记符由系统自动生成或使用者提供。
很多初学者喜欢套用RDBMS中的概念,将_index理解为数据库,将_type理解为表,这是很牵强的理解,实际上这是完全不同的概念,没什么相似性,不同_type下的字段不能冲突,删除整个_type也不会释放空间。在实际应用中,数据模型不同,有不同_type需求的时候,我们应该建立单独的索引,而不是在一个索引下使用不同的_type。删除过期老化的数据时,最好以索引为单位,而不是_type和_id。正由于_type在实际应用中容易引起概念混淆,以及允许索引存在多_type并没有什么实际意义,在ES 6.x版本中,一个索引只允许存在一个_type,未来的7.x版本将完全删除_type的概念。
在分布式系统中,单机无法存储规模巨大的数据,要依靠大规模集群处理和存储这些数据,一般通过增加机器数量来提高系统水平扩展能力。因此,需要将数据分成若干小块分配到各个机器上。然后通过某种路由策略找到某个数据块所在的位置。
除了将数据分片以提高水平扩展能力,分布式存储中还会把数据复制成多个副本,放置到不同的机器中,这样一来可以增加系统可用性,同时数据副本还可以使读操作并发执行,分担集群压力。但是多数据副本也带来了一致性的问题:部分副本写成功,部分副本写失败。我们随后讨论。
为了应对并发更新问题,ES将数据副本分为主从两部分,即主分片(primaryshard)和副分片(replica shard)。主数据作为权威数据,写过程中先写主分片,成功后再写副分片,恢复阶段以主分片为准。
数据分片和数据副本的关系如下图所示。
分片(shard)是底层的基本读写单元,分片的目的是分割巨大索引,让读写可以并行操作,由多台机器共同完成。读写请求最终落到某个分片上,分片可以独立执行读写工作。ES利用分片将数据分发到集群内各处。分片是数据的容器,文档保存在分片内,不会跨分片存储。分片又被分配到集群内的各个节点里。当集群规模扩大或缩小时,ES 会自动在各节点中迁移分片,使数据仍然均匀分布在集群里。
索引与分片的关系如下图所示。
一个ES索引包含很多分片,一个分片是一个Lucene的索引,它本身就是一个完整的搜索引擎,可以独立执行建立索引和搜索任务。Lucene索引又由很多分段组成,每个分段都是一个倒排索引。ES每次“refresh”都会生成一个新的分段,其中包含若干文档的数据。在每个分段内部,文档的不同字段被单独建立索引。每个字段的值由若干词(Term)组成,Term是原文本内容经过分词器处理和语言处理后的最终结果(例如,去除标点符号和转换为词根)。
如果想了解Lucene分段由哪些文件组成,每个文件都存储了什么内容,则可以参考Apache Lucene 的手册:http://lucene.apache.org/core/7_3_0/core/org/apache/lucene/codecs/lucene70/package-summary.html#package.description。
索引建立的时候就需要确定好主分片数,在较老的版本中(5.x 版本之前),主分片数量不可以修改,副分片数可以随时修改。现在(5.x~6.x 版本之后),ES 已经支持在一定条件的限制下,对某个索引的主分片进行拆分(Split)或缩小(Shrink)。但是,我们仍然需要在一开始就尽量规划好主分片数量:先依据硬件情况定好单个分片容量,然后依据业务场景预估数据量和增长量,再除以单个分片容量。
分片数不够时,可以考虑新建索引,搜索1个有着50个分片的索引与搜索50个每个都有1个分片的索引完全等价,或者使用_split API来拆分索引(6.1版本开始支持)。
在实际应用中,我们不应该向单个索引持续写数据,直到它的分片巨大无比。巨大的索引会在数据老化后难以删除,以_id 为单位删除文档不会立刻释放空间,删除的 doc 只在 Lucene分段合并时才会真正从磁盘中删除。即使手工触发分段合并,仍然会引起较高的 I/O 压力,并且可能因为分段巨大导致在合并过程中磁盘空间不足(分段大小大于磁盘可用空间的一半)。因此,我们建议周期性地创建新索引。例如,每天创建一个。假如有一个索引website,可以将它命名为website_20180319。然后创建一个名为website的索引别名来关联这些索引。这样,对于业务方来说,读取时使用的名称不变,当需要删除数据的时候,可以直接删除整个索引。
索引别名就像一个快捷方式或软链接,不同的是它可以指向一个或多个索引。可以用于实现索引分组,或者索引间的无缝切换。
现在我们已经确定好了主分片数量,并且保证单个索引的数据量不会太大,周期性创建新索引带来的一个新问题是集群整体分片数量较多,集群管理的总分片数越多压力就越大。在每天生成一个新索引的场景中,可能某天产生的数据量很小,实际上不需要这么多分片,甚至一个就够。这时,可以使用_shrink API来缩减主分片数量,降低集群负载。
为文档建立索引,使其每个字段都可以被搜索,通过关键词检索文档内容,会使用倒排索引的数据结构。倒排索引一旦被写入文件后就具有不变性,不变性具有许多好处:对文件的访问不需要加锁,读取索引时可以被文件系统缓存等。
那么索引如何更新,让新添加的文档可以被搜索到?答案是使用更多的索引,新增内容并写到一个新的倒排索引中,查询时,每个倒排索引都被轮流查询,查询完再对结果进行合并。
每次内存缓冲的数据被写入文件时,会产生一个新的Lucene段,每个段都是一个倒排索引。在一个记录元信息的文件中描述了当前Lucene索引都含有哪些分段。
由于分段的不变性,更新、删除等操作实际上是将数据标记为删除,记录到单独的位置,这种方式称为标记删除。因此删除部分数据不会释放磁盘空间。
在写操作中,一般会先在内存中缓冲一段数据,再将这些数据写入硬盘,每次写入硬盘的这批数据称为一个分段,如同任何写操作一样。一般情况下(direct方式除外),通过操作系统write接口写到磁盘的数据先到达系统缓存(内存),write函数返回成功时,数据未必被刷到磁盘。通过手工调用flush,或者操作系统通过一定策略将系统缓存刷到磁盘。这种策略大幅提升了写入效率。从write函数返回成功开始,无论数据有没有被刷到磁盘,该数据已经对读取可见。
ES正是利用这种特性实现了近实时搜索。每秒产生一个新分段,新段先写入文件系统缓存,但稍后再执行flush刷盘操作,写操作很快会执行完,一旦写成功,就可以像其他文件一样被打开和读取了。
由于系统先缓冲一段数据才写,且新段不会立即刷入磁盘,这两个过程中如果出现某些意外情况(如主机断电),则会存在丢失数据的风险。通用的做法是记录事务日志,每次对ES进行操作时均记录事务日志,当ES启动的时候,重放translog中所有在最后一次提交后发生的变更操作。比如HBase等都有自己的事务日志。
在ES中,每秒清空一次写缓冲,将这些数据写入文件,这个过程称为refresh,每次refresh会创建一个新的Lucene 段。但是分段数量太多会带来较大的麻烦,每个段都会消耗文件句柄、内存。每个搜索请求都需要轮流检查每个段,查询完再对结果进行合并;所以段越多,搜索也就越慢。因此需要通过一定的策略将这些较小的段合并为大的段,常用的方案是选择大小相似的分段进行合并。在合并过程中,标记为删除的数据不会写入新分段,当合并过程结束,旧的分段数据被删除,标记删除的数据才从磁盘删除。
HBase、Cassandra等系统都有类似的分段机制,写过程中先在内存缓冲一批数据,不时地将这些数据写入文件作为一个分段,分段具有不变性,再通过一些策略合并分段。分段合并过程中,新段的产生需要一定的磁盘空间,我们要保证系统有足够的剩余可用空间。Cassandra系统在段合并过程中的一个问题就是,当持续地向一个表中写入数据,如果段文件大小没有上限,当巨大的段达到磁盘空间的一半时,剩余空间不足以进行新的段合并过程。如果段文件设置一定上限不再合并,则对表中部分数据无法实现真正的物理删除。ES存在同样的问题
分布式系统的集群方式大致可以分为主从(Master-Slave)模式和无主模式。ES、HDFS、HBase使用主从模式,Cassandra使用无主模式。主从模式可以简化系统设计,Master作为权威节点,部分操作仅由Master执行,并负责维护集群元信息。缺点是Master节点存在单点故障,需要解决灾备问题,并且集群规模会受限于Master节点的管理能力。
因此,从集群节点角色的角度划分,至少存在主节点和数据节点,另外还有协调节点、预处理节点和部落节点,下面分别介绍各种类型节点的职能。
主节点负责集群层面的相关操作,管理集群变更。
通过配置 node.master: true
(默认)使节点具有被选举为Master 的资格。主节点是全局唯一的,将从有资格成为Master的节点中进行选举。
主节点也可以作为数据节点,但尽可能做少量的工作,因此生产环境应尽量分离主节点和数据节点,创建独立主节点的配置:
node.master: true
node.data: false
为了防止数据丢失,每个主节点应该知道有资格成为主节点的数量,默认为1,为避免网络分区时出现多主的情况,配置 discovery.zen.minimum_master_nodes 原则上最小值应该是:(master_eligible_nodes / 2)+ 1
负责保存数据、执行数据相关操作:CRUD、搜索、聚合等。数据节点对CPU、内存、I/O要求较高。一般情况下(有一些例外,后续章节会给出),数据读写流程只和数据节点交互,不会和主节点打交道(异常情况除外)。
通过配置node.data: true(默认)
来使一个节点成为数据节点,也可以通过下面的配置创建一个数据节点:
node.master: false
node.data: true
node.ingest: false
这是从5.0版本开始引入的概念。预处理操作允许在索引文档之前,即写入数据之前,通过事先定义好的一系列的processors(处理器)和pipeline(管道),对数据进行某种转换、富化。processors和pipeline拦截bulk和index请求,在应用相关操作后将文档传回给 index 或 bulk API。
默认情况下,在所有的节点上启用ingest,如果想在某个节点上禁用ingest,则可以添加配置node.ingest: false
,也可以通过下面的配置创建一个仅用于预处理的节点:
ode.master: false
node.data: false
node.ingest: true
客户端请求可以发送到集群的任何节点,每个节点都知道任意文档所处的位置,然后转发这些请求,收集数据并返回给客户端,处理客户端请求的节点称为协调节点。
协调节点将请求转发给保存数据的数据节点。每个数据节点在本地执行请求,并将结果返回协调节点。协调节点收集完数据后,将每个数据节点的结果合并为单个全局结果。对结果收集和排序的过程可能需要很多CPU和内存资源。
通过下面的配置创建一个仅用于协调的节点:
node.master: false
node.data: false
node.ingest: false
tribes(部落)功能允许部落节点在多个集群之间充当联合客户端。
在ES 5.0之前还有一个客户端节点(Node Client)的角色,客户端节点有以下属性:
node.master: false
node.data: false
它不做主节点,也不做数据节点,仅用于路由请求,本质上是一个智能负载均衡器(从负载均衡器的定义来说,智能和非智能的区别在于是否知道访问的内容存在于哪个节点),从5.0版本开始,这个角色被协调节点(Coordinating only node)取代。
从数据完整性的角度划分,集群健康状态分为三种:
每个索引也有上述三种状态,假设丢失了一个副分片,该分片所属的索引和整个集群变为Yellow状态,其他索引仍为Green。
集群状态元数据是全局信息,元数据包括内容路由信息、配置信息等,其中最重要的是内容路由信息,它描述了“哪个分片位于哪个节点”这种信息。
集群状态由主节点负责维护,如果主节点从数据节点接收更新,则将这些更新广播到集群的其他节点,让每个节点上的集群状态保持最新。ES 2.0版本之后,更新的集群状态信息只发增量内容,并且是被压缩的。
当扩容集群、添加节点时,分片会均衡地分配到集群的各个节点,从而对索引和搜索过程进行负载均衡,这些都是系统自动完成的。
分片副本实现了数据冗余,从而防止硬件故障导致的数据丢失。
下面演示了当集群只有一个节点,到变成两个节点、三个节点时的shard迁移过程示例(图片来自官网)。
起初,在NODE1上有三个主分片,没有副分片,如下图所示。
其中,P代表Primary shard; R代表Replica shard。以后出现的内容使用相同的简称。
添加第二个节点后,副分片被分配到NODE2,如下图所示。
添加第三个节点后,索引的六个分片被平均分配到集群的三个节点,如下图所示。
分片分配过程中除了让节点间均匀存储,还要保证不把主分片和副分片分配到同一节点,避免单个节点故障引起数据丢失。
分布式系统中难免出现故障,当节点异常时,ES会自动处理节点异常。当主节点异常时,集群会重新选举主节点。当某个主分片异常时,会将副分片提升为主分片。
当需要实现一个客户端对集群进行读写操作时,可以选择REST接口、Java REST API,或者Java API。
Java REST API是对原生REST接口的封装。REST接口、JavaREST API使用9200端口通信,采用JSON over HTTP方式,Java API使用9300端口通信,数据序列化为二进制。
使用Java API理论上来说效率更高一些,但是后来官方发现实际上相差无几,但是版本迭代中却因为Java API向下兼容性的限制不得不做出许多牺牲,Java API带来的微弱效率优势远不及带来的缺点。ES不是高QPS的应用,写操作非常消耗CPU资源,因此写操作属于比较长的操作,聚合由于涉及数据量比较大,延迟也经常到秒级,查询一般也不密集。因此RPC框架的效率没有那么高的要求。后续Java API将逐渐被Java REST API取代。官方计划从ES 7.0开始不建议使用Java API,并且从8.0版本开始完全移除。
在分析内部模块流程之前,我们先了解一下ES中几个基础模块的功能。
Cluster模块是主节点执行集群管理的封装实现,管理集群状态,维护集群层面的配置信息。主要功能如下:
封装了分片分配相关的功能和策略,包括主分片的分配和副分片的分配,本模块由主节点调用。创建新索引、集群完全重启都需要分片分配的过程。
发现模块负责发现集群中的节点,以及选举主节点。当节点加入或退出集群时,主节点会采取相应的行动。从某种角度来说,发现模块起到类似ZooKeeper的作用,选主并管理集群拓扑。
负责对收到Master广播下来的集群状态(cluster state)数据的持久化存储,并在集群完全重启时恢复它们。
索引模块管理全局级的索引设置,不包括索引级的(索引设置分为全局级和每个索引级)。它还封装了索引数据恢复功能。集群启动阶段需要的主分片恢复和副分片恢复就是在这个模块实现的。
HTTP模块允许通过JSON over HTTP的方式访问ES的API,HTTP模块本质上是完全异步的,这意味着没有阻塞线程等待响应。使用异步通信进行 HTTP 的好处是解决了 C10k 问题(10k量级的并发连接)。
在部分场景下,可考虑使用HTTP keepalive以提升性能。注意:不要在客户端使用HTTP chunking。
传输模块用于集群内节点之间的内部通信。从一个节点到另一个节点的每个请求都使用传输模块。
如同HTTP模块,传输模块本质上也是完全异步的。
传输模块使用 TCP 通信,每个节点都与其他节点维持若干 TCP长连接。内部节点间的所有通信都是本模块承载的。
Engine模块封装了对Lucene的操作及translog的调用,它是对一个分片读写操作的最终提供者。
ES使用Guice框架进行模块化管理。Guice是Google开发的轻量级依赖注入框架(IoC)。
软件设计中经常说要依赖于抽象而不是具象,IoC 就是这种理念的实现方式,并且在内部实现了对象的创建和管理。
在Guice框架下,一个典型的模块由 Service 和 Module 类(类名可以自由定义)组成,Service用于实现业务功能,Module类中配置绑定信息。
以 ClusterModule 为例,类结构如下图所示。
AbstractModule是Guice提供的基类,模块需要从这个类继承。Module类的主要作用是定义绑定关系,例如:
protected void configure() {
//绑定实现类bind(ClusterService.class).toInstance(clusterService);
}
定义好的模块由ModulesBuilder类统一管理,ModulesBuilder是ES对Guice的封装,内部调用Guice接口,主要对外提供两个方法。
使用ModulesBuilder进行模块管理的伪代码示例:
ModulesBuilder modules = new ModulesBuilder();
//以Cluster模块为例
ClusterModule clusterModule = new ClusterModule();
modules.add(clusterModule);
//省略其他模块的创建和添加...
//创建Injector,并获取相应类的实例
injector = modules.createInjector();
setGatewayAllocator(injector.getInstance(GatewayAllocator.class)
模块化的封装让 ES 易于扩展,插件本身也是一个模块,节点启动时被模块管理器添加进来。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。