SpringCloud:注册中心 Eureka Server (一)

484 阅读27分钟

服务发现: 注册中心

服务发现由客户端和服务端进行配合完成的。本节探讨服务端中机制。

本文中的分析工作会省略部分不重要的代码,完整流程还请参考源码。

本文使用的 spring-boot 版本为 2.3.0.RELEASEspring-cloud-starter-netflix-eureka-server 版本为 Hoxton.SR5

eureka-coreeureka-client 的版本为 1.9.21

核心机制

  • 服务端启动初始化过程
  • 注册
  • 续约
  • 主动下线
  • 客户端实例状态更新处理
  • 拉取注册表
  • 增量拉取注册表
  • 注册中心间的复制
  • 服务剔除
  • 自我保护机制

服务端启动初始化过程

最原始的 eureka-server 是以 war 包的方式启动的,其中com.netflix.eureka.EurekaBootStrap 是作为一个 listener 配置在 web.xml 中的,所以方法入口在于 com.netflix.eureka.EurekaBootStrap#contextInitialized

可以看到内容如下:

@Override
public void contextInitialized(ServletContextEvent event) {
    try {
        // 初始化环境
        initEurekaEnvironment();
        // 初始化上下文
        initEurekaServerContext();

        ServletContext sc = event.getServletContext();
        sc.setAttribute(EurekaServerContext.class.getName(), serverContext);
    } catch (Throwable e) {
        logger.error("Cannot bootstrap eureka server :", e);
        throw new RuntimeException("Cannot bootstrap eureka server :", e);
    }
}

其中主要做了两件事情:

  1. initEurekaEnvironment(): 初始化环境,这里主要是处理一些关于数据中心和环境的配置数据;
  2. initEurekaServerContext() : 初始化上下文,

以下的时序图示意了启动过程中主要的流程和参与的类:

image-20210403145936879.png

上图通过 eureka-server 模块中的测试类 com.netflix.eureka.resources.EurekaClientServerRestIntegrationTest 中的测试方法断点调试所得,省略部分不重要的流程

看完这个图,我们可以对过程有个整体的认识,但是随之而来的是更多的疑惑,这些类分别都干了什么?接下来我们一一的分析。

相关类功能简述:

  • com.netflix.eureka.DefaultEurekaServerConfig :服务端的配置的默认配置实现;

  • com.netflix.appinfo.providers.EurekaConfigBasedInstanceInfoProvider: 实现 Provider<InstanceInfo>,用于从 com.netflix.appinfo.EurekaInstanceConfig 构造并提供 com.netflix.appinfo.InstanceInfo 类的实例;

  • com.netflix.appinfo.ApplicationInfoManager:初始化了注册和服务发现所需的信息;

  • com.netflix.discovery.DefaultEurekaClientConfig:默认的客户端配置的实现类,用于初始化客户端实例;

  • com.netflix.discovery.DiscoveryClient:作为服务发现的客户端,在 server 中,server 本身作为一个服务端,向其它服务端注册自己;

  • com.netflix.eureka.registry.PeerAwareInstanceRegistryImpl:用于在对等的服务端节点中进行服务数据的同步;启动时,会尝试去所有对等的节点获取所有的注册信息;另外,作为 com.netflix.eureka.registry.AbstractInstanceRegistry 的子类,它也管理着客户端的注册信息。

  • com.netflix.eureka.cluster.PeerEurekaNodes: 用于管理对等的节点的信息;

  • com.netflix.eureka.DefaultEurekaServerContext :服务端的上下文,作为多种信息的集合,包含了以下信息:

    private final EurekaServerConfig serverConfig;
    private final ServerCodecs serverCodecs;
    private final PeerAwareInstanceRegistry registry;
    private final PeerEurekaNodes peerEurekaNodes;
    private final ApplicationInfoManager applicationInfoManager
    

其他更细的功能,我们到具体的逻辑分析中进行探索。

spring-cloud 中的启动过程

spring-cloud-netflix-eureka-server 中,服务端的启动初始化由 org.springframework.cloud.netflix.eureka.server.EurekaServerBootstrap#contextInitialized 方法完成:

public void contextInitialized(ServletContext context) {
    try {
        initEurekaEnvironment();
        // 主要的不同在这
        initEurekaServerContext();

        context.setAttribute(EurekaServerContext.class.getName(), this.serverContext);
    }
    catch (Throwable e) {
        log.error("Cannot bootstrap eureka server :", e);
        throw new RuntimeException("Cannot bootstrap eureka server :", e);
    }
}

乍一看,和 eureka-server 原本的 com.netflix.eureka.EurekaBootStrap#contextInitialized 内容几乎一样。

主要的不同在于 initEurekaServerContext() 方法的内容:

protected void initEurekaServerContext() throws Exception {
    // For backward compatibility
    JsonXStream.getInstance().registerConverter(new V1AwareInstanceInfoConverter(),
                                                XStream.PRIORITY_VERY_HIGH);
    XmlXStream.getInstance().registerConverter(new V1AwareInstanceInfoConverter(),
                                               XStream.PRIORITY_VERY_HIGH);

    if (isAws(this.applicationInfoManager.getInfo())) {
        this.awsBinder = new AwsBinderDelegate(this.eurekaServerConfig,
                                               this.eurekaClientConfig, this.registry, this.applicationInfoManager);
        this.awsBinder.start();
    }

    EurekaServerContextHolder.initialize(this.serverContext);

    log.info("Initialized server context");

    // Copy registry from neighboring eureka node
    int registryCount = this.registry.syncUp();
    this.registry.openForTraffic(this.applicationInfoManager, registryCount);

    // Register all monitoring statistics.
    EurekaMonitors.registerAllStats();
}

com.netflix.eureka.EurekaBootStrap#initEurekaServerContext 对比可以发现:

  • 以下内容的构建交由 spring 配置为 bean 以注入的方式得到了:
    • applicationInfoManager
    • eurekaClientConfig
    • eurekaServerConfig
    • registry
    • serverContext
  • DiscoveryClient 比较特殊,由别处配置为 bean 进行初始化,因为这里不需要所以没有注入到这个启动类中;

org.springframework.cloud.netflix.eureka.server.EurekaServerAutoConfiguration 包含了大部分关于服务端的自动配置;

注册

spring-cloud 中,org.springframework.cloud.netflix.eureka.server.InstanceRegistry 继承了 com.netflix.eureka.registry.PeerAwareInstanceRegistryImpl ,主要做的扩展是发送相关的事件。

我们将断点打在 com.netflix.eureka.registry.PeerAwareInstanceRegistryImpl#register 方法中就可以对注册请求进行调试,得到如下的时序图:

image-20210403163038749.png

可以看出,主要的逻辑是在以下两个方法中:

  • com.netflix.eureka.registry.AbstractInstanceRegistry#register:本服务器处理客户端的注册请求;
  • com.netflix.eureka.registry.PeerAwareInstanceRegistryImpl#replicateToPeers :将实例的信息复制到对等的服务器节点;

这一小节,我们主要分析注册的处理,对等复制稍后分析。

