1.lucene的分段机制

什么叫分段

众所周知,es的底层原理是基于lucene实现的。而每个es的分片,其本质就是一个lucene索引
lucene索引并不是一整个完整的索引构成,而是有多个分段索引组成,每一个分段索引,称为一个segment
segment是lucene索引的最小组成单元。其中包含了文档中的词典、词典的倒排索引以及Document的原始数据等信息。每个segment都具备搜索功能

分段内部的数据结构

lucene索引的每个segment都包含以下数据结构
image.png

词典

经过分词后形成的数据结构,是倒排索引最重要的组成部分。一个词典的查找效率高低直接影响了倒排索引的查询效率。
为了实现高效的单条查询和范围查询,es中将词典分为了前缀索引和后缀词条。前缀索引由FST数据结构或BKDTree构成,而后缀词条以一个有序列表的形式存储。
文档中的每个字段(除了设置index:false)都有其自己的词典。词典文件需要内存加载,存储于内存当中。
倒排索引的数据结构大致如图:
image.png

FST是一种查询效率极高,同时占用内存很小的数据结构(同样支持范围查询)。但是其数据结构复杂,输入时要求必须有序,且不易更新。 具体参考:FST详解segment详解

倒排表

倒排表就是每个词条对应文档ID的集合,Lucene现使用的倒排表结构叫Frame of reference。需要注意的是,倒排表存储在磁盘当中。文档中的每个字段(除了设置index:false)都有其自己的倒排表

Frame of reference包括以下两个特点

  1. 数据压缩
  2. 基于跳表实现交集计算

参考:Frame of reference详解

正向文件

正向文件指的就是原始文档,Lucene对原始文档也提供了存储功能,它存储特点就是分块+压缩。
fdt文件:存放原始文档的文件,它占了索引库90%的磁盘空间,里面一个chunk就是一个块,Lucene索引文档时,先缓存文档,缓存大于16KB时,就会把文档压缩存储。一个chunk包含了该chunk起始文档、多少个文档、压缩后的文档内容。需要注意的是,fdt文件存在磁盘当中
fdx文件:索引文件,通过文档号(自增数字)快速得到文档位置,数据结构为跳跃表。需要注意的是,fdx文件存在内存当中

doc-value

上述所说的三种数据结构,可以实现通过倒排索引快速查询文档这一功能
但是当需要聚合,排序,进行某个字段的计算时,正向文件就不在适用,总不能因为想要获取某个值,就把整个文档检索出来,很明显这样的做法是不合理的
因此doc-value就是为了应对这个问题
Lucene目前有五种类型的DocValues:NUMERIC、BINARY、SORTED、SORTED_SET、SORTED_NUMERIC,针对每种类型Lucene都有特定的压缩方法。
doc-value可以理解为键值对映射,每个字段都有其自己的doc-value。查找时通过倒排索引获取到文档ID,进而通过ID查询doc-value快速获取对应的属性值进行后续操作。需要注意的是,doc-value文件存在内存和磁盘当中

分段不变性

lucene索引的每个segment都具有不变性。可以理解为一旦索引建立,后续就对于已经创建的数据,就不会有任何修改

优点

  1. 因为其不变性,所以对已创建的索引,不会有更新操作。因此无须担心高并发时同时修改数据的问题,不需要悲观锁那种阻塞锁来限制写操作
  2. 因为其不变性,所以不会存在数据不一致的问题,读入缓存的数据,只要缓存空间足够,可以一致存在。这样可以尽最大可能避免磁盘IO
  3. 因为其不变性,因此对于较大的倒排索引,是允许的高度压缩的。减少磁盘 IO 和内存占用率

    缺点

  4. 当对旧数据进行删除时,旧数据不会马上被删除,而是在 .del 文件中被标记为删除。而旧数据只能等到段更新时才能被移除,这样会造成大量的空间浪费。

  5. 若有一条数据频繁的更新,每次更新都是新增新的标记旧的,则会有大量的空间浪费。
  6. 在查询的结果中包含所有的结果集,需要排除被标记删除的旧数据,对查询的性能造成部分影响

    分段的写操作

    因为segment具有不变性,那么对于每个segment的操作,都遵循如下规则

    新增

    segment新增操作相对来说很简单,新写入的文档都被写入到一个新的段中,在后续落盘即可

    删除

    segment的删除操作为逻辑删除,并不是真实删除一个文档,而是将其文档信息写入.del文件。被标识了del的文档仍然会在查询时被命中,但是会在返回客户端时被过滤掉

    修改

    segment的修改操作可以分解为两个动作:删除与新增
    在修改数据时,会将源文档的信息写入.del文件,标识为删除。同时新增一个文档信息写入到新的段中,新文档的版本号version会+1。在查询时可能新文档与老文档都会被命中,但是在返回客户端时老文档会被过滤掉

    分段合并

    经过上述文档的描述可以了解到,segment具有不变形。删除与更新操作理论上都不会清理掉原文档。
    那么就带来了一个问题,随着时间推移,大量的文档被标记为删除并持续占有磁盘空间,直到最终占满。
    这个问题该如何解决?lucene中采用了segment合并的操作来处理这个问题
    在elasticSearch中,es服务器会由后台进程定期进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。
    段合并的时候会将那些被标记为删除的文档从文件系统中清除,被删除的文档不会被拷贝到新的大段中。合并的过程中不会中断索引和搜索。合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中。合并结束后老的段会被删除,新的段被 Flush 到磁盘,同时提交一个新的提交点。
    段合并的计算量庞大, 而且还要吃掉大量磁盘 I/O,段合并会拖累写入速率,如果任其发展会影响搜索性能。Elasticsearch 在默认情况下会对合并流程进行资源限制,所以搜索仍然有足够的资源很好地执行。

    2.写原理

    因为ElasticSearch是基于分布式部署的,因此对于任意一个写请求,实际上对于es服务集群来说,都包含两步

  7. 请求转发的对应的分片服务器

  8. 在分片服务器上执行写操作

