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

1,551 阅读16分钟

系列文章:

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

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

Eureka Client 启动时全量抓取注册表

客户端启动初始化 DiscoveryClient 时,其中有段代码如下:这一步调用 fetchRegistry 就是在启动时全量抓取注册表缓存到本地中。

if (clientConfig.shouldFetchRegistry()) {
    try {
        // 拉取注册表:全量抓取和增量抓取
        boolean primaryFetchRegistryResult = fetchRegistry(false);
        //...
    } catch (Throwable th) {
        logger.error("Fetch registry error at startup: {}", th.getMessage());
        throw new IllegalStateException(th);
    }
}

进入 fetchRegistry 方法,可以看到,首先获取本地的 Applications,如果为空就会调用 getAndStoreFullRegistry 方法全量抓取注册表并缓存到本地。

private boolean fetchRegistry(boolean forceFullRegistryFetch) {
    try {
        // 获取本地的应用实例
        Applications applications = getApplications();

        if (clientConfig.shouldDisableDelta()
                || (!Strings.isNullOrEmpty(clientConfig.getRegistryRefreshSingleVipAddress()))
                || forceFullRegistryFetch
                || (applications == null)
                || (applications.getRegisteredApplications().size() == 0)
                || (applications.getVersion() == -1)) //Client application does not have latest library supporting delta
        {
            // 全量抓取注册表
            getAndStoreFullRegistry();
        } else {
            // 增量更新注册表
            getAndUpdateDelta(applications);
        }
        // 计算hash值设置到本地
        applications.setAppsHashCode(applications.getReconcileHashCode());
    } catch (Throwable e) {
        return false;
    }

    // 发出缓存刷新的通知
    onCacheRefreshed();

    // registry was fetched successfully, so return true
    return true;
}

进入 getAndStoreFullRegistry 方法可以发现,就是调用 GET /apps 接口抓取全量注册表,因此等会服务端就从这个入口进去看抓取全量注册表的逻辑。注册表抓取回来之后,就放到本地变量 localRegionApps 中。localRegionApps 的类型是 AtomicReference<Applications>,实例信息是存储在 Applications 中的。

private void getAndStoreFullRegistry() throws Throwable {
    long currentUpdateGeneration = fetchRegistryGeneration.get();

    logger.info("Getting all instance registry info from the eureka server");

    Applications apps = null;
    EurekaHttpResponse<Applications> httpResponse = clientConfig.getRegistryRefreshSingleVipAddress() == null
            // 调用 server GET /apps 全量抓取注册表
            ? eurekaTransport.queryClient.getApplications(remoteRegionsRef.get())
            : eurekaTransport.queryClient.getVip(clientConfig.getRegistryRefreshSingleVipAddress(), remoteRegionsRef.get());
    if (httpResponse.getStatusCode() == Status.OK.getStatusCode()) {
        apps = httpResponse.getEntity();
    }

    if (apps == null) {
        logger.error("The application is null for some reason. Not storing this information");
    } else if (fetchRegistryGeneration.compareAndSet(currentUpdateGeneration, currentUpdateGeneration + 1)) {
        // 将 Applications 缓存到本地
        localRegionApps.set(this.filterAndShuffle(apps));
        // Applications 中返回了注册中心 apps 的 hash 值
        logger.debug("Got full registry with apps hashcode {}", apps.getAppsHashCode());
    } else {
        logger.warn("Not updating applications as another thread is updating it already");
    }
}

Eureka Server 注册表多级缓存机制

1、全量抓取注册表的接口

全量抓取注册表的接口是 GET /apps,跟找注册接口是类似的,最终可以找到 ApplicationsResourcegetContainers 方法就是全量抓取注册表的入口。

  • 可以看出,我们可以通过请求头来指定返回 xml 格式还是 json 格式,可以指定是否要压缩返回等。
  • 然后创建了全量缓存的 Key 为 ALL_APPS
  • 接着根据缓存的 key 从 responseCache 中全量抓取注册表
