ElasticSearch搭建集群

构建集群的节点

部署的时候集群节点无非就两种,一种是计算机 / 服务的数量不够,通过单个节点承担多个角色的方式来搭建索引;另外一种就是让单个节点承担单个角色。
对于第一种的情况其实没啥好介绍的,看着来就行了,毕竟资源受限最多也就只能那样了;而针对第二个情况,在资源的分配上可以大概参考以下的图表,配置不同服务节点所在的计算机等。
image-20241010155119682

集群架构

基础的架构

基本架构.png

基础架构非常的简单,其实就是在client获取到请求要求之后,首先通过负载均衡器先对请求做负载均衡然后再决定请求本身的处理落实到哪一个协调节点上。
要实现ES的负载均衡效果很简单,也是跟我们一般的服务实现负载均衡的方式相似的,这里可以通过 Nginx 或者是 HAProxy等方式实现负载均衡的效果。

http {
  upstream elasticsearch {
    server es-node1:9200;
    server es-node2:9200;
    server es-node3:9200;
  }

  server {
    listen 80;
    location / {
      proxy_pass http://elasticsearch;
      proxy_http_version 1.1;
      proxy_set_header Connection "Keep-Alive";
      proxy_set_header Proxy-Connection "Keep-Alive";
    }
  }
}

读写分离架构

读写分离.png

读写分离架构其实也非常简单,其实就是直接构建起两套不停的服务,使其中一套作为写请求处理;另外一套则是用来处理读取请求。(这种架构在ES之中似乎并不常见,查也查不到多少相关资料)

多机房的异地多活架构

异地多活.png

这种基本就是有钱,很少见实际上会用上这种级别的架构介绍的,如果真的有相关的需要可以参考官方之中的介绍。CCR实现方案:Bi-directional replication with Elasticsearch cross-cluster replication (CCR) | Elastic Blog
具体官方介绍:Cross-cluster replication | Elasticsearch Guide 7.13 Elastic

ElasticSearch集群问题

在学习分布式事务的时候,里面提到有两种集群实现规则,第一种是有master控制的集群类型;而第二种则是没有master控制的, 而是类似于投票的方式实现的控制。这一点在分布式服务实现方式上非常常见。

脑裂

Master单点故障恢复,在主Master下线的时候,触发故障转移或者说是故障恢复的状态下,在网络故障的等情况下有可能会出现两个Master竞争成功。导致集群状态紊乱。(这一点其实在Redis也是潜在的,只不过很少听到有人提及 【主从情况下完全影响,集群情况下部分分区不可用】 --- Redis通过 Quorum 仲裁机制解决该问题 = 跟ES是一样的)

总的来说,ES通过超过半数的投票决定是否允许某个master竞选成功,但其实本质上这种解决方式存在一个问题,在网络故障等情况下,这个处理本身是失效的 (这个解决脑裂的方式其实跟redis可以说是一模一样)

CAP原则

CAP,其实就是Consistency一致性、Availability可用性、Partition Tolerance分区容错性。

Consistency

集群一致性在集群级别上其实并不完全是只有CAP之中的强一致性,在实现要求上往往这个要求会被一定程度的放宽,这点在分布式事务之中也有所介绍。

  • 强一致性:要求无论何时进行查询,都要求得到当前最准确的值。(等待数据同步到集群所有节点)
  • 弱一致性:允许在某些场景下,进行查询获得的结果是旧的。
  • 最终一致性:在经过一定的时间之后,获取到的查询的结果是最新的值。

Availability

要求无论什么时候集群本身都能提供正常的服务。

Partition Tolerance

分区容错能力,其实就是指代服务集群在某些异常状态场景下的

在介绍es的问题和机制的之前,还是首先要介绍一下所有服务集群都无法避开的一个问题,满足CAP原则中CP / AP之中哪一种?
在实际操作的层面上来看,我们的数据其实是先写入到主分片,并且由于是为实时的原因,es实际是会在一秒之后再将数据保存到es的主分片之中。从这个特点,其实就决定了es本身一定不是cp而是ap类型的服务。

ES的扩容

在针对es数据库需要扩容的场景的时候,es的扩容功能使用起来也非常简单,只需要添加新的机器 / 服务本身即可,es会将一切都处理好。

流程非常简单,只需要在解压好es的压缩包,并针对es配置文件进行修改

