Elasticsearch原理分析——写流程

Elasticsearch原理分析——写流程


本章分析ES写入单个和批量文档写请求的处理流程,仅限于ES内部实现,并不涉及Lucene内部处理。在ES中,写入单个文档的请求称为Index请求,批量写入的请求称为Bulk请求。写单个和多个文档使用相同的处理逻辑,请求被统一封装为BulkRequest。

在分析写流程时,我们把流程按不同节点执行的操作进行划分。写请求的例子可以参考上一章。

1. 文档操作的定义

在ES中,对文档的操作有下面几种类型:

enum OpType {
    
    
    INDEX(0),
    CREATE(1),
    UPDATE(2),
    DELETE(3);
    private final byte op;
    private final String lowercase;
    OpType(int op) {
    
    
        this.op = (byte) op;
        this.lowercase = this.toString().toLowerCase(Locale.ROOT);
    }
}
  • INDEX:向索引中"put" 一个文档的操作称为“索引”一个文档。此处索引为动词。
  • CREATE:put请求可以通过op_type 参数设置操作类型为create,在这种操作下,如果文档已经存在,则请求失败。
  • UPDATE:默认情况下,put一个文档时,如果文档已存在,则更新它。
  • DELETE:删除文档。

在put API 中,通过op_type参数来指定操作类型。

2. 可选参数

Index API 和 Bulk API有一些可选参数,这些参数在请求的URI中指定,例如:

PUT index/type/id?pipeline=pipeline_id
{
    
    
"foo":"bar"
}

下面简单介绍各个参数的作用,这些参数在接下来的流程分析中都会遇到,如下表所示:

参数 简介
version 设置文档版本号。主要用于实现乐观锁。
version_type 默认为internal,请求参数指定的版本号与存储的文档版本号相同则写入。其他可选值有external灯类型。为external类型时,如果当存储的文档版本号小于请求参数指定的版本号,则写入数据。version_type主要控制版本号的比较机制,用于对文档进行并发更新操作时同步数据。
op_type 可设置为create。代表尽在文档不存在时才写入。如果文档已经存在,则写请求失败。
routing ES默认使用文档ID进行路由,指定routing可使用routing值进行路由。
wait_for_active_shards 用于控制写一致性,当指定数量的分片副本可用时才执行写入,否则重试直至超时。默认为1,主分片可用即执行写入。
refresh 写入完毕后执行refresh,使其对搜索可见。
timeout 请求超时时间,默认1分钟。
pipeline 指定事先创建好的pipeline名称。

3. Index/Bulk基本流程

在这里插入图片描述
新建、索引(这里是动词,指写入操作,将文档添加到Lucene的过程称为索引一个文档)和删除请求都是写操作。写操作必须先在主分片执行成功后才能复制到相关的副分片。

写单个文档的流程,如下图所示:

P0 R0 R0 一个副本组

一下是写单个文档所需的步骤:

  1. 客户端向NODE1发生写请求,该节点为协调节点
  2. NODE1使用文档ID来确定文档属于分片0(P0),通过集群状态中的内容路由表信息得知分片0的主分片位于NODE3,因此请求被转发到NODE3上。
  3. NODE3上的主分片执行写操作。如果写入成功,则它将请求并行发到NODE1NODE2的副本分片上,等待返回结果。当所有分副分片都报告成功,NODE3将向协调节点报告成功,协调节点再向客户端报告成功。

在客户端收到成功响应时,意味着写操作已经在主分片和所有副本分片都执行完成。

写一致性的默认策略是quorum,即多数的分片(其中分片副本可以是主分片或副分片)在写入操作时处于可用状态。

quorum = int((primary + number_of_replicas) /2 ) + 1

4. Index/Bulk详细流程

以不同角色节点执行的任务整理流程如下图所示:

在这里插入图片描述

下面分别讨论各个节点上执行的流程。

4.1 协调节点流程

协调节点负责创建索引、转发请求到主分片节点、等待响应、回复客户端。

