SpringCloud 源码系列(2)— 注册中心Eureka 之 服务注册、续约

·  阅读 1004
SpringCloud 源码系列(2)— 注册中心Eureka 之 服务注册、续约

系列文章:

SpringCloud 源码系列(1)— 注册中心Eureka 之 启动初始化

服务注册

实例信息注册器初始化

服务注册的代码位置不容易发现,我们看 DiscoveryClient 初始化调度任务的这个方法,这段代码会去初始化一个实例信息复制器 InstanceInfoReplicator,这个复制器就包含了实例的注册(明明是注册却叫 Replicator 感觉怪怪的)。

1、DiscoveryClient 初始化调度器的流程

  • 先基于 DiscoveryClient、InstanceInfo 构造 InstanceInfoReplicator,然后还有两个参数为实例信息复制间隔时间(默认30秒)、并发的数量(默认为2)。
  • 创建了一个实例状态变更监听器,并注册到 ApplicationInfoManager。当实例状态变更时,就会触发这个监听器,并调用 InstanceInfoReplicator 的 onDemandUpdate 方法。
  • 启动 InstanceInfoReplicator,默认延迟40秒,也就是说服务启动可能40秒之后才会注册到注册中心(SpringCloud集成后是启动立即注册)。
private void initScheduledTasks() {
    // 省略定时刷新注册表的任务...

    if (clientConfig.shouldRegisterWithEureka()) {
        // 省略定时心跳的任务...

        // 实例信息复制器,用于定时更新自己状态,并向注册中心注册
        instanceInfoReplicator = new InstanceInfoReplicator(this, instanceInfo,
                clientConfig.getInstanceInfoReplicationIntervalSeconds(), 2);

        // 实例状态变更的监听器
        statusChangeListener = new ApplicationInfoManager.StatusChangeListener() {
            @Override
            public String getId() {
                return "statusChangeListener";
            }

            @Override
            public void notify(StatusChangeEvent statusChangeEvent) {
                // 通知实例重新注册
                instanceInfoReplicator.onDemandUpdate();
            }
        };

        // 向 ApplicationInfoManager 注册状态变更监听器
        if (clientConfig.shouldOnDemandUpdateStatusChange()) {
            applicationInfoManager.registerStatusChangeListener(statusChangeListener);
        }

        // 启动实例信息复制器,默认延迟时间40秒
        instanceInfoReplicator.start(clientConfig.getInitialInstanceInfoReplicationIntervalSeconds());
    } else {
        logger.info("Not registering with Eureka server per configuration");
    }
}
复制代码

2、InstanceInfoReplicator 的构造方法

  • 创建了一个单线程的调度器
  • 设置 started 为 false
  • 创建了以分钟为单位的限流器,每分钟默认最多只能调度4次
InstanceInfoReplicator(DiscoveryClient discoveryClient, InstanceInfo instanceInfo, int replicationIntervalSeconds, int burstSize) {
    this.discoveryClient = discoveryClient;
    this.instanceInfo = instanceInfo;
    // 单线程的调度器
    this.scheduler = Executors.newScheduledThreadPool(1,
            new ThreadFactoryBuilder().setNameFormat("DiscoveryClient-InstanceInfoReplicator-%d").setDaemon(true).build());

    this.scheduledPeriodicRef = new AtomicReference<Future>();
    // started 设置为 false
    this.started = new AtomicBoolean(false);
    // 以分钟为单位的限流器
    this.rateLimiter = new RateLimiter(TimeUnit.MINUTES);
    // 间隔时间,默认为30秒
    this.replicationIntervalSeconds = replicationIntervalSeconds;
    this.burstSize = burstSize;
    // 允许每分钟更新的频率 60 * 2 / 30 = 4
    this.allowedRatePerMinute = 60 * this.burstSize / this.replicationIntervalSeconds;
    logger.info("InstanceInfoReplicator onDemand update allowed rate per min is {}", allowedRatePerMinute);
}
复制代码

3、启动 InstanceInfoReplicator

  • started 设置为 true,代表已经启动了
  • 调用 instanceInfo.setIsDirty() 方法,将实例设置为 dirty=true,并更新了最后一次设置 dirty 的时间戳
  • InstanceInfoReplicator 实现了 Runnable,它本身被当成任务来调度,然后延迟40秒开始调度当前任务,并将 Future 放到本地变量中
