• 一次写入过程
  • 一次读取过程 (主要是选择协调节点,不用和倒排索引,排序联系)
  • 一次搜索过程 (主要是排序)
  • 不变性
  • 动态更新索引(与MySQL的插入数据进行比较分析)
  • 如何删除和更新数据
  • 近实时搜索
  • 持久化 translog
  • 段合并(提高搜索效率)

节点与分片

es的节点分为三种类型:主节点,数据节点,客户端节点.

主节点主要负责集群操作相关的内容,如创建或删除索引.

数据节点负责对文档进行读写.

客户端节点只能处理路由请求,转发集群操作等.

索引可以设置多个分片(副本),依旧是分布式主流的"主写副读"的模式,保存数据时主分片先写,然后再同步到各副分片上.读取数据时,主分片收到请求,然后把请求轮询到某个副本分片上,由该分片把文档返回给协调节点.

每个数据节点都知道集群文档的分布情况,客户端操作文档时可以对任意一个数据节点发送请求,不管文档的主分片在不在该节点上.

收到请求的节点叫做协调节点,它负责把请求转发给文档主分片所在的节点上.

一次写入过程

  • 客户端向Node1发送写请求
  • Node1根据docId和路由算法计算出文档主分片所在节点Node3,把请求转发给Node3
  • Node3执行请求,写入文档.成功后把数据同步到Node1和Node2的副本分片上
  • 当大部分副本分片都成功时(根据一致性设置判定),视为写入成功,Node3向协调节点Node1报告,最后Node1给客户端返回成功.

使用bulk操作时,主分片是每进行一个操作,就立即异步复制到副本分片,再执行下一个操作,而不是全部执行完了再同步给副本.

一次读取过程

  • 客户端向Node1发送写请求
  • Node1根据docId和路由算法计算出文档主分片所在节点Node3,把请求转发给Node3
  • Node3的主分片接收请求,然后轮询所有副本分片,选择其中一个负责提供数据(有的文章说轮询的范围也包括主分片,这是不对的,不符合主写副读的模式)

官方文档有一段意思写得不太明白:

“在文档被检索时,已经被索引的文档可能已经存在于主分片上但是还没有复制到副本分片。 在这种情况下,副本分片可能会报告文档不存在,但是主分片可能成功返回文档。 一旦索引请求成功返回给用户,文档在主分片和副本分片都是可用的.”

我的理解是,当主分片写完,还没同步到副本分片时就进行查询,副本分片会给主分片返回查询失败,然后由主分片负责提供数据.

一次搜索过程

与上面的读取过程区分开,读取是拿到了docId,因此容易确认文档位置,而搜索是没有docId的.

可以用query then fetch概括,即先在倒排索引里搜索得到docId,然后再执行读取过程得到文档内容,这里很像MySQL的回表.

查询阶段

  • 客户端发送请求,协调节点Node3收到后会创建一个大小为from+size的空队列
  • Node3将搜索请求转发到索引的所有分片,每个分片进行本地搜索并得到一个大小同样为from+size的队列.
  • 各个分片将结果队列返回给Node3,结果包含的内容仅为docId和其对应的排序分值
  • Node合并各个节点的结果并排序

取回阶段

  • Node3确认需要返回哪些文档,然后对相关分片发送multi-get请求
  • 接下来就跟查询过程是一样的了

这里需要注意的是,即使我们最终需要的结果大小只是size,但是我们需要对n * (from + size) 的数据进行排序,当from很大的时候执行过程就相当耗时了.所以这里要避免深分页(Deep Pagination),在业务场景中考虑用search after或者scrollbar来替代.

分片内部原理

下面谈谈文档具体是怎样写入到磁盘的.

es的分片由若干个segment组成,segment可以看做是es存储的最小管理单元,包含倒排索引和文档原始内容等.

Segment的不变性

segment一旦写入磁盘后就不可改变.好处如下:

  • 不需要锁,不用担心并发修改的情况
  • 由于segment不变,所以相关的缓存,比如当它被读入内存后,或者filter缓存可以一直有效,不需要进行像MySQL刷脏页那种复杂操作.

坏处是一旦索引要修改,就只能整个重建,索引规模越大耗时越长.