实现位于TransportBulkAction。执行本地的线程池:http_server_worker

  1. 检查参数

    如同我们平常设计的任何一个对外服务的接口处理一样,收到用户请求后先检查请求的合法性,把检查操作放在处理流程的第一步,有问题就直接拒绝,对异常请求的处理代价是最小的。

    检查操作进行一下参数检查,如下表所示:

    参数 检查
    index 不可为空
    type 不可为空
    source 不可为空
    contentType 不可为空
    opType 当前操作类型如果是创建索引,则校验versionType必须为internal,且version不可为MATCH_DELETED
    resolvedVersion 校验解析的version是否合法
    versionType 不可为FORCE类型,此类型已废
    id 非空时,长度不可以大于512,以及为空时对versionType和resolvedVersion的检查

    每项检查遇到异常都会拒绝当前请求。

  2. 处理pipeline请求

    数据预处理(ingest)工作通过自定义pipline和processors实现。pipline是一系列processors的定义,processors按照声明的顺序执行。添加一个pipeline的简单例子如下:

    PUT _ingest/pipeline/pipeline_id
    {
          
          
        "description":"添加一个pipeline",
        "processors":[
            {
          
          
                "set":{
          
          
                    "field":"foo",
                    "value":"bar"
                }
            }
        ]
    }
    

    pipeline_id是自定义的pipeline名称,processors中定义了一系列的处理器,本利中只有set。

    如果Index或Bulk请求中制定了pipeline参数,则先使用相应的pipeline进行处理。如果本节点不具备预处理资格,则将请求随机转发到其他有预处理资格的节点。预处理节点资格的配置:

    node.master: false
    node.data: false
    node.ingest: true
    
  3. 自动创建索引

    如果配置为允许自动创建索引(默认允许),则计算请求中涉及的索引,可能有多个,其中有哪些索引是不存在的,然后创建它。如果部分索引创建失败,则涉及创建失败索引的请求被标记为失败。其他索引正常执行写流程。

    创建索引请求被发送到Master节点,待收到全部创建请求的Response(无论成功还是失败)之后,才会进入下一个流程。在Master节点执行完创建索引流程,将新的clusterState发布完毕才会返回Response。默认情况下,Master发布clusterState的Request收到半数以上的节点Response,认为发布成功。负责写数据的节点会先执行一遍内容路由的过程以处理没有收到最新clusterState的情况。

    简化的实现如下:

    TransportBulkAction#doExecute

    // Step 1: 收集请求中的index集合
    final Set<String> indices = bulkRequest.requests.stream()
            // delete requests should not attempt to create the index (if the index does not
            // exists), unless an external versioning is used
        .filter(request -> request.opType() != DocWriteRequest.OpType.DELETE
            || request.versionType() == VersionType.EXTERNAL
            || request.versionType() == VersionType.EXTERNAL_GTE)
        .map(DocWriteRequest::index)
        .collect(Collectors.toSet());
    /**
     * Step 2: 过滤掉哪些不存在的,并且不能自动创建的索引
     * 把不存在、可以自动创建的索引放到autoCreateIndices集合,并创建maping
     *
     */
    final Map<String, IndexNotFoundException> indicesThatCannotBeCreated = new HashMap<>();
    //需要自动创建索引的集合
    Set<String> autoCreateIndices = new HashSet<>();
    //获取集群状态
    ClusterState state = clusterService.state();
    for (String index : indices) {
          
          
        boolean shouldAutoCreate;
        try {
          
          
            //通过集群状态,判断索引是否需要自动创建
            shouldAutoCreate = shouldAutoCreate(index, state);
        } catch (IndexNotFoundException e) {
          
          
            shouldAutoCreate = false;
            indicesThatCannotBeCreated.put(index, e);
        }
        //添加进去自动创建索引的集合
        if (shouldAutoCreate) {
          
          
            autoCreateIndices.add(index);
        }
    }
    // Step 3: 创建所有缺少的索引,当所有创建索引请求完成后开始执行索引请求
    //不需要创建索引,直接发送索引请求
    if (autoCreateIndices.isEmpty()) {
          
          
        executeBulk(task, bulkRequest, startTime, listener, responses, indicesThatCannotBeCreated);
    } else {
          
          
        //创建索引响应计数
        final AtomicInteger counter = new AtomicInteger(autoCreateIndices.size());
        //遍历可创建索引集合
        for (String index : autoCreateIndices) {
          
          
            //发送创建索引请求(index,timeout,响应回调)
            createIndex(index, bulkRequest.timeout(), new ActionListener<CreateIndexResponse>() {
          
          
                @Override
                public void onResponse(CreateIndexResponse result) {
          
          
                    //响应计数递减,全部响应完时发送索引请求
                    if (counter.decrementAndGet() == 0) {
          
          
                        threadPool.executor(ThreadPool.Names.WRITE).execute(
                            () -> executeBulk(task, bulkRequest, startTime, listener, responses, indicesThatCannotBeCreated));
                            }
                        }
    
                @Override
                public void onFailure(Exception e) {
          
          
                    if (!(ExceptionsHelper.unwrapCause(e) instanceof ResourceAlreadyExistsException)) {
          
          
                        // 如果创建索引失败,则使该索引的索引请求设置为空
                        for (int i = 0; i < bulkRequest.requests.size(); i++) {
          
          
                            DocWriteRequest<?> request = bulkRequest.requests.get(i);
                            if (request != null && setResponseFailureIfIndexMatches(responses, i, request, index, e)) {
          
          
                                bulkRequest.requests.set(i, null);
                            }
                        }
                    }
                    //响应计数递减,全部响应完时发送索引请求
                    if (counter.decrementAndGet() == 0) {
          
          
                        executeBulk(task, bulkRequest, startTime, ActionListener.wrap(listener::onResponse, inner -> {
          
          
                            inner.addSuppressed(e);
                            listener.onFailure(inner);
                        }), responses, indicesThatCannotBeCreated);
                    }
                }
            });
        }
    }
    
  4. 对请求的预先处理

    这里不同于对数据的预处理,对请求的预处理只是检测参数、自动生成id、处理routing等。

    由于上一步可能有创建索引操作,索引在此先获取最新集群状态信息。然后遍历所有请求,从集群状态中获取对应索引的元信息,检查mapping、routing、id等信息。如果id不存在,则生成一个UUID作为文档id。

    实现位于TransportBulkAction.BulkOperation#doRun

    BulkOperation(Task task, BulkRequest bulkRequest, ActionListener<BulkResponse> listener, AtomicArray<BulkItemResponse> responses,
                    long startTimeNanos, Map<String, IndexNotFoundException> indicesThatCannotBeCreated) {
          
          
        super(listener);
        this.task = task;
        this.bulkRequest = bulkRequest;
        this.responses = responses;
        this.startTimeNanos = startTimeNanos;
        this.indicesThatCannotBeCreated = indicesThatCannotBeCreated;
        //监听集群状态
        this.observer = new ClusterStateObserver(clusterService, bulkRequest.timeout(), logger, threadPool.getThreadContext());
    }
    
  5. 检测集群状态

    协调节点在开始处理会先检测集群状态,若集群异常则取消写入。例如,Master节点不存在,会阻塞等待Master节点直至超时。

    final ClusterState clusterState = observer.setAndGetObservedState();
    //集群异常,不做处理
    if (handleBlockExceptions(clusterState)) {
          
          
        return;
    }
    
    private boolean handleBlockExceptions(ClusterState state) {
          
          
        ClusterBlockException blockException = state.blocks().globalBlockedException(ClusterBlockLevel.WRITE);
        //集群异常,返回true
        if (blockException != null) {
          
          
            if (blockException.retryable()) {
          
          
                logger.trace("cluster is blocked, scheduling a retry", blockException);
                retry(blockException);
            } else {
          
          
                onFailure(blockException);
            }
            return true;
        }
        return false;
    }
    

    因此索引为Red时,如果Master节点存在,则数据可以写到正常shard,Master节点不存在,协调节点会阻塞等待或取消写入。

  6. 内容路由,构建基于shard的请求

    将用户的bulkRequest重新组织为基于shard的请求列表。例如,原始用户请求可能有10个写操作,如果这些文档的主分片属于同一个,则写请求会被合并为1个。所以这里本质上是合并请求的过程。此处尚未确定主分片节点。

    基于shard的请求结果如下:

    Map<ShardId, List<BulkItemRequest>> requestsByShard = new HashMap<>();
    for (int i = 0; i < bulkRequest.requests.size(); i++) {
          
          
        DocWriteRequest<?> request = bulkRequest.requests.get(i);
        if (request == null) {
          
          
            continue;
        }
        String concreteIndex = oncreteIndices.getConcreteIndex(request.index()).getName();
        //根据集群状态和路由算法计算属于哪个分片
        ShardId shardId = clusterService.operationRouting().indexShards(clusterState, concreteIndex, request.id(),request.routing()).shardId();
        List<BulkItemRequest> shardRequests = requestsByShard.computeIfAbsent(shardId, shard -> new ArrayList<>());
        shardRequests.add(new BulkItemRequest(i, request));
    }
    

    根据路由算法计算某个文档属于哪个分片。遍历所有的用户请求。重新封装后添加到上述map结构。

    ShardId类的主要结构如下,shard编号是从0开始递增的序号:

    public class ShardId implements Comparable<ShardId>, ToXContentFragment, Writeable {
          
          
    	//分片属于的索引
        private final Index index;
        //从0开始递增的序号
        private final int shardId;
        private final int hashCode;
    }
    
  7. 路由算法

    路由算法就是根据routing和文档id计算目标shardid的过程,一般情况下,路由计算方式为先的公式:

    shard_num = hash(_routing) % num_primary_shards
    

    默认情况下,_routing值就是文档id。

    ES使用随机id和hash算法来确定文档均匀的分配给分片。当使用自定义id或routing值可能不够随机,造成数据倾斜,部分分片过大。这种情况下,可以使用index.routing_partition_size配置来减少倾斜的风险。routing_partition_size越大,数据的分布越均匀:

    shard_num = (hash(_routing) + hash(id) % routing_partition_size) % num_primary_shards
    

    也就是说,_routing字段用于计算索引中的一组分片,然后使用 _id来选择该组内的分片。

    index.routing_partition_size取值应:

    1 < routing_partition_size < num_primary_shards
    
    {
          
          
      "settings": {
          
          
        "number_of_shards": "3",
        "number_of_replicas": "1",
        "index.routing_partition_size":"2"
      },
      "mappings": {
          
          
        "info": {
          
          
        	"_routing":{
          
          
        		"required":true
        	},
        	
          "properties": {
          
          
          	"id":{
          
          
          		"type":"keyword"
          	},
          	"type":{
          
          
          		"type":"keyword"
          	},
          	"name":{
          
          
          		"type":"text",
          		"analyzer":"ik_max_word"
          	},
          	"address":{
          
          
          		"type":"text",
          		"analyzer":"ik_max_word"
          	},
          	"lng":{
          
          
          		"type":"keyword"
          	},
          	"lat":{
          
          
          		"type":"keyword"
          	},
          	"provinceId":{
          
          
          		"type":"keyword"
          	},
          	"cityId":{
          
          
          		"type":"keyword"
          	},
          	"districtId":{
          
          
          		"type":"keyword"
          	},
          	"streetId":{
          
          
          		"type":"keyword"
          	},
          	"areaList":{
          
          
          		"type":"keyword"
          	},
          	"location":{
          
          
          		"type":"geo_point"
          	}
       
          }
        }
      }
    }
    

    计算过程如下:

    private static int calculateScaledShardId(IndexMetaData indexMetaData, String effectiveRouting, int partitionOffset) {
          
          
        final int hash = Murmur3HashFunction.hash(effectiveRouting) + partitionOffset;
        return Math.floorMod(hash, indexMetaData.getRoutingNumShards()) / indexMetaData.getRoutingFactor();
    }
    

    effectiveRouting是id或者设置的routing的值。partitionOffset一般为0。在设置了index.routing_partition_size的情况下其取值为:

    final int partitionOffset;
    if (indexMetaData.isRoutingPartitionedIndex()) {
          
          
        partitionOffset = Math.floorMod(Murmur3HashFunction.hash(id), indexMetaData.getRoutingPartitionSize());
    } else {
          
          
        partitionOffset = 0;
    }
    

    indexMetaData.getRoutingNumShards()的值为routingNumShards,其取决于配置:index.number_of_routing_shards

    如果没有配置,则routingNumShards的值等于numberOfShards

    indexMetaData.getRoutingFactor()的值为:

    routingNumShards / numberOfShards

    numberOfShards的值取决于配置:index.number_of_shards

    实现过程与公式稍有区别,最后多了一个值,这个值和索引拆分(split)过程有关,此处不详细讨论。

  8. 转发请求并等待响应

    主要是根据集群状态中的内容路由表确定主分片所在的节点,转发请求并等待响应。

    遍历所有需要写的shard。将位于某个shard的请求封装为BulkShardRequest类,调用TransportShardBulkAction#execute执行发送,在listerner中等待响应。每个响应也是以shard为单位。如果某个shard的响应中部分doc写失败了,则将异常信息填充到Response中,整体请求做成功处理。

    待收到所有响应后(无论成功还是失败),回复给客户端。

    转发请求的具体实现于:TransportReplicationAction.ReroutePhase#doRun

    转发前先获取最新集群状态,根据集群状态中的内容路由表找到目的shard所在的主分片,如果主分片不在本机,则转发到相应的节点。否则在本地执行。

    protected void doExecute(Task task, Request request, ActionListener<Response> listener) {
          
          
        assert request.shardId() != null : "request shardId must be set";
        new ReroutePhase((ReplicationTask) task, request, listener).run();
    }
    
    protected void doRun() {
          
          
        setPhase(task, "routing");
        //获取集群状态
        final ClusterState state = observer.setAndGetObservedState();
        final String concreteIndex = concreteIndex(state, request);
        final ClusterBlockException blockException = blockExceptions(state, concreteIndex);
        //集群状态有问题则不执行
        if (blockException != null) {
          
          
            if (blockException.retryable()) {
          
          
                logger.trace("cluster is blocked, scheduling a retry", blockException);
                retry(blockException);
            } else {
          
          
                finishAsFailed(blockException);
            }
        } else {
          
          
            // request does not have a shardId yet, we need to pass the concrete index to resolve shardId
            final IndexMetaData indexMetaData = state.metaData().index(concreteIndex);
            if (indexMetaData == null) {
          
          
                retry(new IndexNotFoundException(concreteIndex));
                return;
            }
            if (indexMetaData.getState() == IndexMetaData.State.CLOSE) {
          
          
                throw new IndexClosedException(indexMetaData.getIndex());
            }
            // resolve all derived request fields, so we can route and apply it
            resolveRequest(indexMetaData, request);
            assert request.waitForActiveShards() != ActiveShardCount.DEFAULT : "request waitForActiveShards must be set in resolveRequest";
            
            //获取primary shard的所在的节点
            final ShardRouting primary = primary(state);
            if (retryIfUnavailable(state, primary)) {
          
          
                return;
            }
            //如果主分片所在节点时本节点,在本地执行。否则转发出去
            final DiscoveryNode node = state.nodes().get(primary.currentNodeId());
            if (primary.currentNodeId().equals(state.nodes().getLocalNodeId())) {
          
          
                performLocalAction(state, primary, node, indexMetaData);
            } else {
          
          
                performRemoteAction(state, primary, node);
            }
        }
    }
    