在正式对 register 方法进行分析之前,我们先看看 com.netflix.eureka.registry.AbstractInstanceRegistry 这个类的重要的属性:

/**
 * key是应用的名称,value是一个Map,value的Map的key是实例的id,value则是Lease<InstanceInfo>
 * 这个Map就是用于存储所有的服务的注册信息的
 */
private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry
            = new ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>();

protected Map<String, RemoteRegionRegistry> regionNameVSRemoteRegistry = new HashMap<String, RemoteRegionRegistry>();
/**
 * key是实例id,value是实例的覆写状态,存放实例的覆写状态的map
 * 这里使用 guava 的缓存包,构建了一个带有失效时间的线程安全的map
 */
protected final ConcurrentMap<String, InstanceStatus> overriddenInstanceStatusMap = CacheBuilder
    .newBuilder().initialCapacity(500)
    .expireAfterAccess(1, TimeUnit.HOURS)
    .<String, InstanceStatus>build().asMap();

// CircularQueues here for debugging/statistics purposes only
private final CircularQueue<Pair<Long, String>> recentRegisteredQueue;
private final CircularQueue<Pair<Long, String>> recentCanceledQueue;

/**
 * 存储最近的变化项的队列
 */
private ConcurrentLinkedQueue<RecentlyChangedItem> recentlyChangedQueue = new ConcurrentLinkedQueue<RecentlyChangedItem>();
/**
 * 读写锁
 */
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Lock read = readWriteLock.readLock();
private final Lock write = readWriteLock.writeLock();
/**
 * 用于 synchronized 的对象,目的是对于属性 expectedNumberOfClientsSendingRenews 进行同步
 */
protected final Object lock = new Object();
/**
 * 用于调度delta数据的清理
 */
private Timer deltaRetentionTimer = new Timer("Eureka-DeltaRetentionTimer", true);
/**
 * 用于调度服务剔除的任务
 */
private Timer evictionTimer = new Timer("Eureka-EvictionTimer", true);
/**
 * 用于计算最近一分钟的续约数量
 */
private final MeasuredRate renewsLastMin;
/**
 * 服务剔除任务的原子引用
 */
private final AtomicReference<EvictionTask> evictionTaskRef = new AtomicReference<EvictionTask>();

protected String[] allKnownRemoteRegions = EMPTY_STR_ARRAY;
/**
 * 每分钟要进行续约的请求的数量的阈值
 */
protected volatile int numberOfRenewsPerMinThreshold;
/**
 * 期待的发送续约请求的客户端数量
 */
protected volatile int expectedNumberOfClientsSendingRenews;
/**
 * 服务端的配置
 */
protected final EurekaServerConfig serverConfig;
/**
 * 服务端作为客户端的配置
 */
protected final EurekaClientConfig clientConfig;
/**
 * 编码解码器
 */
protected final ServerCodecs serverCodecs;
/**
 * 响应缓存
 */
protected volatile ResponseCache responseCache;

现在看 register 方法:

public void register(InstanceInfo registrant, int leaseDuration, boolean isReplication) {
    try {
        // 加读锁
        read.lock();
        // 尝试根据服务的名称获取相关的实例Map
        Map<String, Lease<InstanceInfo>> gMap = registry.get(registrant.getAppName());
        // 统计注册次数
        REGISTER.increment(isReplication);
        // 如果是这个服务下所有实例的首次注册,这个 map 就是为空的
        if (gMap == null) {
            // 创建一个新的 map
            final ConcurrentHashMap<String, Lease<InstanceInfo>> gNewMap = new ConcurrentHashMap<String, Lease<InstanceInfo>>();
            // 将应用名称作为key,如果key不存在,将map放入
            gMap = registry.putIfAbsent(registrant.getAppName(), gNewMap);
            // 返回 null 说明 map 中没有这个 key(这里再次进行一次判断是要考虑并发注册的情况)
            if (gMap == null) {
                // 如果放入成功,赋值
                gMap = gNewMap;
            }
        }
        // 从服务的map根据实例id查询得到已经有的实例租约信息
        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();
            logger.debug("Existing lease found (existing={}, provided={}", existingLastDirtyTimestamp, registrationLastDirtyTimestamp);

            // this is a > instead of a >= because if the timestamps are equal, we still take the remote transmitted
            // InstanceInfo instead of the server local copy.
            // 只有已存在的实例信息的更新时间大于新传递的实例信息的更新时间时,才直接从服务器取注册信息,忽略客户端发送来的实例信息
            if (existingLastDirtyTimestamp > registrationLastDirtyTimestamp) {
                logger.warn("There is an existing lease and the existing lease's dirty timestamp {} is greater" +
                            " than the one that is being registered {}", existingLastDirtyTimestamp, registrationLastDirtyTimestamp);
                logger.warn("Using the existing instanceInfo instead of the new instanceInfo as the registrant");
                registrant = existingLease.getHolder();
            }
        } else {
            // 如果这个实例的注册是首次注册
            // The lease does not exist and hence it is a new registration
            // 只有首次注册才会进入这里加锁
            synchronized (lock) {
                // 这个统计需要向服务器发送续约消息的客户端的数量
                if (this.expectedNumberOfClientsSendingRenews > 0) {
                    // Since the client wants to register it, increase the number of clients sending renews
                    this.expectedNumberOfClientsSendingRenews = this.expectedNumberOfClientsSendingRenews + 1;
                    updateRenewsPerMinThreshold();
                }
            }
            logger.debug("No previous lease information found; it is new registration");
        }
        // 创建一个新的注册信息
        Lease<InstanceInfo> lease = new Lease<InstanceInfo>(registrant, leaseDuration);
        if (existingLease != null) {
            // 在租约中根据实例之前已经存在的租约设置服务UP的时间戳
            lease.setServiceUpTimestamp(existingLease.getServiceUpTimestamp());
        }
        // 将新生成的租约放入map中
        gMap.put(registrant.getId(), lease);
        // 这个队列放置最近进行注册的服务及实例信息
        recentRegisteredQueue.add(new Pair<Long, String>(
            System.currentTimeMillis(),
            registrant.getAppName() + "(" + registrant.getId() + ")"));
        // This is where the initial state transfer of overridden status happens
        // 如果注册信息中包含的覆写状态不是UNKNOWN,
        if (!InstanceStatus.UNKNOWN.equals(registrant.getOverriddenStatus())) {
            logger.debug("Found overridden status {} for instance {}. Checking to see if needs to be add to the "
                         + "overrides", registrant.getOverriddenStatus(), registrant.getId());
            // 如果map中没有存这个实例的覆写状态
            if (!overriddenInstanceStatusMap.containsKey(registrant.getId())) {
                logger.info("Not found overridden id {} and hence adding it", registrant.getId());
                // 将其放入到map中
                overriddenInstanceStatusMap.put(registrant.getId(), registrant.getOverriddenStatus());
            }
        }
        // 从存储实例和覆写状态关系的map中获取这个实例的覆写状态
        InstanceStatus overriddenStatusFromMap = overriddenInstanceStatusMap.get(registrant.getId());
        if (overriddenStatusFromMap != null) {
            logger.info("Storing overridden status {} from map", overriddenStatusFromMap);
            // 如果有覆写状态,将其放入到注册信息中
            registrant.setOverriddenStatus(overriddenStatusFromMap);
        }

        // Set the status based on the overridden status rules
        // 基于注册信息和已有的租约信息应用 InstanceStatusOverrideRule 对实例的状态进行计算
        InstanceStatus overriddenInstanceStatus = getOverriddenInstanceStatus(registrant, existingLease, isReplication);
        // 设置实例的状态为计算所得的状态,但是这个设置并不设置实例的数据为更新过,也就是不参与更新
        registrant.setStatusWithoutDirty(overriddenInstanceStatus);

        // If the lease is registered with UP status, set lease service up timestamp
        // 如果状态是 UP
        if (InstanceStatus.UP.equals(registrant.getStatus())) {
            // 设置租约信息为up
            lease.serviceUp();
        }
        // 设置注册的操作类型是添加
        registrant.setActionType(ActionType.ADDED);
        // 向队列中存放这一项最近的改变
        recentlyChangedQueue.add(new RecentlyChangedItem(lease));
        // 设置最后一次状态更新的时间戳
        registrant.setLastUpdatedTimestamp();
        // 实例状态发生改变,失效掉可能存在的实例信息的缓存数据
        invalidateCache(registrant.getAppName(), registrant.getVIPAddress(), registrant.getSecureVipAddress());
        logger.info("Registered instance {}/{} with status {} (replication={})",
                    registrant.getAppName(), registrant.getId(), registrant.getStatus(), isReplication);
    } finally {
        read.unlock();
    }
}

