ElasticSearch 学习笔记
老李 Lv4

ElasticSearch

ElasticSearch 是什么

ElasticSearch 是一个近实时的分布式搜索和分析引擎,它可以帮助我们很快的处理大规模数据,可以用于全文检索、结构化检索、推荐、分析以及统计聚合等多种场景。ElasticSearch 使用 Java 开发并使用 Lucene 作为其核心来实现所有索引和搜索的功能。

基本概念

  • 集群 (cluster):当数据量或查洵压力超过单机负载时,需要多个节点来协同处理,所有这些节点组成的系统称为集群(cluster)。ElasticSearch 提供了多播和单播两种发现集群方式。
  • 节点 (Node):一个运行的 ElasticSearch 实例。
  • 索引 (Index):ElasticSearch 将它的数据存储在一个或多个索引(index)中。用关系型数据库来类比,索引就像数据库,一个索引的数据文件可能会分布于一台机器,也有可能分布于多台机器。
  • 分片 (Shard):为了支持更大量的数据,索引一般会分成多个部分,每个部分就是一个分片,分片被节点 (Node) 管理。一个节点一般会管理多个分片,这些分片可能是属于同一份索引,也有可能属于不同索引,但是为了可靠性和可用性,同一个索引的分片尽量会分布在不同节点上。分片有两种,主分片和副本分片。分片的优点有:
    1. 便于横向扩展;
    2. 不同分片分布在不同节点中,多个节点可以并行处理,提高吞吐量和性能。
  • 副本 (Replica):同一个分片 (Shard) 的备份数据,一个分片可能会有 0 个或多个副本,这些副本中的数据保证强一致或最终一致。副本的作用有:
    1. 保证服务可用性:当设置了多个副本的时候,如果某一个副本不可用的时候,那么请求流量可以继续发往其他副本,服务可以很快恢复开始服务;
    2. 保证数据可靠性:如果只有一个主分片,没有副本分片,那么当主分片所在的节点出故障的时候,那么这个节点中所有分片的数据会丢失,只能 reindex 了;
    3. 提供更高的查询能力:当分片提供的查询能力无法满足业务需求的时候,可以继续加 N 个副本,这样查询能力就能提高 N 倍,轻松增加系统的并发度。
  • 全文检索:全文检索就是对一篇文章进行索引,可以根据关键字搜索,类似于 mysql 里的 like 语句。全文索引就是把内容根据词的意义进行分词,然后分别创建索引,例如”你们的激情是因为什么事情来的” 可能会被分词成:“你们“,”激情“,“什么事情“,”来“ 等 token,这样当你搜索“你们” 或者 “激情” 都会把这句搜出来。

ElasticSearch 工作流程

启动流程

当 ElasticSearch 节点启动时,它使用广播或者单播技术来发现同一个集群中的其他节点(关键是集群名称)并与它们连接。
集群中会有一个节点被选为管理节点(master node)。该节点负责集群的状态管理以及在集群拓扑变化时做出反应,分发索引分片至集群的相应节点上。

从用户的角度来看,ElasticSearch 中的管理节点并不比其他节点重要,这与其他某些分布式系统不同(如数据库)。实际上,用户不需要知道哪个节点是管理节点,所有操作可以发送至任意节点,ElasticSearch 内部会自行处理这些事情。如果有需要,任意节点可以并行发送子查询给其他节点,并合并搜索结果,然后返回给用户。所有这些操作并不需要经过管理节点处理,因为 ElasticSearch 是基于对等架构的。

管理节点读取集群的状态信息,并在必要时进行恢复处理。在该阶段,管理节点会检查所有索引分片并决定哪些分片将用于主分片。然后,整个集群进人黄色状态。这意味着集群可以执行查询,但是系统的吞吐量以及各种可能的状况是未知的(这种状况可以简单理解为所有的主分片已经分配出去了,而副本没有),因而接下来就是要寻找到冗余的分片并用作副本。如果某个主分片的副本数过少,管理节点将决定基于某个主分片创建分片和副本。如果一切顺利,集群将进人绿色状态(这意味着所有主分片和副本均已分配好)。

故障检测

集群正常工作时,管理节点会监控所有可用节点,检查它们是否正在工作。如果任何节点在预定义的超时时间内没有响应(管理节点会发送 ping 请求至其他节点,然后等待响应),则认为该节点已经断开,然后开始启动错误处理过程。这意味着要在集群分片之间重新做平衡,因为之前已断开节点上的那些分片不可用了,剩下的节点要肩负起相应的责任。换句话说,对每个丢失的主分片,一个新的主分片将会从原来的主分片的副本中脱颖而出。