4.2 主分片节点流程

执行本地流程的线程池:bulk

主分片所在节点负责在本地写主分片节点,写成功后,转发写副本分片请求,等待响应,回复协调节点。

4.2.1 检查请求

主分片所在节点收到协调节点发来的请求后也是先做了校验工作,主要检测要写的是否是主分片,AllocationId是否符合预期,索引是否处于关闭状态等。

4.2.2 是否延迟执行

判断请求是否需要延迟执行,如果需要延迟执行则放入队列,否则继续下面的流程。

4.2.3 判断主分片是否已经发生迁移

如果已经发生迁移,则转发请求到迁移的节点。

4.2.4 检测写一致性

在开始写之前,检测本次写操作设计的shard,活跃shard数量是否足够,不足则不执行写入。默认为1,只要主分片可用就执行写入。

/**
* Returns true iff the active shard count in the shard routing table is enough
* to meet the required shard count represented by this instance.
* 检查副本分片是否正常
*/
public boolean enoughShardsActive(final IndexShardRoutingTable shardRoutingTable) {
    
    
    //活跃的副本数量
    final int activeShardCount = shardRoutingTable.activeShards().size();
    //如果配置的是必须所有的副本活跃才可以写入
    if (this == ActiveShardCount.ALL) {
    
    
        // adding 1 for the primary in addition to the total number of replicas,
        // which gives us the total number of shard copies
        return activeShardCount == shardRoutingTable.replicaShards().size() + 1;
        //配置的是只要有一个副本活跃才可以写入
    } else if (this == ActiveShardCount.DEFAULT) {
    
    
        return activeShardCount >= 1;
    } else {
    
    
        return activeShardCount >= value;
    }
}