@GET
public Response getContainers(@PathParam("version") String version,
                              @HeaderParam(HEADER_ACCEPT) String acceptHeader,
                              @HeaderParam(HEADER_ACCEPT_ENCODING) String acceptEncoding,
                              @HeaderParam(EurekaAccept.HTTP_X_EUREKA_ACCEPT) String eurekaAccept,
                              @Context UriInfo uriInfo,
                              @Nullable @QueryParam("regions") String regionsStr) {
    // 省略部分代码...

    // JSON 类型
    KeyType keyType = Key.KeyType.JSON;
    String returnMediaType = MediaType.APPLICATION_JSON;
    if (acceptHeader == null || !acceptHeader.contains(HEADER_JSON_VALUE)) {
        keyType = Key.KeyType.XML;
        returnMediaType = MediaType.APPLICATION_XML;
    }

    // 全量注册表的缓存key => ALL_APPS
    Key cacheKey = new Key(Key.EntityType.Application, ResponseCacheImpl.ALL_APPS,
            keyType, CurrentRequestVersion.get(), EurekaAccept.fromString(eurekaAccept), regions
    );

    Response response;
    if (acceptEncoding != null && acceptEncoding.contains(HEADER_GZIP_VALUE)) {
        // 压缩返回
        response = Response.ok(responseCache.getGZIP(cacheKey))
                .header(HEADER_CONTENT_ENCODING, HEADER_GZIP_VALUE)
                .header(HEADER_CONTENT_TYPE, returnMediaType)
                .build();
    } else {
        // 根据缓存 key 从 responseCache 获取全量注册表
        response = Response.ok(responseCache.get(cacheKey)).build();
    }
    return response;
}

2、ResponseCache 多级缓存读取

ResponseCache 就是 eureka server 读取注册表的核心组件,它的内部采用了多级缓存的机制来快速响应客户端抓取注册表的请求,下面就来看看 ResponseCache。

缓存读取的流程:

  • 如果设置了使用只读缓存(默认true),就先从只读缓存 readOnlyCacheMap 中读取;readOnlyCacheMap 使用 ConcurrentHashMap 实现,ConcurrentHashMap 支持并发访问,读取速度很快。
  • 如果读写缓存中没有,就从读写缓存 readWriteCacheMap 中读取,读取出来后并写入到只读缓存中;readWriteCacheMap 使用 google guavaLoadingCache 实现,LoadingCache 支持在没有元素的时候使用 CacheLoader 加载元素。
  • 如果没有开启使用只读缓存,就直接从读写缓存中获取。
public String get(final Key key) {
    return get(key, shouldUseReadOnlyResponseCache);
}

String get(final Key key, boolean useReadOnlyCache) {
    // => getValue
    Value payload = getValue(key, useReadOnlyCache);
    if (payload == null || payload.getPayload().equals(EMPTY_PAYLOAD)) {
        return null;
    } else {
        return payload.getPayload();
    }
}