从方法中我们看到,最主要的目的,其实是将租约信息放入 registry 这个 map 中。另外,无论进行注册时,无论 registry 中是否有这个实例信息,或者有实例信息是否相同的情况下,都会重新构建一个 Lease 对象放入到 registry 中,这样会使得这个实例对应的 Lease 刷新它的时间,于是之后的剔除任务就更晚将其剔除。

除了最核心的实例信息的保存之外,还有一些为其它机制所作的配合工作:

  • 更新续约数量的阈值: updateRenewsPerMinThreshold 。(自我保护机制)
  • recentlyChangedQueue 中加入当前的改变事项。(增量拉取注册表)

所作的配合工作我们在分析到对应的机制时,再回头来看。

服务续约

客户端会定时发送心跳请求到服务器来刷新租约信息,流程如下:

image-20210403233044332.png

这个过程还是和注册时类似,底下的子类是发送通知和复制信息到对等节点,主要的逻辑在 AbstractInstanceRegistry#renew 中,如下:

public boolean renew(String appName, String id, boolean isReplication) {
    // renew次数计数器加一
    RENEW.increment(isReplication);
    // 获取这个应用的所有实例的租约信息
    Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
    Lease<InstanceInfo> leaseToRenew = null;
    if (gMap != null) {
        // 如果map不为空,根据实例的id获取实例的租约信息
        leaseToRenew = gMap.get(id);
    }
    // 如果实例的租约信息没找到(是一种不正常的情况)
    if (leaseToRenew == null) {
        // 没找到的计数器加一
        RENEW_NOT_FOUND.increment(isReplication);
        logger.warn("DS: Registry: lease doesn't exist, registering resource: {} - {}", appName, id);
        return false;
    } else {
        // 获取租约中存放的实例的信息
        InstanceInfo instanceInfo = leaseToRenew.getHolder();
        // if块的内容是计算覆写状态
        if (instanceInfo != null) {
            // touchASGCache(instanceInfo.getASGName());
            InstanceStatus overriddenInstanceStatus = this.getOverriddenInstanceStatus(
                instanceInfo, leaseToRenew, isReplication);
            if (overriddenInstanceStatus == InstanceStatus.UNKNOWN) {
                logger.info("Instance status UNKNOWN possibly due to deleted override for instance {}"
                            + "; re-register required", instanceInfo.getId());
                RENEW_NOT_FOUND.increment(isReplication);
                return false;
            }
            if (!instanceInfo.getStatus().equals(overriddenInstanceStatus)) {
                logger.info(
                    "The instance status {} is different from overridden instance status {} for instance {}. "
                    + "Hence setting the status to overridden status", instanceInfo.getStatus().name(),
                    instanceInfo.getOverriddenStatus().name(),
                    instanceInfo.getId());
                instanceInfo.setStatusWithoutDirty(overriddenInstanceStatus);

            }
        }
        // 上一分钟的续约数量加一
        renewsLastMin.increment();
        // 调用 Lease.renew 刷新租约的最后更新时间为当前时间加上租约的内部属性duration也就是持续时间
        leaseToRenew.renew();
        return true;
    }
}

方法内核心的逻辑是给存在的租约对象刷新最后更新时间这个属性。另外方法本身是有返回值的,会返回布尔类型,如果正确刷新了租约返回 true,如果没有找到实例相关的租约返回 false。这里的状态码的处理就要看 com.netflix.eureka.resources.InstanceResource#renewLease 这个方法了:

@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);
    // 刷新租约,拿到调用结果
    boolean isSuccess = registry.renew(app.getName(), id, isFromReplicaNode);

    // Not found in the registry, immediately ask for a register
    // 如果不成功,表明没有找到实例信息,给客户端返回404状态码
    if (!isSuccess) {
        logger.warn("Not Found (Renew): {} - {}", app.getName(), id);
        return Response.status(Status.NOT_FOUND).build();
    }
    // Check if we need to sync based on dirty time stamp, the client
    // instance might have changed some value
    Response response;
    // 根据配置,决定是否要根据时间戳进行同步信息
    if (lastDirtyTimestamp != null && serverConfig.shouldSyncWhenTimestampDiffers()) {
        // 调用方法根据时间戳构建不同状态码的响应
        response = this.validateDirtyTimestamp(Long.valueOf(lastDirtyTimestamp), isFromReplicaNode);
        // Store the overridden status since the validation found out the node that replicates wins
        if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode()
            && (overriddenStatus != null)
            && !(InstanceStatus.UNKNOWN.name().equals(overriddenStatus))
            && isFromReplicaNode) {
            registry.storeOverriddenStatusIfRequired(app.getAppName(), id, InstanceStatus.valueOf(overriddenStatus));
        }
    } else {
        // 不处理就直接返回OK了
        response = Response.ok().build();
    }
    logger.debug("Found (Renew): {} - {}; reply status={}", app.getName(), id, response.getStatus());
    return response;
}

可以看到,renew 结果为 false 时给客户端返回 404 状态码,回想之前分析的客户端的续约过程,客户端收到响应为 404 时,会重新发起一次注册过程,从而解决了服务端没有实例信息的情况。

另外再看看 validateDirtyTimestamp 这个方法做了什么:

private Response validateDirtyTimestamp(Long lastDirtyTimestamp,
                                            boolean isReplication) {
    // 根据实例id获取实例信息
    InstanceInfo appInfo = registry.getInstanceByAppAndId(app.getName(), id, false);
    if (appInfo != null) {
        if ((lastDirtyTimestamp != null) && (!lastDirtyTimestamp.equals(appInfo.getLastDirtyTimestamp()))) {
            Object[] args = {id, appInfo.getLastDirtyTimestamp(), lastDirtyTimestamp, isReplication};
			// 如果客户端传的数据更新时间比服务端中存储的大,说明服务端数据落后了
            if (lastDirtyTimestamp > appInfo.getLastDirtyTimestamp()) {
                logger.debug(
                    "Time to sync, since the last dirty timestamp differs -"
                    + " ReplicationInstance id : {},Registry : {} Incoming: {} Replication: {}",
                    args);
                // 让客户端再发起一次注册请求
                return Response.status(Status.NOT_FOUND).build();
            } else if (appInfo.getLastDirtyTimestamp() > lastDirtyTimestamp) {
                // In the case of replication, send the current instance info in the registry for the
                // replicating node to sync itself with this one.
                if (isReplication) {
                    // 而如果服务端的租约信息的时间戳大于此次请求的时间戳,且是复制请求,那么说明发起复制的请求的另一个
                    // 服务端的数据落后了,也让另外的服务端进行数据同步
                    // 这里返回状态码 Status.CONFLICT,下面分析对等复制的时候会看到这个状态码
                    logger.debug(
                        "Time to sync, since the last dirty timestamp differs -"
                        + " ReplicationInstance id : {},Registry : {} Incoming: {} Replication: {}",
                        args);
                    return Response.status(Status.CONFLICT).entity(appInfo).build();
                } else {
                    return Response.ok().build();
                }
            }
        }

    }
    return Response.ok().build();
}

续约请求只是刷新租约的时间,而不会更新实例的数据,这个方法是在续约完成之后顺便对调用方的实例数据的时间戳与服务端数据进行比对,不一致就通知客户端发起数据同步的机制。

总的来说,续约请求是为了刷新租约时间,顺带比对了实例信息的更新时间戳然后以不同的响应码帮助客户端察觉数据的不一致,然后客户端根据这个信息发起同步的机制。

服务主动下线

客户端在关闭时,可以主动向服务端发送下线请求以停止接收流量。以下是这个过程的服务端时序图:

image-20210406001920742.png

可以看到,主要的逻辑是在 internalCancel 里面:

protected boolean internalCancel(String appName, String id, boolean isReplication) {
    try {
        // 锁住读锁
        read.lock();
        CANCEL.increment(isReplication);
        // 根据应用名称获取应用下所有的实例信息的map
        Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
        Lease<InstanceInfo> leaseToCancel = null;
        if (gMap != null) {
            // 通过实例的id从map中移除并获取租约
            leaseToCancel = gMap.remove(id);
        }
        // 队列中维护最新下线的实例的信息
        recentCanceledQueue.add(new Pair<Long, String>(System.currentTimeMillis(), appName + "(" + id + ")"));
        // 覆写map中移除这个实例的状态
        InstanceStatus instanceStatus = overriddenInstanceStatusMap.remove(id);
        if (instanceStatus != null) {
            logger.debug("Removed instance id {} from the overridden map which has value {}", id, instanceStatus.name());
        }
        if (leaseToCancel == null) {
            // 如果没找到租约,返回 false,表示失败
            CANCEL_NOT_FOUND.increment(isReplication);
            logger.warn("DS: Registry: cancel failed because Lease is not registered for: {}/{}", appName, id);
            return false;
        } else {
            // 调用租约的取消方法,更新 evictionTimestamp 字段为当前时间
            leaseToCancel.cancel();
            // 获取租约中的实例信息
            InstanceInfo instanceInfo = leaseToCancel.getHolder();
            String vip = null;
            String svip = null;
            // 添加一个最近更改事件到队列中
            if (instanceInfo != null) {
                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) {
            // Since the client wants to cancel it, reduce the number of clients to send renews.
            this.expectedNumberOfClientsSendingRenews = this.expectedNumberOfClientsSendingRenews - 1;
            updateRenewsPerMinThreshold();
        }
    }

    return true;
}

下线的操作做了下列事情:

  • 从注册的 map 中移除这个实例对应的信息;
  • recentCanceledQueue 加入当前实例信息;
  • 取消租约;
  • 保存此次操作作为最近更改事项;
  • 失效响应缓存;
  • 更新续约相关指标;

客户端实例状态更新处理

这个实例状态更新过程的处理逻辑在 com.netflix.eureka.registry.AbstractInstanceRegistry#statusUpdate 中:

@Override
public boolean statusUpdate(String appName, String id,
                            InstanceStatus newStatus, String lastDirtyTimestamp,
                            boolean isReplication) {
    try {
        // 获取读锁
        read.lock();
        STATUS_UPDATE.increment(isReplication);
        // 获取实例对应的租约
        Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
        Lease<InstanceInfo> lease = null;
        if (gMap != null) {
            lease = gMap.get(id);
        }
        if (lease == null) {
            return false;
        } else {
            // 顺便刷新租约
            lease.renew();
            InstanceInfo info = lease.getHolder();
            // Lease is always created with its instance info object.
            // This log statement is provided as a safeguard, in case this invariant is violated.
            if (info == null) {
                logger.error("Found Lease without a holder for instance id {}", id);
            }
            // 如果新旧状态相等,就不做操作
            if ((info != null) && !(info.getStatus().equals(newStatus))) {
                // Mark service as UP if needed
                if (InstanceStatus.UP.equals(newStatus)) {
                    lease.serviceUp();
                }
                // This is NAC overriden status
                // 将新的状态设置到覆写状态map中作为一个覆写状态
                overriddenInstanceStatusMap.put(id, newStatus);
                // Set it for transfer of overridden status to replica on
                // replica start up
                info.setOverriddenStatus(newStatus);
                long replicaDirtyTimestamp = 0;
                // 设置实例的状态为新的状态
                info.setStatusWithoutDirty(newStatus);
                if (lastDirtyTimestamp != null) {
                    replicaDirtyTimestamp = Long.valueOf(lastDirtyTimestamp);
                }
                // If the replication's dirty timestamp is more than the existing one, just update
                // it to the replica's.
                // 
                if (replicaDirtyTimestamp > info.getLastDirtyTimestamp()) {
                    info.setLastDirtyTimestamp(replicaDirtyTimestamp);
                }
                info.setActionType(ActionType.MODIFIED);
                // 记录最近的更改
                recentlyChangedQueue.add(new RecentlyChangedItem(lease));
                info.setLastUpdatedTimestamp();
                // 失效掉缓存
                invalidateCache(appName, info.getVIPAddress(), info.getSecureVipAddress());
            }
            return true;
        }
    } finally {
        read.unlock();
    }
}

方法中做了这些事:

  • 刷新租约;
  • 设置覆写状态;
  • 设置实例信息的新状态;
  • 存放一项最近更改;
  • 失效相关的缓存;

注:PeerAwareInstanceRegistryImpl 对这个操作也会复制到对等节点

全量拉取注册表

我们可以找到:查询全量的注册表的接口声明在

com.netflix.eureka.resources.ApplicationsResource#getContainers 中,但是我们在这个方法中却看不到对于 registry 的相关的方法调用。但是我们可以看到,响应是从 com.netflix.eureka.registry.ResponseCache#get 中得到结果数据构建的。这是因为 eureka-server 使用了程序内部缓存来加速请求的响应。

缓存的具体的实现在 com.netflix.eureka.registry.ResponseCacheImpl 中。

我们可以先看看这个缓存的接口的方法:

public interface ResponseCache {
	// 失效指定的 appName 的缓存
    void invalidate(String appName, @Nullable String vipAddress, @Nullable String secureVipAddress);

    // 版本号
    AtomicLong getVersionDelta();

    // 版本号
    AtomicLong getVersionDeltaWithRegions();

     // 通过key获取对应的缓存的应用数据,如果第一次请求时,没有数据,会生成,第一次请求
     // 之后,会通过后台线程定期更新信息
     String get(Key key);

    // 获取gzip压缩的应用信息,内容和上面的方法是一致,只不过压缩了
    byte[] getGZIP(Key key);

    // 关闭缓存
    void stop();
}

里面引入了一个类:com.netflix.eureka.registry.Key,这个类中包含了多种信息,以多种信息定位到具体的缓存的响应数据。其中的属性如下:

// 这个key实体的名称,只用到了有限的几种: ${appName},ALL_APPS,ALL_APPS_DELTA 后面两种的名称都是常量
private final String entityName;
// 所属的 region 信息
private final String[] regions;
// 表示信息的序列化方式,取值为:JSON, XML
private final KeyType requestType;
// 表示请求的数据的版本,取值为:V1, V2
private final Version requestVersion;
// 是一个计算得到的值,在构造函数中由其它字段拼接得到,最终这个值(只用这个值)来计算哈希code
private final String hashKey;
// 实体类型,信息的类型,是个枚举,取值为:Application, VIP, SVIP
private final EntityType entityType;
// 表示客户端接收的格式,取值为:full, compact
private final EurekaAccept eurekaAccept;

用这个类的实例作为缓存的 key 的目的是为了在请求的各种场景下都有自己缓存,而不会冲突。

接下来就看看 com.netflix.eureka.registry.ResponseCacheImpl 的具体实现。

先看其中的重要属性:

// FIXME deprecated, here for backwards compatibility.
// 版本号
private static final AtomicLong versionDeltaLegacy = new AtomicLong(0);
private static final AtomicLong versionDeltaWithRegionsLegacy = new AtomicLong(0);

// 定时器,用于调度缓存更新的任务
private final java.util.Timer timer = new java.util.Timer("Eureka-CacheFillTimer", true);
// 版本号
private final AtomicLong versionDelta = new AtomicLong(0);
private final AtomicLong versionDeltaWithRegions = new AtomicLong(0);


// 没有region的key 映射到 一个带有region的key的list,失效缓存时需要这个属性
/**
     * This map holds mapping of keys without regions to a list of keys with region (provided by clients)
     * Since, during invalidation, triggered by a change in registry for local region, we do not know the regions
     * requested by clients, we use this mapping to get all the keys with regions to be invalidated.
     * If we do not do this, any cached user requests containing region keys will not be invalidated and will stick
     * around till expiry. Github issue: https://github.com/Netflix/eureka/issues/118
     */
private final Multimap<Key, Key> regionSpecificKeys =
    Multimaps.newListMultimap(new ConcurrentHashMap<Key, Collection<Key>>(), new Supplier<List<Key>>() {
        @Override
        public List<Key> get() {
            return new CopyOnWriteArrayList<Key>();
        }
    });

// 只读的缓存,数据从读写缓存得到
private final ConcurrentMap<Key, Value> readOnlyCacheMap = new ConcurrentHashMap<Key, Value>();

// Guava的缓存实现,读写缓存
private final LoadingCache<Key, Value> readWriteCacheMap;
// 是否使用只读缓存
private final boolean shouldUseReadOnlyResponseCache;
// registry实现,读写缓存需要从中获取数据
private final AbstractInstanceRegistry registry;
// 服务端配置信息
private final EurekaServerConfig serverConfig;
// 通信的编码解码实现
private final ServerCodecs serverCodecs;

其中,最重要的属性就是代表两级缓存的实现了。缓存是键值对的形式,其中 Key 是上文提到的类,而 Value 则是包含两个属性的简单的类,用于存放原始的 payload 和压缩后的字节数组。

接下来,我们再深入这个实现类中的机制:

  • 两种缓存的数据来源
  • 缓存主动失效
  • 只读缓存更新

两种缓存的数据来源

从构造器中可以看到,读写缓存使用了 GuavaLoadingCache, 这个实现的特点是:

  • 缓存有过期时间;
  • 可以订阅 key 的移除事件;
  • 如果 key 不存在,会尝试通过传入的 com.google.common.cache.CacheLoader 实现来加载数据,这也是这个缓存的名称由来;

而构造器中给缓存实现传入的 CacheLoader 实现如下:

new CacheLoader<Key, Value>() {
    @Override
    public Value load(Key key) throws Exception {
        if (key.hasRegions()) {
            Key cloneWithNoRegions = key.cloneWithoutRegions();
            regionSpecificKeys.put(cloneWithNoRegions, key);
        }
        // 获取 key 对应的 value
        Value value = generatePayload(key);
        return value;
    }
}

核心方法在 com.netflix.eureka.registry.ResponseCacheImpl#generatePayload,如下:

Stopwatch tracer = null;
try {
    String payload;
    // 根据实体的类型,生成不同的 payload
    switch (key.getEntityType()) {
        case Application:
            boolean isRemoteRegionRequested = key.hasRegions();
			// 如果获取所有的应用数据
            if (ALL_APPS.equals(key.getName())) {
                if (isRemoteRegionRequested) {
                    tracer = serializeAllAppsWithRemoteRegionTimer.start();
                    payload = getPayLoad(key, registry.getApplicationsFromMultipleRegions(key.getRegions()));
                } else {
                    tracer = serializeAllAppsTimer.start();
                    // 获取具体的应用数据
                    payload = getPayLoad(key, registry.getApplications());
                }
            } else if (ALL_APPS_DELTA.equals(key.getName())) {
                if (isRemoteRegionRequested) {
                    tracer = serializeDeltaAppsWithRemoteRegionTimer.start();
                    versionDeltaWithRegions.incrementAndGet();
                    versionDeltaWithRegionsLegacy.incrementAndGet();
                    payload = getPayLoad(key,
                                         registry.getApplicationDeltasFromMultipleRegions(key.getRegions()));
                } else {
                    tracer = serializeDeltaAppsTimer.start();
                    versionDelta.incrementAndGet();
                    versionDeltaLegacy.incrementAndGet();
                    // 获取具体的应用增量数据
                    payload = getPayLoad(key, registry.getApplicationDeltas());
                }
            } else {
                tracer = serializeOneApptimer.start();
                // 获取单个应用的数据
                payload = getPayLoad(key, registry.getApplication(key.getName()));
            }
            break;
        case VIP:
        case SVIP:
            tracer = serializeViptimer.start();
            // 获取对应 VIP 或者 SVIP 的数据
            payload = getPayLoad(key, getApplicationsForVip(key, registry));
            break;
        default:
            logger.error("Unidentified entity type: {} found in the cache key.", key.getEntityType());
            payload = "";
            break;
    }
    return new Value(payload);
} finally {
    if (tracer != null) {
        tracer.stop();
    }
}

本质上是根据请求的数据的类型,以不同的方式从 registry 中获取对应的数据。同时也可以看到,获取的全量的数据,最终是调用 com.netflix.eureka.registry.AbstractInstanceRegistry#getApplications() 这个方法所得到的。

以及缓存的数据来源确定后,再看只读缓存的数据来源,

com.netflix.eureka.registry.ResponseCacheImpl#getValue 方法中:

Value getValue(final Key key, boolean useReadOnlyCache) {
    Value payload = null;
    try {
        // 如果使用只读缓存
        if (useReadOnlyCache) {
            // 尝试只读缓存中获取数据
            final Value currentPayload = readOnlyCacheMap.get(key);
            if (currentPayload != null) {
                payload = currentPayload;
            } else {
                // 如果只读缓存中没有数据,从读写缓存中获取
                //(如果这时候读写缓存没有,会通过上文提到的 CacheLoader 从 registry 中获取)
                payload = readWriteCacheMap.get(key);
                readOnlyCacheMap.put(key, payload);
            }
        } else {
            // 如果不启动只读缓存,直接从读写缓存中获取
            payload = readWriteCacheMap.get(key);
        }
    } catch (Throwable t) {
        logger.error("Cannot get value for key : {}", key, t);
    }
    return payload;
}

显而易见,只读缓存的数据来自于读写缓存。

缓存主动失效

还记得,上文在客户端的信息发生改变时,registry 主动调用了响应缓存的失效方法吗?

调用的就是这个方法 com.netflix.eureka.registry.ResponseCache#invalidate ,实现如下:

@Override
public void invalidate(String appName, @Nullable String vipAddress, @Nullable String secureVipAddress) {
    // 就是构造这个应用相关的key,都主动失效
    for (Key.KeyType type : Key.KeyType.values()) {
        for (Version v : Version.values()) {
            // 调用这个方法失效指定的一组key
            invalidate(
                new Key(Key.EntityType.Application, appName, type, v, EurekaAccept.full),
                new Key(Key.EntityType.Application, appName, type, v, EurekaAccept.compact),
                new Key(Key.EntityType.Application, ALL_APPS, type, v, EurekaAccept.full),
                new Key(Key.EntityType.Application, ALL_APPS, type, v, EurekaAccept.compact),
                new Key(Key.EntityType.Application, ALL_APPS_DELTA, type, v, EurekaAccept.full),
                new Key(Key.EntityType.Application, ALL_APPS_DELTA, type, v, EurekaAccept.compact)
            );
            if (null != vipAddress) {
                invalidate(new Key(Key.EntityType.VIP, vipAddress, type, v, EurekaAccept.full));
            }
            if (null != secureVipAddress) {
                invalidate(new Key(Key.EntityType.SVIP, secureVipAddress, type, v, EurekaAccept.full));
            }
        }
    }
}

失效指定的一组 Key 的方法:

public void invalidate(Key... keys) {
    for (Key key : keys) {
        logger.debug("Invalidating the response cache key : {} {} {} {}, {}",
                     key.getEntityType(), key.getName(), key.getVersion(), key.getType(), key.getEurekaAccept());
		// 从读写缓存中失效
        readWriteCacheMap.invalidate(key);
        Collection<Key> keysWithRegions = regionSpecificKeys.get(key);
        if (null != keysWithRegions && !keysWithRegions.isEmpty()) {
            for (Key keysWithRegion : keysWithRegions) {
                logger.debug("Invalidating the response cache key : {} {} {} {} {}",
                             key.getEntityType(), key.getName(), key.getVersion(), key.getType(), key.getEurekaAccept());
                // 把关联的key也失效
                readWriteCacheMap.invalidate(keysWithRegion);
            }
        }
    }
}

注意:可以看出,以上的缓存失效只失效了读写缓存,即使我们再返回构造器中构造读写缓存的部分,查看其 RemovalListener 也无法找到关于只读缓存的删除,也就是说,失效缓存后,只读缓存依然有可能会为客户端提供过期的响应数据。我们可以查看只读缓存的调用位置,可以看到,readOnlyCacheMap 这个 Map 的方法中,只有 getput 被调用了。

只读缓存数据更新

由 “读写缓存失效,但是只读缓存不失效” 就引出了只读缓存更新的机制。由属性 timer 作为定时器进行调度:

timer.schedule(getCacheUpdateTask(),
                    new Date(((System.currentTimeMillis() / responseCacheUpdateIntervalMs) * responseCacheUpdateIntervalMs)
                            + responseCacheUpdateIntervalMs),
                    responseCacheUpdateIntervalMs);

传入的 TimedTaskgetCacheUpdateTask 方法中得到,而调度的间隔由服务端的配置参数决定,

serverConfig.getResponseCacheUpdateIntervalMs()

具体看更新任务:

private TimerTask getCacheUpdateTask() {
    return new TimerTask() {
        @Override
        public void run() {
            logger.debug("Updating the client cache from response cache");
            // 从只读缓存中遍历 key
            for (Key key : readOnlyCacheMap.keySet()) {
                if (logger.isDebugEnabled()) {
                    logger.debug("Updating the client cache from response cache for key : {} {} {} {}",
                                 key.getEntityType(), key.getName(), key.getVersion(), key.getType());
                }
                try {
                    CurrentRequestVersion.set(key.getVersion());
                    // 从读写缓存中获取值
                    Value cacheValue = readWriteCacheMap.get(key);
                    Value currentCacheValue = readOnlyCacheMap.get(key);
                    // 如果值不一样,更新只读缓存
                    if (cacheValue != currentCacheValue) {
                        readOnlyCacheMap.put(key, cacheValue);
                    }
                } catch (Throwable th) {
                    logger.error("Error while updating the client cache from response cache for key {}", key.toStringCompact(), th);
                } finally {
                    CurrentRequestVersion.remove();
                }
            }
        }
    };
}

逻辑很简单,就是从两个缓存中获取,进行比对,如果不一致,就更新只读缓存。

隐含一个情况:只读缓存只会越来越大,但是鉴于服务不会无限制增长,所以这个缓存增长是有上限的。

至此,响应缓存的主要机制已经分析完毕了。

只差最后一步 com.netflix.eureka.registry.AbstractInstanceRegistry#getApplications() 没有分析了。

这个方法最终调用的方法是 AbstractInstanceRegistry#getApplicationsFromMultipleRegions

public Applications getApplicationsFromMultipleRegions(String[] remoteRegions) {
    boolean includeRemoteRegion = null != remoteRegions && remoteRegions.length != 0;

    logger.debug("Fetching applications registry with remote regions: {}, Regions argument {}",
                 includeRemoteRegion, remoteRegions);

    // 计数
    if (includeRemoteRegion) {
        GET_ALL_WITH_REMOTE_REGIONS_CACHE_MISS.increment();
    } else {
        GET_ALL_CACHE_MISS.increment();
    }
    
    // 构造新的应用
    Applications apps = new Applications();
    apps.setVersion(1L);
    // 遍历注册表
    for (Entry<String, Map<String, Lease<InstanceInfo>>> entry : registry.entrySet()) {
        Application app = null;
		
        if (entry.getValue() != null) {
            for (Entry<String, Lease<InstanceInfo>> stringLeaseEntry : entry.getValue().entrySet()) {
                Lease<InstanceInfo> lease = stringLeaseEntry.getValue();
                if (app == null) {
                    app = new Application(lease.getHolder().getAppName());
                }
                app.addInstance(decorateInstanceInfo(lease));
            }
        }
        if (app != null) {
            apps.addApplication(app);
        }
    }
    
    // 从远程的region抓取注册表
    if (includeRemoteRegion) {
        for (String remoteRegion : remoteRegions) {
            RemoteRegionRegistry remoteRegistry = regionNameVSRemoteRegistry.get(remoteRegion);
            if (null != remoteRegistry) {
                Applications remoteApps = remoteRegistry.getApplications();
                for (Application application : remoteApps.getRegisteredApplications()) {
                    if (shouldFetchFromRemoteRegistry(application.getName(), remoteRegion)) {
                        logger.info("Application {}  fetched from the remote region {}",
                                    application.getName(), remoteRegion);

                        Application appInstanceTillNow = apps.getRegisteredApplications(application.getName());
                        if (appInstanceTillNow == null) {
                            appInstanceTillNow = new Application(application.getName());
                            apps.addApplication(appInstanceTillNow);
                        }
                        for (InstanceInfo instanceInfo : application.getInstances()) {
                            appInstanceTillNow.addInstance(instanceInfo);
                        }
                    } else {
                        logger.debug("Application {} not fetched from the remote region {} as there exists a "
                                     + "whitelist and this app is not in the whitelist.",
                                     application.getName(), remoteRegion);
                    }
                }
            } else {
                logger.warn("No remote registry available for the remote region {}", remoteRegion);
            }
        }
    }
    // 设置 appsHashCode 属性,这个属性用于服务端和客户端快速对比数据是否一致的
    apps.setAppsHashCode(apps.getReconcileHashCode());
    return apps;
}

方法中主要做的事情,就是从注册表中遍历构建 Applications 对象。

appsHashCode 这个属性,也是用于和客户端配合来进行快速数据比对的。

关于远程注册表相关的数据,我们之后的小节中再进行分析。

分析到这里,拉取注册表的机制也相当于分析的差不多了。

总结下: API -> 只读缓存 -> 读写缓存 -> registry

拉取增量注册表

拉取增量的注册表从大体流程上看是和拉取全量的注册表的过程基本一致的,都是用了缓存机制,不同的地方在于从 registry 获取数据的方法不同,从 registry 中获取增量的注册表是

com.netflix.eureka.registry.AbstractInstanceRegistry#getApplicationDeltasFromMultipleRegions 方法。

方法逻辑如下:

public Applications getApplicationDeltasFromMultipleRegions(String[] remoteRegions) {
    if (null == remoteRegions) {
        remoteRegions = allKnownRemoteRegions; // null means all remote regions.
    }

    boolean includeRemoteRegion = remoteRegions.length != 0;

    // 计数器
    if (includeRemoteRegion) {
        GET_ALL_WITH_REMOTE_REGIONS_CACHE_MISS_DELTA.increment();
    } else {
        GET_ALL_CACHE_MISS_DELTA.increment();
    }

    Applications apps = new Applications();
    apps.setVersion(responseCache.getVersionDeltaWithRegions().get());
    // 新建一个 map 存应用名和应用信息的映射
    Map<String, Application> applicationInstancesMap = new HashMap<String, Application>();
    try {
        write.lock();
        // 准备遍历之前维护的队列以获取最近改变的事项(实例)
        Iterator<RecentlyChangedItem> iter = this.recentlyChangedQueue.iterator();
        logger.debug("The number of elements in the delta queue is :{}", this.recentlyChangedQueue.size());
        while (iter.hasNext()) {
            // 拿到租约信息
            Lease<InstanceInfo> lease = iter.next().getLeaseInfo();
            InstanceInfo instanceInfo = lease.getHolder();
            logger.debug("The instance id {} is found with status {} and actiontype {}",
                         instanceInfo.getId(), instanceInfo.getStatus().name(), instanceInfo.getActionType().name());
            // 从 map 中获取应用信息
            Application app = applicationInstancesMap.get(instanceInfo.getAppName());
            if (app == null) {
                // 没有就新加一个
                app = new Application(instanceInfo.getAppName());
                applicationInstancesMap.put(instanceInfo.getAppName(), app);
                apps.addApplication(app);
            }
            // 添加实例信息
            app.addInstance(new InstanceInfo(decorateInstanceInfo(lease)));
        }

        // 如果要从其它region获取数据
        if (includeRemoteRegion) {
            for (String remoteRegion : remoteRegions) {
                RemoteRegionRegistry remoteRegistry = regionNameVSRemoteRegistry.get(remoteRegion);
                if (null != remoteRegistry) {
                    Applications remoteAppsDelta = remoteRegistry.getApplicationDeltas();
                    if (null != remoteAppsDelta) {
                        for (Application application : remoteAppsDelta.getRegisteredApplications()) {
                            if (shouldFetchFromRemoteRegistry(application.getName(), remoteRegion)) {
                                Application appInstanceTillNow =
                                    apps.getRegisteredApplications(application.getName());
                                if (appInstanceTillNow == null) {
                                    appInstanceTillNow = new Application(application.getName());
                                    apps.addApplication(appInstanceTillNow);
                                }
                                for (InstanceInfo instanceInfo : application.getInstances()) {
                                    appInstanceTillNow.addInstance(new InstanceInfo(instanceInfo));
                                }
                            }
                        }
                    }
                }
            }
        }

        Applications allApps = getApplicationsFromMultipleRegions(remoteRegions);
        // 将当前的全量的应用信息的hashcode放入增量的应用信息中,用于给客户端进行快速比对
        apps.setAppsHashCode(allApps.getReconcileHashCode());
        return apps;
    } finally {
        write.unlock();
    }
}

可以看到,数据是从之前维护的队列 recentlyChangedQueue 中得到,而对于每个实例的信息,都直接调用 Application#addInstance 方法,可能会让人疑惑,在一段时间内,某个实例多次更改那不是会有多个实例信息放入其中吗? 进入到 Application#addInstance 方法可以看到,内部也还是会做覆盖处理的:

public void addInstance(InstanceInfo i) {
    // 根据id覆盖了
    instancesMap.put(i.getId(), i);
    synchronized (instances) {
        // 移除旧的,添加新的
        instances.remove(i);
        instances.add(i);
        isDirty = true;
    }
}

另外,既然增量的意思就是从之前时间到现在时间的变化,那么,是怎么将队列维护在一段时间内的呢?

那么就需要回顾下 AbstractInstanceRegistry 的这个属性了:

private Timer deltaRetentionTimer = new Timer("Eureka-DeltaRetentionTimer", true);

这个定时器就是用于调度队列元素清除任务的。

我们可以看到,构造器中,有这么一段:

this.deltaRetentionTimer.schedule(getDeltaRetentionTask(),
                serverConfig.getDeltaRetentionTimerIntervalInMs(),
                serverConfig.getDeltaRetentionTimerIntervalInMs());

可以看到有某个任务以 deltaRetentionTimerIntervalInMs 这个时间间隔定期执行(默认30秒),这个任务来自于方法:

private TimerTask getDeltaRetentionTask() {
    return new TimerTask() {

        @Override
        public void run() {
            // 拿到迭代器
            Iterator<RecentlyChangedItem> it = recentlyChangedQueue.iterator();
            while (it.hasNext()) {
                // 如果队列的元素的放入时间距今超过了指定的保留时间,就被移除
                if (it.next().getLastUpdateTime() <
                    System.currentTimeMillis() - serverConfig.getRetentionTimeInMSInDeltaQueue()) {
                    it.remove();
                } else {
                    break;
                }
            }
        }

    };
}

逻辑很简单:如果队列的元素的放入时间距今超过了指定的保留时间(默认180秒),就被移除。

至此,可以知道,队列是 ① 更改实例信息时放入到队列; ② 定时任务进行过期的数据的移除; ③ 响应时从队列中获取数据构造增量数据然后放入到响应缓存中;

服务剔除

注册中心会定期剔除租约过期的实例。

服务剔除是使用一个定时器实现的,是属性:

private Timer evictionTimer = new Timer("Eureka-EvictionTimer", true);

任务对应的实现类是 com.netflix.eureka.registry.AbstractInstanceRegistry.EvictionTask

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

    @Override
    public void run() {
        try {
            // 获取补偿时间
            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);
        }
    }