public void start(int initialDelayMs) {
    // 启动时 started 设置为 true
    if (started.compareAndSet(false, true)) {
        // 设置为 dirty,便于下一次心跳时同步到 eureka server
        instanceInfo.setIsDirty();
        // 延迟40秒后开始调度当前任务
        Future next = scheduler.schedule(this, initialDelayMs, TimeUnit.SECONDS);
        // 将 Future 放到本地变量中
        scheduledPeriodicRef.set(next);
    }
}

public synchronized void setIsDirty() {
    isInstanceInfoDirty = true;
    lastDirtyTimestamp = System.currentTimeMillis();
}
复制代码

客户端实例注册

1、实现注册的run方法

接着看 InstanceInfoReplicator 的 run 方法,这个方法就是完成注册的核心位置。

  • 首先会更新实例的信息,如果有变更就会设置 dirty=true
  • 如过是 dirty 的,就会调用 DiscoveryClient 的 register 方法注册实例
  • 实例注册后,就把 dirty 设置为 false
  • 最后在 finally 中继续下一次的调度,默认是每隔30秒调度一次,注意他这里是把调度结果 Future 放到本地变量中
public void run() {
    try {
        // 更新本地实例信息,如果实例信息有变更,则 dirty=true
        discoveryClient.refreshInstanceInfo();

        // 设置为 dirty 时的时间戳
        Long dirtyTimestamp = instanceInfo.isDirtyWithTime();
        if (dirtyTimestamp != null) {
            // 注册实例
            discoveryClient.register();
            // 设置 dirty=false
            instanceInfo.unsetIsDirty(dirtyTimestamp);
        }
    } catch (Throwable t) {
        logger.warn("There was a problem with the instance info replicator", t);
    } finally {
        // 30秒之后再调度
        Future next = scheduler.schedule(this, replicationIntervalSeconds, TimeUnit.SECONDS);
        scheduledPeriodicRef.set(next);
    }
}
复制代码

2、实例信息刷新

再来细看下 refreshInstanceInfo 刷新实例信息的方法:

  • 首先刷新了数据中心的信息
  • 然后刷新续约信息,主要就是将 EurekaClientConfig 的续约配置与本地的续约配置做对比,如果变更了就重新创建续约信息,并设置实例为 dirty。这种情况一般就是运行期间动态更新实例的配置,然后重新注册实例信息。
  • 接着使用健康检查器检查实例健康状况,从 getHealthCheckHandler 这段代码进去不难发现,我们可以自定义健康检查器,例如当本地的一些资源未创建成功、某些核心线程池down了就认为实例不可用,这个时候就可以自定义健康检查器。如果没有自定义健康检查器,那就直接返回实例当前的状态。我们可以实现 HealthCheckHandler 接口自定义健康检查器。
  • 最后就会调用 ApplicationInfoManager 的 setInstanceStatus 设置实例状态,会判断如果状态发生变更,就会发出状态变更的通知,这样就会触发前面定义的状态变更监听器,然后调用 InstanceInfoReplicator 的 onDemandUpdate 方法。
void refreshInstanceInfo() {
    // 如果有必要,就更新数据中心的信息
    applicationInfoManager.refreshDataCenterInfoIfRequired();
    // 如果有必要,就更新续约信息,比如动态更新了配置文件,这时就更新续约信息 LeaseInfo,并将实例设置为 dirty
    applicationInfoManager.refreshLeaseInfoIfRequired();

    // 用监控检查器检查实例的状态
    InstanceStatus status = getHealthCheckHandler().getStatus(instanceInfo.getStatus());

    if (null != status) {
        // 设置实例状态,实例状态变了会触发状态变更的监听器
        applicationInfoManager.setInstanceStatus(status);
    }
}

public void refreshLeaseInfoIfRequired() {
    // 当前实例续约信息
    LeaseInfo leaseInfo = instanceInfo.getLeaseInfo();
    
    // 从配置中获取续约信息
    int currentLeaseDuration = config.getLeaseExpirationDurationInSeconds();
    int currentLeaseRenewal = config.getLeaseRenewalIntervalInSeconds();
    // 如果续约信息变了,就重新创建续约信息,并设置实例为 dirty
    if (leaseInfo.getDurationInSecs() != currentLeaseDuration || leaseInfo.getRenewalIntervalInSecs() != currentLeaseRenewal) {
    	// 利用构造器模式创建 LeaseInfo
        LeaseInfo newLeaseInfo = LeaseInfo.Builder.newBuilder()
                .setRenewalIntervalInSecs(currentLeaseRenewal)
                .setDurationInSecs(currentLeaseDuration)
                .build();
        instanceInfo.setLeaseInfo(newLeaseInfo);
        instanceInfo.setIsDirty();
    }
}

