什么是全文搜索

我们生活中的数据总体分为两种:结构化数据非结构化数据

  • 结构化数据: 指具有固定格式或有限长度的数据,如数据库,元数据等。
  • 非结构化数据: 非结构化数据又可称为全文数据,指不定长或无固定格式的数据,如邮件,word文档等。

当然有的地方还会有第三种:半结构化数据,如XML,HTML等,当根据需要可按结构化数据来处理,也可抽取出纯文本按非结构化数据来处理。
根据两种数据分类,搜索也相应的分为两种:结构化数据搜索和非结构化数据搜索。
对于结构化数据,我们一般都是可以通过关系型数据库(mysql,oracle等)的 table 的方式存储和搜索,也可以建立索引。
对于非结构化数据,也即对全文数据的搜索主要有两种方法:顺序扫描法全文检索
顺序扫描:通过文字名称也可了解到它的大概搜索方式,即按照顺序扫描的方式查询特定的关键字。
例如给你一张报纸,让你找到该报纸中“RNG”的文字在哪些地方出现过。你肯定需要从头到尾把报纸阅读扫描一遍然后标记出关键字在哪些版块出现过以及它的出现位置。
这种方式无疑是最耗时的最低效的,如果报纸排版字体小,而且版块较多甚至有多份报纸,等你扫描完你的眼睛也差不多了。
全文搜索:对非结构化数据顺序扫描很慢,我们是否可以进行优化?把我们的非结构化数据想办法弄得有一定结构不就行了吗?将非结构化数据中的一部分信息提取出来,重新组织,使其变得有一定结构,然后对此有一定结构的数据进行搜索,从而达到搜索相对较快的目的。这种方式就构成了全文检索的基本思路。这部分从非结构化数据中提取出的然后重新组织的信息,我们称之索引
还以读报纸为例,我们想关注最近英雄联盟S8全球总决赛的新闻,假如都是 RNG 的粉丝,如何快速找到 RNG 新闻的报纸和版块呢?全文搜索的方式就是,将所有报纸中所有版块中关键字进行提取,如”EDG”,”RNG”,”FW”,”战队”,”英雄联盟”等。然后对这些关键字建立索引,通过索引我们就可以对应到该关键词出现的报纸和版块。注意区别目录搜索引擎

为什么要用全文搜索搜索引擎

之前,有同事问我,为什么要用搜索引擎?我们的所有数据在数据库里面都有,而且 Oracle、SQL Server 等数据库里也能提供查询检索或者聚类分析功能,直接通过数据库查询不就可以了吗?确实,我们大部分的查询功能都可以通过数据库查询获得,如果查询效率低下,还可以通过建数据库索引,优化SQL等方式进行提升效率,甚至通过引入缓存来加快数据的返回速度。如果数据量更大,就可以分库分表来分担查询压力。
那为什么还要全文搜索引擎呢?我们主要从以下几个原因分析:

  • 数据类型
    全文索引搜索支持非结构化数据的搜索,可以更好地快速搜索大量存在的任何单词或单词组的非结构化文本。
    例如 Google,百度类的网站搜索,它们都是根据网页中的关键字生成索引,我们在搜索的时候输入关键字,它们会将该关键字即索引匹配到的所有网页返回;还有常见的项目中应用日志的搜索等等。对于这些非结构化的数据文本,关系型数据库搜索不是能很好的支持。
  • 索引的维护
    一般传统数据库,全文检索都实现的很鸡肋,因为一般也没人用数据库存文本字段。进行全文检索需要扫描整个表,如果数据量大的话即使对SQL的语法优化,也收效甚微。建立了索引,但是维护起来也很麻烦,对于 insert 和 update 操作都会重新构建索引。

什么时候使用全文搜索引擎:

  1. 搜索的数据对象是大量的非结构化的文本数据。
  2. 文件记录量达到数十万或数百万个甚至更多。
  3. 支持大量基于交互式文本的查询。
  4. 需求非常灵活的全文搜索查询。
  5. 对高度相关的搜索结果的有特殊需求,但是没有可用的关系数据库可以满足。
  6. 对不同记录类型、非文本数据操作或安全事务处理的需求相对较少的情况。

    概念

  • 文档( document):索引与搜索的主要数据载体,包含一个或多个字段,存放将要写入索引的或将从索引搜索出来的数据
  • 字段(field):文档的一个片段,包括字段的名称和内容两个部分
  • 词项(term):搜索时的一个单位,代表了文本中的一个词。
  • 词条( token):词项在字段文本中的一次出现,包括词项的文本、开始和结束的偏移量以及类型。

