SpringCloud 源码系列(4)— 注册中心Eureka 之 服务下线、故障、自我保护机制

1,859

系列文章:

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

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

SpringCloud 源码系列(3)— 注册中心Eureka 之 抓取注册表

服务下线

Eureka Client 下线

eureka client 服务关闭停止时,会触发 DiscoveryClient 的 shutdown 关闭 eureka-client,我们就从 shutdown 方法来看看 eureka-client 的下线。

  • 首先移除了注册的状态变更器,这个时候不再需要监听实例状态的变更了
  • 然后关闭了一系列的调度任务,停止与 eureka-server 的交互,比如定时发送心跳。同时也释放了资源。
  • 之后调用了 unregister 取消注册,其实就是调用了 server 端的 DELETE /apps/{appName}/{instanceId} 下线实例
  • 最后再关闭了一些其它资源,如 EurekaTransport。
@PreDestroy
@Override
public synchronized void shutdown() {
    if (isShutdown.compareAndSet(false, true)) {
        logger.info("Shutting down DiscoveryClient ...");

        // 移除状态变更监听器
        if (statusChangeListener != null && applicationInfoManager != null) {
            applicationInfoManager.unregisterStatusChangeListener(statusChangeListener.getId());
        }

        // 停止调度任务,释放资源:
        //    instanceInfoReplicator、heartbeatExecutor、cacheRefreshExecutor
        //    scheduler、cacheRefreshTask、heartbeatTask
        cancelScheduledTasks();

        // If APPINFO was registered
        if (applicationInfoManager != null
                && clientConfig.shouldRegisterWithEureka()
                && clientConfig.shouldUnregisterOnShutdown()) {
            applicationInfoManager.setInstanceStatus(InstanceStatus.DOWN);
            // 调用 eureka-server 的下线接口下线实例
            unregister();
        }

        // 继续释放资源
        if (eurekaTransport != null) {
            eurekaTransport.shutdown();
        }
        heartbeatStalenessMonitor.shutdown();
        registryStalenessMonitor.shutdown();
    }
}

void unregister() {
    // It can be null if shouldRegisterWithEureka == false
    if(eurekaTransport != null && eurekaTransport.registrationClient != null) {
        try {
            logger.info("Unregistering ...");
            // 取消注册:DELETE /apps/{appName}/{instanceId}
            EurekaHttpResponse<Void> httpResponse = eurekaTransport.registrationClient.cancel(instanceInfo.getAppName(), instanceInfo.getId());
            logger.info(PREFIX + "{} - deregister  status: {}", appPathIdentifier, httpResponse.getStatusCode());
        } catch (Exception e) {
            logger.error(PREFIX + "{} - de-registration failed{}", appPathIdentifier, e.getMessage(), e);
        }
    }
}

Eureka Server 服务下线

顺着 DELETE /apps/{appName}/{instanceId} 接口可以找到 InstanceResoucecancelLease 方法就是客户端下线的入口。

进入注册的 cancel 方法,可以看到与前面的一些接口是类似的,先调用服务的 cancel 方法下线实例,然后调用 replicateToPeers 复制到集群中其它节点。然后 cancel 方法其实是调用的 internalCancel 方法。

@DELETE
public Response cancelLease(@HeaderParam(PeerEurekaNode.HEADER_REPLICATION) String isReplication) {
    try {
        // 下线实例
        boolean isSuccess = registry.cancel(app.getName(), id, "true".equals(isReplication));

        if (isSuccess) {
            return Response.ok().build();
        } else {
            return Response.status(Status.NOT_FOUND).build();
        }
    } catch (Throwable e) {
        return Response.serverError().build();
    }
}

public boolean cancel(final String appName, final String id, final boolean isReplication) {
    // 下线实例
    if (super.cancel(appName, id, isReplication)) {
        // 复制到集群
        replicateToPeers(Action.Cancel, appName, id, null, null, isReplication);

        return true;
    }
    return false;
}

