ES(一)设计解析

ES是什么

git上的很简单“Open Source, Distributed, RESTful Search Engine”,很多场景都会用到搜索引擎,而且每天都会用到baidu或Google。

关系数据库不是也可以模糊搜索么?是,如果规模小,其实关系数据库就够用了,但是当规模大的时候,数据库的正向索引的方式会很慢。

从设计上看,很多与Nosql数据库很像,有什么本质区别?自己也是越看越像,如果与Nosql的文档数据库例如Mongodb比,其实更像。都能够进行大规模的数据存储、查询、甚至统计分析,与Mongodb一样都很浪费内存,甚至一些场景下ES能代替Nosql数据库使用,感觉两者的边界越来越不清楚。但是从本质上看,Nosql是数据库,存储是第一天职,而ES是搜索引擎搜索是第一天职,虽然两者在底层的设计上和功能上很多类似,但是一般的用法还是两者都用的,数据写入到nosql然后定期导入到es,此时es可以不保存源数据,只留下索引。

从设计上看,Nosql的读写的吞吐量和响应时间应该是比ES好的。例如Hbase中的列族的设计、压缩的特性都都能够提供更好地吞吐量与响应时间。而ES在搜索速度上占优势,因为有倒排索引,Nosql在这点上肯定比不过。当然,这里的搜索是模糊搜索,如果只是键值的查询,那Nosql或关系数据库都有更好地吞吐量与响应时间。

还有,ES是非常耗内存的,从业务的角度看,如果只使用数据库或只是用ES,即是存储业务又是搜索业务载体,负载会很大,不如用数据库存储检索,ES做搜索,也是一种负载均衡的方式,而且更专业,更减少资源消耗,所以可以推论,大公司不会用ES做主存储的。

基本概念

在Elasticsearch中,文档归属于一种类型(type),而这些类型存在于索引(index)中,我们可以画一些简单的对比图来类比传统关系型数据库:+ Relational DB -Databases -Tables -Rows -Columns Elasticsearch -Indices -Types -Documents -Fields

索引与分片

索引只是一个用来指向一个或多个分片(shards)的“逻辑命名空间(logical namespace)”. 一个分片(shard)是一个最小级别“工作单元(worker unit)”,它只是保存了索引中所有数据的一部分。分片就是一个Lucene实例,并且它本身就是一个完整的搜索引擎。我们的文档存储在分片中,并且在分片中被索引,但是我们的应用程序不会直接与它们通信,取而代之的是,直接与索引通信。

分片不只是在故障的时候提供容错,也提供业务上的负载均衡。

文档通过其_index、_type、_id唯一确定。PUT /{index}/{type}/{id}

version是内部记录的一部分,它确保在多节点间不同操作可以有正确的顺序。与CAS有点类似,不过不是比较相等才交换,而是version值连续才执行。但是生成version的过程是需要同步(分布式锁?)的。

如果想使用自定义的_id,我们必须告诉Elasticsearch应该在_index、_type、_id三者都不同时才接受请求。在URL后加/_create做为端点:PUT /website/blog/123/_create

compound indexes

与一般数据库不同,Mysql、Mongodb都可以建立compound indexes,而且与field的顺序有关,ES没有这复合索引的概念,引用stackoverflow上一个问题的回答

ElasticSearch doesn’t have composite indexes, but it’s very efficient at querying multiple indexes and intersecting them (intersecting bit-vectors FTW).

Most of the time, composite indexes are not needed, even for cases like you mentioned where you query for 4 different fields. ElasticSearch will happily query 4 different indexes and then intersect the results in an efficient manner. In my experience its performance matches and surpasses that of MongoDB in similar situations.

If you absolutely must have a composite index, you might consider indexing an auxiliary field whose value is a composite of the values you want to index.

横向扩展

ES的扩展很容易,直接配置个集群的名字就ok了,集群有自动发现的功能,会主动连接默认的端口。

但是要注意,这里的分片与Redis的分片很像,hash方式计算的,那么只要指定了分片数目,由于有数据需要存储到磁盘,那么变动就比较麻烦,过程很慢,很重,这也可能是没有热修改分片数目的一个原因,在修改的时候,建议用原始集群重建另外一个ES集群,建好了在切换过去。

Redis有预分片的功能,前提是每个Redis的实例占用资源很少,而且过多的分片对于集群效率影响不大。但是没有看到ES的预分片的相关说明,Redis上查询操作很多是根据单条或少量的key的查询,所以很多的分片对于性能影响不大,而ES上过多的分片对于效率的影响是非常明显的,比如一次match操作要返回10个最相关结果,有100个分片,每个分片都需要找到top10的最相关子结果,然后由一个节点合并后,从1000个中再取top10。很显然分片越多,中间结果集合越大,协调越麻烦。