接下来对这两步做一个详细的讲解

分片选择

分片算法

因为es每个节点本身也是一个协调节点,因此无论请求到哪个节点上,都可以通过请求转发的形式,将请求发送到真正执行数据读写的数据节点上
es中采用取模法进行分片服务器的选择,具体公式:

shard = hash(routing) % number_of_primary_shards

其中routing是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。
因为取模法在节点数量有变化时,几乎需要重新计算所有数据的具体存储分片。因此es中在索引模板设置时,在指定了分片数以后,就不可以再次修改了。如果想修改,只能重建一个新的索引并重新录入文档数据

具体步骤

如图,假定当前ElasticSearch集群共有四个实例(四个分片),其中M1节点为主节点,每个实例拥有两个副本实例(副本分片)。
image.png
数据的写入分为两种情况,需要根据取模法判定当前写操作数据要触发到哪些分片。写入当前节点和混合节点(包括当前节点和其他节点)
写入当前节点时
image.png

  1. 客户端发送请求到M2节点
  2. M2节点根据取模法算出写命令要操作当前节点
  3. 直接写入数据,并通过并发机制将数据写入副本分片。es中通过乐观锁来控制并发写问题,具体通过文档的version字段来控制。当触发更新操作时,新生成的文档版本号+1,老的文档版本号不变。

当写入其他节点时
image.png

  1. 客户端发送请求到M2节点,M2节点根据取模法算出具体操作节点为M3节点
  2. M2将请求转发给M3节点
  3. M3进行具体的写操作处理,并通过并发写将数据同步到副本
  4. 所有分片写入完成。M3写入完成响应给M2节点,此时M2就是一个协调节点
  5. M2响应给客户端,数据写入完成

    数据写入

    经过上述文档的描述,了解到了写操作如果在分片之间操作并响应给客户端的。接下来,就需要了解下,在具体的分片上,数据的写入是如何操作的

    数据存储位置

    在明确具体写入原理流程之前,需要先了解一个数据的存储位置概念

  6. jvm内存:因为es是由java语言编写的,因此每个es实例都是运行在jvm上的。那么对于每个es实例来说,都具有一个jvm内存的存储位置

  7. 操作系统内存:jvm也需要运行在一个服务器上,因此,服务器还具有一个操作系统内存的存储位置
  8. 硬盘:es并不是完全基于内存读写的数据引擎,最终的数据最终是要落在磁盘上的

了解了存储位置之后,开始详细讲解下数据的写入流程

refresh

首先,文档数据被写入到jvm内存当中。经过上文描述可以知道,每一个分片实际上就是一个lucene索引,lucene索引的最小组成单元就是segment。
当文档数据还在jvm内存中时,文档数据还未被写成segment,此时的数据不可以读,只可以写
es默认每1s清空jvm内存中相关文档数据,将jvm内存中的文档数据写入到操作系统内存当中,并形成一个segment。这个过程叫做refresh
此时的文档数据可以读,但是不可以写了(因为段的不变性)。这也是为什么es是近实时查询,因为存在这1s的延迟。
可以在索引创建时,调整索引模板中的refresh_interval属性调整refresh频率,默认单位为ms,支持指定时间单位。例如 refresh_interval = “30s”

flush

文档数据在被写入到操作系统内存后,具备了读取功能。但是如果此时服务器宕机,那么数据就会丢失。
为了应对这个问题,es也其他同类产品一样,引入了事务日志(translog)的概念。在数据被写入到jvm内存当中时,同时也会记录一份translog到操作系统内存。
每5s,translog都会执行一次刷盘操作,将日志写入磁盘,这样即使服务器宕机,最多也就是丢失5s的数据
当translog中待写入数据量大于512M或距离上一次刷盘操作超过30分钟时,es会将操作系统内存当中的segment新入磁盘,形成一个提交点。并删除旧的translog,创建一个新的translog。这个过程叫做flush
提交点之前的数据存在于磁盘和操作系统内存中,提交点之后的数据,存在于操作系统内存和translog中

