由于微服务的拆分,服务之间的调用需要通过 RPC 来通讯。
注册中心负责提供端点,供各个服务注册信息到自身存储中。并且提供端点,让各个服务可以从注册中心获取注册表,以便在需要进行 RPC 调用其他服务时,知道服务的 IP、端口等信息。并且通过客户端软负载的方式保存一份注册表在客户端中,在需要进行 RPC 调用时,通过客户端软负载的方式进行负载均衡调度,发起对服务其中一个节点的调用。
在 Spring Cloud 中注册中心使用 eureka server,客户端软负载使用的是 ribbon,ribbon 借助 eureka client 来获取注册中心上保存的注册表,并缓存到内存中。 可以参考文章 详解 Eureka 缓存机制
平滑发布
在迭代更新中,我们需要在任意时刻都可以进行代码发布,而不影响用户正常使用。因此需要实现服务上线、下线无感知。我们需要做几件事情:
- 在服务正常启动后,再注册到eureka server,并通知各个调用方刷新 eureka client 缓存、ribbon 缓存
- 在服务停止时,需要将其从注册中心中取消注册信息,并且通知到各个调用方刷新 eureka client 缓存、ribbon 缓存
正确的注册时机
为了实现需求的第一点,我们需要知道 eureka client 在服务启动时,是如何注册到注册中心的。需要对这一部分的逻辑进行改造。以保证服务完全启动正常后,才注册到注册中心供其他服务调用。
在引入依赖后
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
默认情况下,项目中会自动注册一个 SmartLifecycle 实现类 EurekaAutoServiceRegistration,在项目启动时回调其方法:org.springframework.cloud.netflix.eureka.serviceregistry.EurekaAutoServiceRegistration#start 进行注册。
因此正确的注册时机,eureka client 已经帮我们处理好了。
接下来,需要看如何实现各个调用方在服务启动并且成功注册之后刷新缓存。
正确的下线时机
当服务停机时,需要提前从注册中心取消注册之后,再执行停机操作。以确保在停机过程中,消费方调用到该停机实例。
Spring Cloud Common 中提供了一个端点:org.springframework.cloud.client.serviceregistry.endpoint.ServiceRegistryEndpoint
用来管理实例的注册状态,我们可以通过调用端点来管理实例状态
curl -i -H 'Content-Type: application/json' -X POST -d '{"status": "DOWN"}' 'http://192.168.0.103:8001/actuator/service-registry'
查看源码,可以看到其原理是通过 ServiceRegistry 来设置状态的
private final ServiceRegistry serviceRegistry;
private Registration registration;
@WriteOperation
public ResponseEntity<?> setStatus(String status) {
Assert.notNull(status, "status may not by null");
if (this.registration == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("no registration found");
}
this.serviceRegistry.setStatus(this.registration, status);
return ResponseEntity.ok().build();
}
因此,我们可以通过自己暴露接口,通过同样的方式实现实例状态的变更。并且在变更状态后,通过 MQ 通知消费者刷新自身的 eureka client、ribbon 缓存。
这样就可以保证,服务实例下线被及时感知,后续不会分发流量到该准备下线的节点。
怎么刷新注册表
在前面,我们分析到:在 Spring Cloud 的架构体系中需要实现平滑发布的话,需要在服务启动后、服务停止前让消费方及时感知服务实例的状态。
了解 eureka client 的代码可以知道,其内部通过一个定时任务每隔 30s 刷新一次缓存。定时任务执行的代码如下:
class CacheRefreshThread implements Runnable {
public void run() {
refreshRegistry();
}
}
@VisibleForTesting
void refreshRegistry() {
try {
boolean isFetchingRemoteRegionRegistries = isFetchingRemoteRegionRegistries();
boolean remoteRegionsModified = false;
// This makes sure that a dynamic change to remote regions to fetch is honored.
String latestRemoteRegions = clientConfig.fetchRegistryForRemoteRegions();
if (null != latestRemoteRegions) {
String currentRemoteRegions = remoteRegionsToFetch.get();
if (!latestRemoteRegions.equals(currentRemoteRegions)) {
// Both remoteRegionsToFetch and AzToRegionMapper.regionsToFetch need to be in sync
synchronized (instanceRegionChecker.getAzToRegionMapper()) {
if (remoteRegionsToFetch.compareAndSet(currentRemoteRegions, latestRemoteRegions)) {
String[] remoteRegions = latestRemoteRegions.split(",");
remoteRegionsRef.set(remoteRegions);
instanceRegionChecker.getAzToRegionMapper().setRegionsToFetch(remoteRegions);
remoteRegionsModified = true;
} else {
logger.info("Remote regions to fetch modified concurrently," +
" ignoring change from {} to {}", currentRemoteRegions, latestRemoteRegions);
}
}
} else {
// Just refresh mapping to reflect any DNS/Property change
instanceRegionChecker.getAzToRegionMapper().refreshMapping();
}
}
// 该方法实现从eureka server 读取注册表,并刷新缓存
// 并且在刷新成功后,会广播事件:CacheRefreshedEvent
boolean success = fetchRegistry(remoteRegionsModified);
if (success) {
registrySize = localRegionApps.get().size();
lastSuccessfulRegistryFetchTimestamp = System.currentTimeMillis();
}
if (logger.isDebugEnabled()) {
StringBuilder allAppsHashCodes = new StringBuilder();
allAppsHashCodes.append("Local region apps hashcode: ");
allAppsHashCodes.append(localRegionApps.get().getAppsHashCode());
allAppsHashCodes.append(", is fetching remote regions? ");
allAppsHashCodes.append(isFetchingRemoteRegionRegistries);
for (Map.Entry<String, Applications> entry : remoteRegionVsApps.entrySet()) {
allAppsHashCodes.append(", Remote region: ");
allAppsHashCodes.append(entry.getKey());
allAppsHashCodes.append(" , apps hashcode: ");
allAppsHashCodes.append(entry.getValue().getAppsHashCode());
}
logger.debug("Completed cache refresh task for discovery. All Apps hash code is {} ",
allAppsHashCodes);
}
} catch (Throwable e) {
logger.error("Cannot fetch registry from server", e);
}
}
以上就是 eureka client 刷新注册表本地缓存的逻辑,其中我们发现了在刷新成功后,广播的事件 CacheRefreshedEvent 可以作为我们实时刷新 ribbon 缓存的突破口。
跟踪源码后发现,EurekaNotificationServerListUpdater 中的 EurekaEventListener 监听了该事件。此时,已经很接近我们想要的效果了。接下来看看 EurekaNotificationServerListUpdater 该如何使用。
可惜的是 EurekaNotificationServerListUpdater 并没有作为 ribbon 中默认的 ServerListUpdater 实现来启用,默认启用的实现是 PollingServerListUpdater(每 30 秒拉取一次)
之所以,不默认使用 EurekaNotificationServerListUpdater,我猜测是因为 spring cloud common 的理想是封装实现,只暴露接口,而 EurekaNotificationServerListUpdater 跟 eureka 强耦合了
配置ribbon
那该如何启用呢?参考 ribbon官方文档
通过 java 代码,设置默认的实现。PS:需要保证该类不被类路径扫描到,否则将被所有 ribbon client 共用
@Configuration
public class RibbonDefaultConfig {
@Bean
public ServerListUpdater ribbonServerListUpdater() {
return new EurekaNotificationServerListUpdater();
}
}
将以上配置类,指定为 Ribbon 的默认配置项
@RibbonClients(defaultConfiguration = RibbonDefaultConfig.class)
public class RibbonClientConfig {}
调用eureka方法刷新缓存
由于 DiscoveryClient 刷新缓存的方法 refreshRegistry 是包私有的,因此需要通过反射的方式进行调用。
在上面已经提到过,刷新缓存成功后将广播一个 CacheRefreshedEvent 事件。由 EurekaNotificationServerListUpdater 监听,刷新 ribbon 缓存。从而实现了消费者对于生产者服务的下线、上线的即时感知。
public String testRefreshRegistry() {
try {
Method refreshRegistry = com.netflix.discovery.DiscoveryClient.class.getDeclaredMethod("refreshRegistry");
refreshRegistry.setAccessible(true);
refreshRegistry.invoke(DiscoveryManager.getInstance().getDiscoveryClient());
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException(e);
}
return "Success";
}
总结
综上,要做到服务提供者上线、下线对消费者即时感知。我们需要做到:
- 服务下线时,将自己的注册信息从注册中心中撤销,并通知消费者拉取最新的注册表
- 服务完全启动时,再将自己的注册信息写入到注册中心,并通知消费者拉取最新注册表