ES的默认分片是5,如果副本数目设置成2,那么最多可以利用10台机器,每个机器上一个分片。当然后面可以通过api修改副本数目,修改的更大,能够用更多机器扩展。为什么是5这么小的分片数呢?首先比较好的实践对于机器要求是比较高的,最好SSD的硬盘64G的内存,cpu一般即可,这样来看10台或几十台机器也有不错的处理能力,能够解决相当一部分的问题。另外,分片是真对的一个index,在一个index的5个分片实在不够用的情况下,可以加更多的index,反正一个分片其实也是一个luncene实例。

处理规模上,能处理PB级结构化或非结构化数据,与ceph、Hbase、HDFS是一个规模的。

负载均衡

每个副本分片都能提供业务处理的能力。

读请求的负载均衡

下面我们罗列在主分片或复制分片上检索一个文档必要的顺序步骤:

  1. 客户端给Node 1发送get请求。
  2. 节点使用文档的_id确定文档属于分片0。分片0对应的复制分片在三个节点上都有。此时,它转发请求到Node 2。
  3. Node 2返回文档(document)给Node 1然后返回给客户端。 对于读请求,为了平衡负载,请求节点会为每个请求选择不同的分片——它会循环所有分片副本。

协调负载均衡

当一个搜索请求被发送到一个节点Node,这个节点就变成了协调节点。这个节点的工作是向所有相关的分片广播搜索请求并且把它们的响应整合成一个全局的有序结果集。这个结果集会被返回给客户端。 第一步是向索引里的每个节点的分片副本广播请求。就像document的GET请求一样,搜索请求可以被每个分片的原本或任意副本处理。这就是更多的副本(当结合更多的硬件时)如何提高搜索的吞吐量的方法。对于后续请求,协调节点会轮询所有的分片副本以分摊负载。 每一个分片在本地执行查询和建立一个长度为from+size的有序优先队列——这个长度意味着它自己的结果数量就足够满足全局的请求要求。分片返回一个轻量级的结果列表给协调节点。只包含documentID值和排序需要用到的值,例如_score。 协调节点将这些分片级的结果合并到自己的有序优先队列里。这个就代表了最终的全局有序结果集。到这里,查询阶段结束。 整个过程类似于归并排序算法,先分组排序再归并到一起,对于这种分布式场景非常适用。 没有固定的处理合并请求的节点node,而是分配到谁,谁就做协调节点。也是一种负载均衡。

去中心化

配置的时候,虽然没有逻辑上的区分不同节点,但是ES也有master节点,是通过选举产生,它将临时管理集群级别的一些变更,例如新建或删除索引、增加或移除节点等。主节点不参与文档级别的变更或搜索,这意味着在流量增长的时候,该主节点不会成为集群的瓶颈。

这个master节点其实与Hbase的HMaster一样,有总体上的管理功能,只不过混在一般的节点中。

版本

Elasticsearch中每个文档都有版本号,每当文档变化(包括删除)都会使_version增加。

缓存

缓存的字节集很“聪明”:他们会增量更新。你索引中添加了新的文档,只有这些新文档需要被添加到已存的字节集中,而不是一遍遍重新计算整个缓存的过滤器。过滤器和整个系统的其他部分一样是实时的,你不需要关心缓存的过期时间。

增量自动的更新缓存。

监控

集群健康(cluster health)。集群健康有三种状态:green、yellow或red。

优化

默认情况下,Elasticsearch 用 JSON 字符串来表示文档主体保存在 _source 字段中。像其他保存的字段一样,_source 字段也会在写入硬盘前压缩。 压缩,节省IO。

指定文档中的字段为id,post时候不在传id,虽然这样很方便,但是注意它对 bulk 请求)有个轻微的性能影响。处理请求的节点将不能仅靠解析元数据行来决定将请求分配给哪一个分片,而需要解析整个文档主体。

即使你认为现在的索引设计已经是完美的了,当你的应用在生产环境使用时,还是有可能在今后有一些改变的。 所以请做好准备:在应用中使用别名而不是索引。然后你就可以在任何时候重建索引。别名的开销很小,应当广泛使用。

更新

删除一个文档也不会立即从磁盘上移除,它只是被标记成已删除。Elasticsearch将会在你之后添加更多索引的时候才会在后台进行删除内容的清理。 标记删除法,速度快,标记占用的锁时间只是标记一个字段然后再后面的,必须执行的操作中一起执行,相当于节约了个事务的操作。fastdfs中也有这类处理手段,删除一个storage只是先标记下。