增量更新

为了避免对索引的全量更新,es引入按段搜索的概念,将segment分成一个个小的单元

es的数据不直接写入磁盘,而是先写入内存的buffer中

当buffer满了或者经过一段时间,es会将buffer提交成一个新的段,并清空buffer

进行查询时会对分片内所有segment按顺序查询,所以为了提高查询效率,segment的数量要尽量小(segment内部的倒排索引是按字典树组织的,查询较快)

进行删除或更新时,由于segment不可变,es会生成一个.del文件标记出被删除文档的segment.

被标记的文档仍然能被搜索得到(逻辑删除),但是在得到最终结果之前会被过滤掉.

FST二级索引

由于 Lucene 会为原始数据中的每个词都生成倒排索引,数据量较大。所以倒排索引对应的倒排表被存放在磁盘上。这样如果每次查询都直接读取磁盘上的倒排表,再查询目标关键词,会有很多次磁盘 IO,严重影响查询性能。

为了解磁盘 IO 问题,Lucene 引入排索引的二级索引 FST Finite State Transducer 。原理上可以理解为前缀树,加速查询。

es-fst

其原理如下:

  1. 将原本的分词表,拆分成多个 Block ,每个 Block 会包含 25 ~ 48 个词(Term)。图中做了简单示意,Allen 和 After组成一个 Block 。
  2. 将每个 Block 中所有词的公共前缀抽取出来,如 Allen 和 After 的公共前缀是 A 。
  3. 将各个 Block 的公共前缀,按照类似前缀树的逻辑组合成 FST,其叶子节点携带对应 Block 的首地址 。(实际 FST 结构更为复杂,前缀后缀都有压缩,来降低内存占用量)
  4. 为了加速查询,FST 永驻堆内内存,无法被 GC 回收。
  5. 用户查询时,先通过关键词(Term)查询内存中的 FST ,找到该 Term 对应的 Block 首地址。再读磁盘上的分词表,将该 Block 加载到内存,遍历该 Block ,查找到目标 Term 对应的DocID。再按照一定的排序规则,生成DocID的优先级队列,再按该队列的顺序读取磁盘中的原始数据(行存或列存)。

Refresh与近实时性

像之前提到的,数据先是被写入内存buffer中,然后再提交为新的段,最后再通过fsync写入磁盘.

由于fsync操作代价大,所以操作间隔一般都很长,如果等到fsync结束后才能搜索到写入的文档就太慢了.

因此es引入一个refresh的概念,即写入和打开一个新的segment的轻量过程,refresh后新写入的数据就生效了,也就是数据从内存buffer提交到segment后(写入磁盘前)就能搜索得到.

所以我们称es的搜索是准实时的,这个间隔就是refresh的间隔,由参数refresh_interval控制

Translog与持久化

为了保障性能,数据fsync到磁盘的间隔较长,如果期间服务器宕机,这段时间的数据可能丢失.

于是es引入了Translog机制(类似MySQL的redolog),把每次对索引的操作都记录到translog里面,即便宕机,也能通过translog对数据进行恢复.

与MySQL不同的是,es是先写Lucene,再写translog,原因是写入Lucene失败概率会高一些,如果先写translog,当写入Lucene失败后得回滚translog.

当refresh完成后,内存buffer会清空,但是translog不会,translog只在flush后清空.

translog也同样是先写内存,再fsync到磁盘,写入磁盘之前仍然可能丢数据.如果对性能要求高,fsync的间隔就拉长些,如果对安全性要求高,fsync可以设置为同步操作.通过"index.translog.durability"和"index.translog.sync_interval"两个参数控制.

可能有人会疑惑,写Lucene要fsync,写translog也要fsync,那么translog为什么可以提高性能呢?因为lucene(segment)的数据要大一些,而translog是增量写,fsync的时间会短些.

段合并

每次refresh都会产生一个新的segment,这会导致segment的数量暴增,因此es开了个后台线程去进行段合并.

  • 段合并自动进行,优先选择一部分大小相似的段来合并成更大的新的段.旧的段此时没有删除,不影响其它正常操作.
  • 当合并后新的段提交后,旧的段会被删除(物理删除),合并结束.

Lucene文件

segment内容