文档读写细节
date
Jul 27, 2021
slug
elasticsearch-guide-how-docs-read-write
status
Published
tags
Elasticsearch
读书
summary
type
Page
搜索读取的过程
集群中处理一个搜索请求,内部都发生了什么

请求发送到 node3,此时 node3 被称为协调节点 coordinating node。协调节点负责将请求广播给其他节点上的分片,并收集各节点的查询结果。
- 协调节点首先在本地创建一个优先队列 priority queue,用来等会存放各个分片的结果。
- 各其他节点上的分片接到请求,也都创建一个优先队列,存放 top100 的结果,之后返回给协调节点。注意这里返回的仅仅是文档 id。
- 协调节点合并各个分片的结果到自己的优先队列中,用 score 评判要留下哪些文档。
- 协调节点确认文档 id 之后,再次向文档所在的分片上发出 get 请求,获取具体的文档内容。
深度翻页问题 Deep Pagination
注意这里的 from + size 的形式跟 RDB 中有同样的问题,即深度翻页 Deep Pagination 导致性能降低。因为 Elasticsearch 需要创建 (from+1) * size * number_of_shards 长度的优先队列,来存放文档 id,并且还要对其排序,这对 CPU、内存、带宽都是巨大的压力。
使用游标 scroll 实现深度翻页
深度翻页最大的成本是优先队列的存储和排序,scroll 能够有效降低这部分成本。在查询中带上 scroll 参数并指定一个时间,并按照 _doc 来排序而不是按照 score 排序,可以开启一个查询快照,这个快照在这段时间内的查询都会从快照中查询
标记删除
前面提到可以删除或者修改文档,Elasticsearch 的删除是标记删除,等到段合并时,会移除掉被标记删除的文档。
而对于修改来说,Elasticsearch 也不会在原文档上做修改,而是获取旧文档内容,新增一个新文档,并标记删除旧文档,具体而言则是:
- 从旧文档构建 JSON
- 更改该 JSON
- 删除旧文档
- 索引一个新文档
修改时的并发控制:乐观锁
在分布式系统中,一系列连续的有顺序的操作可能会并发地在集群中被处理,并发处理就会导致顺序会被打乱
使用 _version 版本号做乐观锁控制
Elasticsearch 通过文档中的 _version 字段来保证顺序性,文档每修改一次,_version 就加 1。
在修改请求中可以指定版本号,表明只在版本号匹配时才执行修改,如果不匹配,则返回错误,由业务自己决定如何做。
使用 _seq_no 和 _primary_term 替代 _version
在 Elasticsearch 6.0 版本中,_version 的 OCC(Optimistic Concurrency Control)功能被 _seq_no 和 _primary_term 属性取代了,作用也是做版本控制,参考
使用外部传入的版本号
另外可以不使用 Elasticsearch 自带的 version,可以自定义一个版本号,只要修改请求中的带的 version 大于当前文档的 version 即可修改成功。自定义版本号可以来自于其他存储,如发号器,RDB 的修改时间等等。
如果传入一个比当前 version 小的版本号,则同样会得到一个 version_conflict_engine_exception 错误。
部分修改
前面提到,Elasticsearch 的文档不可变,修改都是查询旧文档,复制成新文档并修改,将旧文档标记删除。但可以用 _update URI 做部分请求:
使用 upsert 实现文档不存在则新增,存在则更新,例如在做计数器的场景。
文档的路由 routing
如何确定一个文档应该存在哪个分片中?通过下面公式计算可知:
shard = hash(routing) % number_of_primary_shards
routing
是一个可变值,默认是文档的 _id
,也可以设置成一个自定义的值。 routing
通过 hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards
(主分片的数量)后得到 余数 。这个分布在 0
到 number_of_primary_shards-1
之间的余数,就是我们所寻求的文档所在分片的位置。这就解释了为什么我们要在创建索引的时候就确定好主分片的数量并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。
主分片和副本分片的交互过程
假设一个集群中有 3 个节点,其中有 2 个主分片,每个主分片有 2 个副本:

我们可以发送请求到集群中的任一节点。 每个节点都有能力处理任意请求。 每个节点都知道集群中任一文档位置,所以可以直接将请求转发到需要的节点上。
我们直接将请求发送到 node1 上,称其为协调节点 coordinating node。
但一般来说发送请求的时候, 为了扩展负载,更好的做法是轮询集群中所有的节点。
新建、索引和删除文档

新建、索引和删除请求都是写操作, 必须在主分片上面完成之后才能被复制到相关的副本分片:
- 客户端向
Node 1
发送新建、索引或者删除请求。
- 节点使用文档的
_id
确定文档属于分片 0 。请求会被转发到Node 3
,因为分片 0 的主分片目前被分配在Node 3
上。
Node 3
在主分片上面执行请求。如果成功了,它将请求并行转发到Node 1
和Node 2
的副本分片上。一旦所有的副本分片都报告成功,Node 3
将向协调节点报告成功,协调节点向客户端报告成功。
影响其过程的因素:
consistency:
为了确保主分片和副本之间能够确保数据一致,允许写入之前需要满足某些条件,有三种可选值:
- one:只要主分片状态 ok 就允许写入
- all:必须要求所有主分片和副本状态 ok 才允许写入
- quorum:有超半数的分片数状态 ok 才允许写入,计算公式为:
int( (primary + number_of_replicas) / 2 ) + 1
。由于默认副本数是 1,1 个主分片和 1 个副本分片之间无法满足超过半数的要求,所以这个要求只在 number_of_replicas 大于 1时才会生效。
timeout:
如果没有足够的副本分片,Elasticsearch 会等待出现更多分片,等待时长由 timeout 控制。
取回文档:

- 客户端向
Node 1
发送获取请求。
- 节点使用文档的
_id
来确定文档属于分片0
。分片0
的副本分片存在于所有的三个节点上。 在当前例子中,它将请求转发到Node 2
。
Node 2
将文档返回给Node 1
,然后将文档返回给客户端。
在处理读取请求时,协调结点在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡。
局部更新:

- 客户端向
Node 1
发送更新请求。
- 它将请求转发到主分片所在的
Node 3
。
Node 3
从主分片检索文档,修改_source
字段中的 JSON ,并且尝试重新索引主分片的文档。 如果文档已经被另一个进程修改,它会重试步骤 3 ,超过retry_on_conflict
次后放弃。
- 如果
Node 3
成功地更新文档,它将新版本的文档并行转发到Node 1
和Node 2
上的副本分片,重新建立索引。 一旦所有副本分片都返回成功,Node 3
向协调节点也返回成功,协调节点向客户端返回成功。
复制的是修改后的完整文档
当主分片把更改转发到副本分片时, 它不会转发更新请求。 相反,它转发完整文档的新版本,即直接把修改后的完整文档复制过去。因为这些修改将会异步转发到副本分片,并且不能保证它们以发送它们相同的顺序到达。 如果 Elasticsearch 仅转发更改请求,则可能以错误的顺序应用更改,导致得到损坏的文档。
多文档查询
使用 mget 操作过程:

1. 客户端向
Node 1
发送 mget
请求。
2. Node 1
为每个分片构建多文档获取请求,然后并行转发这些请求到托管在每个所需的主分片或者副本分片的节点上。一旦收到所有答复, Node 1
构建响应并将其返回给客户端。使用 bulk 操作过程:

1. 客户端向
Node 1
发送 bulk
请求。
2. Node 1
为每个节点创建一个批量请求,并将这些请求并行转发到每个包含主分片的节点主机。
3. 主分片一个接一个按顺序执行每个操作。当每个操作成功时,主分片并行转发新文档(或删除)到副本分片,然后执行下一个操作。 一旦所有的副本分片报告所有操作成功,该节点将向协调节点报告成功,协调节点将这些响应收集整理并返回给客户端。为什么要使用 ndjson 格式?
因为如果使用 json,需要 Elasticsearch 接到参数后解析 json,转成数组,序列化成内部格式等等问题。相反,Elasticsearch 可以直接读取被网络缓冲区接收的原始数据。 它使用换行符字符来识别和解析小的 action/metadata 行来决定哪个分片应该处理每个请求。
这些原始请求会被直接转发到正确的分片。没有冗余的数据复制,没有浪费的数据结构。整个请求尽可能在最小的内存中处理。