public HealthCheckHandler getHealthCheckHandler() {
    HealthCheckHandler healthCheckHandler = this.healthCheckHandlerRef.get();
    if (healthCheckHandler == null) {
        // 可以自定义 HealthCheckHandler 实现健康检查
        if (null != healthCheckHandlerProvider) {
            healthCheckHandler = healthCheckHandlerProvider.get();
        } else if (null != healthCheckCallbackProvider) {
            // 可以自定义 HealthCheckCallback 实现健康检查,HealthCheckCallback 已过期,建议使用 HealthCheckHandler
            healthCheckHandler = new HealthCheckCallbackToHandlerBridge(healthCheckCallbackProvider.get());
        }

        if (null == healthCheckHandler) {
            // 没有自定义的就是用默认的桥接类
            healthCheckHandler = new HealthCheckCallbackToHandlerBridge(null);
        }
        this.healthCheckHandlerRef.compareAndSet(null, healthCheckHandler);
    }

    return this.healthCheckHandlerRef.get();
}

public synchronized void setInstanceStatus(InstanceStatus status) {
    InstanceStatus next = instanceStatusMapper.map(status);

    // 如果状态变更了,才会返回之前的状态,然后触发状态变更监听器
    InstanceStatus prev = instanceInfo.setStatus(next);
    if (prev != null) {
        for (StatusChangeListener listener : listeners.values()) {
           listener.notify(new StatusChangeEvent(prev, next));
        }
    }
}
复制代码

3、向 eureka-server 注册

在 run 方法里调用了 discoveryClient.register() 方法实现了客户端实例向注册中心的注册,进入到 register 方法可以看到,他就是使用前面构造的 EurekaTransport 来发起远程调用。

一层层进去,很容易发现就是调用了 eureka-server 的 POST /apps/{appName} 接口,后面我们就从 eureka-core 中找这个接口就可以找到注册中心实现服务注册的入口了。

boolean register() throws Throwable {
    EurekaHttpResponse<Void> httpResponse;
    // registrationClient => JerseyReplicationClient
    httpResponse = eurekaTransport.registrationClient.register(instanceInfo);
    return httpResponse.getStatusCode() == Status.NO_CONTENT.getStatusCode();
}

public EurekaHttpResponse<Void> register(InstanceInfo info) {
    // 调用的是 POST apps/{appName} 接口
    String urlPath = "apps/" + info.getAppName();
    ClientResponse response = null;
    try {
        Builder resourceBuilder = jerseyClient.resource(serviceUrl).path(urlPath).getRequestBuilder();
        addExtraHeaders(resourceBuilder);
        response = resourceBuilder
                .header("Accept-Encoding", "gzip")
                .type(MediaType.APPLICATION_JSON_TYPE)
                .accept(MediaType.APPLICATION_JSON)
                // post 方法
                .post(ClientResponse.class, info);
        return anEurekaHttpResponse(response.getStatus()).headers(headersOf(response)).build();
    } finally {
        response.close();
    }
}
复制代码

4、注册中心设置实例状态为已启动

再回想下注册中心的初始化流程,在最后调用 openForTraffic 方法时,最后也会调用 ApplicationInfoManager 的 setInstanceStatus 方法,将实例状态设置为已启动,这个时候就会触发客户端注册到注册中心的动作。

applicationInfoManager.setInstanceStatus(InstanceStatus.UP);
复制代码

5、完成监听实例变更的方法

状态变更器会调用 onDemandUpdate 方法来完成实例状态变更后的逻辑。

  • 它这里一个是用到了限流器来限制每分钟这个方法只能被调用 4 次,即避免了频繁的注册行为
  • 然后在调度时,它会从本地变量中取出上一次调度的 Future,如果任务还没执行完,它会直接取消掉
  • 最后就是调用 run 方法,完成服务的注册
