SpringCloud 微服务注册中心 Eureka - Server

1,856 阅读6分钟

前言

上一篇文章介绍了 Eureka Client 端的相关源码。这篇文章我们学习 Eureka Server 是如何存储 Client 注册过来的实例信息,以及 Server 端如何与 Client 端续约。相对于 Client 端来说,Server 端要简单一些。

Eureka Server 启动

我们可以发现 EurekaServerAutoConfiguration 类导入了 EurekaServerInitializerConfiguration

@Import(EurekaServerInitializerConfiguration.class)

观察 EurekaServerInitializerConfiguration 发现它也实现了 SmartLifecycle 接口,在它的 start() 中进行了初始化

initEurekaEnvironment();
initEurekaServerContext();

这里主要是一些环境、基础信息的初始化,以及启动了一个定时剔除未发送心跳的服务实例任务 EvictionTask

事件发布

我们还可以观察到在 EurekaServerInitializerConfiguration.start() 方法中发布了两个事件 EurekaRegistryAvailableEvent、EurekaServerStartedEvent 这和 Client 端的初始化类似。实际上几乎所有组件在启动、销毁等一些关键节点都会发布一些事件,便于我们去扩展,如果我们想在某个节点做某些事,只需要监听该事件写我们自己的处理逻辑即可。

Client 实例注册入口

ApplicationResource.addInstance()是客户端实例注册请求的入口,这里首先做了一些实例信息校验然后调用实例注册器的 register() 方法开始注册

registry.register(info, "true".equals(isReplication));

该方法内部首先发布了一个 EurekaInstanceRegisteredEvent 事件,随后开始调用 AbstractInstanceRegistry.register() 开始执行真正的注册逻辑。该类维护了一个保存服务实例信息的 ConcurrentHashMap

private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry = new ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>();

外层的 key -> appName,内层的 key -> instanceId 。每次过来一个 Client 注册请求,就会更新这个 registry

在更新完 AbstractInstanceRegistry.registry 之后有一行很重要的代码

invalidateCache(registrant.getAppName(), registrant.getVIPAddress(), registrant.getSecureVipAddress());

从这个方法的名字我们可以猜到,失效缓存。点进去之后我们发现了一个类 ResponseCacheImpl,这个类有两个很重要的成员变量

private final ConcurrentMap<Key, Value> readOnlyCacheMap = new ConcurrentHashMap<Key, Value>();
private final LoadingCache<Key, Value> readWriteCacheMap;

等会我们会提到这两个 Map。

Client 实例拉取入口

在 Client 篇中,我们提到了实例注册到 Eureka Server 的同时会从 Server 拉取其他微服务实例信息,而入口就在 ApplicationsResource.getContainers() ,我们可以看到一段代码

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

responseCache 是个接口,通过 debug 发现它的实现类就是 ResponseCacheImpl,这里查看 getGZIP()源码

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

这里逻辑很简单,如果 readOnlyCacheMap 没有就从 readWriteCacheMap 拿,然后更新到 readOnlyCacheMap 。既然如此肯定有个地方去更新 readWriteCacheMap 。很遗憾我找了很久都没有找到在哪更新的,迷茫之际我无意中发现 readWriteCacheMap 并不是一个单纯的 ConCurrentHashMap,而是一个第三方缓存组件,本着多年 Redis 缓存中间件的使用流程的经验,我猜测也许是 get() 发现没有这个值的时候会自动塞进去,于是我找到了 readWriteCacheMap 初始化的代码。 ResponseCacheImpl 的构造方法中

this.readWriteCacheMap =
        CacheBuilder.newBuilder().initialCapacity(serverConfig.getInitialCapacityOfResponseCache())
                .expireAfterWrite(serverConfig.getResponseCacheAutoExpirationInSeconds(), TimeUnit.SECONDS)
                .removalListener(new RemovalListener<Key, Value>() {
                    @Override
                    public void onRemoval(RemovalNotification<Key, Value> notification) {
                        Key removedKey = notification.getKey();
                        if (removedKey.hasRegions()) {
                            Key cloneWithNoRegions = removedKey.cloneWithoutRegions();
                            regionSpecificKeys.remove(cloneWithNoRegions, removedKey);
                        }
                    }
                })
                .build(new CacheLoader<Key, Value>() {
                    @Override
                    public Value load(Key key) throws Exception {
                        if (key.hasRegions()) {
                            Key cloneWithNoRegions = key.cloneWithoutRegions();
                            regionSpecificKeys.put(cloneWithNoRegions, key);
                        }
                        Value value = generatePayload(key);
                        return value;
                    }
                });