剔除的流程如下:

image-20210407232543158.png

整个流程还涉及服务端针对自身遭受网络分区时所需要的保护注册表数据的机制:自我保护机制。

这个机制我们下一小节再说。

流程的核心方法是 evict 方法,而获取补偿时间是为了校准定时任务的时间不精确:

long getCompensationTimeMs() {
    // 获取当前毫秒时间
    long currNanos = getCurrentTimeNano();
    // 获取上次执行的时间
    long lastNanos = lastExecutionNanosRef.getAndSet(currNanos);
    if (lastNanos == 0l) {
        return 0l;
    }
	// 上次到这次之间的时间间隔
    long elapsedMs = TimeUnit.NANOSECONDS.toMillis(currNanos - lastNanos);
    // 补偿时间为,时间间隔减去配置的驱逐时间间隔
    long compensationTime = elapsedMs - serverConfig.getEvictionIntervalTimerInMs();
    // 如果小于0,返回0l,否则直接返回(应该不会小于0吧)
    return compensationTime <= 0l ? 0l : compensationTime;
}

然后将补偿时间传入 evict 方法,如下:

public void evict(long additionalLeaseMs) {
    logger.debug("Running the evict task");
	// 如果启用了自我保护机制,跳过
    if (!isLeaseExpirationEnabled()) {
        logger.debug("DS: lease expiration is currently disabled.");
        return;
    }

    // We collect first all expired items, to evict them in random order. For large eviction sets,
    // if we do not that, we might wipe out whole apps before self preservation kicks in. By randomizing it,
    // the impact should be evenly distributed across all applications.
    // list用于收集所有过期的租约
    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);
                }
            }
        }
    }

    // To compensate for GC pauses or drifting local time, we need to use current registry size as a base for
    // triggering self-preservation. Without that we would wipe out full registry.
    // 获取本地注册表的实例数量
    int registrySize = (int) getLocalRegistrySize();
    // 根据续约的阈值(默认 0.85),计算出至少要保留的实例数据的百分比
    int registrySizeThreshold = (int) (registrySize * serverConfig.getRenewalPercentThreshold());
    // 计算出最大能够剔除的实例的数量(总的 - 要保留的)
    int evictionLimit = registrySize - registrySizeThreshold;

    // 从能够剔除的和最多能剔除这两个数值中选小的作为此次剔除任务要剔除的实例的数量。
    int toEvict = Math.min(expiredLeases.size(), evictionLimit);
    if (toEvict > 0) {
        logger.info("Evicting {} items (expired={}, evictionLimit={})", toEvict, expiredLeases.size(), evictionLimit);
		
        Random random = new Random(System.currentTimeMillis());
        // 用洗牌算法剔除 toEvict,尽量避免将某个应用的所有实例都剔除
        for (int i = 0; i < toEvict; i++) {
            // Pick a random item (Knuth shuffle algorithm)
            // 取出待剔除的
            int next = i + random.nextInt(expiredLeases.size() - i);
            // 和 i 交换位置(交换了之后,所有被选中的元素都在i左侧)
            Collections.swap(expiredLeases, i, next);
            Lease<InstanceInfo> lease = expiredLeases.get(i);

            String appName = lease.getHolder().getAppName();
            String id = lease.getHolder().getId();
            EXPIRED.increment();
            logger.warn("DS: Registry: expired lease for {}/{}", appName, id);
            // 这个方法是服务主动下线时也调用了的
            internalCancel(appName, id, false);
        }
    }
}