我们来看看简单的倒排索引是什么样的。
例如,假设一些只包含title字段的文档,如下所示:
·Elasticsearch Server(文档1)。
·Mastering Elasticsearch(文档2)。
·Elasticsearch Essentials(文档3)。
这些文档索引好以后,可简略地显示如下:
image.png
每个词项指向该词项所出现过的文档数,以及在文档中的位置。这种索引组织方式支持快速有效的搜索操作,例如基于词项的查询。除了词项本身以外,每个词项有一个与之关联的计数,该计数可以告诉Lucene该词项在多少份文档中出现过。

倒排索引的不可变性

:::info 倒排索引采用Immutable Design, 一旦生成, 不可更改 ::: 不可变性,带来了的好处如下:

  • 无需考虑并发写文件的问题,避免了锁机制带来的性能问题
  • 一旦读入內核的文件系统缓存,便留在哪里。只要文件系统存有足够的空间,大部分请求就会直接请求内

存,不会命中磁盘,提升了很大的性能

  • 缓存容易生成和维护/数据可以被压缩

不可变更性,带来了的挑战:
如果需要让一个新的文档可以被搜索,需要重建整个索引。

image.png

  • 在 Lucene中,单个倒排索引文件被称为Segment。 Segment是自包含的,不可变更的。

  • 多个 Segments汇总在一起,称为 Lucene的Index,其对应的就是ES中的 Shard

  • 当有新文档写入时,会生成新 Segment,查询时会同时查询所有 Segments,并且对结果汇总。 Lucene中有一个文件,用来记录所有Segments信息,叫做 commit point

  • 删除的文档信息,保存在“.del”文件中, 查询的时候会进行过滤

  • 定期合并segment, 合并成1个, 并删除已删除的文档

结构

  • 索引(index)

类似的数据放在一个索引,非类似的数据放不同索引, 一个索引也可以理解成一个关系型数据 库。

  • 类型(type)

代表document属于index中的哪个类别(type)也有一种说法一种type就像是数据库的表, 比如dept表,user表。
注意ES每个大版本之间区别很大:
ES 5.x中一个index可以有多种type。
ES 6.x中一个index只能有一种type。
ES 7.x以后 要逐渐移除type这个概念。

  • 映射(mapping)

mapping定义了每个字段的类型等信息。相当于关系型数据库中的表结构。
常用数据类型:text、keyword、number、array、range、boolean、date、geo_point、ip、 nested、object

https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html#_multi_fields_2

关系型数据库(比如Mysql) 非关系型数据库(Elasticsearch)
数据库Database 索引Index
表Table 索引Index类型(原为Type)
数据行Row 文档Document
数据列Column 字段Field
约束Schema 映射Mapping

基本概念

Segments in Lucene

es存储的基本单元是 shard , ES 中一个 Index 可能分为多个 shard, 事实上 每个 shard 都是一个 Lucence 的 Index,并且每个 Lucence Index 由多个 Segment 组成, 每个 Segment 事实上是一些倒排索引的集合, 每次创建一个新的 Document , 都会归属于一个新的 Segment, 而不会去修改原来的 Segment 。且每次的文档删除操作,会仅仅标记 Segment 中该文档 为删除状态, 而不会真正的立马物理删除, 所以说 ES 的 index 可以理解为一个抽象的概念。 就像下图所示:

image.png

Commits in Lucene

Commit 操作意味着将 Segment 合并,并写入磁盘。保证内存数据尽量不丢。但刷盘是很重的 IO 操 作, 所以为了机器性能和近实时搜索, 并不会刷盘那么及时。

Translog

新文档被索引意味着文档会被首先写入内存 buffer 和 translog 文件。每个 shard 都对应一个 translog 文件

image.png

Refresh in Elasticsearch

在 Elasticsearch 中, _refresh 操作默认每秒执行一次, 意味着将内存 buffer 的数据写入到一个新 的 Segment 中,这个时候索引变成了可被检索的。写入新Segment后 会清空内存buffer
image.png

Flush in Elasticsearch

意味着将内存 buffer 的数据全都写入新的 Segments 中, 并将内存中所有的 Segments 全部刷盘, 并且清空 translog 日志的过程。
refresh的时候 transaction log不会清空
image.png

近实时搜索

原理

提交(Commiting)一个新的段到磁盘需要一个 fsync 来确保段被物理性地写入磁盘,这样在断电的时候就不会丢失数据。 但是 fsync 操作代价很大; 如果每次索引一个文档都去执行一次的话会造成很大的性能问题。

我们需要的是一个更轻量的方式来使一个文档可被搜索,这意味着fsync要从整个过程中被移除。

分步骤看数据持久化过程

通过分步骤看数据持久化过程write -> refresh -> flush -> merge

在 Elasticsearch 和磁盘之间是文件系统缓存。 像之前描述的一样, 在内存索引缓冲区中的文档会被写 入到一个新的段中。 但是这里新段会被先写入到文件系统缓存—这一步代价会比较低,稍后再被刷新到 磁盘—这一步代价比较高。不过只要文件已经在系统缓存中, 就可以像其它文件一样被打开和读取了。

