集群的基本概念:
如下图可以看出为了达到高可用,Master 节点会避免将主分片和副本分片放在同一个节点上。
1. 写请求首先会被打到协调节点,协调节点根据路由算法决定数据写入的主分片位置,并在主节点上执行写入操作。
路由算法:
shardId = hash(_routing) % num_primary_shards
通过该公式可以保证使用相同routing的文档被分配到同一个shard上。_routing
是一个可变值,默认是文档的 _id
,也可以设置成一个自定义的值,可以用在INDEX, UPDATE,GET, SEARCH, DELETE等各种操作中手动设置。
routing_partition_size
参数
ES还提供了一个 index.routing_partition_size
参数(仅当使用routing参数时可用),用于将routing相同的文档映射到集群分片的一个子集上,这样一方面可以减少查询的分片数,另一方面又可以在一定程度上防止数据倾斜。引入该参数后计算公式如下
shardId = (hash(_routing) + hash(_id) % routing_partition_size) % num_primary_shards
2. 主分片执行写入操作,如下图S0主分片写入之后,同步到副本分片
当接收到请求时,执行如下步骤:
①判断操作类型:如果是 Bulk Request 会遍历请求中的子操作,根据不同的操作类型跳转到不同的处理逻辑。
②操作转换:将 Update 操作转换为 Index 和 Delete 操作。
③解析文档(Parse Doc):解析文档的各字段。
④更新 Mapping:如果请求中有新增字段,会根据 dynamic mapping 或 dynamic template 生成对应的 mapping,如果 mapping 中有 dynamic mapping 相关设置则按设置处理。
⑤获取 sequence Id 和 Version:从 SequenceNumberService 获取一个 SequenceID 和 Version。SequenceID 用于初始化 LocalCheckPoint,version 是根据当前 versoin+1 用于防止并发写导致数据不一致(乐观锁)。
⑥写入 Lucene:对索引文档 uid 加锁,然后判断 uid 对应的 version v2 和之前 update 转换时的 version v1 是否一致,不一致则返回第二步重新执行。在 version 一致的情况下根据id的情况执行添加或者更新操作。如果同 id 的 doc 已经存在,则调用 updateDocument 接口。
⑦写入 translog:写入 Lucene 的 Segment 后,会以 key value 的形式写 Translog, Key 是 Id,Value 是索引文档的内容。当查询的时候,如果请求的是 GetDocById 则可以直接根据 _id 从 translog 中获取。写入 translog 的操作会在下面的章节中详细讲解。
⑧重构 bulk request:已经多个操作中的 update 操作转换为 index、delete 操作,最终都以 index 或 delete 操作的方式组成 bulk request 请求。
⑨落盘 Translog:默认情况下,translog 要在此处落盘完成,如果对可靠性要求不高,可以设置 translog 异步落盘,同时存在数据有丢失的风险。
⑩发送请求给副本分片:将构造好的 bulk request 发送给各个副本分片,并且等待副本分片返回,然后再响应协调节点。如果某个分片执行失败,主分片会给主节点发请求移除该分片。
⑪等待 replica 响应:当所有的副本分片返回请求时,更新主分片的 LocalCheckPoint。如果设置了index.write.wait_for_active_shards=1,那么写完主节点,直接返回客户端,异步同步到从分片。如果 index.write.wait_for_active_shards=all,那么必须要把所有的副本写入完成才返回客户端。wait_for_active_shards最大值只能是 number_of_replicas +1 ,详情见官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/5.5/docs-index_.html#index-wait-for-active-shards
在了解了文件写入流程以后,再来近距离观察一下写入的细节,看看索引是如何通过内存最终写入磁盘的。
如图 2 所示,将每一步写入操作进行拆解如下:
①写入请求会将索引(Index)存放到内存区域,叫做 Index Buffer。这边的内存是ES JVM内存。此时的索引文件暂时是不能被ES搜索到的。
②当达到默认的时间:1 秒钟(参数refresh_interval=1s)或者内存的数据达到一定量(默认值是 JVM 所占内存容量的 10%)时,会触发一次刷新(Refresh),将内存中的数据生成到一个新的段上并缓存到文件系统缓存(FileSystem Cache)上,并将Index Buffer 的数据会被清空。这时虽然新段未被提交到磁盘,但是此时的数据就可以被 ES 搜索到了且不能被修改。
③ES 每次 refresh 都会生成一个 Segment 文件,这样下来 Segment 文件会越来越多。由于每个 Segment 都会占用文件句柄、内存、CPU 资源,假设每次搜索请求都会访问对应 Segment 获取数据,这就意味着 Segment 越多会加大搜索请求的负担,导致请求变慢。为了提高搜索性能 ES 会定期对 Segment 进行合并(Merge)操作,也就是将多个小 Segment 合并成一个 Segment。那么搜索请求就直接访问合并之后的 Segment,从而提高搜索性能。
④上面 3 个步骤都是在内存中完成的,此时数据还没有写到磁盘中。 随着新文档索引不断被写入,当translog日志数据大小超过 512M (由 index.translog.flush_threshold_size 控制)或者时间超过 30 分钟时,会触发一次 Flush。此时 ES 会创建一个 Commit Point 文件,该文件用来标识被 Flush 到磁盘上的 Segment。
延续上一节的例子,在原有的基础上加入 Translog 的部分。如图 3 所示,在整个 ES 写入流程中加入 Translog,目的是为了提高 ES 的数据存储可靠性。
索引文档最开始是存放在内存的 Index Buffer 中,当执行了 Refresh 操作会将其保存为 Segment,此时就可以供用户查询了。但是 Segment 在 Flush 之前仍然存在于内存中,如果此时服务器宕机,而 ES 还没有 Flush 操作保存在内存中的 Segment 数据将会丢失。为了提高 ES 的数据存储可靠性,引入了 Translog。在每次用户请求 Index Buffer 进行操作的时候都会写一份操作记录到 Translog 中,Translog 使用特有的机制保存到磁盘中。上一节提到的 Flush 操作,当 Flush 操作将 Segment 进行落盘同时还会将 Translog 的文件进行落盘,之后会将内存中原有的Translog 移除,在内存中会重新创建一个新的 Translog。
translog相关配置:
##translog的刷盘策略durability为request表示同步(默认),async表示异步
index.translog.durability: async
##translog刷盘间隔时间。默认为5s,不可低于100ms
index.translog.sync_interval: 120s
##超过这个大小会导致flush操作,产生新的Lucene分段。默认值为512MB
index.translog.flush_threshold_size: 1024mb
通过这种方式当断电或需要重启时,ES 不仅要根据提交点去加载已经持久化过的段,还需要工具 Translog 里的记录,把未持久化的数据重新持久化到磁盘上,避免了数据丢失的可能。
图中 Translog 存在于内存和磁盘中,分别有两个线将其相连,表示了 Translog 同步的两种方式:
在 ES 处理用户请求时追加 Translog,追加的内容就是对ES的请求操作。此时会根据配置同步或者异步的方式将操作记录追加信息保存到磁盘中。
另一种 Translog 从内存到磁盘的操作是在 Flush 发生的时候,上节中介绍过,Flush 操作会把 Segment 保存到磁盘同时还会将 Translog 的文件进行落盘。落盘以后存在与内存中的 Translog 就会被移除。
在执行文档的CRUD操作时,通过文档ID就能计算出文档存储在哪个分片中,所以只要将客户端请求转发给对应的分片(或其副本)所在的节点进行处理即可。
而执行文档搜索操作时只会提供搜索条件,并不包含文档ID,无法直接知道哪些分片包含搜索结果,需要先搜索所有的分片(或其副本),还要对每个分片上的结果进行汇总以得到最终结果。所以,文档的搜索过程会分为如下两步执行:
搜索所有的分片(或其副本),确定哪些文档属于搜索结果;
从相关分片上读取文档数据,再汇总组装成最终的结果;
将第一步称为query,第二步称为fetch,整个搜索过程称为query-then-fetch。
以分页查询为例,from + size 分页方式是 ES 最基本的分页方式,类似于关系型数据库中的 limit 方式。from 参数表示:分页起始位置;size 参数表示:每页获取数据条数。例如:
{
"query": {
"match_all": {}
},
"from": 10,
"size": 20
}
如上图所示,Query 阶段大致分为 3 步:
第一步:Client 发送查询请求到 Server 端,Node1 接收到请求然后创建一个大小为 from + size的优先级队列用来存放结果,此时 Node1 被称为 coordinating node(协调节点);
第二步:Node1 将请求广播到涉及的 shard (主分片或副本分片,根据随机或轮询算法)上,每个 shard 内部执行搜索请求,然后将执行结果存到自己内部的大小同样为 from+size 的优先级队列里;
第三步:每个 shard 将暂存的自身优先级队列里的结果返给 Node1,Node1 拿到所有 shard 返回的结果后,对结果进行一次合并,产生一个全局的优先级队列,存在 Node1 的优先级队列中。(如上图中,Node1 会拿到 (from + size) * 6 条数据,这些数据只包含 doc 的唯一标识_id 和用于排序的_score,然后 Node1 会对这些数据合并排序,选择前 from + size 条数据存到优先级队列);
如上图所示,当 Query 阶段结束后立马进入 Fetch 阶段,Fetch 阶段也分为 3 步:
第一步:Node1 根据刚才合并后保存在优先级队列中的 from+size 条数据的 id 集合,发送请求到对应的 shard 上查询 doc 数据详情;
第二步:各 shard 接收到查询请求后,查询到对应的数据详情并返回为 Node1;(Node1 中的优先级队列中保存了 from + size 条数据的_id,但是在 Fetch 阶段并不需要取回所有数据,只需要取回从 from 到 from + size 之间的 size 条数据详情即可,这 size 条数据可能在同一个 shard 也可能在不同的 shard,因此 Node1 使用 multi-get 来提高性能)
第三步:Node1 获取到对应的分页数据后,返回给 Client;
索引文件分段存储并且不可修改,那么新增、更新和删除如何处理呢?
删除操作 :commit
的时候会生成一个 .del
文件,里面将某个 doc
标识为 deleted
状态,那么搜索的时候根据 .del
文件就知道这个 doc 是否被删除了,最终结果被返回前从结果集中移除。
更新操作 :就是将原来的 doc
标识为 deleted
状态,然后新写入一条数据。
merge
操作时会将多个 segment file
合并成一个,同时将标识为 deleted
的 doc
「物理删除」 ,将新的 segment file
写入磁盘,最后打上 commit point
标识所有新的 segment file
。