另外,注意下,com.netflix.eureka.lease.Lease#isExpired(long) 这个用于判断租约是否过期的方法,在注释上说明了,renew 方法错误的多加了一次 duration 导致租约的过期时间是服务实际租约的时长的两倍(默认的时长是90秒,这样就会变成180秒),但是这样如果是正常关闭且网络畅通的情况下,服务会主动下线,所以这个问题的影响范围比较小,只影响到以下两种实例:

  • 没有正常关闭(也就没有主动调用下线方法);
  • 正常关闭了,但是实例到注册中心的网络不可达;

总结下剔除方法:

  • 如果注册中心当前处于自我保护机制中,不剔除任何实例;
  • 每次最多只剔除一定百分比的实例(基于总的注册表的实例数,默认 15%),无论过期的实例的数量有多少;
  • 决定了剔除的实例的数量后,从所有收集到的过期的实例中,随机剔除这指定的数量;
  • 具体剔除某个实例的过程和实例主动下线的处理方法是一致的,都是调用 internalCancel 方法,在其中:
    • 从注册表 map 中移除这个实例对应的信息;
    • 保存更改事项到 recentCanceledQueue 队列;
    • 调用实例对应的租约的 cancel 方法;
    • 失效相关的响应缓存;
    • 更新续约相关的数据(用于自我保护机制);

自我保护机制

因篇幅问题,请参考 续篇

对等复制

因篇幅问题,请参考 续篇

总结

Eureka Server 的分析到此告一段落了,在这个过程中,有以下内容对我来说比较有启发性:

  • 响应缓存的处理(读写缓存,只读缓存)
  • 增量数据的处理
  • 对等复制的设计考虑(批量任务、拥塞和网络错误处理、重复任务取消)
  • 冲突数据通过状态码提示
  • 自我保护机制对于 CAP 特性的取舍权衡
  • 服务剔除时,使用洗牌算法尽量避免某个应用的所有实例被剔除
  • 并发编程相关 API 的使用

参考资料