write 过程

image.png
一个新文档过来,会存储在 in-memory buffer 内存缓存区中,顺便会记录 Translog(Elasticsearch 增加了一个 translog ,或者叫事务日志,在每一次对 Elasticsearch 进行操作时均进行了日志记录)。
这时候数据还没到 segment ,是搜不到这个新文档的。数据只有被 refresh 后,才可以被搜索到。

refresh 过程

image.png
refresh 默认 1 秒钟,执行一次上图流程。ES 是支持修改这个值的,通过 index.refresh_interval 设置 refresh (冲刷)间隔时间。refresh 流程大致如下:

  1. in-memory buffer 中的文档写入到新的 segment 中,但 segment 是存储在文件系统的缓存中。此时文档可以被搜索到
  2. 最后清空 in-memory buffer。注意: Translog 没有被清空,为了将 segment 数据写到磁盘
  3. 文档经过 refresh 后, segment 暂时写到文件系统缓存,这样避免了性能 IO 操作,又可以使文档搜索到。refresh 默认 1 秒执行一次,性能损耗太大。一般建议稍微延长这个 refresh 时间间隔,比如 5 s。因此,ES 其实就是准实时,达不到真正的实时。

    flush 过程

    每隔一段时间—例如 translog 变得越来越大—索引被刷新(flush);一个新的 translog 被创建,并且一个全量提交被执行
    image.png
    上个过程中 segment 在文件系统缓存中,会有意外故障文档丢失。那么,为了保证文档不会丢失,需要将文档写入磁盘。那么文档从文件缓存写入磁盘的过程就是 flush。写入次怕后,清空 translog。具体过程如下:

  4. 所有在内存缓冲区的文档都被写入一个新的段。

  5. 缓冲区被清空。
  6. 一个Commit Point被写入硬盘。
  7. 文件系统缓存通过 fsync 被刷新(flush)。
  8. 老的 translog 被删除。

    merge 过程

    由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦。 每一个段都会消耗文件句柄、内存和cpu运行周期。更重要的是,每个搜索请求都必须轮流检查每个段;所以段越多,搜索也就越慢。
    Elasticsearch通过在后台进行Merge Segment来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。
    当索引的时候,刷新(refresh)操作会创建新的段并将段打开以供搜索使用。合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中。这并不会中断索引和搜索。
    image.png
    一旦合并结束,老的段被删除:

  9. 新的段被刷新(flush)到了磁盘。 ** 写入一个包含新段且排除旧的和较小的段的新提交点。

  10. 新的段被打开用来搜索。
  11. 老的段被删除。

image.png
合并大的段需要消耗大量的I/O和CPU资源,如果任其发展会影响搜索性能。Elasticsearch在默认情况下会对合并流程进行资源限制,所以搜索仍然 有足够的资源很好地执行。

写入流程

refresh是写到os cache的
数据写入到这里的时候还无法被搜索到,默认1s后执行refresh操作,将内容写入文件系统缓冲区(os cache)中的新段(segment)。此segment的内容尚未被fsynced(未被写入到硬盘),但是segment是打开的,内容可被搜索。
image.png
client写入数据时首先先连接到elasticsearch cluster的coordinator node(如果对elasticsearch的node不了解的话,可以参考下这篇文章),coordinator node根据需要写入数据的doc id字段(默认使用该字段,如果数据写入指定了routing的话,则使用routing进行分片路由)进行路由到对应的分片。路由计算方式:shard=hash(document id)%(num_of_primary_shards),然后根据节点的cluster state找到该shard所在的data node,请求就被转发到了该data node

refresh

下面就开始正式的写数据流程了。首先数据被写入到elasticsearch的index buffer中,该buffer在elasticsearch的heap中。写完index buffer后,数据才会写入到translog中,translog写入完毕后即可以返回客户端写入成功。此处有几个问题需要强调下,也是网上的资料经常出问题的地方,划重点