# 以下部分需要保持和集群一致
cluster.name: ${es_group_name}   # 集群名 (需要保持一致)
node.name: ${new_node_name}   # 节点名
node.attr.rack: ${rack_name}    # 指定当前节点的部落属性[比集群大],这个值一般保持跟集群其他一样
node.master: $[true / false]
node.data: $[true / false]    # 是否设置为数据节点
path.data: ${data_path}       # 数据保存路径
path.log: ${log_path}         # 配置日志路径
http.port: ${port}
network.host: ${ipaddress}
transport.port: ${transport_port}     # 对外公布的端口号
discovery.seed_hosts: ["other_es_node_ipaddress", ······]
cluster.initial_master_nodes: ["other_es_node_name", ······]
bootstrap.system_call_filter: $[true / false]  # centos6 需要开启这个不然启动报错
node.attr.box_type: $[hot / warm / cold]   # 节点类型
indices.fielddata.cache.size: ${n percent}   # 现在堆内存大小,降低内存占用率的

主副分片数据一致性

从集群的角度来看,整个es集群之中的数据同步主要实现方式跟PacificA算法有较大的关系,可以参考以下的介绍。

Master

从索引的级别来看:在Master之中会存储者用于维护索引的元信息、索引列表、映射的信息 、分片的分配
从集群的角度来看:存储有当前整个集群之中所有节点的信息、除此以外,还会包含有针对整个集群的任务队列的信息

Data(replica)

从索引的级别来看:往往直接记录有索引的相关分片,包括有主分片和副分片
从集群的角度来看:会记录有routing table,一些跟自己相关的集群之中的信息,但是没有整个集群完整的信息

PacificA算法

机房内部使用的内网级别的数据同步技术,无法支持跨机房的数据同步 (由微软提出)

  • Replica Group:将整个副本算为一个集合

  • Configuration:配置信息保存了副本组的信息,每个副本组都有哪些副本,主副本是谁,从副本有哪些,这些副本都分配在哪个节点上。

  • Configuration Version:配置信息也是有版本号的,每次更新配置信息时都会递增。如果某个节点发起要更新配置信息的请求,必须带上其配置信息的版本,当节点的配置信息版本与配置管理器的配置信息版本相同时才能执行操作。

    (在数据节点向配置管理器发起增加、移除副本的请求时,需要将节点当前配置的版本号带上,只有这个版本与配置管理器中的版本号一致时才能执行操作。执行成功后,配置的版本将会增加)

  • Serial Number(简称 SN) :为每个写入操作都赋予一个顺序号,每次写入时都会递增,其由主副本维护。

  • Prepared List:写入操作的请求将会先存储到这个队列,并且按 Serial Number 排序。

  • Committed List:已经提交的写入操作队列。

为了实现数据的同步,PacificA将配置的一致性和数据的同步的一致性,拆成了两个管理器来实现。

PacificA的数据父子处理策略

  1. 主副本节点接收到写入请求后,会为此次写入操作分配 SN,然后把写入请求插入到 prepared list 中。其中 prepared list 中的记录是按照 SN 排序的。
  2. 主副本节点将写入请求和 SN 发送到其他副本节点,从副本节点收到写入请求后会插入到自己的 prepared list 中,成功后给主副本节点进行回复。
  3. 当主副本节点收到所有从副本的回复,并且确认所有的从副本都已经把写请求写入到自己的 prepared list 中,主副本把这写入操作插入到 committed list,committed list 向前移动。
  4. 主副本节点响应客户端,返回写入成功的消息,并且主副本会向所有返回响应的从副本发送一个 commit 的通知,告诉它们 commit point 的位置,各个从副本收到通知后会移动 commit point 到相同的位置。

简单的来说,从数据的数量 / 完整性来看,主节点的 prepared list >= 从节点的 prepared list >= 主节点的 committed list >= 从节点的 committed list

数据复制框架.png

保证主副本的唯一性

在现实的世界中,节点下线、网络分区等异常是普遍存在的,而 PacificA 的数据一致性是由主副本来维持,那么如何避免在异常环境中出现两个主副本就非常重要了。PacificA 使用了 lease(租约)机制来避免出现双主的情况

lease.png

如上图,lease 机制主要说了这么一件事:主副本每隔一段时间(lease period)向副本组中所有从副本发起心跳请求并且等待响应来获取租约,而从副本则每隔一段时间(grace period)检查是否收到主副本的心跳请求。很明显这个过程中可能会有两种异常:

  1. 从副本异常 主副本没有收到从副本对心跳请求的响应;
  2. 主副本异常 从副本在 grace period 内没有收到主副本的心跳请求。

在从副本异常时,主副本会自动降级,不再作为主副本,停止处理读写请求,并且会向配置管理器汇报,将异常的副本从副本组中删除。如下图所示:

主副本下线.png

在从副本认为主副本下线时,从副本会向配置管理器汇报主副本发生异常,将主副本从副本组中移除,并且会请求提升自己为新一任主副本。在有多个从副本发起上述操作时,谁先成功谁就会成为新的主副本(First Win 原则)。如下图所示:

主副本下线.png

可以发现在 lease 机制下,主副本和从副本可以看作是“互相监督、互相举报”的,而配置管理器在响应“互相举报”时的策略是 First Win 原则。所以,如果没有时钟漂移,只需 grace period >= lease period,那么就可以保证主副本先于从副本知道有副本异常了,而且还可以保证在主副本降级后,各个从副本才去竞争成为主副本,从而避免出现两主副本的情况。需要注意的是只要主副本发生了变更,Configuration Version 就需要自增了,这样带有旧版本的配置变更请求就会被拒绝。

至于对于单个节点来说或者说单个分片的数据同步方式,其实本质上跟Mysql的主从复制的实现几乎是一模一样的都是通过复制日志复制的方式来实现的。

基于日志的数据同步实现

在将数据记录到磁盘的

通用的数据存储框架.png

如上图,展示的是通用的以日志为基础的存储系统架构,主要包括 5 个步骤:

  1. 数据写入时先写日志,类似于 MySQL 中的 Binlog;
  2. 写日志成功后将数据写入到内存中,在系统重启时会重放日志来重建内存状态;
  3. 定时将内存中的数据写入到磁盘中,并记录 Checkpoint;
  4. 当 Checkpoint 生成后,日志中与 Checkpoint 相关的日志就可以被清理了;
  5. 由于 Checkpoint 过多,系统会定期将多个 Checkpoint 合并成一个 On-Disk Image。

需要注意的是,这里的checkpoint其实不仅仅在单个分片之中会包含有,在整个集群的级别之中也包含有一个checkpoint,而这个checkpoint是全局的,又被成为 global-checkpoint ,在各个分片内部之中存在的又被称为 local-checkpoint。

**global-checkpoint:**作为索引在整个集群之中的 checkpoint 文件,本质上记录的是当前整个集群之中所有还活跃分片 (包括有:主分片 / 副分片) 都已经全部完成处理了的 sequence ID的值

**local-checkpoint:**作为单个分片已经处理了的 Sequence ID的值,一般来说Local-CheckPoint的值是 >= global-CheckPoint的值的。

总结:

PacificA算法需要使用以下的内容:

  • Master 会负责维护索引的元信息,实际上这个部分的信息,跟 PacificA 之中说提及的配置管理器 维护 Configuration
  • 副本集群的成员和状态:集群状态 (使用GET /_cluster/state 查询集群的状态) 主要几种在 Index Metadata 之中可以通过
  • 每一次写入操作的编号序列:SN (Serial Number)
  • 集群情况变化:Configuration Version
  • 写入操作的位置:Checkpoint -> Commit Point (从内存提交到的时候,会产生Check Point) [这里的CheckPoint记录的其实是数据写入到磁盘之中的操作日志 --- checkpoint 提交后基本代表此次操作已经结束]

主分片的选举

在ElasticSearch最初的ES介绍文档之中,有针对主副数据的数据节点的介绍,也就是Data节点。也是基础的,一个Data节点往往会包含有多个索引的主分片和副分片。但问题是,ES集群是怎么知道应该把主分片放到哪一个节点底下呢?

主分片的分配

主分片的分配或者说选举,显然的会有两种不同的情况需要处理:

  1. 在索引刚刚创建出来的时候,此时还没创建出主分片和副分片,此时分片的分配其实是依靠在分片的分配算法的。
    因为篇幅问题这里不做详细的介绍,但是可以知道的是:主要考量以下的因素:CPU、内存、磁盘空间等

  2. 主分片所在的节点因为某些原因挂掉之后,就需要重新选举主分片。简单的来说会根据以下的信息考量:同步的情况、版本号、健康状况、Allocation ID(根据活跃程度做ID,尽量选举活跃程度高的)

  3. 新增了节点 (集群有可能认为将主节点从原先的位置迁移到新机器会更好) 主要的影响因素:集群的设置 cluster.routing.rebalance.enable、分片分配感知设置、自定义的分片分配规则、集群的负载和分片分布状况

    // 取消新增节点的再平衡策略
    PUT /_cluster/settings
    {
      "transient": {
        "cluster.routing.rebalance.enable": "none"
      }
    }
    

使用了自定义的分配过滤规则,可以影响集群的分配规则

PUT _cluster/settings
{
  "persistent": {
    "cluster.routing.allocation.awareness.attributes": "zone"
  }
}

PUT _cluster/settings
{
  "persistent": {
    "cluster.routing.allocation.exclude._ip": "10.0.0.1"   // 排除某个IP的es机器充当主分片
  }
}

ES故障恢复

故障恢复的相关概念

Sequence ID

值得一提的是,其实SN的构成部分我们在学习ElasticSearch搜索的时候,其实都已经见过, SN 实际上是通过:Sequence ID = Primary Term (就是 _primary_term) + Sequence Number (实际上就是ES文档自带字段 _seq_no) 组成来实现的。

Sequence Number

其中 Sequence Number 又 Primary 分配和管理,每次写入操作之后都会自动递增生成,并分配一个唯一的的序列号。(索引、更新、删除任一修改实际数据的操作都会生成序列化)
值得一提的是这里的 Sequence Number 在不同的分片之中是不一样的,在不同的分片之中该值的生成是独立的,所以在全局之中,Sequence Number在不同的主分片之中,相同的sequence number很有可能指定不一样的文档。

Primary Term

Primary Term本身代表的是主分片的一个版本,实际Primary Term代表的是当前主分片的一个版本,一般来说这个分片本身是master来分配的版本记录。(本质上可以理解为,该值代表着分片的主分片角色被分配的次数,每次选举新的分片时候,Primary Term都会递增)

在通过这两个生成的信息拼接的基础上,构成的sequence number,在进行写操作的时候,主分片在转发数据的时候都会自己带上 Primary Term 和 Sequence Numgber来判断当前的写操作的顺序、保证同步等功能。具体包括以下的作用:

  • 唯一标识操作:每个索引操作(写入、更新、删除)都有一个唯一的 Sequence ID、用于区分和排序分片内的操作。

  • 数据一致性保证:帮助确保分布式系统中的数据一致性、用于检测和解决潜在的冲突。

  • 并发控制:实现乐观并发控制机制、防止旧版本的数据覆盖新版本。

    可以利用Sequence ID实现简单的乐观锁级别的控制

    POST /my_index/_update/1?if_seq_no=100&if_primary_term=2  // 更新操作,但前提是 primary term 和 
    													 // seq_no 都是某个值
    {
      "doc": {
        "field": "new value"
      }
    }
    
  • 分片恢复:在分片恢复过程中,用于确定需要重放的操作、帮助识别主分片和副本分片之间的差异。

    Sequence ID在之前的Checkpoint会记录最后一个写入磁盘的操作的位置,结合在Index Buffer -> Systemfile cache 时产生的Transaction Log,用Sequence ID可以知道应当从哪里开始恢复分片

  • 复制过程:在主分片和副本分片之间同步操作、确保所有副本都达到与主分片相同的状态。

    跟上文的分片恢复一样的道理,都是定位帮助数据的同步

故障发生和恢复流程

写入故障处理

数据写入的时候,有可能会出现两种不同情况的故障:

从节点的级别上来看:

  1. 主节点的故障,es的故障转移机制,跟redis几乎一模一样,都是遵守Raft选举机制实现的,主节点宕机之后整个集群的状态将会从GREEN状态将会变为RED,然后进入到故障转移处理的流程之中。也就是前文提到的解决脑裂的 Quorum 选举机制,必须要超过一半的投票之后,就算master选举成功。
  2. 数据节点上的故障异常:Data的节点可能会出现以下的异常问题
    • 主分片的故障:需要注意的是,如果是主分片出现故障,索引的集群状态将会变为 RED
      解决:需要master对节点再做一次主分片的分配工作 (在默认的情况下,大概需要 1 分钟) ,在主分片重新分配之前,写入的操作会失败。
    • 副本分片的故障:副本分片的故障不会对写入的操作产生影响
      解决:master只需要将这个会产生异常的副本本身,从metadata之中的 in-sync replica set之中移除,然后重新构建一个副分片提供之前的分片功能,那么集群状态就会从 yellow 改变为 green 恢复正常。

从资源的角度来看

  1. 网络故障:针对主节点和主分片之间的网络问题,就可能会出现节点通信故障、客户端和集群的连接也可能会出现异常。
  2. 磁盘故障:当磁盘空间不足、IO繁忙的时候,就会出现故障异常的可能
  3. 内存故障:当然由于实际上因为针对ES的操作首先会在内存的 Index Buffer 之中写入数据,然后再做后续的操作,这就导致了在内存不足 (堆空间不够的时候) 就可能会出现故障

读取故障处理

这里首先要确认一个点,那就是在ES之中查询请求的处理,其实不仅仅只会交给副分片做查询操作,同时针对主分片也会需要承担写入的压力 (不是完全的读写分离的处理,如果想要实现类似于读写分离的效果,需要自己再搭一个服务,并在代码和层面区分两种操作指向的不同的服务来实现)

  • 主分片出现故障:实际的结果是,在读取操作发生故障的时候,此次的查询请求会出现超时或者直接返回失败的提示

  • 副分片出现故障:副分片的故障不会直接影响某一次查询的效果,协调节点 crooding node 将会通过index的metadata之中指明的其他副分片组的成员来完成这个查询操作。(但是如果已经没有其他的副分片可以协助完成此次的查询操作的话,那么此次的查询只会返回一部分的查询结果)

    {
      "_shards": {
        "total": 5,
        "successful": 4,   // 成功查询的分片数量
        "failed": 1,       // 失败查询的分片数量 
        "failures": [
          {
            "shard": 1,
            "index": "${index}",
            "node": "node_1",
            "reason": {
              "type": "exception",
              "reason": "Shard not available"
            }
    	······
    }
    

    当查询到的失败分片的数量大于1的时候,那就说明此次的查询很有可能只会返回其中一部分的数据信息。(因为默认情况下会有该配置 ?search_type=query_then_fetch )

    (需要注意的是,如果在查询的时候,添加了一些参数,那么这有可能会导致最终查询的结果会是直接报错,主要有一下几种可能:将 ?search_type=dfs_query_then_fetch / ?consistency=all 都有可能会出现)

故障恢复的实现思路

当然在实现故障恢复的前提是,只有一部分的节点或者说分片是宕机的情况下是能恢复的,但是如果全部的节点,或者说损坏的节点太多的情况下,其实是恢复不了的。

一般来说节点需要恢复故障主要有两种恢复的手段:1. 全量恢复、2. 增量恢复
全量恢复: 其实指代的就是类似于整个文件的复制来实现,显然的通过全量恢复的方式来实现恢复效果,一定是漫长且会导致磁盘性能在期间会大量占用的。
**增量恢复:**所谓的增量恢复,其实就是通过上文提到过的Sequence ID来标记出哪些数据已经同步到目标节点,哪些数据没有同步,然后将没有同步到的数据的方式实现的数据同步。

全量恢复其实没啥好说的,本质上跟我们在OS系统下做cp没什么区别,下面介绍一下增量恢复的细节。

增量恢复的细节

在上文之中介绍,PacificA算法的时候,曾经介绍过Sequence ID的机制,并且在其中介绍过一个叫做checkPoint的分片最后持久化的节点信息。并且在介绍的时候,还提到了两个不同的概念,分别叫做 global-Checkpoint 和 local-checkpoint,以及local-checkpoint >= global-checkpoint。
因此实际在实现增量恢复的方式其实比较简单,其实无非就是先利用全局的检查点,首先做全局的重放,将目标副本的进度同步到全局的情况。然后再判断是否存在local-checkpoint,如果有则帮助继续恢复,如果没有则到此为止。需要注意的是如果是主分片出现异常的话,这里即使用副分片重新增量或者是全量恢复其实都有丢失数据的可能。在transaction log之中、index buffer 之中都有可能会丢失数据。

分库分表下ES索引结构调整

如果出现了初始构造索引的时候,实在没有预料到后续需要使用到的字段而需要调整索引的结构的时候,我们可以利用别名的结构来帮助我们调整索引的结构。

  1. 创建初始索引

    PUT /my_index_v1
    {
      "mappings": {
        "properties": {
          "title": { "type": "text" },
          "content": { "type": "text" },
          "created_at": { "type": "date" },
          "user_id": { "type": "keyword" }
        }
      }
    }
    
  2. 创建别名

    创建一个指向这个索引的别名。

    POST /_aliases
    {
      "actions": [
        {
          "add": {
            "index": "my_index_v1",
            "alias": "my_index"
          }
        }
      ]
    }
    
  3. 在应用中使用别名

  4. 修改索引结构

    PUT /my_index_v2
    {
      "mappings": {
        "properties": {
          "title": { "type": "text" },
          "content": { "type": "text" },
          "created_at": { "type": "date" },
          "user_id": { "type": "keyword" },
          "tags": { "type": "keyword" }  // 新增字段
        }
      }
    }
    
  5. 重新索引数据

    POST /_reindex
    {
      "source": {
        "index": "my_index_v1"
      },
      "dest": {
        "index": "my_index_v2"
      }
    }
    
  6. 更新别名

    POST /_aliases
    {
      "actions": [
        { "remove": { "index": "my_index_v1", "alias": "my_index" } },
        { "add": { "index": "my_index_v2", "alias": "my_index" } }
      ]
    }
    
  7. 删除旧索引(可选)

    DELETE /my_index_v1