4.2.5 写Lucene和事务日志

遍历请求,处理动态更新字段映射,然后调用InternalEngine#index逐条对doc进行索引。

Engine封装了Lucenetranslog的调用,对外提供读写接口。

在写入Lucene之前,先生成mermaid sequenceDiagram NumberVersion。这些都是在InternalEngine类中实现的。mermaid sequenceDiagram Number每次递增1,Version根据当前doc的最大版本号加1。

索引过程先写Lucene,后写translog

因为Lucene写入时对数据有检查,写操作可能会失败。如果先写translog,写入Lucene时失败,则还需要对translog进行处理。

4.2.6 flush translog

根据配置的translog flush策略进行刷盘控制,定时或立即刷盘:

private void maybeSyncTranslog(final IndexShard indexShard) throws IOException {
    
    
    if (indexShard.getTranslogDurability() == Translog.Durability.REQUEST &&
            indexShard.getLastSyncedGlobalCheckpoint() < indexShard.getLastKnownGlobalCheckpoint()) {
    
    
        
        indexShard.sync();
    }
}

4.2.7 写副分片

现在已经为要写的副本shard准备了一个列表,循环处理每个shard,跳过**unassigned(未分配)**状态的shard,向目标节点发送请求,等待响应。这个过程是异步并行的。

转发请求时会将SequenceID、PrimaryTerm、GlobalCheckPoint、version等传递给副本分片。

replicasProxy.performOn(shard, replicaRequest, primaryTerm, globalCheckpoint, maxSeqNoOfUpdatesOrDeletes,
            new ActionListener<ReplicaResponse>() {
    
    
                @Override
                public void onResponse(ReplicaResponse response) {
    
    
                    successfulShards.incrementAndGet();
                    try {
    
      primary.updateLocalCheckpointForShard(shard.allocationId().getId(), response.localCheckpoint());
                      primary.updateGlobalCheckpointForShard(shard.allocationId().getId(), response.globalCheckpoint());
                        } catch (final AlreadyClosedException e) {
    
    
                        // the index was deleted or this shard was never activated after a relocation; fall through and finish normally
                    } catch (final Exception e) {
    
    
                        // fail the primary but fall through and let the rest of operation processing complete
                        final String message = String.format(Locale.ROOT, "primary failed updating local checkpoint for replica %s", shard);
                        primary.failShard(message, e);
                    }
                    decPendingAndFinishIfNeeded();
                }
                @Override
                public void onFailure(Exception replicaException) {
    
    
                    logger.trace(() -> new ParameterizedMessage(
                        "[{}] failure while performing [{}] on replica {}, request [{}]",
                        shard.shardId(), opType, shard, replicaRequest), replicaException);
                    // Only report "critical" exceptions - TODO: Reach out to the master node to get the latest shard state then report.
                    if (TransportActions.isShardNotAvailableException(replicaException) == false) {
    
    
                        RestStatus restStatus = ExceptionsHelper.status(replicaException);
                        shardReplicaFailures.add(new ReplicationResponse.ShardInfo.Failure(
                            shard.shardId(), shard.currentNodeId(), replicaException, restStatus, false));
                    }
                    String message = String.format(Locale.ROOT, "failed to perform %s on replica %s", opType, shard);
                    replicasProxy.failShardIfNeeded(shard, primaryTerm, message, replicaException,
                        ActionListener.wrap(r -> decPendingAndFinishIfNeeded(), ReplicationOperation.this::onNoLongerPrimary));
                }

                @Override
                public String toString() {
    
    
                    return "[" + replicaRequest + "][" + shard + "]";
                }
            });

在等待Response的过程中,本节点发出了多少给Request,就要等待多少个Response。无论这些Response是否成功,直到超时。

收集到全部Response后,执行finish()。给协调节点返回信息,告知哪些成功,哪些失败了。

private void decPendingAndFinishIfNeeded() {
    
    
    assert pendingActions.get() > 0 : "pending action count goes below 0 for request [" + request + "]";
    if (pendingActions.decrementAndGet() == 0) {
    
    
        finish();
    }
}

4.2.8 处理副本写失败情况

主分片所在节点将发送一个shardFailed请求给Master:

replicasProxy.failShardIfNeeded(shard, primaryTerm, message, replicaException,
                        ActionListener.wrap(r -> decPendingAndFinishIfNeeded(), ReplicationOperation.this::onNoLongerPrimary));

向Master发送shardFailed请求:

sendShardAction(SHARD_FAILED_ACTION_NAME, currentState, shardEntry, listener);

然后Master会更新集群状态,在新的集群状态中,这个shard将:

  • in_sync_allocations列表中删除;
  • routing_table的shard列表中将state由STARTED更改为UNASSIGNED
  • 添加到routingNodesunassignedShards列表

4.3 副本分片节点流程

执行本流程的线程池:bulk

执行与主分片基本相同的写doc过程,写完毕后回复主分片节点。