index buffer和translog到底孰先孰后

  • 和数据库不同,数据库是先写CommitLog,然后再写内存,而Elasticsearch是先写内存,最后才写TransLog。这个有悖常理,笔者也曾经很污理解,后来考虑到可能是因为Lucene的内存写入很重很复杂,很容易失败,比如分词,字段类型不匹配且无法转型,字段长度超过限制等,为了避免TransLog中有大量无效记录,减少recover的复杂度并提升效率,所以就把写Lucene放在了写translog前面。
  • 此处需要说明下,网上很多博客都说先写translog后写index buffer或者同时写translog和index buffer的,这些优势有文艺的,为了证明,拿出最好的证据-源码来说明问题:
    1. public IndexResult index(Index index) throws IOException {
    2. .......
    3. final IndexResult indexResult;
    4. if (plan.earlyResultOnPreFlightError.isPresent()) {
    5. indexResult = plan.earlyResultOnPreFlightError.get();
    6. assert indexResult.getResultType() == Result.Type.FAILURE : indexResult.getResultType();
    7. } else if (plan.indexIntoLucene || plan.addStaleOpToLucene) {
    8. // 将数据写入lucene,最终会调用lucene的文档写入接口
    9. indexResult = indexIntoLucene(index, plan);
    10. } else {
    11. indexResult = new IndexResult(
    12. plan.versionForIndexing, getPrimaryTerm(), plan.seqNoForIndexing, plan.currentNotFoundOrDeleted);
    13. }
    14. if (index.origin().isFromTranslog() == false) {
    15. final Translog.Location location;
    16. if (indexResult.getResultType() == Result.Type.SUCCESS) {
    17. location = translog.add(new Translog.Index(index, indexResult));
    18. ......
    19. // 将数据写入lucene后才开始写translog
    20. indexResult.setTranslogLocation(location);
    21. }
    22. .......
    23. }

什么时候执行refresh

image.png

  • Refresh频率:默认1秒发生一次,可通过Index.refresh_interval配置。 Refresh后数据就可以被搜索到了。这也是为什么Elasticsearch被称为近实时搜索

  • 如果系统有大量的数据写入,那就会产生很多的 Segment

  • Index Buffer被占满时,会触发 Refresh,默认值是JVM的10%

    refresh API

    refresh不执行fsync操作
    在 Elasticsearch 中,写入和打开一个新段的轻量的过程叫做 refresh 。 默认情况下每个分片会每秒自 动刷新一次。这就是为什么我们说 Elasticsearch 是 近实时搜索: 文档的变化并不是立即对搜索可见, 但会在一秒之内变为可见。

这些行为可能会对新用户造成困惑: 他们索引了一个文档然后尝试搜索它,但却没有搜到。这个问题的 解决办法是用 refresh API 执行一次手动刷新:

  1. 1. POST /_refresh
  2. 2. POST /my_blogs/_refresh
  3. 3. PUT /my_blogs/_doc/1?refresh
  4. {"test": "test"}
  5. PUT /test/_doc/2?refresh=true
  6. {"test": "test"}
  1. 刷新(Refresh)所有的索引。
    2. 只刷新(Refresh) blogs 索引
    3. 只刷新 文档

并不是所有的情况都需要每秒刷新。可能你正在使用 Elasticsearch 索引大量的日志文件, 你可能想优 化索引速度而不是近实时搜索, 可以通过设置 refresh_interval , 降低每个索引的刷新频率

  1. PUT /my_logs
  2. {
  3. "settings": { "refresh_interval": "30s" }
  4. }

refresh_interval可以在既存索引上进行动态更新。 在生产环境中,当你正在建立一个大的新索引时,可以先关闭自动刷新,待开始使用该索引时,再把它们调回来:

  1. PUT /my_logs/_settings
  2. { "refresh_interval":-1 }
  3. PUT /my_logs/_settings
  4. { "refresh_interval": "1s" }

多副本时如何写入

  • 上面的是单副本情况下的写入,如果是多副本写入参考下面的图。默认情况下,当primary shard写入成功后,即返回写入成功,后续replica shard通过异步的方式同步数据或者translog恢复数据。
  • 该机制可以通过设置index.write.wait_for_active_shards参数进行设置。该参数可以使用all或者在1到副本数加1(number_of_replicas+1)之间任何的一个整数值。如果是all也就是等待主分片和副本都写入成功,请求才返回,这样可以保证elasticsearch的强一致性,但是代价一是写入线程会阻塞,影响吞吐量;二是会影响elasticsearch的可用性。有一个节点不正常写入就会阻塞,直到节点恢复或者写入超时。

image.png

index buffer refresh

  • 数据写入到这里的时候还无法被搜索到,默认1s后执行refresh操作,将内容写入文件系统缓冲区(os cache)中的新段(segment)。此segment的内容尚未被fsynced(未被写入到硬盘),但是segment是打开的,内容可被搜索。
  • 但是1s一次的refresh会导致频繁生成新文件,在占用大量句柄以及系统资源的同时也会影响到查询数据的效率。所以对于实时性要求不是很高的业务场景,可以将refresh的时间拉长,比如30s,”index.refresh_interval”:”30s”
  • 最后在refresh写完segment后会更新shard的commit point。commit point在shard中以segments_xxx名字的文件存在。用来记录每个shard中segment相关的信息。

    flush

  • 当os cache中的segments数据积累到一定时间(默认30分钟)或者translog达到一定大小时(默认512M),os cache中的segments会被flush到硬盘上进行持久化,持久化成功后,对应的translog由于失去存在的意义而被删除。

  • 此处有几个参数和flush相关,大家可以根据需求进行配置: index.translog.flush_threshold_ops:当发生多少次操作时进行一次flush。默认是 unlimited。 index.translog.flush_threshold_size:当translog的大小达到此值时会进行一次flush操作。默认是512mb。 index.translog.flush_threshold_period:在指定的时间间隔内如果没有进行flush操作,会进行一次强制flush操作。默认是30m。 index.translog.interval:多少时间间隔内会检查一次translog,来进行一次flush操作。es会随机的在这个值到这个值的2倍大小之间进行一次操作,默认是5s。

Delete&&Update操作

Delete&&Update操作也是一种特殊的写操作,但是由于Delete&&Update操作并不是即时生效,而是通过标记删除的方式来实现,最终通过segment merge操作实现真删。所以和标准的写入还是有一定的差别,下面来说一下具体差别:

  • Delete:磁盘上的每个分段(segement)都有一个.del文件与它关联。当客户端发送删除请求时,该文档未被真正删除,而是在.del文件中标记为已删除。此文档仍然可能被搜到,但会从结果中过滤掉。当分段合并时,在.del文件中标记为删除的文档不会包括在新的合并段中。
  • Update:创建新文件,Elasticsearch将该文档分配一个版本号。对文档的每次更改都会产生一个新的版本号,版本号使用versionMap来进行管理,用以减少热点数据的多次磁盘IO开销。当执行更新时,旧版本在.del文件中标记为已删除,并且并且在新版本的分片中编入索引。旧版本可能仍然与搜索查询匹配,但是从结果中将其将其过滤掉

    Segment 文件的清理:Merge

    在每次 Refresh 后都会创建一个新的 Segment 文件,在分段文件过多时候会带来一些问题,这些分段文件都要消耗文件句柄和内存,每次搜索都要检查每个段然后再合并结果,于是段越多、搜索也越慢。所以需要通过一定的策略将这些小的段文件合并为较大的段,并且合并的过程中会将文件中标记删除的数据过滤掉,合并结束后会将旧的数据文件删除,这个时候被标记删除的数据才真正从磁盘上删除。所以我们删除文档后会看到磁盘空间并不是立刻释放的。

ES 和 Lucene 会自动执行 Merge 操作,当然用户也可以手动触发合并操作:

  1. POST you_index/_forcemmerge

通过 Refresh、写 Transaction Log、Flush、Merge 等操作,系统将用户写入的数据完成了缓存和落盘的操作

防止数据丢失:Transaction Log

在默认的情况下,文档写入时数据是没有刷盘的,所以存在数据丢失的风险。为了防止数据丢失,在文档写入的时候不仅需要写 Index Buffer,而且还会写 Transaction Log 文件。而在当前的版本中,Transaction Log 默认是刷盘的。每个分片都会有自己的 Transaction Log,在 Refresh 的时候系统会清空 Index Buffer,但不会清空 Transaction Log。重启的时候系统会从 Transaction Log 中恢复数据,从而防止数据丢失
image.png
ES 在文档写入的时候先写 Index Buffer 和 Transaction Log 到底有什么意义呢?而且 Transaction Log 是默认刷盘的不会很慢吗?写 Index Buffer 你可以认为是使用内存将一批数据缓存下来,然后再一次性批量写磁盘、索引数据。这个设计肯定是非常快的,比一条条写磁盘效率高得多,而且一条数据创建一个 Segment 文件多少有点浪费。其实写 Index Buffer 就是写 Lucene,这期间做了很多数据校验的操作,所以先写 Index Buffer 的另一个好处是为了减少写入失败时产生回滚。

每次写入一条文档就要写一次 Transaction Log,其实现在非常多系统都是这样来做事务日志记录的,Transaction Log 是顺序写的,速度比较快,而要做事务日志这部分数据必须进行刷盘进行持久化。但不管怎么样,毕竟要写磁盘,还是有点慢的!

读数据

elasticsearch中的读操作包含get操作以及search操作,下面就根据这两个操作来详细讲解elasticsearch是如何进行读数据操作的。为了节省篇幅,此处统一说明一下,client从coordinator节点路由到对应数据所在节点的过程与写入数据的流程相同,请大家参考上面写数据相关的内容。下面直接就从数据节点开始讲解。

名词解释

  • 正排索引:doc id -> value,方向是正常方向,所以称为正排索引。使用场景是get这种通过doc id找value的场景。在shard中以后缀名为fdx与fdt结尾的文件存储,dfx是索引文件,fdt是数据文件。这两部分数据是硬盘消耗的大户,elasticsearch中的source字段即存储在fdt文件中。
  • 倒排索引:index -> doc id,方向与上面的相反,所以称为倒排索引。使用场景是search通过查询条件匹配对应的倒排索引拿到对应数据的doc id,拿到doc id后查询正排索引拿到真正的数据。在shard中以后缀名除了正排索引外绝大部分都是各种类型的倒排索引,每一种倒排索引也分为索引文件和数据文件。这两部分数据是内存消耗的大户,elasticsearch中的倒排索引都会加载到off heap中用来加速查询,这也是要留给lucene一半内存最主要的原因。

    get

    get操作即使用doc id字段进行单条数据的查询,查询流程图如下:
    image.png

  • 首先使用doc id字段从os cache中的translog中查询,如果能查询到就直接返回客户端;

  • 如果os cache中的translog没有查询到的话,再去disk上的tranlog中查询,如果能查询到就直接返回客户端;
  • 如果reanslog中没有查询到对应的数据,再去segment中查询对应的数据。首先把正排索引的fdx数据加载到off heap中去查询doc id。如果查询不到则直接返回null;如果能查询到doc id,根据查询结果中的偏移量直接去硬盘上查询对应的原始数据并返回。
  • 此处大家思考下get操作(其实update以及delete操作也会优先查询translog)为什么要优先查询translog后再去查询segment数据?这里说明一下原因:上文已经说过了delete以及update操作在elasticsearch中都是先打删除标记然后通过segment merge操作进行真删,所以一条数据可能在elasticsearch中有几个版本,而最新的版本可能会存在于translog中。所以如果在translog中查询到目标数据直接返回即可,一定是最新的数据;如果translog中没有目标数据,再去segments中查询。

    search

    search也是elasticsearch中比较复杂的流程。总体分为term search以及分词search。分词search内容较多,考虑到篇幅,后续会单独写一篇文章讲述。此处仅仅是为了讲解search的原理和流程,所以使用term search来进行讲解。
    search操作的阶段取决于search type的选择,后续单独写一篇文章来说明elasticsearch的search type。而search type默认使用query then fetch类型,该类型由两个阶段组成:查询阶段(query)和获取阶段(fetch)阶段。

  • 查询阶段:在此阶段,协调节点将搜索请求路由到索引中的所有分片(包括:主分片和副本分片)。分片独立执行搜索,并根据相关性分数创建一个优先级排序结果.所有分片将匹配到的文档和相关性分数的文档id返回给协调节点。协调节点创建一个新的优先级队列,并对全局结果进行排序。可以有很多文档匹配结果,但默认情况下,每个分片将前10个结果发送到协调节点,协调节点创建优先级队列,从所有分片中分选结果并返回前10个匹配结果。

  • 获取阶段:在协调节点对所有的结果进行排序,生成全局排序的文档列表后,它将所有分片请求原始文档。所有的分片都会丰富文档并将其返回到协调节点。

term search的流程如下:
image.png

  • 首先client会根据search中的term去elasticsearch heap中的Segment Cache中查询FST数据(如果不知道FST数据建议参考之前的博文),简单来说就是倒排索引的前缀树。
  • 根据FST数据来查询对应的倒排索引tip文件,将tip文件加载到off heap中进行查询,将查询后的索引数据查询倒排索引数据tim文件,得到对应的doc id列表。
  • 根据doc id列表将对应的正排索引文件fdx数据当如到off heap中查询对应doc id的地址,查询之后去fdt文件中取出对应的原始数据中必要的信息返回到协调节点(完整的数据在fetch阶段获取)

    持久化flush, 变更

    通过调用 Flush 操作,ES 可以将操作系统缓存中的数据刷写到磁盘上。ES 的 Flush 操作会触发 Refresh,将当前 Index Buffer 中的数据写入到操作系统的缓存中,然后再调用 fsync 将操作系统缓存中的数据刷盘,并且 Flush 还会清空 Transaction Log。因为需要刷盘,所以 Flush 的操作是比较耗时的。Flush 的两个触发点如下:

  • 默认 30 分钟调用一次;

  • Transaction Log 的默认容量为 512M(由 index.translog.flush_threshold_size 控制),在 Transaction Log 被写满的时候会触发 Flush。

fush负责将内存中的 segment写入磁盘,主要做如下的工作

  • 将 translog写入磁盘
  • 将 index buffer清空,其中的文档生成一个新的 segment,相当于个 refresh操作
  • 更新 commit point并写入磁盘
  • 执行 fsync操作,将内存中的 segment写入磁盘
  • 删除旧的 translog文件

    原理

    如果没有用 fsync 把数据从文件系统缓存刷(flush)到硬盘,我们不能保证数据在断电甚至是程序正常退出之后依然存在。为了保证 Elasticsearch 的可靠性,需要确保数据变化被持久化到磁盘。

在动态更新索引时,我们说一次完整的提交会将段刷到磁盘,并写入一个包含所有段列表的提交点。 Elasticsearch 在启动或重新打开一个索引的过程中使用这个提交点来判断哪些段隶属于当前分片。

即使通过每秒刷新(refresh)实现了近实时搜索,我们仍然需要经常进行完整提交来确保能从失败中 恢复。但在两次提交之间发生变化的文档怎么办?我们也不希望丢失掉这些数据。

Elasticsearch 增加了一个 translog ,或者叫事务日志,在每一次对 Elasticsearch 进行操作时均进行了 日志记录。通过 translog ,整个流程看起来是下面这样:

一个文档被索引之后,就会被添加到内存缓冲区,并且追加到了translog

新的文档被添加到内存缓冲区并且被追加到了事务日志
image.png

  1. 刷新(refresh)使分片处于下面描述的状态,分片每秒被刷新(refresh)一次:
  • 这些在内存缓冲区的文档被写入到一个新的段中,且没有进行fsync
  • 这个段被打开,使其可被搜索。
  • 内存缓冲区被清空。

刷新(refresh)完成后, 缓存被清空但是事务日志不会
image.png
2. 这个进程继续工作,更多的文档被添加到内存缓冲区和追加到事务日志

事务日志不断积累文档
image.png

  1. 每隔一段时间—例如 translog 变得越来越大—索引被刷新(flush);一个新的 translog 被创建,
    并且一个全量提交被执行:
  • 所有在内存缓冲区的文档都被写入一个新的段。
  • 缓冲区被清空。
  • 一个提交点被写入硬盘。
  • 文件系统缓存通过 fsync 被刷新(flush)。
  • 老的 translog 被删除。

translog 提供所有还没有被刷到磁盘的操作的一个持久化纪录。当 Elasticsearch 启动的时候, 它会从 磁盘中使用最后一个提交点去恢复已知的段,并且会重放 translog 中所有在最后一次提交后发生的变 更操作。

translog 也被用来提供实时 CRUD 。当你试着通过 ID 查询、更新、删除一个文档,它会在尝试从相应 的段中检索之前, 首先检查 translog 任何最近的变更。这意味着它总是能够实时地获取到文档的最新版本。

在刷新(flush)之后,段被全量提交,并且事务日志被清空
image.png

flush API

这个执行一个提交并且截断 translog 的行为在 Elasticsearch 被称作一次 flush 。 分片每 30 分钟被自动刷新(flush),或者在 translog 太大的时候也会刷新。

flush API 可以 被用来执行一个手工的刷新(flush):

POST /blogs/_flush 
POST /_flush?wait_for_ongoin
  1. 刷新(flush)blogs索引。
    2. 刷新(flush)所有的索引并且等待所有刷新在返回前完成。
    我们很少需要自己手动执行一个的 flush 操作;通常情况下,自动刷新就足够了。

这就是说,在重启节点或关闭索引之前执行 flush有益于你的索引。当 Elasticsearch 尝试恢复或重新打 开一个索引, 它需要重放 translog 中所有的操作,所以如果日志越短,恢复越快。

Translog 有多安全? translog 的目的是保证操作不会丢失。这引出了这个问题: Translog 有多安全? 在文件被 fsync 到磁盘前,被写入的文件在重启之后就会丢失。默认 translog 是每 5 秒被 fsync 刷新到硬盘, 或者在每次写请求完成之后执行(e.g. index, delete, update, bulk)。这个过 程在主分片和复制分片都会发生。最终,这意味着在整个请求被 fsync 到主分片和复 制分片的 translog 之前,你的客户端不会得到一个 200 OK 响应。 在每次写请求后都执行一个 fsync 会带来一些性能损失,尽管实践表明这种损失相对较小(特别 是 bulk 导入,它在一次请求中平摊了大量文档的开销)。 但是对于一些大容量的偶尔丢失几秒数据问题也并不严重的集群,使用异步的 fsync 还是比较有 益的。比如,写入的数据被缓存到内存中,再每 5 秒执行一次 fsync 。

这个行为可以通过设置durability参数为async来启用:

PUT /my_index/_settings { 
  "index.translog.durability": "async", 
  "index.translog.sync_interval": "5s" 
}

这个选项可以针对索引单独设置,并且可以动态进行修改。如果你决定使用异步 translog 的话,你需要保证 在发生 crash 时, 丢失掉 sync_interval 时间段的数据也无所谓。请在决定前知晓这个特性。

如果你不确定这个行为的后果,最好是使用默认的参数

"index.translog.durability": "request"

来避免数据丢失。

倒排搜索

image.png
term dictionary
于是乎就有了 term dictionary ,ES 为了能快速查找到 term,将所有的 term 排了一个序,二分法查找。是不是感觉有点眼熟,这不就是 MySQL 的索引方式的,直接用 B+树建立索引词典指向被索引的数据。

term index
但是问题又来了,你觉得 Term Dictionary 应该放在哪里?肯定是放在内存里面吧?磁盘 io 那么慢。就像 MySQL 索引就是存在内存里面了。
但是如果把整个 term dictionary 放在内存里面会有什么后果呢?
内存爆了…
别忘了,ES 默认可是会对全部 text 字段进行索引,必然会消耗巨大的内存,为此 ES 针对索引进行了深度的优化。在保证执行效率的同时,尽量缩减内存空间的占用。
于是乎就有了 term index 。
Term index 从数据结构上分类算是一个“Trie 树”,也就是我们常说的字典树。这是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。
这棵树不会包含所有的 term,它包含的是 term 的一些前缀(这也是字典树的使用场景,公共前缀)。通过 term index 可以快速地定位到 term dictionary 的某个 offset,然后从这个位置再往后顺序查找。就想右边这个图所表示的。(怎么样,像不像我们查英文字典,我们定位 S 开头的第一个单词,或者定位到 Sh 开头的第一个单词,然后再往后顺序查询)
lucene 在这里还做了两点优化,一是 term dictionary 在磁盘上面是分 block 保存的,一个 block 内部利用公共前缀压缩 ,比如都是 Ab 开头的单词就可以把 Ab 省去。二是 term index 在内存中是以 FST(finite state transducers)的数据结构保存的。

FST 有两个优点:

  • 空间占用小。通过对词典中单词前缀和后缀的重复利用,压缩了存储空间
  • 查询速度快。O(len(str)) 的查询时间复杂度。

    总结写入操作

    集群的角度来看,数据写入会先路由到主分片,在主分片上写入成功后,会并发写副本分片,最后响应给客户端。
    分片的角度来看,数据到达分片后需要对内容进行格式校验、分词处理然后再索引数据。
    节点的角度来看,ES 数据持久化的步骤可归纳为:Refresh、写 Transaction Log、Flush、Merge。

  • 默认的情况下,系统会每一秒执行一次 Refresh 操作把 Index Buffer 的数据写入磁盘中,但不会调用 fsync 刷盘。 ES 提供近实时搜索的原因是因为数据被 Refresh 后才能被检索出来 。

  • 为了保证数据不丢失,在写完 Index Buffer 后,系统还有写 Transaction Log。Transaction Log 的写操作是顺序写的,并且默认是调用 fsync 进行刷盘的。
  • Flush 操作会将操作系统磁盘缓存持久化到磁盘中,默认 30 分钟或者在 Transaction Log 写满时触发执行。Flush 将磁盘缓存持久化到磁盘后,会清空 Transaction Log。
  • 最后 ES 和 Lucene 会自动执行 Merge 操作清理过多的 Segment 文件,这个时候被标记为删除的文档会正式被物理删除

那为啥 ES 可以保证数据的可靠性呢?其实,一方面,ES 的分片有副本冗余,并且进行分布式存储。另一方面,通过数据持久化步骤可以保证数据在分片写入时不丢失。另外,ES 通过先写 Index Buffer 后写 Transaction Log 的方式也保证了写入的吞吐量不会太差。
image.png

文档搜索机制

es路由算法
当我们需要将数据分散到不同节点上时,我们首先可以想到的是利用映射算法将文档 ID 映射到某个固定的节点上。对于映射算法,一般有以下几个:

算法 描述
1. 随机算法 数据写入时,将数据随机写到一个分片中去。在查询时由于无法知道对应的文档存在于哪个分片,所以需要遍历所有分片
2. 中心节点维护数据路由的映射关系 存在单点故障、文档量大的时候维护成本高、效率低下、数据迁移可能会非常麻烦等问题
3. 通过对路由 key 的值进行计算,得出的值对应到相应的分片 维护简单,通过计算可以快速路由到对应的分片

符合我们需求的映射算法,应该要满足数据写入后可以进行快速定位、查询,并且占用资源还要小,所以随机的算法并不适合。第二种算法通常需要非常多的资源来维护映射关系,并不经常选择。而第三种算法则可以满足系统设计的需求。
ES 的数据路由算法是根据文档 ID 和 routing key 来确定 Shard ID 的过程。默认的情况下 routing key 为文档 ID,路由算法一般情况下的计算公式如下:

shard_number = hash(_routing) % numer_of_primary_shards

ES 使用随机的文档 ID(当我们写入数据时不指定文档 ID,系统会自动分配随机 ID) 和 Hash 算法是可以保证文档均匀地分散到各个分片中的,但是如果文档 ID 是用户自定义的(如上面例子的 doc_id),或者是用户指定 routing key 的时候,这个 hash 出来的值可能会不够随机而导致出现数据倾斜的情况。

倒排索引项

倒排索引顺项( Posting)主要包含如下信息

  • 文档Id,用于获取原始信息
  • 单词频率( TF Term Frequency),记录该单词在该文档中的出现次数,用于后续相关性算分
  • 位置( Position),记录单词在文档中的分词位置(多个),用于做词语搜索(Phrase Query)
  • 偏移(Offset),记录单词在文档的开始和结束位置,用于做高亮显示

image.png