public boolean cancel(String appName, String id, boolean isReplication) {
    // 调用内部方法下线实例
    return internalCancel(appName, id, isReplication);
}

再来看下 internalCancel 方法:

  • 首先根据服务名从注册表取出服务所有实例的租约信息,再根据实例ID移除实例租约信息
  • 将移除的实例加入到最近下线的一个循环队列 recentCanceledQueue
  • 下线实例,注意这里设置了实例的下线时间 evictionTimestamp 为当前时间
  • 然后设置实例的 ActionType 为 DELETED,再将下线的实例加入最近变更的队列 recentlyChangedQueue
  • 之后失效掉缓存 readWriteCacheMap,服务实例变更了就必须清理缓存。不过 readWriteCacheMap 可能要30秒才会同步到 readOnlyCacheMap
  • 最后将期望续约的客户端数量-1,然后更新了每分钟续约次数阈值
protected boolean internalCancel(String appName, String id, boolean isReplication) {
    read.lock();
    try {
        CANCEL.increment(isReplication);
        // 根据服务名称取出服务租约信息
        Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
        Lease<InstanceInfo> leaseToCancel = null;
        if (gMap != null) {
            // 根据实例ID移除实例租约信息
            leaseToCancel = gMap.remove(id);
        }
        // 将移除的实例ID加入到最近下线的队列中
        recentCanceledQueue.add(new Pair<Long, String>(System.currentTimeMillis(), appName + "(" + id + ")"));
        InstanceStatus instanceStatus = overriddenInstanceStatusMap.remove(id);
        
        if (leaseToCancel == null) {
            CANCEL_NOT_FOUND.increment(isReplication);
            return false;
        } else {
            // 下线实例,设置了实例的下线时间 evictionTimestamp 为当前时间戳
            leaseToCancel.cancel();
            InstanceInfo instanceInfo = leaseToCancel.getHolder();
            String vip = null;
            String svip = null;
            if (instanceInfo != null) {
                // 设置实例 ActionType 为 DELETED
                instanceInfo.setActionType(ActionType.DELETED);
                // 加入最近变更队列中
                recentlyChangedQueue.add(new RecentlyChangedItem(leaseToCancel));
                // 更新时间
                instanceInfo.setLastUpdatedTimestamp();
                vip = instanceInfo.getVIPAddress();
                svip = instanceInfo.getSecureVipAddress();
            }
            // 失效缓存
            invalidateCache(appName, vip, svip);
            logger.info("Cancelled instance {}/{} (replication={})", appName, id, isReplication);
        }
    } finally {
        read.unlock();
    }

    synchronized (lock) {
        if (this.expectedNumberOfClientsSendingRenews > 0) {
        	// 期望续约的客户端数量 - 1
            this.expectedNumberOfClientsSendingRenews = this.expectedNumberOfClientsSendingRenews - 1;
            // 更新每分钟续约次数的阈值
            updateRenewsPerMinThreshold();
        }
    }
    return true;
}

服务故障

服务正常停止时,会触发 DiscoveryClient 的 shutdown 方法关闭 eureka-client,并向注册中心发送下线的通知。但如果客户端宕机或非正常关机,注册中心就无法接收到客户端下线的通知了,这时注册中心就会有一个定时任务,根据心跳来判断客户端实例是否故障下线了,然后摘除故障的实例。

摘除实例的定时任务初始化

EurekaBootStrap 初始化的最后几步中,调用了注册表的 openForTraffic 做一些最后的设置,其中最后一步调用了 super.postInit 方法做最后的初始化,里面就创建了定时摘除过期实例的调度任务。

registry.openForTraffic(applicationInfoManager, registryCount);
public void openForTraffic(ApplicationInfoManager applicationInfoManager, int count) {
    // 期望的客户端每分钟的续约次数
    this.expectedNumberOfClientsSendingRenews = count;
    // 更新每分钟续约阈值
    updateRenewsPerMinThreshold();
    //...
    // 设置实例状态为已启动
    applicationInfoManager.setInstanceStatus(InstanceStatus.UP);
    // 调用父类的后置初始化
    super.postInit();
}

