前言
本文主要从分布式的角度,来描述ES中数据的读写过程。写过程分为底层和分片之间的交互两个角度。对于ES,很多地方提到了一个词------"近实时",为什么是"近实时"而不是"实时"呢,看完本文一定有答案。
一、集群
集群的引入
单机的Elasticsearch做数据存储,会面临两个问题:海量数据存储和单点故障。 引入集群可以解决这两个问题:
-
海量数据存储问题:做水平切分,将索引库从逻辑上拆分为N个分片(shard),存储到多个节点 。
-
单点故障问题:将分片的数据在不同节点做备份(replica) ,单点发生故障之后,其他节点对应的备份数据仍然可以使用。
现在,每个分片都有1个备份,存储在3个节点:
node-0:保存了分片0和1
node-1:保存了分片1和2
node-2:保存了分片2和0
当node-0发生故障,对应的分片0和1的数据在node-1和node-2,因此仍然可以使用。
集群核心概念
名称 | 解释 |
---|---|
集群(cluster) | 一组拥有共同的 cluster name 的 节点 |
节点(node) | 集群中的一个 Elasticearch 实例 |
分片(shard) | 索引可以被拆分为不同的部分进行存储,称为分片。在集群环境下,一个索引的不同分片可以拆分到不同的节点中。 |
主分片(Primary shard) | 文档会先被写入主分片。 |
副本分片(Replica shard) | 每个主分片可以有一个或者多个副本,数据和主分片一样。 |
节点的职责划分
-
主节点(master node):节点可以管理和记录集群状态、决定分片在哪个节点、处理创建和删除索引库的请求;
-
协调节点(coordinate node):路由请求到其它节点,合并其它节点处理的结果并返回给用户;
-
数据节点(data node):保存数据的节点,主要是存储数据、搜索、聚合、CRUD;
-
预处理节点(ingest node):数据前置处理转换的节点,支持pipeline管道设置,可以对数据进行过滤、转换等操作;
扩展阅读
真实的集群尽量将集群职责分离,不同节点对硬件的要求不同:
master节点:对CPU要求高,但是内存要求低;
data节点:对CPU和内存要求都高 ;
coordinating节点:协调节点,对网络带宽、CPU要求高 。
职责分离可以让我们根据不同节点的需求分配不同的硬件去部署,而且避免业务之间的互相干扰。
脑裂问题
脑裂是因为集群中的节点失联导致的。
(1)脑裂的产生
例如一个集群中,主节点Node 1 由于网络堵塞等原因与其它节点失联:
此时,Node 2和Node 3认为Node1宕机,重新选主:
当Node 3当选后,集群继续对外提供服务,Node 2和Node 3自成集群,Node 1自成集群,两个集群数据
不同步,出现数据差异。
当网络恢复后,因为集群中有两个master节点,集群状态的不一致,出现脑裂的情况。
(2)解决方案
解决脑裂的方案是,要求选票超过 ( eligible节点数量 + 1 )/ 2 才能当选为主,因此eligible节点数量最
好是奇数。对应配置项是discovery.zen.minimum_master_nodes,在es7.0以后,已经成为默认配
置,因此一般不会发生脑裂问题
例如:3个节点形成的集群,选票必须超过 (3 + 1) / 2 ,也就是2票。node3得到node2和node3的选
票,当选为主。node1只有自己1票,没有当选。集群中依然只有1个主节点,没有出现脑裂。
集群故障转移
集群的master节点会监控集群中的节点状态,如果发现有节点宕机,会立即将宕机节点的分片数据迁移
到其它节点,确保数据安全,这个叫做故障转移。
1)例如一个集群结构如图:
现在,Node 1是主节点,其它两个节点是从节点。
2)突然,Node 1发生了故障:
宕机后的第一件事,需要重新选主,例如选中了Node 2:
Node 2成为主节点后,会检测集群监控状态,发现:shard-1、shard-0没有副本节点。因此需要将
Node 1上的数据迁移到Node 2、Node 3
二、几个基本概念
document 和 segment
ES是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为Json格式后存储在ES中,而Json文档中往往包含很多的字段(Field),类似于数据库中的列。如下所示:
段(Segment)是ES中用于存储和管理文档的物理单元。当文档被索引时,ES将文档存储在一个或多个段中。段是不可变的,一旦创建就不会被修改,只能通过合并(merge)或删除操作来改变。Segment 内部有着许多数据结构,例如:
-
Inverted Index
-
Stored Fields:单独把指定Field拉出来存储,查询时取值速度快
内存模型---index buffer和filesystem cache
-
Indexing Buffer:存储的是文档,当其达到阈值时,会将文档写入filesystem cache中,它是ES 进程的 JVM 堆内存。
-
filesystem cache:存储Segment,可以被搜索但是不在磁盘(断电会丢失)。它是操作系统的 page cache 缓存。
refresh和flush
-
refresh:数据从堆内存进入到文件系统(操作系统缓存),此时数据从不可搜索变为可搜索状态,默认refresh是每隔一秒执行一次
-
flush:数据从文件系统到磁盘,也就是数据的持久化操作,此时数据不会丢失,除非硬盘坏了。
Lucene文件模型
Elasticsearch 存储的基本单元是分片(shard),ES 的一个分片就是一个 lucene index(与es的index不同)。lucene 实例由一个或多个 segment 构成。lucene 每次 flush 操作都会对一个 segment 进行持久化操作。
拓展:同一个段涉及到的文件前缀都是相同的 generation,它是36进制的一个数字,每次 flush 会加一。
commit point
每一个分片都有一个commit point,commit point 是一个文件,保存了当前分片中已经成功落盘的所有segment。
同时还会维护一个.del文件,用来记录被声明删除的document文件。
-
查询时,获取到的结果在返回前会经过 .del 过滤。
-
更新时,也会标记旧的 docment 被删除,写入到 .del 文件,同时会写入一个新的document。此时查询会查询到两个版本的数据,但在返回前会被移除掉旧版本的数据。
translog
为什么要有translog
Indexing Buffer和filesystem cache里面的数据断电后都会丢失,为了减少数据丢失,每当接到写请求或者修改请求的时候,就会写一份数据到translog。translog通过同步或异步两种方式将数据写入到磁盘中。两种方式的区别在于
-
同步模式:每次请求之后,translog 持久化到磁盘
-
异步模式:默认每隔5秒钟,translog 持久化到磁盘。也可以自定义间隔时间,最小值不能低于100ms。
translog的持久化和清理
- translog持久化:也就是将translog从内存写到磁盘里面,当然这个时候translog对应的数据还没有在磁盘里面(Indexing Buffer或filesystem cache),异步模式下默认translog持久化时间是5s
所以最坏情况下,机器如果发生故障,会丢失5s的数据(异步模式下)。也就是5秒内translog记录的数据刚要持久化到硬盘的时候丢失。
- translog清理:不需要translog的时候,也就是清理它的时机。根据translog的作用,可以推出清理时机------数据已经持久化flush到硬盘里面的时候,不需要translog保驾护航了。
默认的清空触发条件是:
- 时间过了30分钟
- translog内存达512mb
三、分布式文档存储---底层原理
整体过程
-
数据写到内存中的index buffer,操作被记录在translog中,translog(事务日志)记录了数据的变更,它是为了保证数据的完整性。
-
document通过refresh操作被写入文件系统缓存,并构成一个分段(segment),此时文档可以被搜索到。index buffer的内容被清空,默认每1秒进行一次refresh,也就是生成一个新的Segment。
-
当达到一定条件,文件系统缓存中缓存的所有的index segment文件被fsync强制刷到磁盘,也就是flush操作。
-
当达成过了30分钟/translog长度达到512mb等条件,触发flush操作,translog被清空,创建一个新的translog。
触发条件:
- refresh触发条件:达到最大文档数、最大内存上限,或定时1s触发, 或 REST API 请求指定refresh等。
- document os cache flush触发条件:API触发、定时30min触发等。
- translog flush触发条件:API触发、达到大小(默认512mb)、定时5s触发。
因为从index buffer(文档不可搜索)到filesystem cache(文档可以被搜索)默认有1s的时间差,所以ES的搜索被称为"近实时"。在对实时性要求高的场景,可以通过设置更小的refresh_interval
。
分步解析
这里给出每一步的磁盘和缓存区变化
(1)提交点与段
每个ES分片有一个提交点文件记录,它记录着可以被搜索到的segment,segment记录着每个term的倒排索引,磁盘内部如下:
上图中,绿色的圆柱体表示segment,commit point 通过黑实箭头指向segment表示这三个segment是已经写入磁盘的数据。
(2)新的请求
客户端发起请求,文档被写入内存中的index buffer(即图中所写in-memory buffer,下同),并且被translog记录,此时文档是不可以被搜索到的。
(3)refresh
默认每秒refresh一次:文档从 index buffer写入到 filesystem cache 里面一个新的段中,buffer内容被清空。下图中,灰色的圆柱体标识 filesystem cache中的segment,它还未写入磁盘中,但已经可以被搜索到。
(4)事务日志不断积累
更多的文档被添加到内存缓冲区和追加到事务日志,translog越来越大
(5)flush
每隔一段时间(默认30分钟)或者 translog 达到512mb,进行flush操作
-
所有在内存缓冲区的文档都被写入一个新的段,缓冲区被清空
-
文件系统的segment被持久化(通过 fsync )到硬盘
-
老的 translog 被删除
(6)段合并
refresh每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。 每一个段都会消耗文件句柄、内存和cpu运行周期。更重要的是,每个搜索请求都必须轮流检查每个段;所以段越多,搜索也就越慢。
Elasticsearch通过在后台进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。
-
刷新(refresh)操作会创建新的段并将段打开以供搜索使用。
-
合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中。这并不会中断索引和搜索。
四、分布式文档存储---分片交互
1.路由文档到分片
elasticsearch会通过hash算法来计算文档应该存储到哪个分片:
shard = hash(routing) % number_of_primary_shards
routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。 routing 通过 hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards (主分片的数量)后得到余数 ------分布在 0 到 number_of_primary_shards-1 之间,就是我们所寻求的文档所在分片的位置。
我们在创建索引的时候就确定好主分片的数量,并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。
2.新建、索引和删除文档
新建、索引和删除请求都是写操作, 必须在主分片上面完成之后才能被复制到相关的副本分片
以下是在主副分片和任何副本分片上面 成功新建,索引和删除文档所需要的步骤顺序:
-
客户端向 Node 1 发送新建、索引或者删除请求。接收到请求的节点,是这次请求的协调节点。
-
节点使用文档的 _id 确定文档属于分片 0 。请求会被转发到 Node 3,因为分片 0 的主分片目前被分配在 Node 3 上。
-
Node 3 在主分片上面执行请求。如果成功了,它将请求并行转发到 Node 1 和 Node 2 的副本分片上。一旦所有的副本分片都报告成功, Node 3 将向协调节点报告成功,协调节点向客户端报告成功。
在客户端收到成功响应时,文档变更已经在主分片和所有副本分片执行完成,变更是安全的。
3.获取单个文档
以下是从主分片或者副本分片检索文档的步骤顺序:
1、客户端向 Node 1 发送获取请求。在处理读取请求时,协调节点在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡。
2、节点使用文档的 _id 来确定文档属于分片 0 。分片 0 的副本分片存在于所有的三个节点上。 在这种情况下,它将请求转发到 Node 2 。
3、Node 2 将文档返回给 Node 1 ,然后将文档返回给客户端。
4.局部更新文档
-
客户端向 Node 1 发送更新请求。
-
它将请求转发到主分片所在的 Node 3 。
-
Node 3 从主分片检索文档,修改 _source 字段中的 JSON ,并且尝试重新索引主分片的文档。 如果文档已经被另一个进程修改,它会重试步骤 3 ,超过 retry_on_conflict 次后放弃。
-
如果 Node 3 成功地更新文档,它将新版本的文档并行转发到 Node 1 和 Node 2 上的副本分片,重新建立索引。 一旦所有副本分片都返回成功, Node 3 向协调节点也返回成功,协调节点向客户端返回成功。
五、分布式文档查询
elasticsearch的查询分成两个阶段:
Query phase
在初始查询阶段,查询将广播到索引中每个分片的分片副本(主分片或副本分片)。每个分片在本地执行搜索并构建匹配文档的优先级队列。
优先级队列是一个存有 top-n 匹配文档的有序列表,比如我们想要优先级排名在第91名---第100名的文档,那么给ES指定两个参数:from指定查询的起始位置,size表示从起始位置开始的文档数量。
优先队列的大小等于from + size 。
Java
GET /_search
{
"from": 90,
"size": 10
}
过程如下:
-
客户端发送一个 search 请求到 Node 3 ,这个节点就变成了协调节点, Node 3 会创建一个大小为 from + size 的空优先队列。
-
Node 3 将查询请求转发到索引的每个主分片或副本分片中。每个分片在本地执行查询并添加结果到大小为 from + size 的本地有序优先队列中。
扩展阅读
查询请求可以被某个主分片或某个副本分片处理, 这就是为什么更多的副本结合更多的硬件能够增加搜索吞吐率。 协调节点将在之后的请求中轮询所有的分片拷贝来分摊负载。
- 每个分片返回各自优先队列中所有文档的 ID 和排序值给协调节点,也就是 Node 3 ,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。
扩展阅读
分片返回一个轻量级的结果列表到协调节点,它仅包含文档 ID 集合以及任何排序需要用到的值,例如
_score
。协调节点将这些分片级的结果合并到自己的有序优先队列里,它代表了全局排序结果集合。
2.Fetch Phase
Fetch Phase由以下步骤构成:
-
协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。
-
每个分片加载并完善文档,如果有需要的话,接着返回文档给协调节点。
-
一旦所有的文档都被取回了,协调节点决定哪些文档需要被返回,并将最终结果返回给客户端。比如我们的查询指定了 { "from": 90, "size": 10 } 。虽然排序优先级第一到第一百名,但是最初的90个结果会被丢弃,只有从第91个开始的10个结果需要被取回。
参考: