微服務治理2 - Eureka服務註冊

微服務治理1 - Eureka服務治理架構及客戶端裝配和啓動流程分析中介紹了Eureka Client端主要提供以下功能:

  • 服務註冊:服務在啓動時通過發送REST請求將自己註冊到Eureka註冊中心
  • 服務續約(Renew):週期性的向Eureka註冊中心發送心跳信息,以防止Eureka註冊中心將服務剔除
  • 服務下線:關閉服務時,向Eureka註冊中心發送REST請求,告訴Eureka註冊中心服務下線
  • 獲取服務列表:向Eureka註冊中心發送REST請求以獲取Eureka註冊中心上的註冊的服務清單

接下來將逐條分析上述功能的源碼。

1. 服務註冊

在文章微服務治理1 - Eureka服務治理架構及客戶端裝配和啓動流程分析中的步驟(14)~(15)中說明了服務註冊是在DiscoverClientinitScheduledTasks()函數中完成的,接下來看看initScheduledTasks()函數的源碼:

private void initScheduledTasks() {
    ......

    (1)if (clientConfig.shouldRegisterWithEureka()) {
        int renewalIntervalInSecs = instanceInfo.getLeaseInfo().getRenewalIntervalInSecs();
        int expBackOffBound = clientConfig.getHeartbeatExecutorExponentialBackOffBound();
        logger.info("Starting heartbeat executor: " + "renew interval is: {}", renewalIntervalInSecs);

        // Heartbeat timer
        (2)scheduler.schedule(
                new TimedSupervisorTask(
                        "heartbeat",
                        scheduler,
                        heartbeatExecutor,
                        renewalIntervalInSecs,
                        TimeUnit.SECONDS,
                        expBackOffBound,
                        new HeartbeatThread()
                ),
                renewalIntervalInSecs, TimeUnit.SECONDS);

        // InstanceInfo replicator
        (3)instanceInfoReplicator = new InstanceInfoReplicator(
                this,
                instanceInfo,
                clientConfig.getInstanceInfoReplicationIntervalSeconds(),
                2); // burstSize

        statusChangeListener = new ApplicationInfoManager.StatusChangeListener() {
            @Override
            public String getId() {
                return "statusChangeListener";
            }

            @Override
            public void notify(StatusChangeEvent statusChangeEvent) {
                if (InstanceStatus.DOWN == statusChangeEvent.getStatus() ||
                        InstanceStatus.DOWN == statusChangeEvent.getPreviousStatus()) {
                    // log at warn level if DOWN was involved
                    logger.warn("Saw local status change event {}", statusChangeEvent);
                } else {
                    logger.info("Saw local status change event {}", statusChangeEvent);
                }
                instanceInfoReplicator.onDemandUpdate();
            }
        };

        if (clientConfig.shouldOnDemandUpdateStatusChange()) {
            applicationInfoManager.registerStatusChangeListener(statusChangeListener);
        }

        (4)instanceInfoReplicator.start(clientConfig.getInitialInstanceInfoReplicationIntervalSeconds());
    } else {
        logger.info("Not registering with Eureka server per configuration");
    }
}

(1) 是否向Eureka Server註冊服務信息,默認爲true,可以通過eureka.client.register-with-eureka配置屬性控制

(2) 服務續約,在後續的章節詳細介紹

(3) 實例化InstanceInfoReplicator對象,該類的註釋如下:

/**
 * A task for updating and replicating the local instanceinfo to the remote server. Properties of this task are:
 * - configured with a single update thread to guarantee sequential update to the remote server
 * - update tasks can be scheduled on-demand via onDemandUpdate()
 * - task processing is rate limited by burstSize
 * - a new update task is always scheduled automatically after an earlier update task. However if an on-demand task
 *   is started, the scheduled automatic update task is discarded (and a new one will be scheduled after the new
 *   on-demand update).
 */

從註釋中可以看出其是任務類,負責將服務實例信息週期性的上報到Eureka server,其特性如下:

  • 兩個觸發場景:單線程週期性自動更新實例信息、通過調用onDemandUpdate()函數按需更新實例信息
  • 任務處理通過burstSize參數來控制頻率
  • 先創建的任務總是先執行,但是onDemandUpdate()方法中創建的任務會將週期性任務給丟棄掉

在實例化InstanceInfoReplicator對象時,傳入了4個參數,其中:

  • instanceInfo代表服務實例信息,該實例信息的初始化在請參考附錄代碼一
  • clientConfig.getInstanceInfoReplicationIntervalSeconds(),該方法返回的是服務實例信息向Eureka Server同步的週期(默認30s),可以通過eureka.client.instance-info-replication-interval-seconds屬性進行配置
  • 2爲burstSize的值,控制任務處理的頻率

(4) 通過調用instanceInfoReplicator.start()啓動週期性任務,傳入的參數爲clientConfig.getInitialInstanceInfoReplicationIntervalSeconds(),該參數代表產自注冊服務實例信息的延遲時間(默認40s),可以通過屬性eureka.client.initial-instance-info-replication-interval-seconds配置,這也是爲什麼首次註冊服務實例信息比較慢的原因

接下來進入InstanceInfoReplicator類中,分析其註冊服務信息的過程,其構造函數爲:

InstanceInfoReplicator(DiscoveryClient discoveryClient, InstanceInfo instanceInfo, int replicationIntervalSeconds, int burstSize) {
        this.discoveryClient = discoveryClient;
        this.instanceInfo = instanceInfo;
        (5)this.scheduler = Executors.newScheduledThreadPool(1,
                new ThreadFactoryBuilder()
                        .setNameFormat("DiscoveryClient-InstanceInfoReplicator-%d")
                        .setDaemon(true)
                        .build());

        this.scheduledPeriodicRef = new AtomicReference<Future>();

        this.started = new AtomicBoolean(false);
        (6)this.rateLimiter = new RateLimiter(TimeUnit.MINUTES);
        this.replicationIntervalSeconds = replicationIntervalSeconds;
        this.burstSize = burstSize;

        (7)this.allowedRatePerMinute = 60 * this.burstSize / this.replicationIntervalSeconds;
        logger.info("InstanceInfoReplicator onDemand update allowed rate per min is {}", allowedRatePerMinute);
    }

(5) core size爲1的線程池

(6) 工具類,用於限制單位時間內的任務次數

(7) 通過週期間隔,和burstSize參數,計算每分鐘允許的任務數

步驟(4)中通過調用InstanceInfoReplicator類中的start方法啓動任務,代碼如下:

public void start(int initialDelayMs) {
    (8)if (started.compareAndSet(false, true)) {
        instanceInfo.setIsDirty();  // for initial register
        (9)Future next = scheduler.schedule(this, initialDelayMs, TimeUnit.SECONDS);
        (10)scheduledPeriodicRef.set(next);
    }
}

(8) CAS操作,保證start方法的邏輯只執行一次

(9) 提交一個延時執行任務,由於第一個參數是this,因此延時結束時會調用InstanceInfoReplicator中的run方法

(10) 將任務的Feature對象放在成員變量scheduledPeriodicRef中,方便後續在調用onDemandUpdate函數時取消任務

當延遲結束時,會調用InstanceInfoReplicator中的run方法,代碼如下:

public void run() {
    try {
        (11)discoveryClient.refreshInstanceInfo();

        Long dirtyTimestamp = instanceInfo.isDirtyWithTime();
        (12)if (dirtyTimestamp != null) {
            (13)discoveryClient.register();
            instanceInfo.unsetIsDirty(dirtyTimestamp);
        }
    } catch (Throwable t) {
        logger.warn("There was a problem with the instance info replicator", t);
    } finally {
        Future next = scheduler.schedule(this, replicationIntervalSeconds, TimeUnit.SECONDS);
        scheduledPeriodicRef.set(next);
    }
}

(11) 更新服務實例信息

(12) 只有服務實例信息改變後纔會真正執行服務註冊操作,從而能夠減少網絡通信量

(13) 向Eureka Server註冊服務

可以看到最終雙調到了DiscoveryClient類的register()方法,代碼如下:

/**
 * Register with the eureka service by making the appropriate REST call.
 */