postInit

  • 首先是开启了最近一分钟续约次数的计数器
  • 然后创建了定时摘除过期实例的调度任务,调度任务每隔60秒执行一次
protected void postInit() {
    // 启动 统计最近一分钟续约次数的计数器
    renewsLastMin.start();
    if (evictionTaskRef.get() != null) {
        evictionTaskRef.get().cancel();
    }
    // 定时剔除任务
    evictionTaskRef.set(new EvictionTask());
    evictionTimer.schedule(evictionTaskRef.get(),
            serverConfig.getEvictionIntervalTimerInMs(),
            // 每隔60秒执行一次
            serverConfig.getEvictionIntervalTimerInMs());
}

定时摘除过期的实例

1、摘除实例的定时任务

可以看到,每次运行摘除实例的任务时,会先获取一个补偿时间,因为两次 EvictionTask 执行的间隔时间可能超过了设置的60秒,比如 GC 导致的停顿或本地时间漂移导致计时不准确等。然后就调用了 evict 方法摘除实例。

在计算时间差的场景中,这种补偿时间的思路是值得学习的,要考虑到时间差的不准确性。

class EvictionTask extends TimerTask {
    private final AtomicLong lastExecutionNanosRef = new AtomicLong(0l);

    @Override
    public void run() {
        try {
            // 获取补偿时间,因为两次 EvictionTask 执行的间隔时间可能超过了设置的60秒,比如 GC 导致的停顿或本地时间漂移导致计时不准确
            long compensationTimeMs = getCompensationTimeMs();
            logger.info("Running the evict task with compensationTime {}ms", compensationTimeMs);
            evict(compensationTimeMs);
        } catch (Throwable e) {
            logger.error("Could not run the evict task", e);
        }
    }

    long getCompensationTimeMs() {
        long currNanos = getCurrentTimeNano();
        long lastNanos = lastExecutionNanosRef.getAndSet(currNanos);
        if (lastNanos == 0L) {
            return 0L;
        }
        // 两次任务运行的间隔时间
        long elapsedMs = TimeUnit.NANOSECONDS.toMillis(currNanos - lastNanos);
        // 补偿时间 = 任务运行间隔时间 - 剔除任务的间隔时间(默认60秒)
        long compensationTime = elapsedMs - serverConfig.getEvictionIntervalTimerInMs();
        return compensationTime <= 0L ? 0L : compensationTime;
    }

    long getCurrentTimeNano() {  // for testing
        return System.nanoTime();
    }
}

2、摘除实例

摘除实例的过程如下:

  • 首先判断是否启用了租约过期的机制(主要就是自我保护机制,下一章节再说)。
  • 遍历注册表,判断实例是否过期,将过期的实例加入集合列表中。
  • 计算摘除实例的数量限制,主要就是出于自我保护机制,避免一次摘除过多实例。
  • 然后就是从要摘除的集合中随机选择限制数量内的过期实例来摘除掉。
  • 摘除实例其实就是调用了实例下线的方法 internalCancel,主要就是从注册表中移除实例、加入最近变更的队列、失效缓存等,具体可以回看服务下线那节。