这种分两次操作与JVM的老年代的标记整理方式思想有些类似,标记的时候其实速度很快,在决定要删除或清理的时候,批量进行操作。

Elasticsearch中只存储最后被索引的任何文档。如果其他人同时也修改了这个文档,他们的修改将会丢失。很多时候,这并不是一个问题。或许我们主要的数据存储在关系型数据库中,然后拷贝数据到Elasticsearch中只是为了可以用于搜索。或许两个人同时修改文档的机会很少。

通过重新索引文档保存修改时,我们这样指定了version参数:PUT /website/blog/1?version=1 <1>,只希望文档的_version是1时更新才生效。我们需要重新检索最新文档然后申请新的更改操作。

一种常见的结构是使用一些其他的数据库做为主数据库,然后使用Elasticsearch搜索数据,这意味着所有主数据库发生变化,就要将其拷贝到Elasticsearch中。如果有多个进程负责这些数据的同步,就会遇到上面提到的并发问题。

如果主数据库有版本字段——或一些类似于timestamp等可以用于版本控制的字段——是你就可以在Elasticsearch的查询字符串后面添加version_type=external来使用这些版本号。版本号必须是整数,大于零小于9.2e+18——Java中的正的long。 外部版本号与之前说的内部版本号在处理的时候有些不同。它不再检查_version是否与请求中指定的一致,而是检查是否小于指定的版本。如果请求成功,外部版本号就会被存储到_version中。 外部版本号不仅在索引和删除请求中指定,也可以在创建(create)新文档中指定。例如,创建一个包含外部版本号5的新博客,我们可以这样做: PUT /website/blog/2?version=5&version_type=external

局部更新

对于多用户的局部更新,文档被修改了并不要紧。例如,两个进程都要增加页面浏览量,增加的顺序我们并不关心——如果冲突发生,我们唯一要做的仅仅是重新尝试更新既可。 这些可以通过retry_on_conflict参数设置重试次数来自动完成,这样update操作将会在发生错误前重试——这个值默认为0。

与乐观锁的接口很像。

批量操作

像Elasticsearch一样,检索多个文档依旧非常快。合并多个请求可以避免每个请求单独的网络开销。如果你需要从Elasticsearch中检索多个文档,相对于一个一个的检索,更快的方式是在一个请求中使用multi-get或者mget API。 就像mget允许我们一次性检索多个文档一样,bulk API允许我们使用单一请求来实现多个文档的create、index、update或delete。这对索引类似于日志活动这样的数据流非常有用,它们可以以成百上千的数据为一个批次按序进行索引。一个好的批次最好保持在5-15MB大小间。

与Redis中的批量操作一样,减少IO。

shard = hash(routing) % number_of_primary_shards routing值是一个任意字符串,它默认是_id但也可以自定义。这个routing字符串通过哈希函数生成一个数字,然后除以主切片的数量得到一个余数(remainder)。 ngnix中也有hash后负载均衡的算法。简单实用。

新建、索引和删除文档

下面我们罗列在主分片和复制分片上成功新建、索引或删除一个文档必要的顺序步骤:+

  1. 客户端给Node 1发送新建、索引或删除请求。
  2. 节点使用文档的_id确定文档属于分片0。它转发请求到Node 3,分片0位于这个节点上。 Node 3在主分片上执行请求,如果成功,它转发请求到相应的位于Node 1和Node 2的复制节点上。当所有的复制节点报告成功,Node 3报告成功到请求的节点,请求的节点再报告给客户端。
  3. 客户端接收到成功响应的时候,文档的修改已经被应用于主分片和所有的复制分片。你的修改生效了。

对于写请求,这个与Kafka中的partition处理机制有些类似,都是主分片进行处理,然后复制到复制分片。只不过kafka中是从zk中直接查询到partition的路由。而路由的处理与Redis cluster有些类似,都可以直接发送给任意一个节点。只不过Redis cluster是返回节点路由给客户端(客户端缓存下)客户端二次查询,而ES是客户端简化了,节点内部路由处理后直接返回。

replication

复制默认的值是sync。这将导致主分片得到复制分片的成功响应后才返回。 如果你设置replication为async,请求在主分片上被执行后就会返回给客户端。它依旧会转发请求给复制节点,但你将不知道复制节点成功与否。 上面的这个选项不建议使用。默认的sync复制允许Elasticsearch强制反馈传输。async复制可能会因为在不等待其它分片就绪的情况下发送过多的请求而使Elasticsearch过载。