public boolean onDemandUpdate() {
    // 限流控制
    if (rateLimiter.acquire(burstSize, allowedRatePerMinute)) {
        if (!scheduler.isShutdown()) {
            scheduler.submit(new Runnable() {
                @Override
                public void run() {
                    // 如果上一次的任务还没有执行完,直接取消掉,然后执行注册的任务
                    Future latestPeriodic = scheduledPeriodicRef.get();
                    if (latestPeriodic != null && !latestPeriodic.isDone()) {
                        latestPeriodic.cancel(false);
                    }

                    InstanceInfoReplicator.this.run();
                }
            });
            return true;
        } else {
            logger.warn("Ignoring onDemand update due to stopped scheduler");
            return false;
        }
    } else {
        logger.warn("Ignoring onDemand update due to rate limiter");
        return false;
    }
}
复制代码

6、限流器 RateLimiter

最后简单看下限流器 RateLimiter 的设计:

  • 从它的注释中可以看出,eureka 的 RateLimiter 是基于令牌桶算法实现的限流器
  • acquire 方法有两个参数:
    • burstSize:允许以突发方式进入系统的最大请求数
    • averageRate:设置的时间窗口内允许进入的请求数
/**
 * Rate limiter implementation is based on token bucket algorithm. There are two parameters:
 *     burst size - maximum number of requests allowed into the system as a burst
 *     average rate - expected number of requests per second (RateLimiters using MINUTES is also supported)
 */
public class RateLimiter {
    private final long rateToMsConversion;

    private final AtomicInteger consumedTokens = new AtomicInteger();
    private final AtomicLong lastRefillTime = new AtomicLong(0);

    @Deprecated
    public RateLimiter() {
        this(TimeUnit.SECONDS);
    }

    public RateLimiter(TimeUnit averageRateUnit) {
        switch (averageRateUnit) {
            case SECONDS:
                rateToMsConversion = 1000;
                break;
            case MINUTES:
                rateToMsConversion = 60 * 1000;
                break;
            default:
                throw new IllegalArgumentException("TimeUnit of " + averageRateUnit + " is not supported");
        }
    }

    public boolean acquire(int burstSize, long averageRate) {
        return acquire(burstSize, averageRate, System.currentTimeMillis());
    }

    public boolean acquire(int burstSize, long averageRate, long currentTimeMillis) {
        if (burstSize <= 0 || averageRate <= 0) { // Instead of throwing exception, we just let all the traffic go
            return true;
        }

        refillToken(burstSize, averageRate, currentTimeMillis);
        return consumeToken(burstSize);
    }

    private void refillToken(int burstSize, long averageRate, long currentTimeMillis) {
        // 上一次填充 token 的时间
        long refillTime = lastRefillTime.get();
        // 时间差
        long timeDelta = currentTimeMillis - refillTime;
        // 固定生成令牌的速率,即每分钟4次
        // 例如刚好间隔15秒进来一个请求,就是 15000 * 4 / 60000 = 1,newTokens 代表间隔了多少次,如果等于0,说明间隔不足15秒
        long newTokens = timeDelta * averageRate / rateToMsConversion;
        if (newTokens > 0) {
            long newRefillTime = refillTime == 0
                    ? currentTimeMillis
                    // 注意这里不是直接设置的当前时间戳,而是根据 newTokens 重新计算的,因为有可能同一周期内同时有多个请求进来,这样可以保持一个固定的周期
                    : refillTime + newTokens * rateToMsConversion / averageRate;
            if (lastRefillTime.compareAndSet(refillTime, newRefillTime)) {
                while (true) {
                    // 调整令牌的数量
                    int currentLevel = consumedTokens.get();
                    int adjustedLevel = Math.min(currentLevel, burstSize);
                    // currentLevel 可能为2,重置为了 0 或 1
                    int newLevel = (int) Math.max(0, adjustedLevel - newTokens);
                    if (consumedTokens.compareAndSet(currentLevel, newLevel)) {
                        return;
                    }
                }
            }
        }
    }

    private boolean consumeToken(int burstSize) {
        while (true) {
            int currentLevel = consumedTokens.get();
            // 突发数量为2,也就是允许15秒内最多有两次请求进来
            if (currentLevel >= burstSize) {
                return false;
            }
            if (consumedTokens.compareAndSet(currentLevel, currentLevel + 1)) {
                return true;
            }
        }
    }

    public void reset() {
        consumedTokens.set(0);
        lastRefillTime.set(0);
    }
}
复制代码