boolean register() throws Throwable {
    logger.info(PREFIX + "{}: registering service...", appPathIdentifier);
    EurekaHttpResponse<Void> httpResponse;
    try {
        (14)httpResponse = eurekaTransport.registrationClient.register(instanceInfo);
    } catch (Exception e) {
        logger.warn(PREFIX + "{} - registration failed {}", appPathIdentifier, e.getMessage(), e);
        throw e;
    }
    if (logger.isInfoEnabled()) {
        logger.info(PREFIX + "{} - registration status: {}", appPathIdentifier, httpResponse.getStatusCode());
    }
    return httpResponse.getStatusCode() == Status.NO_CONTENT.getStatusCode();
}

(14) 可以看到最終是通過發送REST請求來註冊服務實例信息

2. 服務註冊的REST請求分析

上節講到了最終調用DiscoveryClientregister()函數來註冊服務,本節看一下register()是如何同Eureka Server進行通信的。

Eureka Client同Eureka Server通信的所有API都封裝在接口EurekaHttpClient接口中,其中包含的API如下:
在這裏插入圖片描述
該接口的繼承關係如下(請讀者可自行分析):
在這裏插入圖片描述
register()調用時序圖如下(請讀者自行分析):
在這裏插入圖片描述
結合上面兩幅圖可知:

  • EurekaHttpClientDecorator採用的是裝飾器模式設計註冊的業務邏輯
  • 每個EurekaHttpClientDecorator的實現類在execute函數中封裝其特有的業務邏輯
  • 最終調JerseyApplicationClient實現真正的同Eureka Server通信

每個類的具體作用如下:

  • SessionEurekaHttpClient:維持與Eureka Server的會話,同時爲了防止長時間佔用一個Eureka Server和實現Eureka Server的負載均衡,會定期重連Eureka Server
  • RetryableEurekaHttpClient:當與某一個Eureka Server通信失敗時,嘗試與集羣中的其他Eureka Server通信,默認嘗試次數爲3,不可在屬性中配置。RetryableEurekaHttpClient會將失敗的Eureka Server放入其quarantineSet集合中(該集合的大小超過閾值後會被清空)
  • RedirectingEurekaHttpClient:處理Eureka Server重定向的業務邏輯
  • MetricsCollectingEurekaClient:分類統計與Eureka Server通信的異常信息
  • JerseyApplicationClient:完成與Eureka Server的通信

3. Eureka Client的Regin和Zone

在文章微服務治理1 - Eureka服務治理架構及客戶端裝配和啓動流程分析中,我們看到Eureka Server服務治理架構中有區域親和特性,即Eureka Client可以優先訪問與其處於同一個區域的Eureka Server。通過這種特性,配合實際部署的物理結構,就可以設計出對區域性故障的容錯集羣。在Eureka Client中通過Regin和Zone兩個概念來實現區域親和特性。

使用Region和Zone的一般配置方式如下:

eureka:
  client:
    service-url:
      test1: http://peer3.9003/erureka
      test2: http://peer3.9003/erureka
    region: test
    availability-zones:
      test: test1, test2

eureka.client.service-url是Map<String, String>類行,其中key爲Zone,value爲Eureka Server的地址,多個地址可以用逗號分割;eureka.client.region是String類型,代表當前Eureka Client的Region,eureka.client.availability-zones也是Map<String, String>類型,其中key爲Region,value爲Zone,多個Zone用逗號分割。可以看到Region和Zone的關係是一對多。

上一節介紹了當與某一個Eureka Server通信失敗時,RetryableEurekaHttpClient會嘗試與集羣中的其他Eureka Server通信,其中的關鍵代碼爲:

@Override
protected <R> EurekaHttpResponse<R> execute(RequestExecutor<R> requestExecutor) {
        List<EurekaEndpoint> candidateHosts = null;
        int endpointIdx = 0;
        for (int retry = 0; retry < numberOfRetries; retry++) {
            EurekaHttpClient currentHttpClient = delegate.get();
            EurekaEndpoint currentEndpoint = null;
            if (currentHttpClient == null) {
                if (candidateHosts == null) {
                    (15)candidateHosts = getHostCandidates();
                    
                (16)currentEndpoint = candidateHosts.get(endpointIdx++);
                currentHttpClient = clientFactory.newClient(currentEndpoint);
            }

            try {
                EurekaHttpResponse<R> response = requestExecutor.execute(currentHttpClient);
            } catch (Exception e) {
                
            }
        }
    }

(15) 獲得當前所有可用的Eureka Server

(16) 獲得下一個將要通信的Eureka Server

這裏的關鍵是步驟(15)中通過getHostCandidates()獲得所有Eureka Server,其通過調用AsyncResolver類中的getClusterEndpoints()獲得配置的所有Eureka Server信息,而AsyncResolver類的初始化則在EurekaHttpClients類中完成,代碼如下:

static ClosableResolver<AwsEndpoint> defaultBootstrapResolver(final EurekaClientConfig clientConfig, final InstanceInfo myInstanceInfo, final EndpointRandomizer randomizer) {
    (17)String[] availZones = clientConfig.getAvailabilityZones(clientConfig.getRegion());
    (18)String myZone = InstanceInfo.getZone(availZones, myInstanceInfo);

    (19)ClusterResolver<AwsEndpoint> delegateResolver = new ZoneAffinityClusterResolver(
                new ConfigClusterResolver(clientConfig, myInstanceInfo),
                myZone,
                true,
                randomizer
        );

     (20)List<AwsEndpoint> initialValue = delegateResolver.getClusterEndpoints();
     if (initialValue.isEmpty()) {
         String msg = "Initial resolution of Eureka server endpoints failed. Check ConfigClusterResolver logs for more info";
         logger.error(msg);
         failFastOnInitCheck(clientConfig, msg);
     }

     (21)return new AsyncResolver<>(
             EurekaClientNames.BOOTSTRAP,
             delegateResolver,
             initialValue,
             1,
             clientConfig.getEurekaServiceUrlPollIntervalSeconds() * 1000
        );
    }

(17) 根據Region獲得配置的可用Zone,代碼如下:

@Override
public String[] getAvailabilityZones(String region) {
		String value = this.availabilityZones.get(region);
		if (value == null) {
      //默認Zone爲defaultZone,即通過eureka.client.service-url.defaultZone配置的值
			value = DEFAULT_ZONE; 
		}
		return value.split(",");
}

如果使用本節開始介紹的一般配置,該步驟將獲得test1, test2

(18) 獲取服務實例所在的Zone,默認返回步驟(17)中獲得的第一個Zone

(19) 初始化ZoneAffinityClusterResolver

(20) 獲得配置的Eureka Server集羣節點,這個方法是最關鍵的方法,其代碼如下:

@Override
public List<AwsEndpoint> getClusterEndpoints() {
    //獲得配置的Eureka Server集羣節點
    List<AwsEndpoint>[] parts = ResolverUtils.splitByZone(delegate.getClusterEndpoints(), myZone);
    List<AwsEndpoint> myZoneEndpoints = parts[0];
    List<AwsEndpoint> remainingEndpoints = parts[1];
    List<AwsEndpoint> randomizedList = randomizeAndMerge(myZoneEndpoints, remainingEndpoints);
    if (!zoneAffinity) {
        Collections.reverse(randomizedList);
    }

    logger.debug("Local zone={}; resolved to: {}", myZone, randomizedList);
		//返回隨機化後的Eureka Server集羣節點信息
    return randomizedList;
}

其中delegate.getClusterEndpoints()函數最調用的是ConfigClusterResolver類中的getClusterEndpointsFromConfig()函數,後者又調用EndpointUtils類中的getServiceUrlsMapFromConfig函數以獲得配置的Eureka Server集羣節點信息。

(21) 初始化AsyncResolver

以上就是Region和Zone配置信息初始化及生效的代碼分析過程。

附錄

代碼一

該附錄分析一下本文步驟(3)中的instanceInfo實例的初始化過程。instanceInfo是類InstanceInfo類的實例,InstanceInfo即代表向Eureka Server註冊的服務實例信息,同時也是服務消費者從Eureka Server獲取的服務實例信息,比如應用名、ip地址、端口、homePageUrl、healthCheckUrl、statusPageUrl、securePort等。在DiscoveryClient實例中instanceInfo通過ApplicationInfoManager實例初始化,而後者的實例爲EurekaClientAutoConfiguration類中向Spring容器注入的bean(可以看下CloudEurekaClient的初始化過程),代碼如下:

@Bean
@ConditionalOnMissingBean(value = ApplicationInfoManager.class,
      search = SearchStrategy.CURRENT)
public ApplicationInfoManager eurekaApplicationInfoManager(
      EurekaInstanceConfig config) {
   (1)InstanceInfo instanceInfo = new InstanceInfoFactory().create(config);
   return new ApplicationInfoManager(config, instanceInfo);
}

(1) 通過工廠方法創建InstanceInfo實例,需要用到注入的EurekaInstanceConfig實例,其同樣是在EurekaClientAutoConfiguration類中初始化的,代碼如下:

@Bean
@ConditionalOnMissingBean(value = EurekaInstanceConfig.class,
      search = SearchStrategy.CURRENT)
public EurekaInstanceConfigBean eurekaInstanceConfigBean(InetUtils inetUtils,
      ManagementMetadataProvider managementMetadataProvider) {
   String hostname = getProperty("eureka.instance.hostname");
   boolean preferIpAddress = Boolean
         .parseBoolean(getProperty("eureka.instance.prefer-ip-address"));
   String ipAddress = getProperty("eureka.instance.ip-address");
   boolean isSecurePortEnabled = Boolean
         .parseBoolean(getProperty("eureka.instance.secure-port-enabled"));

   String serverContextPath = env.getProperty("server.servlet.context-path", "/");
   int serverPort = Integer.parseInt(
         env.getProperty("server.port", env.getProperty("port", "8080")));

   Integer managementPort = env.getProperty("management.server.port", Integer.class);
   String managementContextPath = env
         .getProperty("management.server.servlet.context-path");
   Integer jmxPort = env.getProperty("com.sun.management.jmxremote.port",
         Integer.class);
   EurekaInstanceConfigBean instance = new EurekaInstanceConfigBean(inetUtils);

   instance.setNonSecurePort(serverPort);
   instance.setInstanceId(getDefaultInstanceId(env));
   instance.setPreferIpAddress(preferIpAddress);
   instance.setSecurePortEnabled(isSecurePortEnabled);
   if (StringUtils.hasText(ipAddress)) {
      instance.setIpAddress(ipAddress);
   }

   if (isSecurePortEnabled) {
      instance.setSecurePort(serverPort);
   }

   if (StringUtils.hasText(hostname)) {
      instance.setHostname(hostname);
   }
   String statusPageUrlPath = getProperty("eureka.instance.status-page-url-path");
   String healthCheckUrlPath = getProperty("eureka.instance.health-check-url-path");

   if (StringUtils.hasText(statusPageUrlPath)) {
      instance.setStatusPageUrlPath(statusPageUrlPath);
   }
   if (StringUtils.hasText(healthCheckUrlPath)) {
      instance.setHealthCheckUrlPath(healthCheckUrlPath);
   }

   ManagementMetadata metadata = managementMetadataProvider.get(instance, serverPort,
         serverContextPath, managementContextPath, managementPort);

   if (metadata != null) {
      instance.setStatusPageUrl(metadata.getStatusPageUrl());
      instance.setHealthCheckUrl(metadata.getHealthCheckUrl());
      if (instance.isSecurePortEnabled()) {
         instance.setSecureHealthCheckUrl(metadata.getSecureHealthCheckUrl());
      }
      Map<String, String> metadataMap = instance.getMetadataMap();
      metadataMap.computeIfAbsent("management.port",
            k -> String.valueOf(metadata.getManagementPort()));
   }
   else {
      // without the metadata the status and health check URLs will not be set
      // and the status page and health check url paths will not include the
      // context path so set them here
      if (StringUtils.hasText(managementContextPath)) {
         instance.setHealthCheckUrlPath(
               managementContextPath + instance.getHealthCheckUrlPath());
         instance.setStatusPageUrlPath(
               managementContextPath + instance.getStatusPageUrlPath());
      }
   }

   setupJmxPort(instance, jmxPort);
   return instance;
}

可以看到我們在配置文件中設置的很多屬性最終都會在這裏生效。

Github博客地址
知乎

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章