在kafka中也有类似的参数。牺牲一些安全来提高性能。很少使用因为如上所述,如果异步的方式,给“发送者”一种错觉,ES的索引速度好快,然后就会没有限流的发送,导致最后流量过大ES负载过高。

consistency 默认主分片在尝试写入时需要规定数量(quorum)或过半的分片(可以是主节点或复制节点)可用。这是防止数据被写入到错的网络分区。

如果不能操作,很可能是这原因。

新索引默认有1个复制分片,这意味着为了满足quorum的要求需要两个活动的分片。当然,这个默认设置将阻止我们在单一节点集群中进行操作。为了避开这个问题,规定数量只有在number_of_replicas大于一时才生效。

为什么bulk API需要带换行符的奇怪格式,而不是像mget API一样使用JSON数组?

批量中每个引用的文档属于不同的主分片,每个分片可能被分布于集群中的某个节点上。这意味着批量中的每个操作(action)需要被转发到对应的分片和节点上。 如果每个单独的请求被包装到JSON数组中,那意味着我们需要:

  • 解析JSON为数组(包括文档数据,可能非常大)
  • 检查每个请求决定应该到哪个分片上
  • 为每个分片创建一个请求的数组
  • 序列化这些数组为内部传输格式
  • 发送请求到每个分片 这可行,但需要大量的RAM来承载本质上相同的数据,还要创建更多的数据结构使得JVM花更多的时间执行垃圾回收。 取而代之的,Elasticsearch则是从网络缓冲区中一行一行的直接读取数据。它使用换行符识别和解析action/metadata行,以决定哪些分片来处理这个请求。 这些行请求直接转发到对应的分片上。这些没有冗余复制,没有多余的数据结构。整个请求过程使用最小的内存在进行。

超时

time_out值告诉我们查询超时与否。一般的,搜索请求不会超时。如果响应速度比完整的结果更重要,你可以定义timeout参数为10或者10ms(10毫秒),或者1s(1秒) GET /_search?timeout=10ms timeout不会停止执行查询,它仅仅告诉你目前顺利返回结果的节点然后关闭连接。在后台,其他分片可能依旧执行查询,尽管结果已经被发送。

在集群系统中深度分页

为了理解为什么深度分页是有问题的,让我们假设在一个有5个主分片的索引中搜索。当我们请求结果的第一页(结果1到10)时,每个分片产生自己最顶端10个结果然后返回它们给请求节点(requesting node),它再排序这所有的50个结果以选出顶端的10个结果。 现在假设我们请求第10000页——结果100001到100010。工作方式都相同,不同的是每个分片都必须产生顶端的10010个结果。然后请求节点排序这50050个结果并丢弃50040个! 你可以看到在分布式系统中,排序结果的花费随着分页的深入而成倍增长。这也是为什么网络搜索引擎中任何语句不能返回多于10000个结果的原因。

高效的过滤

一条过滤语句会询问每个文档的字段值是否包含着特定值,查询语句会询问每个文档的字段值与特定值的匹配程度如何?

使用过滤语句得到的结果集 – 一个简单的文档列表,快速匹配运算并存入内存是十分方便的, 每个文档仅需要1个字节。这些缓存的过滤结果集与后续请求的结合使用是非常高效的。 查询语句不仅要查找相匹配的文档,还需要计算每个文档的相关性,所以一般来说查询语句要比 过滤语句更耗时,并且查询结果也不可缓存。 幸亏有了倒排索引,一个只匹配少量文档的简单查询语句在百万级文档中的查询效率会与一条经过缓存 的过滤语句旗鼓相当,甚至略占上风。 但是一般情况下,一条经过缓存的过滤查询要远胜一条查询语句的执行效率。 过滤语句的目的就是缩小匹配的文档结果集,所以需要仔细检查过滤条件。

原则上来说,使用查询语句做全文本搜索或其他需要进行相关性评分的时候,剩下的全部用过滤语句.做精确匹配搜索时,你最好用过滤语句,因为过滤语句可以缓存数据。

参考: 最重要的查询过滤语句

term 过滤 term主要用于精确匹配哪些值,比如数字,日期,布尔值或 not_analyzed的字符串(未经分析的文本数据类型):

为了提高排序效率,ElasticSearch 会将所有字段的值加载到内存中,这就叫做”数据字段”。 ElasticSearch将所有字段数据加载到内存中并不是匹配到的那部分数据。 而是索引下所有文档中的值,包括所有类型。 将所有字段数据加载到内存中是因为从硬盘反向倒排索引是非常缓慢的。尽管你这次请求需要的是某些文档中的部分数据, 但你下个请求却需要另外的数据,所以将所有字段数据一次性加载到内存中是十分必要的。

