Elasticsearch学习系列(二)——数据输入输出、集群内的原理

238 阅读14分钟

上一期学习了ES的基本使用,这一期从数据的输入输出、数据一致性、分布式三方面深入了解一下ES

一、数据输入输出

在上篇文章中介绍了ES简单的增删改查,了解了这个过程中ES的数据输入形式,那么ES数据存储以及输出形式是怎样的,在输入过程中常能碰到冲突的场景,ES又是怎么处理的呢?这节会做详细的介绍。

1.1 ES中存储的数据的形式是什么

ES数据是以文档的形式进行存储的,类似mysql数据库中的行。那么什么是文档?在大多数应用中,多数实体或对象可以被序列化为包含键值对的 JSON 对象,我们理解的对象概念是可以有嵌套关系的,即一个对象可以包含其他对象,在 Elasticsearch 中,术语“文档”有着特定的含义。它是指最顶层或者根对象,这个根对象被序列化成 JSON 并存储到 Elasticsearch 中,指定了唯一 ID。比如下面这个例子,整体 JSON 对象就是ES中的一个文档,其中包含了home、accounts这两个对象

{
    "name":         "John Smith",
    "age":          42,
    "confirmed":    true,
    "join_date":    "2014-06-01",
    "home": {
        "lat":      51.5,
        "lon":      0.1
    },
    "accounts": [
        {
            "type": "facebook",
            "id":   "johnsmith"
        },
        {
            "type": "twitter",
            "id":   "johnsmith"
        }
    ]
}

1.2 ES数据输出形式

"took": 55, // 请求时间(单位ms)
"timed_out": false, // 是否超时① 
"_shards": {
    "total": 1, // 查询分片数量
    "successful": 1, // 成功返回结果分片数量
    "skipped": 0, // 预过滤分片数
    "failed": 0 // 返回结果失败分片数量
},
"hits": {
    "total": {
        "value": 2906, // 查询到的数据数量
        "relation": "eq" // ②
    },
    "max_score": 1.0, // 查询到数据最高相关分数
    "hits": [
        {
            "_index": "community_train_process_config_search", // 索引
            "_type": "_doc", // 类型(ES7之后默认为_doc)
            "_id": "5481-0-7145846629847810092", // 唯一标识
            "_version": 1, // 版本号
            "_score": 1.0, // 相关分数
            "_source": {
                // 内容(与输入数据一致的 JSON 对象)
            }
        },
    ]
}

:默认请求是没有超时限制的。设置请求超时时间GET /_search?timeout=10ms,应当注意的是,假如请求需要100ms但是设置了10ms超时限制, 超时并不是停止执行查询,它仅仅是告知正在协调的节点返回到目前为止收集的结果并且关闭连接,并且将这些信息返回给调用者。

:relation字段如果是"eq"表示value字段获取的数据是准确数量,ES默认搜索数据量超过10000不获取精确的命中数量,此时relation字段是"gte"。这个数量级已经能够满足绝大部分场景需求,当然也可以通过设置track_total_hits为true来表明我们需要知道这次请求命中的精确数量即便超过了10000条,但同时带来的将是不小的性能损耗。同理,当我们不需要知道请求命中的精确数量时,可以设置track_total_hits为false来提高搜索的性能。

1.3 数据输入冲突处理

1.3.1 冲突处理方式

在高并发数据写入的场景中,写入冲突是很常见的。在关系型数据库中,悲观并发控制这种方法被广泛使用,它假定有变更冲突可能发生,因此阻塞访问资源以防止冲突。 一个典型的例子是读取一行数据之前先将其锁住,确保只有拿到锁的线程能够对这行数据进行修改。

Elasticsearch* 中使用的是乐观并发控制来处理写入冲突的情况*,当写入冲突出现时不会阻塞正在尝试的操作,而是更新将会失败,将失败的情况返回给调用方,由调用方决定接下来该如何解决冲突。 例如,可以重试更新、使用新的数据、或者将相关情况反馈给用户。

1.3.2 乐观并发控制的实现

在ES中每个文档都有一个 _version (版本号),if_seq_no(序列号),if_primary_term(主分片号)。当文档被修改时_version和if_seq_no会递增,_version和if_seq_no是_version是针对于单个文档,当前文档改动时_version才会增加,if_seq_no针对于整个索引号,索引中的每个文档改动时该序列号都会增加,if_primary_term标记了当前文档所在的主分片。

1.3.2.1 内部版本号控制

内部版本号控制主要应用于要将当前修改应用到指定版本的场景,使用if_seq_no和if_primary_term参数来实现,实现语句示例如下

// 写入数据指定序列号和主分片号
POST tl_test3/_doc/1?if_seq_no=3&if_primary_term=2

1.3.2.2 外部版本号控制