整体流程梳理

image.png
如图:

  1. 文档数据先被写入jvm内存,此时文档不能被查询。同时会写入一份数据到操作系统内存的tranlog
  2. 每1s钟,jvm将数据写入到操作系统内存,形成一个segment(可以理解为建立倒排索引等数据结构)。此时数据可以查询,但是不能修改
  3. 每5s钟,操作系统内存中的translog数据刷新到磁盘一次
  4. 每30分钟或待写入数据量达到512M,操作系统内存中的segment刷新到磁盘一次。形成一个提交点,并且删除旧的translog,创建一个新的translog。每一个提交点都有一个.del文件,包含了这个提交点中被标记为删除的文档ID
  5. es服务器会在后台持续的对操作系统内存和磁盘中的segment进行合并,包括写入磁盘与未写入磁盘的segment,新的段在合并完成后会被flush到磁盘。合并后的新segment不包含.del标记的文档,并且在合并后会删除原segment。

    3.读原理

    分片选择

    读操作的分片的选择原理,基本上与写操作一致。
    但是有一点需要注意,写操作只能是主节点进行,但是读操作是可以在副本节点进行的。因此对于读取操作,es中的协调节点在取模后,采用轮询方式的负载均衡方式进行请求转发

    数据读取

    数据的读取大致可以分为单条数据和批量查询两种,接下来对其原理进行说明

    单条查询

    通常来说就是根据ID进行数据查询,大致分为以下步骤

  6. 客户端发送请求到协调节点

  7. 协调节点通过取模判定具体读取节点
  8. 如果是当前节点,直接根据ID通过segment中的正向文件获取文档内容
  9. 如果是其他节点。通过轮询负载均衡,将请求转发到具体读取节点
  10. 具体读取节点通过segment中的正向文件获取文档内容返回给协调节点
  11. 协调节点响应给客户端

    批量查询

    包括分页,聚合等查询,大致分为以下步骤

  12. 请求发送到协调节点

  13. 协调节点通过轮询负载均衡,将查询条件发送给所有节点
  14. 每个节点通过查询条件在segment的倒排索引文件中进行查询,将查询的文档ID返回给协调节点
  15. 协调节点对其他节点的返回结果进行聚合,分页,排序等操作,得到最终的文档ID集合
  16. 协调节点再将文档ID集合发送给对应的节点,
  17. 对应节点通过segment的正向文件获取文档内容,在返回给协调节点
  18. 协调节点获取到所有节点返回的文档内容,之后响应给客户端

    4.ElasticSearch调优

    参数调优

  19. 给每个文档指定有序的具有压缩良好的文档 ID,避免随机的 UUID-4 这样的 ID,这样的 ID 压缩比很低,会明显拖慢 Lucene。

  20. 对于那些不需要聚合和排序的索引字段禁用 Doc values。
  21. 不需要做模糊检索的字段使用 Keyword 类型代替 Text 类型,这样可以避免在建立索引前对这些文本进行分词。
  22. 如果你的搜索结果不需要近实时的准确度,考虑把每个索引的 index.refresh_interval 改到 30s 。
    如果你是在做大批量导入,导入期间你可以通过设置这个值为 -1 关掉刷新,还可以通过设置 index.number_of_replicas: 0 关闭副本。别忘记在完工的时候重新开启它。
  23. 避免深度分页查询建议使用 Scroll 进行分页查询。普通分页查询时,会创建一个 from+size 的空优先队列,每个分片会返回 from+size 条数据,默认只包含文档 ID 和得分 Score 给协调节点。
    如果有 N 个分片,则协调节点再对(from+size)×n 条数据进行二次排序,然后选择需要被取回的文档。当 from 很大时,排序过程会变得很沉重,占用 CPU 资源严重。
  24. 文档中的字段尽可能精简,只提供需要检索,聚合或排序的字段。其他字段可存在其他存储设备上,例如 Hbase,在 ES 中得到结果后再去 Hbase 查询这些字段。
  25. 创建索引和查询时指定路由 Routing 值,这样可以精确到具体的分片查询,提升查询效率。路由的选择需要注意数据的分布均衡。

    JVM调优

  26. 确保堆内存最小值( Xms )与最大值( Xmx )的大小是相同的,防止程序在运行时改变堆内存大小。Elasticsearch 默认安装后设置的堆内存是 1GB。可通过 ../config/jvm.option 文件进行配置,但是最好不要超过物理内存的50%和超过 32GB。

  27. GC 默认采用 CMS 的方式。CMS垃圾收集器使用的是标记清除算法,单次GC效率高,但是会产生内存碎片。因此在一定的时间内,会触发多次垃圾回收,存在 STW 的问题,可以考虑使用 G1 收集器。8.3版本已经默认使用G1垃圾收集器
  28. ES 非常依赖文件系统缓存(操作系统内存),快速搜索。一般来说,应该至少确保物理上有一半的可用内存分配到文件系统缓存。