Value getValue(final Key key, boolean useReadOnlyCache) {
    Value payload = null;
    try {
        if (useReadOnlyCache) {
            // 开启使用只读缓存,则先从只读缓存读取
            // readOnlyCacheMap 结构 => ConcurrentHashMap<Key, Value>
            final Value currentPayload = readOnlyCacheMap.get(key);
            if (currentPayload != null) {
                payload = currentPayload;
            } else {
                // 只读缓存中没有,则从读写缓存中读取,然后放入只读缓存中
                // readWriteCacheMap 结构 => LoadingCache<Key, Value>
                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;
}

3、ResponseCache 初始化

分析 eureka server EurekaBootStrap 启动初始化时,最后有一步去初始化 eureka server 上下文,它里面就会去初始化注册表,初始化注册表的时候就会初始化 ResponseCache,这里就来分析下这个初始化干了什么。

  • 主要就是使用 google guava cache 构造了一个读写缓存 readWriteCacheMap,初始容量为 1000。注意这个读写缓存的特性:每隔 180秒定时过期,然后元素不存在的时候就会使用 CacheLoader 从注册表中读取。
  • 接着如果配置了使用只读缓存,还会开启一个定时任务,每隔30秒将读写缓存 readWriteCacheMap 的数据同步到只读缓存 readOnlyCacheMap
ResponseCacheImpl(EurekaServerConfig serverConfig, ServerCodecs serverCodecs, AbstractInstanceRegistry registry) {
    this.serverConfig = serverConfig;
    this.serverCodecs = serverCodecs;
    // 是否使用只读缓存,默认为 true
    this.shouldUseReadOnlyResponseCache = serverConfig.shouldUseReadOnlyResponseCache();
    // 保存注册表
    this.registry = registry;
    // 缓存更新间隔时间,默认30秒
    long responseCacheUpdateIntervalMs = serverConfig.getResponseCacheUpdateIntervalMs();
    // 使用 google guava cache 构造一个读写缓存
    this.readWriteCacheMap =
            // 初始容量为1000
            CacheBuilder.newBuilder().initialCapacity(serverConfig.getInitialCapacityOfResponseCache())
                    // 缓存的数据在写入多久后过期,默认180秒,也就是说 readWriteCacheMap 会定时过期
                    .expireAfterWrite(serverConfig.getResponseCacheAutoExpirationInSeconds(), TimeUnit.SECONDS)
                    // 移除元素...
                    // 当key对应的元素不存在时,使用定义 CacheLoader 加载元素
                    .build(new CacheLoader<Key, Value>() {
                        @Override
                        public Value load(Key key) throws Exception {
                            // 获取元素
                            Value value = generatePayload(key);
                            return value;
                        }
                    });

    if (shouldUseReadOnlyResponseCache) {
        // 如果配置了使用只读缓存,就开启一个定时任务,定期将 readWriteCacheMap 的数据同步到 readOnlyCacheMap 中
        // 默认间隔时间是 30 秒
        timer.schedule(getCacheUpdateTask(),
                new Date(((System.currentTimeMillis() / responseCacheUpdateIntervalMs) * responseCacheUpdateIntervalMs)
                        + responseCacheUpdateIntervalMs),
                responseCacheUpdateIntervalMs);
    }
}

generatePayload 方法:根据不同的Key类型从注册表获取 Applicaion

private Value generatePayload(Key key) {
    try {
        String payload;
        switch (key.getEntityType()) {
            case Application:
                boolean isRemoteRegionRequested = key.hasRegions();

                // ALL_APPS => 获取所有应用
                if (ALL_APPS.equals(key.getName())) {
                    // 从注册表读取所有服务实例
                    payload = getPayLoad(key, registry.getApplications());
                }
                // ALL_APPS_DELTA => 增量获取应用
                else if (ALL_APPS_DELTA.equals(key.getName())) {
                    versionDelta.incrementAndGet();
                    versionDeltaLegacy.incrementAndGet();
                    // 从注册表获取增量应用
                    payload = getPayLoad(key, registry.getApplicationDeltas());
                } else {
                    payload = getPayLoad(key, registry.getApplication(key.getName()));
                }
                break;
            case VIP:
            case SVIP:
                payload = getPayLoad(key, getApplicationsForVip(key, registry));
                break;
            default:
                payload = "";
                break;
        }
        return new Value(payload);
    }
}

Eureka Server 注册表多级缓存过期机制

这节来总结下 eureka server 注册表多级缓存的过期时机,其实前面都已经分析过了。

1、主动过期

分析服务注册时已经说过,服务注册完成后,调用了 invalidateCache 来失效缓存,进去可以看到就是将读写缓存 readWriteCacheMap 中的服务、所有服务、增量服务的缓存失效掉。

那这里就要注意了,如果服务注册、下线、故障之类的,这里只是失效了读写缓存,然后可能要间隔30秒才能同步到只读缓存 readOnlyCacheMap,那么其它客户端可能要隔30秒后才能感知到。

private void invalidateCache(String appName, @Nullable String vipAddress, @Nullable String secureVipAddress) {
    // invalidate cache
    responseCache.invalidate(appName, vipAddress, secureVipAddress);
}

缓存失效:

@Override
public void invalidate(String appName, @Nullable String vipAddress, @Nullable String secureVipAddress) {
    for (Key.KeyType type : Key.KeyType.values()) {
        for (Version v : Version.values()) {
            invalidate(
                    // 失效服务的缓存
                    new Key(Key.EntityType.Application, appName, type, v, EurekaAccept.full),
                    new Key(Key.EntityType.Application, appName, type, v, EurekaAccept.compact),
                    // 失效所有 APP 的缓存
                    new Key(Key.EntityType.Application, ALL_APPS, type, v, EurekaAccept.full),
                    new Key(Key.EntityType.Application, ALL_APPS, type, v, EurekaAccept.compact),
                    // 失效增量 APP 的缓存
                    new Key(Key.EntityType.Application, ALL_APPS_DELTA, type, v, EurekaAccept.full),
                    new Key(Key.EntityType.Application, ALL_APPS_DELTA, type, v, EurekaAccept.compact)
            );
            //...
        }
    }
}

public void invalidate(Key... keys) {
    for (Key key : keys) {
        // 失效读写缓存
        readWriteCacheMap.invalidate(key);
        //...
    }
}

2、定时过期

读写缓存 readWriteCacheMap 在构建的时候,指定了一个自动过期的时间,默认值是180秒,所以往 readWriteCacheMap 中放入一个数据过后,等180秒过后,它就自动过期了。然后下次读取的时候发现缓存中没有这个 key,就会使用 CacheLoader 重新加载到这个缓存中。

这种定时过期机制就是每隔一段时间来同步注册表与缓存的数据。

3、被动过期

初始化 ResponseCache 时,如果启用了只读缓存,就会创建一个定时任务(每隔30秒运行一次)来同步 readWriteCacheMapreadOnlyCacheMap 中的数据,对于 readOnlyCacheMap 来说这就是一种被动过期。

private TimerTask getCacheUpdateTask() {
    return new TimerTask() {
        @Override
        public void run() {
            for (Key key : readOnlyCacheMap.keySet()) {
                try {
                    // 获取读写缓存中的数据
                    Value cacheValue = readWriteCacheMap.get(key);
                    // 获取只读缓存中的数据
                    Value currentCacheValue = readOnlyCacheMap.get(key);
                    // 如果 readOnlyCacheMap 中缓存的值与 readWriteCacheMap 缓存的值不同,就用 readWriteCacheMap 的值覆盖 readOnlyCacheMap 的值
                    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);
                }
            }
        }
    };
}

Eureka Client 定时拉取增量注册表

1、客户端注册表刷新定时任务

前面介绍 DiscoveryClient 初始化时,在初始化调度任务这一步,如果要抓取注册表,就会创建一个调度器每隔30秒执行一次 cacheRefreshTask,它对 CacheRefreshThread 做了封装,进去可以看到,它其实就是调用 refreshRegistry 方法刷新注册表。

private void initScheduledTasks() {
    if (clientConfig.shouldFetchRegistry()) {
        // 抓取注册表的间隔时间,默认30秒
        int registryFetchIntervalSeconds = clientConfig.getRegistryFetchIntervalSeconds();
        // 刷新缓存调度器延迟时间扩大倍数,在任务超时的时候,将扩大延迟时间
        // 这在出现网络抖动、eureka-sever 不可用时,可以避免频繁发起无效的调度
        int expBackOffBound = clientConfig.getCacheRefreshExecutorExponentialBackOffBound();
        // 注册表刷新的定时任务
        cacheRefreshTask = new TimedSupervisorTask(
                "cacheRefresh", scheduler, cacheRefreshExecutor,
                registryFetchIntervalSeconds, TimeUnit.SECONDS, expBackOffBound,
                new CacheRefreshThread() // 刷新注册表的任务
        );
        // 30秒后开始调度刷新注册表的任务
        scheduler.schedule(cacheRefreshTask, registryFetchIntervalSeconds, TimeUnit.SECONDS);
    }
}

refreshRegistry 方法:调用 fetchRegistry 抓取注册表

class CacheRefreshThread implements Runnable {
    public void run() {
        refreshRegistry();
    }
}

@VisibleForTesting
void refreshRegistry() {
    try {
        //...
        // 抓取注册表
        boolean success = fetchRegistry(remoteRegionsModified);
        if (success) {
            registrySize = localRegionApps.get().size();
            lastSuccessfulRegistryFetchTimestamp = System.currentTimeMillis();
        }
    } catch (Throwable e) {
        logger.error("Cannot fetch registry from server", e);
    }
}

refreshRegistry 里面又调用了 fetchRegistry 抓取注册表,fetchRegistry 在前面分析全量抓取注册表时已经展示过了。全量抓取注册表之后,本地 applications 不为空了,这时就会走 getAndUpdateDelta 增量更新的方法。

走增量抓取注册表首先判断 shouldDisableDelta,默认为 true,可以配置 eureka.client.disable-delta 关闭增量抓取。

private boolean fetchRegistry(boolean forceFullRegistryFetch) {
    try {
        // 获取本地的应用实例
        Applications applications = getApplications();

        if (clientConfig.shouldDisableDelta()
                || (!Strings.isNullOrEmpty(clientConfig.getRegistryRefreshSingleVipAddress()))
                || forceFullRegistryFetch
                || (applications == null)
                || (applications.getRegisteredApplications().size() == 0)
                || (applications.getVersion() == -1)) //Client application does not have latest library supporting delta
        {
            // 全量抓取注册表
            getAndStoreFullRegistry();
        } else {
            // 增量更新注册表
            getAndUpdateDelta(applications);
        }
        // 从新计算hahs值设置到本地
        applications.setAppsHashCode(applications.getReconcileHashCode());
    } catch (Throwable e) {
        return false;
    }

    // 发出刷新缓存的通知
    onCacheRefreshed();

    // registry was fetched successfully, so return true
    return true;
}

2、增量更新本地注册表

接着看 getAndUpdateDelta 增量更新方法:

  • 首先调用 eureka server GET /apps/delta 接口获取增量的注册表
  • 如果增量的注册表为空,就会调用 getAndStoreFullRegistry 方法全量抓取注册表
  • 增量注册表不为空,就将其合并到本地注册表中
  • 然后根据本地注册表的 applications 重新计算一个 hash 值
  • eureka server 返回的 delta 中包含一个 appsHashCode,代表了 eureka server 端的注册表的 hash 值,如果与本地计算的 hash 值不同,则说明本地注册表与server端注册表不一致,那就会全量拉取注册表更新到本地缓存中

可以看到,eureka 默认采用增量抓取的思路来更新本地缓存,并使用 hash 值来保证服务端与本地的数据一致性。在分布式系统里,要进行数据同步,采用 hash 值比对的思想,这是值得学习的一个思路。

private void getAndUpdateDelta(Applications applications) throws Throwable {
    long currentUpdateGeneration = fetchRegistryGeneration.get();

    Applications delta = null;
    // 调用远程接口增量抓取:GET apps/delta
    EurekaHttpResponse<Applications> httpResponse = eurekaTransport.queryClient.getDelta(remoteRegionsRef.get());
    if (httpResponse.getStatusCode() == Status.OK.getStatusCode()) {
        delta = httpResponse.getEntity();
    }

    // 如果增量抓取的数据为空,就会进行一次全量抓取
    if (delta == null) {
        getAndStoreFullRegistry();
    }

    else if (fetchRegistryGeneration.compareAndSet(currentUpdateGeneration, currentUpdateGeneration + 1)) {
        String reconcileHashCode = "";
        // 加锁更新本地注册表
        if (fetchRegistryUpdateLock.tryLock()) {
            try {
                // 抓取到增量的注册表后,跟本地的注册表合并
                updateDelta(delta);
                // 注册表合并完成后,根据本地 applications 计算一个 hash 值
                reconcileHashCode = getReconcileHashCode(applications);
            } finally {
                fetchRegistryUpdateLock.unlock();
            }
        }
        // delta 中会返回 server 端注册表的 hash 值,如果和本地计算出来的 hash 值不一样,
        // 说明本地注册表跟 server 端注册表不一样,就会从 server 全量拉取注册表更新到本地缓存
        if (!reconcileHashCode.equals(delta.getAppsHashCode()) || clientConfig.shouldLogDeltaDiff()) {
            reconcileAndLogDifference(delta, reconcileHashCode);  // this makes a remoteCall
        }
    }
}

3、增量注册表合并到本地

再来看下增量注册表合并到本地发方法 updateDelta,其实就是遍历返回来的服务实例,然后根据实例的 ActionType 分别处理,比如前面分析实例注册时 ActionType 就设置了 ADDED,后面分析实例下线时还可以看到设置了 ActionType 为 DELETED

private void updateDelta(Applications delta) {
    int deltaCount = 0;
    // 变量增量注册的服务
    for (Application app : delta.getRegisteredApplications()) {
        // 遍历实例
        for (InstanceInfo instance : app.getInstances()) {
            Applications applications = getApplications();
            //...
            ++deltaCount;
            // ADDED 新增的实例:服务注册
            if (ActionType.ADDED.equals(instance.getActionType())) {
                Application existingApp = applications.getRegisteredApplications(instance.getAppName());
                if (existingApp == null) {
                    applications.addApplication(app);
                }
                // 实例添加到 Application
                applications.getRegisteredApplications(instance.getAppName()).addInstance(instance);
            }
            // MODIFIED 变更的实例:续约,信息变更
            else if (ActionType.MODIFIED.equals(instance.getActionType())) {
                Application existingApp = applications.getRegisteredApplications(instance.getAppName());
                if (existingApp == null) {
                    applications.addApplication(app);
                }
				// 覆盖本地实例
                applications.getRegisteredApplications(instance.getAppName()).addInstance(instance);
            }
            // DELETED 移除实例:实例下线、故障
            else if (ActionType.DELETED.equals(instance.getActionType())) {
                Application existingApp = applications.getRegisteredApplications(instance.getAppName());
                if (existingApp != null) {
                	// 移除实例
                    existingApp.removeInstance(instance);
                    // 所有实例都移除后,移除 Application
                    if (existingApp.getInstancesAsIsFromEureka().isEmpty()) {
                        applications.removeApplication(existingApp);
                    }
                }
            }
        }
    }
}

4、客户端实例存储

可以看到,全量更新或者增量更新都是在更新本地的 Applications,可以看下 Applications 和 Application 的结构:

  • 应用实例是被封装到 Application 中的,实例列表是一个 LinkedHashSet,并用一个 ConcurrentHashMap 缓存了实例ID和实例的关系,便于快速检索。
  • 应用 Application 再被封装到 Applications 中,应用列表是一个 ConcurrentLinkedQueue,并用 ConcurrentHashMap 缓存了应用名称和 Application 的关系。
  • Applications 中还保存了这个应用的 appsHashCode 值,这个应该是和 eureka server 的 hash 是一致的。

Applications

private final AbstractQueue<Application> applications;
private final Map<String, Application> appNameApplicationMap;
private final Map<String, VipIndexSupport> virtualHostNameAppMap;
private final Map<String, VipIndexSupport> secureVirtualHostNameAppMap;

public Applications(@JsonProperty("appsHashCode") String appsHashCode,
        @JsonProperty("versionDelta") Long versionDelta,
        @JsonProperty("application") List<Application> registeredApplications) {
    // 实例应用队列
    this.applications = new ConcurrentLinkedQueue<Application>();
    // key 为 appName,Value 为对应的应用
    this.appNameApplicationMap = new ConcurrentHashMap<String, Application>();
    this.virtualHostNameAppMap = new ConcurrentHashMap<String, VipIndexSupport>();
    this.secureVirtualHostNameAppMap = new ConcurrentHashMap<String, VipIndexSupport>();
    // hash 值
    this.appsHashCode = appsHashCode;
    this.versionDelta = versionDelta;

    for (Application app : registeredApplications) {
        this.addApplication(app);
    }
}

Application

public class Application {
	// 实例名称
    private String name;
    // 是否变更
    private volatile boolean isDirty = false;
    // 实例信息
    private final Set<InstanceInfo> instances;

    private final AtomicReference<List<InstanceInfo>> shuffledInstances;
    // map 结构实例
    private final Map<String, InstanceInfo> instancesMap;

    public Application() {
        instances = new LinkedHashSet<InstanceInfo>();
        instancesMap = new ConcurrentHashMap<String, InstanceInfo>();
        shuffledInstances = new AtomicReference<List<InstanceInfo>>();
    }
}

Eureka Server 返回增量注册表

1、抓取增量注册表的入口

从前分析知道,增量抓取注册表单接口为 GET/apps/delta,可以很容易找到位于 ApplicationsResource 下的 getContainerDifferential 就是抓取增量注册表的入口。

可以看到,跟抓取注册表类似,也是先构建一个缓存的Key ALL_APPS_DELTA,然后从多级缓存 ResponseCache 中获取。

@Path("delta")
@GET
public Response getContainerDifferential(
        @PathParam("version") String version,
        @HeaderParam(HEADER_ACCEPT) String acceptHeader,
        @HeaderParam(HEADER_ACCEPT_ENCODING) String acceptEncoding,
        @HeaderParam(EurekaAccept.HTTP_X_EUREKA_ACCEPT) String eurekaAccept,
        @Context UriInfo uriInfo, @Nullable @QueryParam("regions") String regionsStr) {

    Key cacheKey = new Key(Key.EntityType.Application,
            // 增量服务:ALL_APPS_DELTA
            ResponseCacheImpl.ALL_APPS_DELTA,
            keyType, CurrentRequestVersion.get(), EurekaAccept.fromString(eurekaAccept), regions
    );

    final Response response;
    if (acceptEncoding != null && acceptEncoding.contains(HEADER_GZIP_VALUE)) {
    	// 支持压缩返回
         response = Response.ok(responseCache.getGZIP(cacheKey))
                .header(HEADER_CONTENT_ENCODING, HEADER_GZIP_VALUE)
                .header(HEADER_CONTENT_TYPE, returnMediaType)
                .build();
    } else {
        // 从多级缓存中获取增量注册表
        response = Response.ok(responseCache.get(cacheKey)).build();
    }
    return response;
}

与全量抓取注册表,读取多级缓存的流程都是类似的,唯一的区别就是 Key 不同,全量抓取时是 ALL_APPS,增量抓取时 ALL_APPS_DELTA,区别就在于 readWriteCacheMap 加载数据到缓存中时走的逻辑不一样,可以再看看下面的 generatePayload 方法就知道了。

private Value generatePayload(Key key) {
    try {
        String payload;
        switch (key.getEntityType()) {
            case Application:
                // 获取所有应用
                if (ALL_APPS.equals(key.getName())) {
                    // 从注册表读取所有服务实例
                    payload = getPayLoad(key, registry.getApplications());
                }
                // 增量获取应用
                else if (ALL_APPS_DELTA.equals(key.getName())) {
                    // 获取增量注册表
                    payload = getPayLoad(key, registry.getApplicationDeltas());
                } else {
                    payload = getPayLoad(key, registry.getApplication(key.getName()));
                }
                break;
            default:
                payload = "";
                break;
        }
        return new Value(payload);
    }
}

2、增量注册表的设计

之后会调用 registry.getApplicationDeltas() 获取增量注册表,进去可以发现,增量的注册表其实就是 recentlyChangedQueue 这个最近变更队列里的数据,通过遍历 recentlyChangedQueue 生成 Applications

在返回 apps 之前,先获取了本地所有应用,并计算了一个 hash 值,然后设置到 apps 中。这就和前一节对应起来了,抓取增量注册表时,服务端会返回一个全量注册表的 hash 值,然后客户端将增量注册表合并到本地后,再根据本地的全量注册表计算一个 hash 值,然后将两个 hash 值做对比,如果不一致,说明服务端和客户端的数据是不一致的,这时客户端就会重新向服务端全量拉取注册表到本地。

public Applications getApplicationDeltas() {
    GET_ALL_CACHE_MISS_DELTA.increment();
    // Applications
    Applications apps = new Applications();
    Map<String, Application> applicationInstancesMap = new HashMap<String, Application>();
    write.lock();
    try {
        // 最近变更队列 recentlyChangedQueue,这就是增量的注册表
        // recentlyChangedQueue 只保留了最近3分钟有变化的实例,如实例上线、下线、故障剔除
        Iterator<RecentlyChangedItem> iter = this.recentlyChangedQueue.iterator();
        while (iter.hasNext()) {
        	// 获取实例信息
            Lease<InstanceInfo> lease = iter.next().getLeaseInfo();
            InstanceInfo instanceInfo = lease.getHolder();
            
            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)));
        }
		//....
        // 获取所有应用实例
        Applications allApps = getApplications(!disableTransparentFallback);
        // 根据所有应用实例计算一个 hash 值,并设置到要返回的 apps 中
        apps.setAppsHashCode(allApps.getReconcileHashCode());
        return apps;
    } finally {
        write.unlock();
    }
}