TransportReplicationAction#doRun

@Override
protected void doRun() throws Exception {
    
    
    setPhase(task, "replica");
    final String actualAllocationId = this.replica.routingEntry().allocationId().getId();
    //检查 allocationId
    if (actualAllocationId.equals(replicaRequest.getTargetAllocationID()) == false) {
    
         
        throw new ShardNotFoundException(this.replica.shardId(), "expected allocation id [{}] but found [{}]",
                    replicaRequest.getTargetAllocationID(), actualAllocationId);
    }
    acquireReplicaOperationPermit(replica, replicaRequest.getRequest(), this, replicaRequest.getPrimaryTerm(),
                replicaRequest.getGlobalCheckpoint(), replicaRequest.getMaxSeqNoOfUpdatesOrDeletes());
}

在副本分片的写入过程中,参数检查的实现与主分片略有不同,最终都调用IndexShardOperationPermits#acquire判断是否需要delay,继续后面的写流程。

5. IO异常处理

在一个shard上执行的一些操作可能会产生IO异常之类的情况。一个shard上的CRUD等操作在ES里面由一个Engine对象封装,在Engine处理过程中,部分操作产生的部分异常ES会认为有必要关闭此Engine,上报Master。例如:系统IO层面的写入失败,这可能意味着磁盘损坏。