Eureka Server 接收注册请求

1、找到实例注册的API入口

从前面的分析中,我们知道服务端注册的API是 POST /apps/{appName},由于 eureka 是基于 jersey 来通信的,想找到API入口还是有点费劲的,至少没有 springmvc 那么容易。

先看 ApplicationsResource 这个类,可以找到 getApplicationResource 这个方法的路径是符合 /apps/{appName} 这个规则的。然后可以看到它里面创建了 ApplicationResource,再进入到这个类里面,就可以找到 @Post 标注的 addInstance 方法,这就是注册的入口了。可以看到它是调用了注册表的 register 方法来注册实例的。

@Path("/{version}/apps")
@Produces({"application/xml", "application/json"})
public class ApplicationsResource {

    // 符合规则 /apps/{appName}
    @Path("{appId}")
    public ApplicationResource getApplicationResource(@PathParam("version") String version, @PathParam("appId") String appId) {
        // 真正的入口
        return new ApplicationResource(appId, serverConfig, registry);
    }
}

@Produces({"application/xml", "application/json"})
public class ApplicationResource {
    private final PeerAwareInstanceRegistry registry;

    @POST
    @Consumes({"application/json", "application/xml"})
    public Response addInstance(InstanceInfo info, @HeaderParam(PeerEurekaNode.HEADER_REPLICATION) String isReplication) {
    	// 调用注册表的注册方法来注册实例
        registry.register(info, "true".equals(isReplication));
        return Response.status(204).build();  // 204 to be backwards compatible
    }
}
复制代码

addInstance 接口有两个参数:

  • InstanceInfo:服务实例,主要有两块数据:
    • 基本信息:主机名、IP地址、端口号、URL地址
    • 租约信息:保持心跳的间隔时间、最近心跳的时间、服务注册的时间、服务启动的时间
  • isReplication:这个参数是从请求头中取的,表示是否是在同步 server 节点的实例。在集群模式下,因为客户端实例注册到注册中心后,会同步到其它 server节点,所以如果是eureka-server之间同步信息,这个参数就为 true,避免循环同步。

2、实例注册

进入到注册表的 register 方法,可以看到主要就是调用父类的 register 方法注册实例,然后同步到 eureka server 集群中的其它 server 节点。集群同步放到后面来看,现在只需要知道注册实例时会同步到其它server节点即可。

@Override
public void register(final InstanceInfo info, final boolean isReplication) {
    int leaseDuration = Lease.DEFAULT_DURATION_IN_SECS;
    // 如果实例中没有周期的配置,就设置为默认的 90 秒
    if (info.getLeaseInfo() != null && info.getLeaseInfo().getDurationInSecs() > 0) {
        leaseDuration = info.getLeaseInfo().getDurationInSecs();
    }
    // 注册实例
    super.register(info, leaseDuration, isReplication);
    // 复制到集群其它 server 节点
    replicateToPeers(Action.Register, info.getAppName(), info.getId(), info, null, isReplication);
}
复制代码

接着看父类的注册方法,它的主要流程如下:

  • 首先可以看到 eureka server 保存注册表(registry)的数据结构是 ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>,key 就是服务名称,value 就是对应的实例,因为一个服务可能会部署多个实例。
  • 根据服务名称从注册表拿到实例表,然后根据实例ID拿到实例的租约信息 Lease<InstanceInfo>
  • 如果租约信息存在,说明已经注册过相同的实例了,然后就对比已存在实例和新注册实例的最后更新时间,如果新注册的是旧的,就替换为已存在的实例来完成注册。
  • 如果租约信息不存在,说明是一个新注册的实例,这时会更新两个阈值:
    • 期望续约的客户端数量 +1
    • 每分钟续约次数的阈值,如果低于这个值,说明有很多客户端没有发送心跳,这时eureka就认为可能网络出问题了,就会有另一些机制,这个后面再说
  • 然后就根据注册的实例信息和续约周期创建新的租约,并放入注册表中去。
  • 接着根据当前时间戳、服务名称、实例ID封装一个 Pair,然后放入到最近注册的队列中 recentRegisteredQueue,先记住这个队列就行了。
  • 根据实例的 overriddenStatus 判断,不为空的话,可能就只是要更新实例的状态,这个时候就会只变更实例的状态,而不会改变 dirty。
  • 然后是设置了实例的启动时间戳,设置了实例的 ActionType 为 ADDED
  • 将租约加入到最近变更的队列 recentlyChangedQueue,先记住这个队列,这个队列就是增量抓取注册表的来源。
  • 最后一步失效缓存,一步步进去可以发现,主要就是将读写缓存 readWriteCacheMap 中与这个实例相关的缓存失效掉,这个缓存后面分析抓取注册表的时候再来细看。