我在 build() 打了个断点,发现果然是每次 get() 的时候发现没有这个值就会走到这塞进去,那么是从哪里取值放进缓存的呢?查看 generatePayload() 方法发现最终取值的地方就是我们上面说的 AbstractInstanceRegistry.registry

也就是说他们三个的关系是 readOnlyCacheMap → readWriteCacheMap → registry

现在有一个问题,就是 readOnlyCacheMap 他是一个 Map 集合,元素不会自动过期,而 readWriteCacheMap 是一个 LoadingCache ,从初始化代码会发现,这个缓存时间是 180S 后自动过期。那么我们可以猜测应该有一个定时任务定时置空 readOnlyCacheMap

随后在该构造方法中还发现了一个定时任务 getCacheUpdateTask(),这里会根据默认配置每隔 30SreadWriteCacheMap 同步到 readOnlyCacheMap

缓存之间的数据同步

现在我们来整理一下两个缓存 readOnlyCacheMap、readWriteCacheMap 和一个注册表 AbstractInstanceRegistry.registry 之间的数据同步关系。

sanjihuancun.png

  1. 微服务请求 Eureka Server 注册,将数据存在 registry
  2. 每次有微服务注册的时候会失效 readWriteCacheMap (因为它缓存的 key 不是具体微服务名,而是 ALL_APPS、ALL_APPS_DELTA
  3. 其他微服务拉取实例首先从 readOnlyCacheMap
  4. readOnlyCacheMap 没有,从 readWriteCacheMap 查,并将数据写到 readOnlyCacheMap
  5. readWriteCacheMap 也没有,从 registry 查,并将数据写到 readWriteCacheMap,readOnlyCacheMap
  6. 定时任务每隔 30S 定时从 readWriteCacheMap 同步数据到 readOnlyCacheMap

Eureka Client 的感知

从上面的内容我们可以知道这些实例信息的数据并不能保证是实时正确的,这也正好反应了 Eureka 是 AP 原则,对于强一致性是不支持的。我们可以计算服务上下线被感知到的临界时间

行为时间备注
上线Eureka Client (registryFetchIntervalSeconds = 30)+ readOnlyCacheMap(responseCacheUpdateIntervalMs = 30) = 60S客户端拉取间隔+缓存同步间隔
正常下线同上同上
非正常下线Eureka Server (evictionIntervalTimerInMs = 60) * 3 + readOnlyCacheMap(responseCacheUpdateIntervalMs = 30) = 210S服务端定时剔除间隔 * 3+只读缓存同步间隔

前面两个好理解。现在我们看在非正常下线的时候由于我们是每 60S 定时剔除 90S 未续约的服务,最大需要三次定时任务扫描到,如下图

time.png

然后再加上最后 readOnlyCacheMap(responseCacheUpdateIntervalMs = 30),总共 210S

值得注意的是我们上面的表格只是单纯的服务发现的时间,但通常我们并不会直接关心这个时间,因为我们都是在服务间调用的时候才会涉及到获取其他微服务实例,这需要用到负载均衡器。

前面我们提到两种负载均衡器,RibbonLoadBalancer,它们都会将服务实例信息缓存一份,ribbon30S ,不过在新一代负载均衡器 LoadBalancer 出来之后,我们大多更新到了 LoadBalancer,通常我喜欢用现在流行的内存缓存中间件 caffeine ,在 LoadBalancerCachePropertiescaffeine 默认的过期时间是 35S,所以在上面的数据基础上还要加上负载均衡器的缓存时间。详细流程我会在后面的 SpringCloud LoadBalancer 文章中介绍。

结语

相对于 Eureka Client 来说,Server 要简单一些,我们重点需要关注服务上下线被延迟感知的临界时间,在这上面做优化,尽可能减少由此导致的微服务之间调用失败。

如果这篇文章对你有帮助,记得点赞加关注!你的支持就是我继续创作的动力!