倒排索引 (inverted index)

什么是倒排索引

倒排索引,是一种索引方法,常被用于全文检索系统中,是一种 关键词 —> 文档 形式的映射结构。

有以下文档:

uid name age gender desc
1 Alice 25 female hello, i am Alice.
2 Alan 26 female hi, i am Alan, nice to meet you
3 Tom 26 male i am Tom, nice to meet you too.

可以构建如下倒排索引:

age 字段倒排索引:

term posting list
25 [1]
26 [2, 3]

gender 字段倒排索引:

term posting list
female [1, 2]
male [3]

在 ElasticSearch 中,默认情况下,文档中每个字段都会被索引的,ElasticSearch 是分布式的可以支持海量数据,数据量很大时,倒排索引中的 term 列表会很大,如果 term 列表是乱序的,要找出特定的 term 的话,需要将整个列表遍历一遍,这样的时间复杂度太高,搜索的响应时间会很长。

为了降低时间复杂度,我们可以对这些 term 排序,排序后的 term 列表可以使用二分法快速找到目标,这个排序后的列表叫做 term dictionary 。有了 term dictionary 就可以用 logN 次磁盘来找到目标。但是磁盘操作是很耗时的,所以为了减少磁盘操作,可以把数据尽可能多的加载到内存中。

不过 term dictionary 是很大的,无法完整的放到内存里,于是就有了 term index ,term index 是一棵 trie 树,它会包含 term 的一些前缀,通过 term index 就可以快速地定位到 term dictionary 的某个 term 的 offset,然后从这个位置再往后顺序查找就可以快速查找到目标了。再加上一些压缩技术, term index 的尺寸可以做到只有所有 term 的尺寸的几十分之一,这样就可以使用内存缓存整个 term index,从而加快查找的速度。

最后的效果类似于下图。

倒排索引

联合索引查询

要搜索年龄 26 的女性怎么处理呢?分两步,先根据搜索条件得到各个条件对应的 posting list,然后将得到的 posting list 取交集,结果就是查询结果。

从年龄字段倒排索引的 term index 中找到 26 在 term dictionary 的大概位置,再从 term dictionary 里精确地找到 26 这个 term,得到 26 对应的 posting list,这就找到了所有满足年龄为 26 搜索条件的文档。然后再查询性别为女的文档,过程同上。得到两个 posting list 后,把这两个 posting list 取交集得到的结果就是我们需要搜索的结果。

怎样求两个集合的交集呢?

  • 使用 skip list 数据结构,同时遍历年龄和性别的 posting list,不需要每个元素都遍历,利用跳表的思想,以更大的步伐遍历元素;
  • 使用 bitset 数据结构,对年龄和性别两个 filter 分别求出 bitset,对两个 bitset 做 AND 操作。

索引

索引流程

  1. 客户端选择一个节点发送请求过去,这个节点就是协调节点;
  2. 协调节点对文档进行路由,将请求转发给对应的节点(建索引操作只会发生在主分片上,而不是副本上。当把一个索引请求发送至某节点时,如果该节点没有对应的主分片或者只有副本,那么这个请求会被转发到拥有正确的主分片的节点。);
  3. 节点上的主分片处理请求,然后将数据同步到副本分片;
  4. 协调节点将索引结果返回请求到客户端。

深入索引流程

ElasticSearch 采用多个副本后,避免了单机或磁盘故障发生时,对已经持久化后的数据造成损害,但是 ElasticSearch 里为了减少磁盘 IO 保证读写性能,一般是每隔一段时间(比如 5 分钟)才会把 Lucene 的 Segment 写入磁盘持久化,对于写入内存,但还未 Flush 到磁盘的 Lucene 数据,如果发生机器宕机或者掉电,那么内存中的数据也会丢失,这时候如何保证?

对于这种问题,ElasticSearch 学习了数据库中的处理方式:增加 CommitLog 模块,ElasticSearch 中叫 TransLog。