public void register(InstanceInfo registrant, int leaseDuration, boolean isReplication) {
    read.lock();
    try {
        // registry 结构 => ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>
        Map<String, Lease<InstanceInfo>> gMap = registry.get(registrant.getAppName());
        REGISTER.increment(isReplication);
        if (gMap == null) {
            // 初次注册时,创建一个 ConcurrentHashMap,key 为 appName
            final ConcurrentHashMap<String, Lease<InstanceInfo>> gNewMap = new ConcurrentHashMap<String, Lease<InstanceInfo>>();
            gMap = registry.putIfAbsent(registrant.getAppName(), gNewMap);
            if (gMap == null) {
                gMap = gNewMap;
            }
        }
        Lease<InstanceInfo> existingLease = gMap.get(registrant.getId());
        // Retain the last dirty timestamp without overwriting it, if there is already a lease
        if (existingLease != null && (existingLease.getHolder() != null)) {
            // 已存在的实例的最后更新时间
            Long existingLastDirtyTimestamp = existingLease.getHolder().getLastDirtyTimestamp();
            // 新注册的实例的最后更新时间
            Long registrationLastDirtyTimestamp = registrant.getLastDirtyTimestamp();

            // 如果存在的实例比新注册尽量的实例后更新,就直接把新注册的实例设置为已存在的实例
            if (existingLastDirtyTimestamp > registrationLastDirtyTimestamp) {
                registrant = existingLease.getHolder();
            }
        } else {
            // 新注册时,续约信息不存在
            synchronized (lock) {
                if (this.expectedNumberOfClientsSendingRenews > 0) {
                    // Since the client wants to register it, increase the number of clients sending renews
                    // 期望续约的客户端数量 + 1
                    this.expectedNumberOfClientsSendingRenews = this.expectedNumberOfClientsSendingRenews + 1;
                    // 更新每分钟续约请求次数的阀值,这个阀值在后面很多地方都会用到
                    updateRenewsPerMinThreshold();
                }
            }
        }
        // 创建新的续约
        Lease<InstanceInfo> lease = new Lease<InstanceInfo>(registrant, leaseDuration);
        if (existingLease != null) {
            lease.setServiceUpTimestamp(existingLease.getServiceUpTimestamp());
        }
        gMap.put(registrant.getId(), lease);
        // 放入最近注册的队列
        recentRegisteredQueue.add(new Pair<Long, String>(System.currentTimeMillis(),
                registrant.getAppName() + "(" + registrant.getId() + ")"));
        // 覆盖状态
        if (!InstanceStatus.UNKNOWN.equals(registrant.getOverriddenStatus())) {
            if (!overriddenInstanceStatusMap.containsKey(registrant.getId())) {
                overriddenInstanceStatusMap.put(registrant.getId(), registrant.getOverriddenStatus());
            }
        }
        InstanceStatus overriddenStatusFromMap = overriddenInstanceStatusMap.get(registrant.getId());
        if (overriddenStatusFromMap != null) {
            registrant.setOverriddenStatus(overriddenStatusFromMap);
        }

        InstanceStatus overriddenInstanceStatus = getOverriddenInstanceStatus(registrant, existingLease, isReplication);
        // 仅仅是变更实例状态,不会设置为 dirty
        registrant.setStatusWithoutDirty(overriddenInstanceStatus);

        if (InstanceStatus.UP.equals(registrant.getStatus())) {
            // UP 时设置 Lease 的时间戳
            lease.serviceUp();
        }
        // 设置动作是 ADDED,这个在后面会做 switch 判断
        registrant.setActionType(ActionType.ADDED);
        // 添加到最近变更的队列
        recentlyChangedQueue.add(new RecentlyChangedItem(lease));
        // 设置最后更新时间
        registrant.setLastUpdatedTimestamp();
        // 失效缓存
        invalidateCache(registrant.getAppName(), registrant.getVIPAddress(), registrant.getSecureVipAddress());
    } finally {
        read.unlock();
    }
}
复制代码

