前言
上一篇文章介绍了 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()
,这里会根据默认配置每隔 30S
将 readWriteCacheMap
同步到 readOnlyCacheMap
。
缓存之间的数据同步
现在我们来整理一下两个缓存 readOnlyCacheMap、readWriteCacheMap
和一个注册表 AbstractInstanceRegistry.registry
之间的数据同步关系。
- 微服务请求
Eureka Server
注册,将数据存在registry
- 每次有微服务注册的时候会失效
readWriteCacheMap
(因为它缓存的 key 不是具体微服务名,而是ALL_APPS、ALL_APPS_DELTA
) - 其他微服务拉取实例首先从
readOnlyCacheMap
查 readOnlyCacheMap
没有,从readWriteCacheMap
查,并将数据写到readOnlyCacheMap
readWriteCacheMap
也没有,从registry
查,并将数据写到readWriteCacheMap,readOnlyCacheMap
- 定时任务每隔
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
未续约的服务,最大需要三次定时任务扫描到,如下图
然后再加上最后 readOnlyCacheMap(responseCacheUpdateIntervalMs = 30)
,总共 210S
。
值得注意的是我们上面的表格只是单纯的服务发现的时间,但通常我们并不会直接关心这个时间,因为我们都是在服务间调用的时候才会涉及到获取其他微服务实例,这需要用到负载均衡器。
前面我们提到两种负载均衡器,Ribbon
和 LoadBalancer
,它们都会将服务实例信息缓存一份,ribbon
是 30S
,不过在新一代负载均衡器 LoadBalancer
出来之后,我们大多更新到了 LoadBalancer
,通常我喜欢用现在流行的内存缓存中间件 caffeine
,在 LoadBalancerCacheProperties
中 caffeine
默认的过期时间是 35S
,所以在上面的数据基础上还要加上负载均衡器的缓存时间。详细流程我会在后面的 SpringCloud LoadBalancer
文章中介绍。
结语
相对于 Eureka Client
来说,Server
要简单一些,我们重点需要关注服务上下线被延迟感知的临界时间,在这上面做优化,尽可能减少由此导致的微服务之间调用失败。