对Engine异常的捕获目前通过IOException实现。例如,索引文档过程中的异常处理:

try {
    
    
    //索引文档到 Lucene
    indexResult = indexIntoLucene(index, plan);
} catch (RuntimeException | IOException e) {
    
    
    try {
    
    
        maybeFailEngine("index", e);
    } catch (IOException inner) {
    
    
        e.addSuppressed(inner);
    }
    throw e;
}

Engine类中的maybeFailEngine()负责检查是否应当关闭引擎failEngine()

可能会触发maybeFailEngine()的操作如下表所示:

操作 简介 可能的异常
createSearcherManager 创建搜索管理器 IOException
index 索引文档 RuntimeException、IOException
delete 删除文档 RuntimeException、IOException
sync flush 同步刷新 IOException
sync commit 同步提交 IOException
flush 刷入磁盘 FlushFailedEngineException
force merge 手工合并Lucene分段 Exception

注意:其中不包含get操作,也就是说,读取doc失败不会触发shard迁移。

5.1 Engine关闭过程

将Lucene标记为异常,简化的实现如下:

public void failEngine(String reason, @Nullable Exception failure) {
    
    
    failedEngine.set((failure != null) ? failure : new IllegalStateException(reason));
    store.markStoreCorrupted(new IOException("failed engine (reason: [" + reason + "])",
                                    ExceptionsHelper.unwrapCorruption(failure)));
}

关闭shard,然后汇报给Master:

private void failAndRemoveShard(ShardRouting shardRouting, boolean sendShardFailure, String message, @Nullable Exception failure,
                                    ClusterState state) {
    
    
    //关闭 shard
    indexService.removeShard(shardRouting.shardId().id(), message);
    //向Master 节点发送 SHARD_FAILED_ACTION_NAME 请求
    sendFailShard(shardRouting, message, failure, state);
}
    

5.2 Master的对应处理

收到节点的SHARD_FAILED_ACTION_NAME消息后,Master通过reroute将失败的shard通过reroute迁移到新的节点,并更新集群状态。

5.3 异常流程总结

  1. 如果请求在协调节点的路由阶段失败,则会等待集群状态更新,拿到更新后,进行重试。如果再次失败,仍旧等集群状态更新。直到超时1分钟为止。超时后仍然失败则进行整体请求失败处理。
  2. 在主分片写入过程中,写入时阻塞的。只有写入成功,才会发起写副本请求。如果主shard写失败,则整个请求被认为处理失败。如果有部分副本写失败,则整个请求被认为处理成功。
  3. 无论主分片还是副分片,当写一个doc失败时,集群不会重试,而是关闭本地shard,然后向Master汇报。删除是以shard为单位的。

6. 系统特性

ES本身也是一个分布式存储系统,如同其他分布式系统一样,我们经常关注的一些特性如下:

  • 数据可靠性:通过分片副本和事务日志机制保障数据安全。
  • 服务可用性:在可用性和一致性的取舍方面。默认情况下ES更倾向于可用性,只要主分片可用即可执行写入操作。
  • 一致性:笔者认为是弱一致性。只要主分片写成功,数据就可能读取。因此读取操作在主分片和副本分片上可能会得到不同结果。
  • 原子性:索引的读写、别名更新是原子操作,不会出现中间状态。但bulk不是原子操作,不能用来实现事务。
  • 扩展性:主副分片都可以承担读请求,分担系统负载。

7. 思考

分析完写入流程后,也许读者已经意思到了这个过程的一个缺点:

  • 副本分片写入过程需要重新生成索引,不能单纯复制数据,浪费计算能力,影响入库速度。
  • 磁盘管理能力较差,对坏盘检查和容忍性比HDFS差不少。例如,在配置多磁盘路径的情况下,有一块坏盘就无法启动节点。

8. 关注我

搜索微信公众号:java架构强者之路
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/dwjf321/article/details/106673811