更新每分钟续约次数的阈值:

  • 每分钟续约阈值 = 期望续约的客户端数量 * (60 / 续约间隔时间) * 续约百分比

  • 例如,一共注册了 10 个实例,那么期望续约的客户端数量为 10,间隔时间默认为 30秒,就是每个客户端应该每30秒发送一次心跳,续约百分比默认为 0.85,那么每分钟续约次数阈值 = 10 * (60.0 / 30) * 0.85 = 17,也就是说每分钟至少要接收到 17 次续约请求。

protected void updateRenewsPerMinThreshold() {
    this.numberOfRenewsPerMinThreshold = (int) (this.expectedNumberOfClientsSendingRenews
            * (60.0 / serverConfig.getExpectedClientRenewalIntervalSeconds())
            * serverConfig.getRenewalPercentThreshold());
}
复制代码

这就是注册表 registry 缓存服务实例信息的结构,可以看出 eureka 是基于内存来组织注册表的,使用的是 ConcurrentHashMap 来保证多线程并发安全。

Eureka Server 控制台

前面已经将服务实例注册上去了,现在来看下 eureka server 的控制台页面是怎么获取这些数据的。

前面已经分析过 eureka-server 的 web.xml 中配置了欢迎页为 status.jsp ,这就是控制台的页面。

从 status.jsp 可以看出,其实就是从 EurekaServerContext 上下文获取注册表,然后读取注册表注册的服务实例,然后遍历展示到表格中。

     // 获取 eureka server 上下文 EurekaServerContext
     EurekaServerContext serverContext = (EurekaServerContext) pageContext.getServletContext()
             .getAttribute(EurekaServerContext.class.getName());
     // 从上下文中取出注册表,获取 Application
     for(Application app : serverContext.getRegistry().getSortedApplications()) {
         out.print("<tr><td><b>" + app.getName() + "</b></td>");
         Map<String, Integer> amiCounts = new HashMap<String, Integer>();
         Map<InstanceStatus,List<Pair<String, String>>> instancesByStatus = new HashMap<InstanceStatus, List<Pair<String,String>>>();
         Map<String,Integer> zoneCounts = new HashMap<String, Integer>();
         // 实例信息
         for(InstanceInfo info : app.getInstances()){
             String id = info.getId();
             String url = info.getStatusPageUrl();
             InstanceStatus status = info.getStatus();
             String ami = "n/a";
         }
     }
复制代码

服务注册的整体流程图

面通过一张图来看看服务实例注册的整个流程。

服务续约

在分布式系统中,服务续约机制是非常重要的,这样能让中心系统(注册中心)知道客户端还存活着。接下来就来看看服务续约的机制。

Eureka Client 定时发送心跳

在初始化 DiscoveryClient 的调度任务时,下面这部分代码就是在创建定时发送心跳的任务,心跳每隔30秒发送一次。发送心跳的接口是 PUT /apps/{appName}/{instanceId}

private void initScheduledTasks() {
    // 定时刷新本地缓存...

    if (clientConfig.shouldRegisterWithEureka()) {
        // 续约间隔时间,默认30秒
        int renewalIntervalInSecs = instanceInfo.getLeaseInfo().getRenewalIntervalInSecs();
        // 心跳调度器的延迟时间扩大倍数,默认10
        int expBackOffBound = clientConfig.getHeartbeatExecutorExponentialBackOffBound();
        logger.info("Starting heartbeat executor: " + "renew interval is: {}", renewalIntervalInSecs);

        // 心跳的定时任务
        heartbeatTask = new TimedSupervisorTask(
                "heartbeat", scheduler, heartbeatExecutor,
                renewalIntervalInSecs, TimeUnit.SECONDS, expBackOffBound,
                new HeartbeatThread()
        );
        // 30秒后开始调度心跳的任务
        scheduler.schedule(heartbeatTask, renewalIntervalInSecs, TimeUnit.SECONDS);

        // 服务注册...
    }
}

private class HeartbeatThread implements Runnable {
    public void run() {
        if (renew()) {
        	// 更新最后心跳时间
            lastSuccessfulHeartbeatTimestamp = System.currentTimeMillis();
        }
    }
}