再来看看 recentlyChangedQueue 是如何设计来保存增量信息的。

再看看前面提到过的注册表初始化的构造方法,最后创建了一个每隔30秒执行一次的定时调度任务。这个任务会遍历 recentlyChangedQueue 这个队列,判断每个元素的最后更新时间是否超过了 180秒,如果超过了,就会从队列中移除这个元素。超过 180秒的实例变更信息,就会认为这些变更信息都已经同步到客户端了,因为客户端是每隔30秒拉取一次增量注册表的。因此客户端多次拉取增量注册表可能拉取到同样的变更信息,不过最终合并到本地都是一样的。

因此可以看出,eureka 利用 recentlyChangedQueue 这个最近变更队列保存了最近3分钟以内实例的变更信息,如新服务注册、服务下线等,然后客户端每次就是拉取这个变更队列。

protected AbstractInstanceRegistry(EurekaServerConfig serverConfig, EurekaClientConfig clientConfig, ServerCodecs serverCodecs) {
    this.serverConfig = serverConfig;
    this.clientConfig = clientConfig;
    this.serverCodecs = serverCodecs;
    // 最近下线的循环队列
    this.recentCanceledQueue = new CircularQueue<Pair<Long, String>>(1000);
    // 最近注册的循环队列
    this.recentRegisteredQueue = new CircularQueue<Pair<Long, String>>(1000);

    // 最近一分钟续约的计数器
    this.renewsLastMin = new MeasuredRate(1000 * 60 * 1);

    // 一个定时调度任务,定时剔除最近改变队列中过期的实例
    this.deltaRetentionTimer.schedule(getDeltaRetentionTask(),
            // 调度任务延迟 30 秒开始执行
            serverConfig.getDeltaRetentionTimerIntervalInMs(),
            // 默认每隔 30 秒执行一次
            serverConfig.getDeltaRetentionTimerIntervalInMs());
}

private TimerTask getDeltaRetentionTask() {
    return new TimerTask() {
        @Override
        public void run() {
            // 最近变更的队列
            Iterator<RecentlyChangedItem> it = recentlyChangedQueue.iterator();
            while (it.hasNext()) {
                // 最近更新时间超过 180 秒就认为数据已经同步到各个客户端了,就从队列中移除
                if (it.next().getLastUpdateTime() <
                        // retentionTimeInMSInDeltaQueue:delta队列数据保留时间,默认 180 秒
                        System.currentTimeMillis() - serverConfig.getRetentionTimeInMSInDeltaQueue()) {
                    it.remove();
                } else {
                    break;
                }
            }
        }
    };
}

Eureka 抓取注册表总体流程图

下面还是用一张图整体展示下服务抓取注册表的整理流程。

服务注册、服务下线、实例故障剔除都会将读写缓存 readWriteCacheMap 中对应的实例失效掉,然后加入到最近变更队列 recentlyChangedQueue 中,因此这三种情况下,增量抓取注册表的逻辑都是类似的。