Elasticsearch分布式存储的写流程
ES作为一个分布式存储组件,它的写流程是什么样子的?为什么说它是近实时搜索?什么会影响它的写性能?我们有必要了解。
ES文档的写流程
写的动作包含新建文档、更新文档(全部覆盖和部分更新 略有不同)、删除文档等操作。
先简单描述整体流程,后边细化:
- 客户端写请求打到ES的协调节点,协调节点路由去找该文档所属主分片
- 主分片新建或更新(更新实际是检索+新建文档)
- 主分片同步给其他副本,等待成功
- 主分片响应给协调节点
- 协调节点响应给客户端
- 文档的refresh和flush(暂且放在这步)
一个新建文档流程图:
路由到哪个分片
分布式存储组件都会有路由环节,去找某个文档或某个消息所属存储区域。一个简单的方式,hash指定key,再对分片数取模。ES也正是这样干的。
shard = hash(routing) % number_of_primary_shards
routing 是一个可变值,默认是文档的 _id,也可以设置一个自定义的值。
默认路由,且不指定文档id
学习这里的时候产生这样一个疑问,如果不指定文档id,那么ES是在哪里给我们生成的_id?
我们知道如果不自定义路由,协调节点就要根据_id计算出路由到哪个分片,所以它要先把_id生成出来。因此我猜测,是协调节点计算出来的文档id,而不是主分片。
POST /qmgroup/groupinfo/
{
"data": "auto gen _id"
}
自定义路由
自定义路由参数可以用来确保所有相关的文档(例如所有属于同一个用户的文档)都被存储到同一个分片中。这种方式对单用户的聚合操作比较方便。
PUT index/type/userid?routing=userid
{
"data": "routing userid"
}
主分片和副本分片的交互
主分片写成功后,同步给其他副本。这里有几个问题值得关注:
- 同步的数据内容是什么?写语句还是文档本身?
- 同步给副本的一致性问题
同步数据是文档本身,而非改语句
ES同步副本是基于文档的复制,而不是写语句。
这些更改是异步转发到副本分片,并且不能保证它们以发送它们相同的顺序到达(并发更新的场景)。如果Elasticsearch仅转发更改请求,则可能以错误的顺序应用更改,导致得到损坏的文档。基于文档复制,且文档中带有_version版本,小版本过来的会被忽略。
一致性问题
分布式组件的一致性问题:同步副本时,难免会有网络问题、宕机问题等造成的同步失败。那么ES怎么处理的?
在默认设置下,执行一个写操作之前,主分片都会要求必须要有规定数量(即必须要有大多数)的分片副本处于活跃可用状态,才会去执行写操作(分片副本包含主分片和其他副本)。这是为了避免在发生网络分区故障而导致的数据不一致问题。大多数是指总数/2+1:
int( (primary + number_of_replicas) / 2 ) + 1
consistency 参数的值:
参数值 | 含义 |
---|---|
one | 只要主分片状态ok |
all | 必须要主分片和所有副本分片的状态没问题 |
quorum | 默认值为quorum, 即大多数的分片副本状态没问题 |
ES采用的也是最终一致性,主分片和副本有对外不一致的窗口。比如不同副本所属不同机器进程,刷新(使文档可被检索的一个动作)频率和时间不同。
更新文档的流程
更新和新建、删除不同,值得说一下。以下是部分更新流程:
- 客户端向协调节点 Node 1发送更新请求。
- 它将请求转发到主分片所在的Node 3。
- Node 3 从主分片检索文档,修改 _source 字段中的 JSON ,并且尝试重新索引主分片的文档。 如果文档已经被另一个进程修改,它会重试步骤 3 ,超过 retry_on_conflict 次后放弃。
- 如果 Node 3 成功地更新文档,它将新版本的文档并行转发到 Node 1 和 Node 2 上的副本分片,重新建立索引。 一旦所有副本分片都返回成功, Node 3 向协调节点也返回成功,协调节点向客户端返回成功。
ES的文档是不可变的,不能被修改,只能被替换。从外部来看,我们在一个文档的某个位置进行部分更新。然而在内部,要经过如下理过程:
- 从旧文档构建 JSON
- 更改该 JSON
- 删除旧文档 (ES不会立即删除,先加删除标记,后台清理)
- 索引一个新文档
并发写问题
我们了解MySQL事务处理,如果根据主键更新某一行,数据库先给索引加上行锁,然后再更新,释放锁。ES并非采用悲观锁的方式,而是基于版本控制的乐观锁处理。
每个文档都有一个 _version,当文档被修改时版本号递增。 Elasticsearch 使用这个 _version来确保变更以正确顺序得到执行。如果旧版本的文档在新版本之后到达,它可以被简单的忽略。所有文档的更新或删除 API,都可以接受 version 参数,即乐观锁的并发控制。
PUT /website/blog/1?version=1
{
"title": "My first blog entry",
"text": "Starting to get the hang of this..."
}
如果源数据在读写当中被修改,更新将会失败(如上version当前是1才会成功)。把问题抛给用户,接下来用户来决定该如何解决冲突。 例如,可以重试更新、使用新的数据、或者将相关情况报告给用户。
如果版本号是从外部系统导入的,ES同样也支持。处理略有不同:Elasticsearch 不是检查当前 _version 和请求中指定的版本号是否相同,而是检查当前 _version 是否小于 指定的版本号。 如果请求成功,外部的版本号作为文档的新 _version 进行存储。
# 需要增加 version_type=external
PUT /website/blog/2?version=5&version_type=external
{
"title": "My first external blog entry",
"text": "Starting to get the hang of this..."
}
锁
除了基于版本号的乐观锁,检索旧文档后,在添加新文档时,Lucene底层仍然要加锁处理。
分片内部原理
了解分片内部原理,要理清几个概念及其关系如 ES的索引、ES的分片、Lucene索引和Segment的关系。再看下ES内部组件的概念架构:
ES的索引是各分片的集合,一个ES分片的底层即为一个Lucene索引,Lucene索引是段的集合。
延迟删除
前边介绍了文档的更新,其实是重新生成的新文档,旧文档标记的删除。我们看下背后是如何实现的:
段或者文档是不可改变的,所以既不能把文档从旧的段中移除,也不能修改旧的段来进行反映文档的更新。ES 在每个提交点会包含一个 .del 文件,文件中会列出这些被删除文档的段信息。
当一个文档被“删除”时,它实际上只是在 .del文件中被标记删除。一个被标记删除的文档仍然可以被查询匹配到,但它会在最终结果被返回前从结果集中移除。
文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。
近实时搜索
在说这之前,先了解下分片内部三个区域:
-
内存buffer
ES优先写入的区域,数据在内存。此时不能被检索。 如图:
-
文件系统缓冲区
refresh 到os cache,数据还是在内存。但此时可以被检索到。如图:
-
磁盘
flush 到磁盘,可以被检索。如图:
refresh
在Elasticsearch和磁盘之间是文件系统缓存,在内存索引缓冲区中的文档会被写入到一个新的段中,但是这里新段会被先写入到文件系统缓存(这一步代价会比较低),稍后再被刷新到磁盘(这一步代价比较高)。不过只要文件已经在缓存中,就可以像其它文件一样被打开和读取了。
在 Elasticsearch 中,写入和打开一个新段的轻量的过程叫做 refresh。默认情况下每个分片会每秒自动refresh一次。这就是为什么我们说 Elasticsearch是近实时搜索,文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。
提升写性能的最佳实践
- 并不是所有的情况都需要默认的每秒刷新。
比如我们后端系统的业务日志,使用Elasticsearch 索引大量的日志文件,我们更关心优化索引速度而不是近实时搜索(不着急马上查日志), 可以通过设置 refresh_interval , 降低每个索引的刷新频率:
PUT /my_logs
{
"settings": {
"refresh_interval": "30s"
}
}
- 新建一个大索引,可以先关闭自动刷新
在生产环境中,正在建立一个大的新索引时,可以先关闭自动刷新,待开始使用该索引时,再把它们调回来。可以大大提升写性能。
PUT /my_logs/_settings
{ "refresh_interval": -1 }
PUT /my_logs/_settings
{ "refresh_interval": "1s" }
持久化 事务日志
类比MySQL,怎么保证崩溃后的数据恢复?数据不丢呢?MySQL采用了redo log日志机制。ES同样,也有一个事务日志即 translog,每次写操作都会追加到该日志。
为什么要有事务日志?
如上介绍,一次完整的提交会将段刷到磁盘,并写入一个包含所有段列表的提交点。Elasticsearch 在启动或重新打开一个索引的过程中使用这个提交点来判断哪些段隶属于当前分片。那如果下一次提交前崩溃了,在内存buffer或者文件缓冲区的数据就丢失了。因此引入了事务日志。
流程
-
文档写入内存buffer,并追加到 translog
-
refresh时,会把这些在内存缓冲区的文档写入到一个新的段中,且没有进行 fsync 操作。内存缓冲区也被清空,translog不会被清。(这个段被打开,可被搜索。)
-
越来越多的文档被添加到内存缓冲区和追加到事务日志
-
每隔一段时间(或者translog足够大),索引被flush;一个新的 translog 被创建,并且一个全量提交被执行
- 所有在内存缓冲区的文档都被写入一个新的段。
- 缓冲区被清空。
- 一个提交点被写入硬盘。
- 文件系统缓存通过 fsync 被刷新(flush)。
- 老的 translog 被删除。
类似MySQL的redo log,translog 提供所有还没有被刷到磁盘的操作的一个持久化纪录。当 Elasticsearch 启动的时候,它会从磁盘中使用最后一个提交点去恢复已知的段,并且会重放 translog 中所有在最后一次提交后发生的变更操作。
段合并
refresh 流程默认每秒执行一次创建一个新的段,这会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦:每一个段都会消耗文件句柄、内存和cpu运行周期。更重要的是,每个搜索请求都必须轮流检查每个段,所以段越多,搜索也就越慢。
Elasticsearch后台会进行段合并来解决这个问题:小的段被合并到大的段,然后这些大的段再被合并到更大的段。
段合并的时候会将那些旧的已删除文档从文件系统中清除,被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。合并后,再删除老的段。