本章,我将讲解Eureka的应用实例下线(cancel)机制。应用实例关闭时,Eureka-Client 会向 Eureka-Server 发起下线(Cancel)应用实例的HTTP/DELETE请求。需要满足如下条件才可发起cancel请求:
- 配置
eureka.registration.enabled = true
,即应用实例开启注册开关。默认为false
。 - 配置
eureka.shouldUnregisterOnShutdown = true
,即应用实例开启关闭时下线开关。默认为true
。
一、Eureka-Client发起下线
Eureka-Client的shutdown()
方法用来发起服务下线请求,这个方法需要应用程序自己调用,比如可以放在Runtime.getRuntime().addShutdownHook()
里面,在JVM退出前下线服务。
Eureka-Client发起下线(cancel)的整体流程如下:
- 调用DiscoveryClient的shutdown()方法;
- 执行
DiscoveryClient.unregister()
方法,内部调用了EurekaHttpClient.cancel()
方法,向Eureka-Server发送HTTP/DELETE请求; - 请求的URL类似http://localhost:8080/v2/apps/ServiceA/i-00000-1。
1.1 调用shutdown
我们首先来看下DiscoveryClient的shutdown()方法:
/**
* DiscoveryClient.java
*/
public synchronized void shutdown() {
if (isShutdown.compareAndSet(false, true)) {
logger.info("Shutting down DiscoveryClient ...");
// 1.注销监听器
if (statusChangeListener != null && applicationInfoManager != null) {
applicationInfoManager.unregisterStatusChangeListener(statusChangeListener.getId());
}
// 2.取消定时调度任务
cancelScheduledTasks();
// 3.设置当前实例状态(DOWN),并发送下线请求
if (applicationInfoManager != null && clientConfig.shouldRegisterWithEureka()) {
applicationInfoManager.setInstanceStatus(InstanceStatus.DOWN);
unregister();
}
// 4.关闭底层通信组件
if (eurekaTransport != null) {
eurekaTransport.shutdown();
}
// 5.关闭监视器
heartbeatStalenessMonitor.shutdown();
registryStalenessMonitor.shutdown();
logger.info("Completed shut down of DiscoveryClient");
}
}
我们重点看第三步,也就是设置当前应用实例状态,并发送下线请求:
/**
* DiscoveryClient.java
*/
void unregister() {
if(eurekaTransport != null && eurekaTransport.registrationClient != null) {
try {
logger.info("Unregistering ...");
// 调用AbstractJerseyEurekaHttpClient#cancel(...) 方法,发送DELETE请求,实现应用实例的下线
EurekaHttpResponse<Void> httpResponse = eurekaTransport.registrationClient.cancel(instanceInfo.getAppName(), instanceInfo.getId());
logger.info(PREFIX + appPathIdentifier + " - deregister status: " + httpResponse.getStatusCode());
} catch (Exception e) {
logger.error(PREFIX + appPathIdentifier + " - de-registration failed" + e.getMessage(), e);
}
}
}
1.2 EurekaHttpClient
调用 AbstractJerseyEurekaHttpClient#cancel(...)
方法,发送HTTP/DELETE
请求到 Eureka-Server,实现应用实例信息的下线:
/**
* AbstractJerseyEurekaHttpClient.java
*/
@Override
public EurekaHttpResponse<Void> cancel(String appName, String id) {
// URL: http://localhost:8080/v2/apps/ServiceA/i-00000-1
String urlPath = "apps/" + appName + '/' + id;
ClientResponse response = null;
try {
Builder resourceBuilder = jerseyClient.resource(serviceUrl).path(urlPath).getRequestBuilder();
addExtraHeaders(resourceBuilder);
// 发送DELETE请求
response = resourceBuilder.delete(ClientResponse.class);
return anEurekaHttpResponse(response.getStatus()).headers(headersOf(response)).build();
} finally {
if (logger.isDebugEnabled()) {
logger.debug("Jersey HTTP DELETE {}/{}; statusCode={}", serviceUrl, urlPath, response == null ? "N/A" : response.getStatus());
}
if (response != null) {
response.close();
}
}
}
二、Eureka-Server接受下线
Eureka-Server接受下线(Cancel)的整体流程如下:
- 负责接受下线请求的Controller是
InstanceResource#cancelLease()
,相当于Jersey的一个MVC框架; - 调用AbstractInstanceRegistry的cancel()方法,进行应用实例的下线;
- 根据应用名称和实例ID,将应用实例从AbstractInstanceRegistry内部的map注册表中移除;
- 设置实例对应的Lease 对象的evictionTimestamp时间戳(标识了该实例的下线时间);
- 将该应用实例放到最近变化队列(recentChangedQueue)中;
- 由于注册表发生了变化,所以还要清除ResponseCache的可读写缓存readWriteCacheMap。
经过上面的处理,那么下一次Eureka-Client来拉取增量注册表时,会发现readOnlyCacheMap里没有(定时任务每隔30秒与readWriteCacheMap进行比对并同步),从readWriteCacheMap获取也没有,则会从原始注册表里registry中获取增量注册表,而增量注册表最终就是从recentChangedQueue中获取的。
2.1 接受下线请求
com.netflix.eureka.resources.InstanceResource
,是处理单个应用实例的请求的 Resource ( Controller ),我们看下它的cancelLease
方法:
/**
* InstanceResource.java
*/
@DELETE
public Response cancelLease(
@HeaderParam(PeerEurekaNode.HEADER_REPLICATION) String isReplication) {
// 调用AbstractInstanceRegistry.cancel进行下线
boolean isSuccess = registry.cancel(app.getName(), id, "true".equals(isReplication));
if (isSuccess) {
logger.debug("Found (Cancel): " + app.getName() + " - " + id);
return Response.ok().build();
} else {
logger.info("Not Found (Cancel): " + app.getName() + " - " + id);
return Response.status(Status.NOT_FOUND).build();
}
}
2.2 下线应用实例
AbstractInstanceRegistry#cancel(...)
方法,下线应用实例信息:
/**
* AbstractInstanceRegistry.java
*/
private ConcurrentLinkedQueue<RecentlyChangedItem> recentlyChangedQueue = new ConcurrentLinkedQueue<RecentlyChangedItem>();
@Override
public boolean cancel(String appName, String id, boolean isReplication) {
return internalCancel(appName, id, isReplication);
}
protected boolean internalCancel(String appName, String id, boolean isReplication) {
try {
read.lock();
CANCEL.increment(isReplication);
// key为应用实例ID,value为Lease对象
Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
Lease<InstanceInfo> leaseToCancel = null;
// 1.从Map中移除应用实例
if (gMap != null) {
leaseToCancel = gMap.remove(id);
}
// 添加到最近取消注册的调试队列,无实际业务用途
synchronized (recentCanceledQueue) {
recentCanceledQueue.add(new Pair<Long, String>(System.currentTimeMillis(), appName + "(" + id + ")"));
}
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) {
CANCEL_NOT_FOUND.increment(isReplication);
logger.warn("DS: Registry: cancel failed because Lease is not registered for: {}/{}", appName, id);
return false;
} else {
// 2.取消租约,即设置租约的下线时间戳
leaseToCancel.cancel();
InstanceInfo instanceInfo = leaseToCancel.getHolder();
String vip = null;
String svip = null;
if (instanceInfo != null) {
// 3.添加到【最近变化队列】
instanceInfo.setActionType(ActionType.DELETED);
recentlyChangedQueue.add(new RecentlyChangedItem(leaseToCancel));
instanceInfo.setLastUpdatedTimestamp();
vip = instanceInfo.getVIPAddress();
svip = instanceInfo.getSecureVipAddress();
}
// 4.清除ResponseCache的可读写缓存
invalidateCache(appName, vip, svip);
logger.info("Cancelled instance {}/{} (replication={})", appName, id, isReplication);
return true;
}
} finally {
read.unlock();
}
}
private void invalidateCache(String appName, @Nullable String vipAddress, @Nullable String secureVipAddress) {
// invalidate cache
responseCache.invalidate(appName, vipAddress, secureVipAddress);
}
上述代码中,除了从内部的gmap这个注册表移除应用实例外,其它最关键的几个步骤分别是:
- 取消租约;
- 将下线的应用实例添加到最近变化队列recentlyChangedQueue;
- 清除ResponseCache的可读写缓存。
其中添加到变化队列和清除可读写缓存,我在【增量拉取注册表】一章中已经详细讲解过了,这里就不再赘述了。这里重点看下取消租约。
2.3 取消租约
/**
* Lease.java
*/
public void cancel() {
if (evictionTimestamp <= 0) {
evictionTimestamp = System.currentTimeMillis();
}
}
可以看到,取消租约,其实就是设置了租约对象的evictionTimestamp,用来标注该实例对应的租约已经取消。
三、总结
本章我讲解了Eureka的服务下线(Cancel)机制,本质就是Eureka-Client发送一个HTTP/DELETE请求到Eureka-Server端。Eureka-Server端接受到请求后,在自己的注册表中删除应用实例和对应的租约,然后将该实例添加到最近变化队列中。
这样下次其它应用实例进行增量拉取注册表时,就可以从recentChangedQueue中感知到已下线的应用实例,然后就可以在自己本地缓存中删除已经下线的应用实例了。
Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。
它的内容包括:
- 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
- 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
- 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
- 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
- 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
- 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
- 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
- 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw
目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:
想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询
同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。