Dubbo优雅上下线
文章目录
什么是Dubbo优雅上下线?
首先让我们来解释一下看一下优雅的意思:
指人行为举止优美,自然且高雅。
而在程序员眼中的优雅呢?
代码具有以下特点:
良好的命名:方法名规范,见名知意
清晰的结构:代码整洁、避免过长的方法,拆分成多个小的方法、适当的注释
不十分差劲的算法:性能良好
而对于应用来说什么是优雅?
先让我们来看一下哪些是不优雅的情况:
- 应用启动时,服务还没准备好,就开始对外提供服务,导致很多的失败调用。
- 应用启动时,没有检查应用健康状态(bug),对外提供服务导致失败调用。
- 应用停止时,有些还在执行的线程没有执行完毕,就直接停止服务。
- 应用停止时,没有通知调用方,导致还有很多的请求打过来。
- 应用停止时,没有关闭对应的监控,导致应用停止后发生报警。
而这些相反的,就是优雅的情况。
为什么要Dubbo优雅上下线?
为什么要优雅上线(延迟发布)?
-
相关资源就位需要时间。如提供的service需要初始化缓存数据,该数据需要从数据库中获得,然后进行计算,假如时间需要3S,则延迟发布时间>3S是很有必要的。
-
平滑上线。如刚上线的服务会产生响应抖动:程序刚启动时,JAVA还处于解释行模式,只有当运行了一段时间后,代码方法或者循环代码运行到了一定的次数,才会被编译成机器码,执行效率会上升。接着,在执行了一段时间后,JVM的优化手段会慢慢跟上。
为什么要优雅下线?
- 为了保证正常的业务不受影响。某节点上的服务停机时,不能影响正常的业务执行。
优雅上下线原理
优雅上线原理
优雅上线也称为延迟暴露。
其实主要是添加以下代码(如无需延迟则不需要配置该段代码):
<dubbo:service delay="延迟暴露的时间" />
这段配置,会让Dubbo服务在Spring容器启动X S之后,再执行暴露逻辑。
<dubbo: service delay="-1">
上面这段配置可以延迟到Spring容器初始化完成后,在暴露服务。
这段时间用于代码的预热、初始化缓存,等待相关资源就位等。
优雅停机原理
我们来一下DUBBO官方给出的原理:
服务提供方
- 停止时,先标记为不接收新请求,新请求过来时直接报错,让客户端重试其它机器。
- 然后,检测线程池中的线程是否正在运行,如果有,等待所有线程执行完成,除非超时,则强制关闭。
服务消费方
- 停止时,不再发起新的调用请求,所有新的调用在客户端即报错。
- 然后,检测有没有请求的响应还没有返回,等待响应返回,除非超时,则强制关闭。
Dubbo延迟发布源码解读
大致流程图:
入口
以下代码位于serviceBean中:
//该方法是在spring容器初始化完成后触发的一个事件回调
@Override
public void onApplicationEvent(ApplicationEvent event) {
if (ContextRefreshedEvent.class.getName().equals(event.getClass().getName())) {
if (isDelay() && ! isExported() && ! isUnexported()) {
if (logger.isInfoEnabled()) {
logger.info("The service ready on spring started. service: " + getInterface());
}
export();
}
}
}
//注意,这段代码,如果配置了delay则返回为FALSE,没有配置返回为TRUE
private boolean isDelay() {
//取service中的delay
Integer delay = getDelay();
//取Provider中的delay
ProviderConfig provider = getProvider();
//如果service的delay为空,provider的不为空
if (delay == null && provider != null) {
delay = provider.getDelay();
}
//这里supportedApplicationListener基本可以认为是TRUE,所以基本判断只要看后面的。
return supportedApplicationListener && (delay == null || delay.intValue() == -1);
}
@Override
public void afterPropertiesSet() throws Exception {
//...
if (!isDelay()) {
export();
}
}
上面这段代码,初始化一个serviceBean的时候,会执行afterPropertiesSet(),在Spring容器完成初始化的时候,会执行onApplicationEvent()。这两个方法中的export()只会执行其中一个,当然,主要还是通过isDelay()来判断。
实现
接下来,进入ServiceConfig中,做export():
public synchronized void export() {
if (provider != null) {
if (export == null) {
export = provider.getExport();
}
//如果service配置的delay为null,则取provide的
if (delay == null) {
delay = provider.getDelay();
}
}
if (export != null && ! export.booleanValue()) {
return;
}
//如果配置了delay,则延迟,再做暴露。doExport()是真正实现暴露的服务
if (delay != null && delay > 0) {
Thread thread = new Thread(new Runnable() {
public void run() {
try {
Thread.sleep(delay);
} catch (Throwable e) {
}
doExport();
}
});
thread.setDaemon(true);
thread.setName("DelayExportServiceThread");
thread.start();
} else {
doExport();
}
}
上面这段代码是处理完delay的逻辑后,做真正的暴露接口逻辑。
如果配置了delay()的话,会在afterPropertiesSet()中执行export()方法,会新建一个线程进行延时,再做暴露服务,执行doExport()方法(真正实现服务暴露的方法)。
如果没有配置delay()的话,则会在onApplicationEvent()中执行export()方法,会立即触发doExport()方法(真正实现服务暴露的方法)。
Dubbo优雅停机源码解读
大致流程图
停机入口
入口代码位于AbstractConfig:
static {
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
public void run() {
if (logger.isInfoEnabled()) {
logger.info("Run shutdown hook now.");
}
ProtocolConfig.destroyAll();
}
}, "DubboShutdownHook"));
}
这里注册了一个ShutDownHook,如果停机就会触发ProtocolConfig.destroyAll()。
接下来,进入ProtocolConfig:
public static void destroyAll() {
//注销注册中心
AbstractRegistryFactory.destroyAll();
//等待消费者接收到该节点服务已下线通知
try {
Thread.sleep(ConfigUtils.getServerShutdownTimeout());
} catch (InterruptedException e) {
logger.warn("Interrupted unexpectedly when waiting for registry notification during shutdown process!");
}
ExtensionLoader<Protocol> loader = ExtensionLoader.getExtensionLoader(Protocol.class);
for (String protocolName : loader.getLoadedExtensions()) {
try {
Protocol protocol = loader.getLoadedExtension(protocolName);
if (protocol != null) {
//注销协议
protocol.destroy();
}
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
}
从上面可以看出,优雅停机过程主要分为了两部分:
- 注销注册中心
- 注销协议
注册中心注销
该代码位于AbstractRegistryFactory:
public static void destroyAll() {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Close all registries " + getRegistries());
}
// 锁定注册中心关闭过程
LOCK.lock();
try {
for (Registry registry : getRegistries()) {
try {
registry.destroy();
} catch (Throwable e) {
LOGGER.error(e.getMessage(), e);
}
}
REGISTRIES.clear();
} finally {
// 释放锁
LOCK.unlock();
}
}
registry.destroy()主要就是将注册中心对应本节点的服务提供者地址删除。
注册中心的服务提供者地址删除后,订阅该服务的消费者就会知道该节点的服务已下线,不会再调用该节点的服务。
接下来,在ProtocolConfi中,注册中心注销完成后,有这样一段代码:
try {
Thread.sleep(ConfigUtils.getServerShutdownTimeout());
} catch (InterruptedException e) {
logger.warn("Interrupted unexpectedly when waiting for registry notification during shutdown process!");
}
这里有一个Thread.sleep(),目的是为了等待消费者从注册中心接收到该节点的服务已下线的通知,默认时间为10S,这个时间是一个经验值。为什么?
- 时间太短,可能消费者还没从注册中心接收到服务下线的通知,会继续使用该节点的服务进行请求。
- 时间太长,白白增加无意义的等待时间。
影响因素:
- 集群规模大小
- 注册中心的选型。以 Naocs 和 Zookeeper 为例,同等规模服务实例下 Nacos 在推送地址方面的能力远超 Zookeeper。
- 网络状况。
注销协议
ExtensionLoader<Protocol>loader = ExtensionLoader.getExtensionLoader(Protocol.class);
for (String protocolName : loader.getLoadedExtensions()) {
try {
Protocol protocol = loader.getLoadedExtension(protocolName);
if (protocol != null) {
//注销协议
protocol.destroy();
}
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
这里protocol 返回主要有两种协议:
- DubboProtocol:与服务端请求交互(远程服务暴露协议)(重点介绍)
- InjvmProtocol:与内部请求交互(本地服务暴露协议)
public interface Protocol {
<T> Exporter<T> export(Invoker<T> invoker) throws RpcException;
<T> Invoker<T> refer(Class<T> type, URL url) throws RpcException;
void destroy();
}
其实Protocol就是Dubbo的三个生命周期的方法:暴露、引用、销毁。
protocol.destroy()该代码位于DubboProtocol:
public void destroy() {
//服务端关闭
for (String key : new ArrayList<String>(serverMap.keySet())) {
ExchangeServer server = serverMap.remove(key);
if (server != null) {
try {
if (logger.isInfoEnabled()) {
logger.info("Close dubbo server: " + server.getLocalAddress());
}
server.close(getServerShutdownTimeout());
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
}
//客户端关闭
for (String key : new ArrayList<String>(referenceClientMap.keySet())) {
ExchangeClient client = referenceClientMap.remove(key);
if (client != null) {
try {
if (logger.isInfoEnabled()) {
logger.info("Close dubbo connect: " + client.getLocalAddress() + "-->" + client.getRemoteAddress());
}
client.close();
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
}
for (String key : new ArrayList<String>(ghostClientMap.keySet())) {
ExchangeClient client = ghostClientMap.remove(key);
if (client != null) {
try {
if (logger.isInfoEnabled()) {
logger.info("Close dubbo connect: " + client.getLocalAddress() + "-->" + client.getRemoteAddress());
}
client.close();
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
}
stubServiceMethodsMap.clear();
super.destroy();
}
以上代码主要分为了服务端注销和客户端注销。先注销服务端,停止接收新的请求,降低了服务再被消费者调用的可能性。
服务端注销:
public void close(final int timeout) {
if (timeout > 0) {
final long max = (long) timeout;
final long start = System.currentTimeMillis();
if (getUrl().getParameter(Constants.CHANNEL_SEND_READONLYEVENT_KEY, false)){
sendChannelReadOnlyEvent();
}
//如果还有服务还在运行中,则等待其运行完成直至超时
while (HeaderExchangeServer.this.isRunning()
&& System.currentTimeMillis() - start < max) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
logger.warn(e.getMessage(), e);
}
}
}
//停止心跳检测
doClose();
//关闭底层通讯框架NettyServer
server.close(timeout);
}
private void doClose() {
if (!closed.compareAndSet(false, true)) {
return;
}
//其实就是把heartbeatTimer置为null
stopHeartbeatTimer();
try {
scheduled.shutdown();
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
public void close(int timeout) {
//会先关闭线程池。
//尽量等到线程池中的线程都执行完成之后,再关闭线程池,之后执行关闭Netty Server
ExecutorUtil.gracefulShutdown(executor, timeout);
close();
}
服务端注销主要有三个步骤:
- 如果还有服务还在运行中,则等待其运行完成或运行直至超时
- 停止心跳检测
- 关闭NettyServer
客户端注销与之基本相同,不同的是,客户端如果还有请求没有返回,会等待请求返回或等待直至超时,其余一致。
Spring下的Dubbo优雅停机
缺点
上述的流程不适用于在Spring容器下的优雅停机,其存在一些缺陷:
Spring容器也使用shutdown hook进行优雅停机,会与Dubbo 2.5.X版本的优雅停机并发执行,而Dubbo的一些Bean是由Spring托管的,当 Spring 容器优先关闭时,会导致 Dubbo 的优雅停机流程无法获取相关的 Bean,导致无法从BeanService入口进入,从而优雅停机失效。
解决
为了解决这个问题,Dubbo在2.6.X开始重构这部分的逻辑,不断迭代,2.7.X版本的为最终的逻辑。
新版本中增加了ShutdownHookListener,继承 Spring ApplicationListener 接口,用以监听 Spring 相关事件。这里 ShutdownHookListene仅仅监听 Spring 关闭事件,当 Spring 开始关闭,将会触发ShutdownHookListener内部逻辑。
public class SpringExtensionFactory implements ExtensionFactory {
private static final Logger logger = LoggerFactory.getLogger(SpringExtensionFactory.class);
private static final Set<ApplicationContext> CONTEXTS = new ConcurrentHashSet<ApplicationContext>();
private static final ApplicationListener SHUTDOWN_HOOK_LISTENER = new ShutdownHookListener();
public static void addApplicationContext(ApplicationContext context) {
CONTEXTS.add(context);
if (context instanceof ConfigurableApplicationContext) {
// 注册 ShutdownHook
((ConfigurableApplicationContext) context).registerShutdownHook();
// 取消 AbstractConfig 注册的 ShutdownHook 事件
DubboShutdownHook.getDubboShutdownHook().unregister();
}
BeanFactoryUtils.addApplicationListener(context, SHUTDOWN_HOOK_LISTENER);
}
// 继承 ApplicationListener,这个监听器将会监听容器关闭事件
private static class ShutdownHookListener implements ApplicationListener {
@Override
public void onApplicationEvent(ApplicationEvent event) {
if (event instanceof ContextClosedEvent) {
DubboShutdownHook shutdownHook = DubboShutdownHook.getDubboShutdownHook();
shutdownHook.doDestroy();
}
}
}
}
public abstract class AbstractConfig implements Serializable {
static {
Runtime.getRuntime().addShutdownHook(DubboShutdownHook.getDubboShutdownHook());
}
}
当Spring容器开始初始化的时候,会触发SpringExtensionFactory的逻辑,在其addApplicationContext方法中会注销AbstractConfig 注册的 ShutdownHook 事件,增加SHUTDOWN_HOOK_LISTENER,这样就避免了上面的Spring和Dubbo都有ShutdownHook的执行问题。
总结
本文通过流程图和代码带你走了一遍Dubbo优雅上下线的流程,通过流程图把Dubbo优雅上下线整体概括、分割部分,帮助你更好地理解。
参考
-
《一文聊透 Dubbo 优雅停机》https://www.cnkirito.moe/dubbo-gracefully-shutdown/
-
《Dubbo 优雅停机演进之路》https://juejin.cn/post/6844903986277908488
-
《深入分析dubbo延迟暴露》https://www.jianshu.com/p/0ce318f98e74