外部版本号控制主要应用于控制当前文档的修改顺序。Elasticsearch 所有写入操作都支持指定_version版本号,ES使用这个_version版本号来确保变更以正确顺序得到执行。如果旧版本的文档在新版本之后到达,则请求将会失败。Optimistic concurrency control | Elasticsearch Guide [8.5] | Elastic。我们可以利用这一特性来保证数据的写入顺序性。

// 写入数据指定版本号示例
POST tl_test3/_doc/1?version=14&version_type=external

注:内部版本号和外部版本号控制的区别就是,内部版本号控制是当且仅当序列号if_seq_no和主分片号if_primary_term与输出参数一致时才会执行,外部版本号控制是只要版本号_version大于等于es中当前版本号时就能够执行成功。

二、集群内的原理

2.1 分片数据写入过程

分片是ES中最小的工作单元,在之前的创建索引的时候我们也配置过,每个索引可以包含多个分片,这些分片也存储了这个索引的所有数据,而每个分片是一个Lucene索引结构。我们先用一张图了解下 ES 的整体存储架构图,方便后面内容的理解。

image

在这一节中会介绍

  • Elasticsearch 是怎样保证更新被持久化在断电时也不丢失数据?

  • 为什么搜索是 * *实时的?

  • 为什么删除文档不会立刻释放空间?

  • refresh,flush,和optimizeAPI 都做了什么,什么情况下应该使用他们?

先来看一下ES分片写入数据的整个过程

①:写入translog

为了保证写入不丢失,保证写入的可靠性,Elasticsearch增加了一个 translog ,或者叫事务日志,在每一次对 Elasticsearch 进行操作时均进行了日志记录。默认在每次请求时都进行一次fsync操作将 translog 日志写入到磁盘中,这个写入过程在主分片和副本分片中都会进行。并且在整个请求被fsync到主分片和副本分片磁盘之前,写入请求不会收到200OK的响应,这也保证了每次数据写入不丢失。

为了保证数据写入稳定性,在每次请求时对 translog 都执行一个fsync虽然实践表明这种损失相对较小,但确实会带来一些性能损失。当然如果我们在对数据准确性要求并不强的一些场合,可以调整fsync的频率,如调整到5s执行一次fsync,能够减少每次写入操作的性能消耗。

PUT /my_index/_settings
{
    "index.translog.durability": "async",
    "index.translog.sync_interval": "5s"
}

并且ES也提供了手动刷新的能力,让我们可以手动将日志刷新到磁盘中(flushAPI)。

POST /my_index/_flush

②:写入内存缓冲区(不可搜)

数据开始写入内存缓冲区,为了提高写入性能,一次请求的数据会被先放到内存缓冲区中,默认1s之后这些数据才会被统一写入到内存,在内存缓冲区这段时间这些数据是不可搜状态,这也是ES是 实时搜索的原因。当然,我们也可以调整这个配置,当我们不需要及时搜索到数据效果,可以通过延长刷新到内存的时间来提高写入性能;当我们需要数据实时搜索性较高时,可以缩短刷新到内存的时间满足我们的需求。

PUT /my_index
{
  "settings": {
    "refresh_interval": "30s"  // 调整数据刷新到内存的时间为30s
  }
}

当我们面对很多场景,不同场景对数据实时性要求不一致时,这时ES也提供了手动刷新的方法,可以针对高实时性场景进行手动刷新,从而可以不用统一调整,在数据实时性要求不高的情况下也降低性能(refreshAPI)。

POST /my_index/_refresh

③:在内存中创建新的段并写入数据

默认每秒数据从内存缓冲区刷新到内存的时候会创建一个新的段,这样会导致短时间内的段数量暴增。段数目太多会带来较大的麻烦。每一个段都会消耗文件句柄、内存和cpu调度。更重要的是,每个搜索请求都必须轮流检查每个段;所以段越多,搜索也就越慢。

为什么要新增加一个段?

为了保证写入时的高性能,在ES中每个段都是一个单独的倒排索引,在增加内容的时候直接增加一个新的段,而不是对之前的段进行倒排索引重构(性能消耗很高)。

同理,在文件删除的时候也不会对倒排索引的结构做改动,而是去标记改文档被删除的状态,这些文档虽然可以被搜索出来,但是会在汇总搜索集的时候进行过滤,因此文件删除时ES不会立刻释放磁盘空间

ES的解决方式

Elasticsearch通过在后台进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。段合并的时候会将那些旧的已删除文档从文件系统中清除。被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。

2.2 分布式存储

2.2.1 路由一个文档到一个分片中

当索引一个文档的时候,文档会被存储到一个主分片中。Elasticsearch 如何知道一个文档应该存放到哪个分片中呢?当我们创建文档时,它如何决定这个文档应当被存储在分片1还是分片2中呢?实际上,这个过程是根据下面这个公式决定的:

shard = hash(routing) % number_of_primary_shards

routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值,所有的文档 API( get 、 index 、 delete 、 bulk 、 update 以及 mget )都接受一个叫做 routing 的路由参数 ,通过这个参数我们可以自定义文档到分片的映射。 number_of_primary_shards 是主分片的数量。

这就解释了为什么主分片的数量为什么是静态资源设定后不支持修改,因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。

2.2.2 新建、更新和删除文档

以下是在主分片和任何副本分片上面成功新建,更新和删除文档所需要的步骤顺序:

  1. 客户端向master 1发送新建、更新或者删除请求。

  2. 节点使用文档的_id确定文档属于分片 0 。因为分片 0 的主分片目前被分配在Node 3上,请求会被转发到 Node 3。

  3. Node 3在主分片上面执行请求。如果成功了,它将请求并行转发到master 1和Node 2的副本分片上。一旦所有的副本分片都报告成功,Node 3将向master1节点报告成功,master1节点向客户端反馈成功。

2.3 分布式检索

2.3.1 取回一个文档

  1. 客户端向Node 1发送获取请求。

  2. 节点使用文档的_id来确定文档属于分片0。分片0的副本分片存在于所有的三个节点上。在这种情况下,它将请求转发到Node 2(检索的时候查询主分片和副本分片都可以,具体查询哪个分片和分片所在节点的负载情况相关)。

  3. Node 2将文档返回给Node 1,然后将文档返回给客户端。

在文档被检索时,已经被索引的文档可能已经存在于主分片上但是还没有复制到副本分片。在这种情况下,副本分片可能会报告文档不存在,但是主分片可能成功返回文档。具体场景需要根据业务方的调用场景决定。

2.3.2 批量查询

  1. 客户端发送一个search请求到master 1。

  2. master 1根据各个节点的负载情况进行调度,将查询请求转发到索引的每个主分片或副本分片上。每个分片在当前分片内执行查询并添加结果到大小为from + size的本地有序队列中。

  3. 每个分片返回各自队列中所有文档的ID和排序值给master 1,master 1会根据各个分片返回的结果进行全局排序后产生最终的结果列表返回给客户端。

2.4 集群

2.4.1 概念

一台运行中的 Elasticsearch 实例称为一个节点,集群是由一个或者多个拥有相同 cluster.name 配置的节点组成,它们共同承担数据和负载的压力。当有节点加入集群中或者从集群中移除节点时,集群将会重新平均分布所有的数据。

当一个节点被选举成为节点时,将负责管理集群范围内的所有变更,例如增加、删除索引,或者增加、删除节点等。任何节点都可以成为主节点。

我们可以将请求发送到集群中的任何节点 ,包括主节点。 每个节点都知道任意文档所处的位置,并且能够将我们的请求直接转发到存储我们所需文档的节点。 无论我们将请求发送到哪个节点,它都能负责从各个包含我们所需文档的节点收集回数据,并将最终结果返回给客户端。

2.4.2 集群健康

// 查询
GET /_cluster/health

// 返回结果
{
  "cluster_name" : "byte.es.demo_21_7",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 6,
  "number_of_data_nodes" : 3,
  "active_primary_shards" : 5519,
  "active_shards" : 11005,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}

①:status字段指示着当前集群在总体上是否工作正常,包含以下三个数值

  • green**:**所有的主分片和副本分片都正常运行。

  • yellow**:**所有的主分片都正常运行,但不是所有的副本分片都正常运行。

  • red**:**有主分片没能正常运行。

比如我们想创建一个有3个主分片1个副本分片的索引,而集群中只有一个节点,此时这个节点也只会包含3个设定的主分片(不会创建副本分片,因为只有一个节点,节点挂了集群也就挂了,副本分片存在不会有任何作用),此时集群status会展示为yellow。

如果我们将节点扩展到两个(主分片并不会都被分配到一个节点上),此时集群status会展示为green。

2.4.3 水平扩容

以上述3主分片1副本分片两个节点的集群为例。

主分片数:3->3

副本分片数:1->1

节点数:2->3

如上述集群概念中所描述的,当集群进行水平扩容时集群将会重新平均分布所有的数据。此时我们的搜索性能也有了一定的提高。

当集群中某个节点出现故障的时候,比如主节点master发生故障后发生的第一件事情就是选举一个新的主节点: Node 2。我们可以看到因为主节点master挂了之后主分片P1和P2也不能使用了,此时集群的健康状态就会变为red。此时,因为节点Node 2和Node 3中包含了主分片P1和P2的副本分片R1和R2,此时主节点会立即将Node 2和Node 3上对应的副本分片提升为主分片,这个提升主分片的过程是瞬间发生的,如同按下一个开关一般, 此时集群的状态将会为 yellow。

参考文档:

集群内的原理 | Elasticsearch: 权威指南 | Elastic