public void evict(long additionalLeaseMs) {
    logger.debug("Running the evict task");

    // 是否启用了租约过期
    if (!isLeaseExpirationEnabled()) {
        logger.debug("DS: lease expiration is currently disabled.");
        return;
    }

    List<Lease<InstanceInfo>> expiredLeases = new ArrayList<>();
    for (Entry<String, Map<String, Lease<InstanceInfo>>> groupEntry : registry.entrySet()) {
        Map<String, Lease<InstanceInfo>> leaseMap = groupEntry.getValue();
        if (leaseMap != null) {
            for (Entry<String, Lease<InstanceInfo>> leaseEntry : leaseMap.entrySet()) {
                Lease<InstanceInfo> lease = leaseEntry.getValue();
                // 判断实例租约是否过期
                if (lease.isExpired(additionalLeaseMs) && lease.getHolder() != null) {
                    // 加入到过期的集合列表中
                    expiredLeases.add(lease);
                }
            }
        }
    }

    // 先获取注册表已注册的实例数量
    int registrySize = (int) getLocalRegistrySize();
    // 注册表数量保留的阈值:已注册的实例数 * 续约百分比阈值(默认0.85)
    int registrySizeThreshold = (int) (registrySize * serverConfig.getRenewalPercentThreshold());
    // 剔除的数量限制,也就是说一次最多只能剔除 15% 的实例
    int evictionLimit = registrySize - registrySizeThreshold;

    // 得到最小的剔除数量
    int toEvict = Math.min(expiredLeases.size(), evictionLimit);
    if (toEvict > 0) {
        Random random = new Random(System.currentTimeMillis());
        for (int i = 0; i < toEvict; i++) {
            // 根据要剔除的数量从 expiredLeases 中随机剔除 toEvict 个过期实例
            int next = i + random.nextInt(expiredLeases.size() - i);
            Collections.swap(expiredLeases, i, next);
            Lease<InstanceInfo> lease = expiredLeases.get(i);

            String appName = lease.getHolder().getAppName();
            // 实例ID
            String id = lease.getHolder().getId();
            EXPIRED.increment();
            // 调用下线的方法
            internalCancel(appName, id, false);
        }
    }
}

3、分批次摘取实例

可以看到,过期的实例并不是一次性摘除的,而是计算了一个阈值 toEvict,一次只随机摘除 toEvict 个过期实例,采用了分批摘取 + 随机摘取的机制。

比如注册表一共有20个实例,那么最多可以摘除的实例数 toEvict = 20 - 20 * 0.85 = 3 个,也就是说就算有5个实例过期了,那这一次也只能随机摘除其中3个,另外两个要等到下一次执行摘除任务时再摘除。

这种分批摘取机制+随机摘取机制可能会导致有些过期实例要过很久才能下线,尤其是在开发环境这种频繁启动、停止服务的场景中。

如何判断实例是否过期

从上面可以看到,eureka 调用了 lease.isExpired(additionalLeaseMs) 来判断实例是否过期。进入 isExpired 这个方法可以看到,如果已经设置了摘除时间,或者 当前时间 > (实例最后更新时间 + 续约周期(90秒) + 补偿时间),就认为实例已经过期了,说明实例已经超过一个周期没有续约了,就认为这个客户端实例发生了故障,无法续约,要被摘除掉。

/**
 * Checks if the lease of a given {@link com.netflix.appinfo.InstanceInfo} has expired or not.
 *
 * Note that due to renew() doing the 'wrong" thing and setting lastUpdateTimestamp to +duration more than
 * what it should be, the expiry will actually be 2 * duration. This is a minor bug and should only affect
 * instances that ungracefully shutdown. Due to possible wide ranging impact to existing usage, this will
 * not be fixed.
 *
 * @param additionalLeaseMs any additional lease time to add to the lease evaluation in ms.
 */
public boolean isExpired(long additionalLeaseMs) {
    // 已经设置过剔除时间,或者 当前时间 > (实例最后更新时间 + 续约周期(90秒) + 补偿时间)
    return (evictionTimestamp > 0 || System.currentTimeMillis() > (lastUpdateTimestamp + duration + additionalLeaseMs));
}

这里其实要注意的是另外一个问题,可以看看 isExpired 的注释,eureka 说这其实是一个bug,但不打算修复了,因为它的 duration 其实是被加了两次的,下面来看看怎么回事。

先看下 lastUpdateTimestamp 是如何更新的,在客户端续约的时候会更新 lastUpdateTimestamp,将其设置为 当前时间 + duration 间隔周期(默认90秒)

public void renew() {
    // 更新最后更新时间,在当前时间的基础上加了一个周期间隔时间,默认90秒
    lastUpdateTimestamp = System.currentTimeMillis() + duration;
}

这个 duration 在注册的时候也有设置,我们通过这个来看看它的含义是什么。可以看到,如果客户端没有配置 durationInSecs,就会设置为默认的 90秒

getDurationInSecs 的注释可以看出,这个 duration 的意思是等待客户端多久没有续约之后就将其剔除,默认为 90秒。比如客户端每隔 30 秒续约一次,那可能超过3次没有续约之后,就会认为客户端实例故障了,就要将其摘除掉。

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);
}

/**
 * Returns client specified setting for eviction (e.g. how long to wait w/o renewal event)
 *
 * @return time in milliseconds since epoch.
 */
public int getDurationInSecs() {
    return durationInSecs;
}

但实际上并不是90秒后摘除实例,可以看到 isExpired 里面将 lastUpdateTimestamp 又加了一个 duration,也就是180秒了。也就是说客户端实例超过180秒未续约才被认为是故障了,然后要将其摘除。

isExpired 的注释也说了,续约的方法 renew() 错误的计算了 lastUpdateTimestamp,实际的过期周期是 2 * duration,但是 eureka 并不打算修复这个bug,因为它的影响范围很小。

所以这里得出一个结论,客户端关闭了(非正常下线),注册表中的实例并不是90秒后就摘除了,至少是 180秒后才会被摘除

自我保护机制

如果网段偶尔抖动或暂时不可用,就无法接收到客户端的续约,因此 eureka server 为了保证可用性,就会去判断最近一分钟收到的心跳次数是否小于指定的阈值,是的就会触发自我保护机制,关闭租约失效剔除,不再摘除实例,从而保护注册信息。

摘除实例时的自我保护机制

摘除实例的 evict 方法调用了 isLeaseExpirationEnabled 这个方法判断是否触发自我保护机制,如果返回 false,就不会摘除实例了。

先看看 isLeaseExpirationEnabled 这个方法:

  • 首先,如果没有启用自我保护机制,就返回 true,那就可以摘除实例
  • 如果启用了自我保护机制(默认启用),就判断每分钟续约阈值 > 0 且 最近一分钟续约次数 > 每分钟续约阈值就是启用了租约过期
public boolean isLeaseExpirationEnabled() {
    // 先判断是否启用了自我保护机制
    if (!isSelfPreservationModeEnabled()) {
        // The self preservation mode is disabled, hence allowing the instances to expire.
        return true;
    }
    // 每分钟续约阈值大于0, 且 最近一分钟续约次数 大于 每分钟续约阈值
    return numberOfRenewsPerMinThreshold > 0 && getNumOfRenewsInLastMin() > numberOfRenewsPerMinThreshold;
}

public boolean isSelfPreservationModeEnabled() {
    return serverConfig.shouldEnableSelfPreservation();
}

这个每分钟续约阈值 numberOfRenewsPerMinThreshold 在前面很多地方都看到过了,服务注册、下线、openForTraffic、以及有个定时任务每隔15分钟都会调用下面这个方法来更新 numberOfRenewsPerMinThreshold

protected void updateRenewsPerMinThreshold() {
    // 每分钟续约阈值 = 期望续约的客户端数量 * (60 / 续约间隔时间) * 续约阈值
    // 例如,一共注册了 10 个实例,那么期望续约的客户端数量为 10,间隔时间默认为 30秒,就是每个客户端应该每30秒发送一次心跳,续约百分比默认为 0.85
    // 每分钟续约次数阈值 = 10 * (60.0 / 30) * 0.85 = 17,也就是说每分钟至少要接收到 17 此续约请求
    this.numberOfRenewsPerMinThreshold = (int) (this.expectedNumberOfClientsSendingRenews
            * (60.0 / serverConfig.getExpectedClientRenewalIntervalSeconds())
            * serverConfig.getRenewalPercentThreshold());
}

expectedNumberOfClientsSendingRenews 在实例注册的时候会 + 1,在实例下线的时候会 - 1,其代表的就是期望续约的客户端数量。

/////////////// 实例注册 ///////////////
synchronized (lock) {
    if (this.expectedNumberOfClientsSendingRenews > 0) {
        // 期望续约的客户端数量 + 1
        this.expectedNumberOfClientsSendingRenews = this.expectedNumberOfClientsSendingRenews + 1;
        // 更新每分钟续约请求次数的阈值,这个阈值在后面很多地方都会用到
        updateRenewsPerMinThreshold();
    }
}


/////////////// 实例下线(下线、故障摘除) ///////////////
synchronized (lock) {
    // 期望续约的客户端数量 - 1
    if (this.expectedNumberOfClientsSendingRenews > 0) {
        this.expectedNumberOfClientsSendingRenews = this.expectedNumberOfClientsSendingRenews - 1;
        // 更新每分钟续约次数的阈值
        updateRenewsPerMinThreshold();
    }
}

而最近一分钟续约次数计数器 renewsLastMin 在每个客户端续约的时候就会+1,可以回看下 renew 方法,最后调用了 renewsLastMin.increment() 增加一次续约次数。而 renewsLastMin.getCount() 返回的是上一分钟总的续约次数。

public long getNumOfRenewsInLastMin() {
    return renewsLastMin.getCount();
}

根据以上代码举个例子来看看实例故障时的自我保护机制:

  • 比如注册了20个实例,实例默认发送心跳续约的间隔时间为30秒,续约的阈值为 0.85,并且开启了自我保护机制。
  • 那么期望续约的客户端数量 expectedNumberOfClientsSendingRenews = 20,每分钟发送心跳的阈值 numberOfRenewsPerMinThreshold = 20 * (60 / 30 )* 0.85 = 34
  • 正常来说20个实例每分钟发送心跳的次数 renewsLastMin = 20 * (60 / 30)= 40 次。
  • 那么 numberOfRenewsPerMinThreshold(34) > 0 && renewsLastMin(40)> numberOfRenewsPerMinThreshold(34)就是满足的,就允许摘除故障的实例。
  • 那如果有 3 个实例上一分钟没有发送续约了,这个时候 renewsLastMin = 17 * (60 / 30)= 34 次,而 numberOfRenewsPerMinThreshold 还是不变,因为注册表的实例并未移除,因此这时条件就不满足了,就算实例真的故障了,也不能摘除实例了。

这就是 eureka-server 的自我保护机制,他认为如果短时间内有过的的实例未发送心跳(超过15%),它会认为是自己网络故障导致客户端不能发送心跳,就进入自我保护机制,避免误摘除实例。

自我保护机制导致实例未下线的情况

在开发环境中,因为会频繁重启服务,会发现有些服务已经是下线状态了(DOWN),但服务实例一直未被摘除,就是因为 eureka-server 的自我保护机制导致的,下面来看下。

1、启用自我保护机制的情况

首先 eureka-server 做了如下配置,启用注册中心:

eureka:
  server:
    # 是否启用自我保护机制
    enable-self-preservation: true

启动几个客户端实例:

然后快速将 demo-consumer 停止掉(如果正常关闭,会调用 cancel 下线实例),这时就会看到 demo-consumer 已经DOWN了,但是实例一直未被移除。

可以看到,上一分钟续约的次数为 4 次,期望每分钟续约次数为 6 次,因为不满足判断的条件,所以就触发了自我保护机制,导致一直无法摘除实例。

注意期望续约的客户端数量为 4,而实际注册的客户端实例是 3 个,这是因为 springcloud 在调用 openForTraffic 设置了初始值为 1。

2、关闭自我保护机制的情况

配置如下,关闭自我保护机制:

eureka:
  server:
    # 是否启用自我保护机制
    enable-self-preservation: false

这时注册中心控制台会提示我们关闭了自我保护机制:

同样的操作,快速停掉实例,发现实例还是未被摘除:

那其实是因为实例要180秒后才会被认为是过期的,所以等3分钟以后,实例就会下线了。

public boolean isExpired(long additionalLeaseMs) {
    return (evictionTimestamp > 0 || System.currentTimeMillis() > (lastUpdateTimestamp + duration + additionalLeaseMs));
}

3、快速关闭多个实例

这次同时关闭 2 个实例来看看,在2分钟之后,发现只有一个实例下线了,这因为 eureka-server 一次只会摘除15%的实例。

4、DOWN 是怎么来的

那么DOWN这个状态是怎么来的呢?由于我本地是用IDEA启动的客户端实例,其实在关闭的时候,会触发状态变更监听器,然后就会触发一次注册的调用,注册的状态是 DOWN,因此实例状态马上就变为 DOWN 了。

如果直接 kill -9 这个进程,就不会触发状态变更监听器了,注册中心的实例就不会变为 DOWN 了,但是实例已经下线变为不可用的状态了。

5、实例快速下线

经过前面的测试可以总结出来,要想实例快速下线,可以调整如下一些参数。

eureka-server 配置:

eureka:
  server:
    # 是否启用自我保护机制
    enable-self-preservation: false
    # 每分钟续约阈值
    renewal-percent-threshold: 0
    # 摘除实例的定时任务的间隔时间
    eviction-interval-timer-in-ms: 10000

eureka-client 配置:

eureka:
  instance:
    # 判断实例多久未发送心跳就判定为过期
    lease-expiration-duration-in-seconds: 60

最近一分钟计数器的设计

来看下最近一分钟续约次数计数器 renewsLastMin 是如何统计上一分钟的续约次数的,renewsLastMin 的类型是 MeasuredRate,这个类的设计也是值得学习的一个点。

MeasuredRate 利用两个桶来计数,一个保存上一间隔时间的计数,一个保存当前这一间隔时间的计数,然后使用定时任务每隔一定间隔时间就将当前这个桶的计数替换到上一个桶里。然后增加计数的时候增加当前桶,获取数量的时候从上一个桶里获取,就实现了获取上一个间隔时间的计数。

public class MeasuredRate {
    private static final Logger logger = LoggerFactory.getLogger(MeasuredRate.class);
    // 利用了两个桶来计数,一个是上一分钟,一个是当前这一分钟
    private final AtomicLong lastBucket = new AtomicLong(0);
    private final AtomicLong currentBucket = new AtomicLong(0);

    private final long sampleInterval;
    private final Timer timer;
    private volatile boolean isActive;

    /**
     * @param sampleInterval in milliseconds
     */
    public MeasuredRate(long sampleInterval) {
        // 间隔时间
        this.sampleInterval = sampleInterval;
        // 定时器
        this.timer = new Timer("Eureka-MeasureRateTimer", true);
        this.isActive = false;
    }

    public synchronized void start() {
        if (!isActive) {
            timer.schedule(new TimerTask() {
                @Override
                public void run() {
                    try {
                        // 每分钟执行一次,将当前这一分钟的次数设置到上一分钟的桶里
                        lastBucket.set(currentBucket.getAndSet(0));
                    } catch (Throwable e) {
                        logger.error("Cannot reset the Measured Rate", e);
                    }
                }
            }, sampleInterval, sampleInterval);

            isActive = true;
        }
    }

    public synchronized void stop() {
        if (isActive) {
            timer.cancel();
            isActive = false;
        }
    }

    /**
     * Returns the count in the last sample interval.
     */
    public long getCount() {
        // 获取计数时是获取的上一分钟这个桶的计数
        return lastBucket.get();
    }

    /**
     * Increments the count in the current sample interval.
     */
    public void increment() {
        // 增加计数的时候是增加的当前这个桶的计数
        currentBucket.incrementAndGet();
    }
}

服务故障摘除和自我保护机制图

下面用一张图来总结下服务故障摘除和自我保护机制。

eureka 服务故障摘除和自我保护机制.jpg