速度是快了,但是在海量数据的时候,内存可能是个瓶颈,这里的所有,难道是整个文档?PB级的数据,难道需要PB级的内存?

在 bool 条件中过滤器的顺序对性能有很大的影响。更详细的过滤条件应该被放置在其他过滤器之前,以便在更早的排除更多的文档。+ 假如条件 A 匹配 1000 万个文档,而 B 只匹配 100 个文档,那么需要将 B 放在 A 前面。 缓存的过滤器非常快,所以它们需要被放在不能缓存的过滤器之前。

结果震荡(Bouncing Results)

想像一下,你正在按照timestamp字段来对你的结果排序,并且有两个document有相同的timestamp。由于搜索请求是在所有有效的分片副本间轮询的,这两个document可能在原始分片里是一种顺序,在副本分片里是另一种顺序。 这就是被称为结果震荡(bouncing results)的问题:用户每次刷新页面,结果顺序会发生变化。避免这个问题方法是对于同一个用户总是使用同一个分片。方法就是使用一个随机字符串例如用户的会话ID(session ID)来设置preference参数。

虽然query_then_fetch是默认的搜索类型,但也可以根据特定目的指定其它的搜索类型如:GET /_search?search_type=count count(计数)搜索类型只有一个query(查询)的阶段。当不需要搜索结果只需要知道满足查询的document的数量时,可以使用这个查询类型。

可以用来做统计数目,最好使用filter,还能缓存。

routing(路由选择)

在路由值那节里,我们解释了如何在建立索引时提供一个自定义的routing参数来保证所有相关的document(如属于单个用户的document)被存放在一个单独的分片中。在搜索时,你可以指定一个或多个routing 值来限制只搜索那些分片而不是搜索index里的全部分片: GET /_search?routing=user_1,user2 这个技术在设计非常大的搜索系统时就会派上用场了。

优化的一种方法。相当于客户端也感知到了集群的部分内容。

硬件消耗

硬件消耗

如果有一种资源是最先被耗尽的, 它可能是内存.排序和聚合都是很耗内存的, 所以使用足够的堆空间来应付它们是很重要的 64 GB内存的机器是非常理想的, 但32 GB 和 16 GB 机器也是很常见的. 少于8 GB 会适得其反 (你最终需要很多很多的小机器), 大于64 GB的机器也会有问题

大多数Elasticsearch部署对CPU要求很小,如果你要在更快的CUPs和更多的核心之间选择,选择更多的核心更好. 多核心提供的额外并发将远远超出稍微快点的时钟速度

硬盘对所有的集群都很重要,对高度索引的集群更是加倍重要 (例如那些存储日志数据的). 硬盘是服务器上最慢的子系统,这意味着那些写多的集群很容易让硬盘饱和, 使得它成为集群的瓶颈. 如果你负担得起SSD, 它将远远超出任何旋转媒介(注:机械硬盘,磁带等). 基于SSD 的节点的查询和索引性能都有提升. 如果你负担得起,SSD是一个好的选择.

使用RAID 0是提高硬盘速度的有效途径, 对旋转硬盘和SSD来说都是如此. 没有必要使用镜像或其它RAID变体, 因为高可用已经通过replicas内建于Elasticsearch之中.

避免使用网络附加存储 (NAS). 人们常声称他们的NAS解决方案比本地驱动器更快更可靠. 除却这些声称, 我们从没看到NAS能配得上它的大肆宣传. NAS常常很慢, 显露出更大的延时和更宽的平均延时方差, 而且它是单点故障的.

大多数 *nix 发行版下的调度程序都叫做 cfq (Completely Fair Queuing). 调度程序分配 时间片 到每个进程, 并且优化这些到硬盘的众多队列的传递. 但它是为旋转媒介优化的: 旋转盘片的固有特性意味着它写入数据到基于物理布局的硬盘会更高效. 这对SSD来说是低效的, 然而, 尽管这里没有涉及到旋转盘片. 但是, deadline 或 noop 应该被使用. deadline 调度程序基于写入等待时间进行优化, noop 只是一个简单的FIFO队列.

也许你使用ES索引百万日志文件,你更想要优化索引的速度,而不是进实时搜索。你可以通过修改配置项refresh_interval减少刷新的频率。

参考

Elasticsearch权威指南(中文版)
Mastering Elasticsearch(中文版)
Day19 ES内存那点事

Table of Contents