在每一个分片中,写入流程分为两部分,先写入 Lucene,再写入 TransLog。写入请求到达分片后,先写 Lucene 文件,创建好索引,此时索引还在内存里面,接着去写 TransLog,写完 TransLog 后,刷新 TransLog 数据到磁盘上,写磁盘成功后,请求返回给用户。这里有几个关键点:

  1. 和数据库不同,数据库是先写 CommitLog,然后再写内存,而 ElasticSearch 是先写内存,最后才写 TransLog,一种可能的原因是 Lucene 的内存写入会有很复杂的逻辑,很容易失败,比如分词,字段长度超过限制等,比较重,为了避免 TransLog 中有大量无效记录,减少 recover 的复杂度和提高速度,所以就把写 Lucene 放在了最前面。
  2. 写 Lucene 内存后,并不是可被搜索的,需要通过 Refresh 把内存的对象转成完整的 Segment 后,然后再次 reopen 后才能被搜索,一般这个时间设置为 1 秒钟,导致写入 ElasticSearch 的文档,最快要 1 秒钟才可被从搜索到,所以 ElasticSearch 在搜索方面是 NRT(Near Real Time) 近实时的系统。
  3. 当 ElasticSearch 作为 NoSQL 数据库时,查询方式是 GetById,这种查询可以直接从 TransLog 中查询,这时候就成了 RT(Real Time) 实时系统。
  4. 每隔一段比较长的时间,比如 5 分钟后,Lucene 会把内存中生成的新 Segment 刷新到磁盘上,刷新后索引文件已经持久化了,历史的 TransLog 就没用了,会清空掉旧的 TransLog。

如下图所示。

Refresh && Flush

Lucene 中不支持部分字段的 Update,所以需要在 ElasticSearch 中实现该功能,流程如下:

  1. 收到 Update 请求后,从 Segment 或者 TransLog 中读取同 id 的完整 Doc,记录版本号为 V1;
  2. 将版本 v1 的全量 Doc 和请求中的部分字段 Doc 合并为一个完整的 Doc,同时更新内存中的 VersionMap。获取到完整 Doc 后,Update 请求就变成了 Index 请求;
  3. 加锁;
  4. 再次从 versionMap 中读取该 id 的最大版本号 v2,如果 versionMap 中没有,则从 Segment 或者 TransLog 中读取,这里基本都会从 versionMap 中获取到;
  5. 检查版本是否冲突 (v1==v2),如果冲突,则回退到开始的“Update doc”阶段,重新执行。如果不冲突,则执行最新的 Add 请求;
  6. 在 Index Doc 阶段,首先将 Version + 1 得到 V3,再将 Doc 加入到 Lucene 中去,Lucene 中会先删同 id 下的已存在 doc id,然后再增加新 Doc。写入 Lucene 成功后,将当前 v3 更新到 versionMap 中;
  7. 释放锁,部分更新的流程就结束了。

如下图所示。

update

搜索

搜索过程

查询过程大体上分为查询和取回这两个阶段,广播查询请求到所有相关分片,并将它们的响应整合成全局排序后的结果集合,这个结果集合会返回给客户端。

查询阶段

  1. 当一个节点接收到一个搜索请求,这这个节点就会变成协调节点,第一步就是将广播请求到搜索的每一个节点的分片拷贝,查询请求可以被某一个主分片或某一个副分片处理,协调节点将在之后的请求中轮训所有的分片拷贝来分摊负载;
  2. 每一个分片将会在本地构建一个优先级队列,如果客户端要求返回结果排序中从 from 名开始的数量为 size 的结果集,每一个节点都会产生一个 from+size 大小的结果集,因此优先级队列的大小也就是 from+size,分片仅仅是返回一个轻量级的结果给协调节点,包括结果级中的每一个文档的 ID 和进行排序所需要的信息;
  3. 协调节点将会将所有的结果进行汇总,并进行全局排序,最终得到排序结果。

取值阶段

  1. 查询过程得到的排序结果,标记处哪些文档是符合要求的;
  2. 协调节点确定实际需要的返回的文档后,向含有该文档的分片发送 get 请求,分片获取的文档返回给协调节点,协调节点将结果返回给客户端。

深入搜索流程

ElasticSearch 中每个分片都会有多个副本,主要是为了保证数据可靠性,除此之外,还可以增加读能力,因为写的时候虽然要写大部分副本分片,但是查询的时候只需要查询主分片和副本分片中的任何一个就可以了。

Search On Replicas

在上图中,该分片有 1 个主分片和 2 个副本分片,当查询的时候,从三个节点中根据 Request 中的 preference 参数选择一个节点查询。preference 可以设置_local,_primary,_replica 以及其他选项。如果选择了主分片,则每次查询都是直接查询主分片,可以保证每次查询都是最新的。如果设置了其他参数,那么可能会查询到 R1 或者 R2,这时候就有可能查询不到最新的数据。

接下来看一下,ElasticSearch 中的查询是如何支持分布式的。

search

ElasticSearch 中通过分区实现分布式,数据写入的时候根据 _routing 规则将数据写入某一个分片中,这样就能将海量数据分布在多个分片以及多台机器上,以达到分布式的目标。这样就导致了查询的时候,潜在数据会在当前索引的所有的分片中,所以 ElasticSearch 查询的时候需要查询所有分片,同一个分片的主分片和副本分片选择一个即可,查询请求会分发给所有分片,每个分片都是一个独立的查询引擎。比如需要返回 Top 10 的结果,那么每个分片都会查询并且返回 Top 10 的结果,然后在 Client Node 里面会接收所有分片的结果,然后通过优先级队列二次排序,选择出 Top 10 的结果返回给用户。

这里有一个问题就是请求膨胀,用户的一个搜索请求在 ElasticSearch 内部会变成分片个请求,这里有个优化点,虽然是分片个请求,但是这个分片个数不一定要是当前 Index 中的分片个数,只要是当前查询相关的分片即可,这个需要基于业务和请求内容优化,通过这种方式可以优化请求膨胀数。

ElasticSearch 中的查询主要分为两类,Get 请求:通过 ID 查询特定 Doc;Search 请求:通过 Query 查询匹配 Doc。

对于 Search 类请求,查询的时候是一起查询内存和磁盘上的 Segment,最后将结果合并后返回。这种查询是近实时的,主要是由于内存中的 Index 数据需要一段时间后才会刷新为 Segment。对于 Get 类请求,查询的时候是先查询内存中的 TransLog,如果找到就立即返回,如果没找到再查询磁盘上的 TransLog,如果还没有则再去查询磁盘上的 Segment。这种查询是实时的。这种查询顺序可以保证查询到的 Doc 是最新版本的 Doc,这个功能也是为了保证 NoSQL 场景下的实时性要求。

search & get

寻你推荐实现

需求

以满足用户筛选条件(性别条件:全部、男、女,位置条件:全部、同城)的在线用户作为数据集,用户推荐列表需满足如下优先逻辑:

  • 年龄非常相近且同城
  • 年龄非常相近且同省
  • 年龄比较相近且同城
  • 年龄比较相近且同省
  • 年龄非常相近且不同省
  • 同城
  • 同省
  • 其他

实现

需求可以拆分成两个部分:

  1. 筛选条件作为数据的过滤条件,用于确定搜索的数据集,不参与评分;

  2. 优先逻辑作为查询条件,可以分为 6 个维度:

    • 年龄非常相近
    • 年龄比较相近
    • 年龄其他
    • 位置同城
    • 位置同省
    • 位置其他

    并且每个维度有不同的权重,最后评分的正序应满足上述优先逻辑。

这样问题就转为了,1. ElasticSearch 查询,2. 权重定义。

ElasticSearch 查询

查询使用的是搜索中台提供的查询接口,最后的查询请求如下:

1
http://14.17.109.28:8088/search?app=90&typ=1&v=1&uid=17797368&start=0&rows=20&q=(age:[17 TO 23]^19 OR age:[14 TO 26]^16) OR city:287^5 OR province:9^20 OR (time:[1573523461587 TO *]^10 OR time:[1573521661587 TO *]^60)&fq=city:287

其中 fq=city:287 为过滤条件,表示城市 ID 必须是 287,也就是位置条件为同城时的过滤条件,q=(age:[17 TO 23]^19 OR age:[14 TO 26]^16) OR city:287^5 OR province:9^20 OR (time:[1573523461587 TO *]^10 OR time:[1573521661587 TO *]^60)为查询条件。

这里有一点需要注意的是 (age:[17 TO 23]^19 OR age:[14 TO 26]^16) 不是短路或,也就是说一个文档满足 age:[17 TO 23]^19 条件时也会满足 age:[14 TO 26]^16 条件,得分为 19+16=35,所以计算权重时要注意,不然得不到想要的结果。

权重定义

维度 权重
年龄非常相近 19
年龄比较相近 16
年龄其他 0
位置同城 5
位置同省 20
位置同省 0

推荐优先逻辑评分如下:

条件 维度 评分
年龄非常相近且同城 年龄非常相近、位置同城 60
年龄非常相近且同省 年龄非常相近、位置同省 55
年龄比较相近且同城 年龄比较相近、位置同城 41
年龄比较相近且同省 年龄比较相近、位置同省 36
年龄非常相近且不同省 年龄非常相近、位置其他 35
同城 年龄其他、同城 25
同省 年龄其他、同省 20
其他 年龄比较相近、位置其他 16
其他 年龄其他、位置其他 0

这样就实现了需求,如果要调整优先逻辑只需要改变权重即可,而且这些权重的配置使用的是 cnt2 所以可以做到动态调整。