boolean renew() {
    EurekaHttpResponse<InstanceInfo> httpResponse;
    try {
        // 发送心跳的接口:PUT /apps/{appName}/{instanceId}
        httpResponse = eurekaTransport.registrationClient.sendHeartBeat(instanceInfo.getAppName(), instanceInfo.getId(), instanceInfo, null);
        if (httpResponse.getStatusCode() == Status.NOT_FOUND.getStatusCode()) {
            REREGISTER_COUNTER.increment();
            long timestamp = instanceInfo.setIsDirtyWithTime();
            // 服务端未找到对应的实例,就重新注册
            boolean success = register();
            if (success) {
                instanceInfo.unsetIsDirty(timestamp);
            }
            return success;
        }
        // 续约成功
        return httpResponse.getStatusCode() == Status.OK.getStatusCode();
    } catch (Throwable e) {
        return false;
    }
}
复制代码

Eureka Server 接收心跳续约

顺着 PUT /apps/{appName}/{instanceId} 找可以发现,服务端接收注册的入口在 InstanceResourcerenewLease 方法中,它调用了注册表单 renew 方法进行服务续约。

@PUT
public Response renewLease(
        @HeaderParam(PeerEurekaNode.HEADER_REPLICATION) String isReplication,
        @QueryParam("overriddenstatus") String overriddenStatus,
        @QueryParam("status") String status,
        @QueryParam("lastDirtyTimestamp") String lastDirtyTimestamp) {
    boolean isFromReplicaNode = "true".equals(isReplication);
    // 调用注册表的 renew 进行服务续约
    boolean isSuccess = registry.renew(app.getName(), id, isFromReplicaNode);

    // 注册表中没有实例,续约失败,返回 NOT_FOUND,让客户端重新注册,在集群同步时会用到
    if (!isSuccess) {
        logger.warn("Not Found (Renew): {} - {}", app.getName(), id);
        return Response.status(Status.NOT_FOUND).build();
    }
    //....
    return response;
}
复制代码

进去可以看到,调用了父类的 renew 方法续约,然后会判断 isReplication ,如果是复制,说明是来自 eureka-server 集群中其它节点的同步请求,就复制到其它节点。复制到其它集群这块代码在前面已经提到过了,就不再展示。

public boolean renew(final String appName, final String id, final boolean isReplication) {
    // 调用父类(AbstractInstanceRegistry)的 renew 续约
    if (super.renew(appName, id, isReplication)) {
        // 续约完成后同步到集群其它节点
        replicateToPeers(Action.Heartbeat, appName, id, null, null, isReplication);
        return true;
    }
    return false;
}
复制代码

接着看父类的 renew 续约方法:

  • 首先根据服务名从注册表中取出租约信息
  • 然后根据实例ID取出实例的租约信息
  • 然后判断是否是覆盖实例状态
  • 将最近一分钟续约次数计数器 renewsLastMin +1
  • 最后调用实例租约对象的 renew 方法进行续约,其内部只是更新了租约的最后更新时间 lastUpdateTimestamp,更新为当前时间 + 续约间隔时间
public boolean renew(String appName, String id, boolean isReplication) {
    RENEW.increment(isReplication);
    // 根据服务名从注册表取出租约信息
    Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
    Lease<InstanceInfo> leaseToRenew = null;
    if (gMap != null) {
        // 根据实例ID取出实例租约信息
        leaseToRenew = gMap.get(id);
    }
    if (leaseToRenew == null) {
        RENEW_NOT_FOUND.increment(isReplication);
        return false;
    } else {
        InstanceInfo instanceInfo = leaseToRenew.getHolder();
        if (instanceInfo != null) {
            InstanceStatus overriddenInstanceStatus = this.getOverriddenInstanceStatus(instanceInfo, leaseToRenew, isReplication);
            if (overriddenInstanceStatus == InstanceStatus.UNKNOWN) {
                RENEW_NOT_FOUND.increment(isReplication);
                return false;
            }
            if (!instanceInfo.getStatus().equals(overriddenInstanceStatus)) {
                instanceInfo.setStatusWithoutDirty(overriddenInstanceStatus);
            }
        }
        // 最近一分钟续约计数器+1
        renewsLastMin.increment();
        // 续约
        leaseToRenew.renew();
        return true;
    }
}

public void renew() {
    // 更新最后更新时间,在当前时间的基础上加了周期时间,默认90秒
    lastUpdateTimestamp = System.currentTimeMillis() + duration;
}
复制代码
分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改