文/朱季谦
Dubbo如何实现优雅下线?
这个问题困扰了我一阵,既然有优雅下线这种说法,那么,是否有非优雅下线的说法呢?
这,还真有。
可以从linux进程关闭说起,其实,我们经常使用到杀进程的指令背后,就涉及到是否优雅下线的理念。
在日常开发当中,经常用到kill来关掉正在运行的进程,可能你曾看到过一些文章是不推荐使用kill -9 pid的指令来删除进程。当执行该执行时,系统会发出一个SIGKILL信号给将被关掉的进程,接收到该信号的进程,都立即结束运行,假如此时内部仍有请求还没有执行完,那怎么办?你想,整个进程都被立即杀死了,线程作为进程里的某一部分,还能活吗?
打个比方,假如你正在吃东西,物业突然打电话给你,说房子立马就要被炸掉了,你必须立马关门离开,这时,你只能把还没吃完的饭丢下,什么贵重的东西都来不及打理,立马就被迫关门跑路了。
这样强制执行的后果,可能就会造成一些贵重东西的丢失。
这种,就属于非优雅下线,简单,粗暴,不管三七二十一,统统停止关闭。
一般而言,是不推荐使用kill -9 pid来强制杀死进程。
在线上环境,用到更多的,是kill pid指令,这个指令,等同于kill -15 pid指令,因此,当你在网上看到一些介绍kill -15 pid指令时,不用纠结好像没用到过,其实,就是你用到最多的kill pid指令。使用这个指令时,系统会对pid进程发送一个SIGTERM信号,就像给pid打了一个电话,告诉他,你的房子就要到期了,麻烦快点清理好东西搬走。这时,你仍有充裕的时间,把自己的东西打包好,好好清理下房间,没问题了,再搬出去。
换到具体程序代码中,就是执行kill pid指令后,该程序不会立马被强制关闭,而是会接受到一个通知,可以在这个通知方法内,做一些清理操作,若是Dubbo容器,则可以关闭zookeeper注册,暂停新的请求,可以把已经执行一半的请求先执行完成,等等。
这种下线操作,就属于优雅下线。
指令kill -15 pid是操作系统级别的优雅下线操作,那么,在具体进程当中,是如何根据SIGTERM信号来进行具体的优雅下线处理呢?
在Dubbo官网上,关于优雅停机的操作有相关介绍:
Dubbo 是通过 JDK 的 ShutdownHook 来完成优雅停机的,所以如果用户使用 kill -9 PID
等强制关闭指令,是不会执行优雅停机的,只有通过 kill PID
时,才会执行。
设置优雅停机超时时间,缺省超时时间是 10 秒,如果超时则强制关闭。
- # dubbo.properties
- dubbo.service.shutdown.wait=15000
如果 ShutdownHook 不能生效,可以自行调用,使用tomcat等容器部署的場景,建议通过扩展ContextListener等自行调用以下代码实现优雅停机:
ProtocolConfig.destroyAll();
根据以上信息可以得知,其实Dubbo的优雅实现其实是依赖了JVM的ShutdownHook来实现的,JDK提供了一个在JVM关闭时会执行的方法,可以在该方法当中,执行ProtocolConfig.destroyAll()来实现Dubbo的优雅停机操作,而这个JDK的 ShutdownHook方法,正是在系统执行kill -15 pid时,会执行的方法,这样,我们就可以在该方法里做一些关闭前的清理工作了。
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- ProtocolConfig.destroyAll();
- }));
这几行代码具体都实现了什么呢?
简单而言,这里通过JDK注册了一个shutdownHook钩子函数,一旦应用停机就会触发该方法,进而执行ProtocolConfig.destroyAll()。
这个ProtocolConfig.destroyAll()源码如下:
- public static void destroyAll() {
- //1.注销注册中心
- AbstractRegistryFactory.destroyAll();
- ExtensionLoader
loader = ExtensionLoader.getExtensionLoader(Protocol.class); - Iterator var1 = loader.getLoadedExtensions().iterator();
- // 2.循环获取存活的协议
- while(var1.hasNext()) {
- String protocolName = (String)var1.next();
-
- try {
- Protocol protocol = (Protocol)loader.getLoadedExtension(protocolName);
- if (protocol != null) {
- //关闭暴露协议
- protocol.destroy();
- }
- } catch (Throwable var4) {
- logger.warn(var4.getMessage(), var4);
- }
-
这个destroyAll()里边主要做了两件事:
下面主要按照这两个模块大体介绍下其底层逻辑:
- public static void destroyAll() {
- if (LOGGER.isInfoEnabled()) {
- LOGGER.info("Close all registries " + getRegistries());
- }
- //加锁,防止关闭多次
- LOCK.lock();
- try {
- Iterator var0 = getRegistries().iterator();
- //关闭所有已创建的注册中心
- while(var0.hasNext()) {
- Registry registry = (Registry)var0.next();
- try {
- registry.destroy();
- } catch (Throwable var6) {
- LOGGER.error(var6.getMessage(), var6);
- }
- }
- REGISTRIES.clear();
- } finally {
- //释放锁
- LOCK.unlock();
- }
- }
首先获取到所有的注册中心连接,封装成迭代器模式
Iterator var0 = getRegistries().iterator();
接下来,迭代获取每一个注册连接对象进行关闭:
registry.destroy();
该destroy方法定义在接口Node当中,其具体实现将会在对应的Dubbo注册对象里:
- public interface Node {
- URL getUrl();
- boolean isAvailable();
- void destroy();
- }
这里Dubbo使用的注册中心是Zookeeper,故而destroy会在ZookeeperRegistry类中具体实现:
进入到ZookeeperRegistry类,找到registry.destroy()对应的destroy()方法,可以看到,调用destroy(),其本质是关闭zk客户端连接,当客户端关闭之后,其注册到zk里的生产者或者消费者信息,都会被自动删除。
- public void destroy() {
- super.destroy();
- try {
- // 关闭zk客户端
- this.zkClient.close();
- } catch (Exception var2) {
- logger.warn("Failed to close zookeeper client " + this.getUrl() + ", cause: " + var2.getMessage(), var2);
- }
- }
在这里,还有一个需要进一步研究的地方,即 super.destroy(),这个方法实现了什么功能呢?从源码当中,可以看出,其有一行这样的 this.retryFuture.cancel(true)代码,这行代码大概意思是,将失败重试取消方式设置为true,即取消了失败重试的操作,我的理解是,这里是关闭了失败重试,可以在下线过程当中,避免出现因RPC生产者接口缺少而发生反复的失败重试操作,因为到这一步,已经不需要再有失败重试的操作了。
- public void destroy() {
- //移除内存中已经注册的服务,取消所有服务订阅
- super.destroy();
- try {
- //取消失败重试
- this.retryFuture.cancel(true);
- } catch (Throwable var2) {
- this.logger.warn(var2.getMessage(), var2);
- }
- }
注意一点,这里在取消失败重试机制之前,还执行了一行 super.destroy()代码,这行代码的主要功能包括两个:
第一是移除内存中已经注册的服务,第二是取消所有服务订阅。
我们先来看一下其方法详情:
- public void destroy() {
- if (this.logger.isInfoEnabled()) {
- this.logger.info("Destroy registry:" + this.getUrl());
- }
- // 1.移除内存中已经注册的服务
- Set
destroyRegistered = new HashSet(this.getRegistered()); - if (!destroyRegistered.isEmpty()) {
- Iterator var2 = (new HashSet(this.getRegistered())).iterator();
-
- while(var2.hasNext()) {
- URL url = (URL)var2.next();
- if (url.getParameter("dynamic", true)) {
- try {
- this.unregister(url);
- if (this.logger.isInfoEnabled()) {
- this.logger.info("Destroy unregister url " + url);
- }
- } catch (Throwable var10) {
- this.logger.warn("Failed to unregister url " + url + " to registry " + this.getUrl() + " on destroy, cause: " + var10.getMessage(), var10);
- }
- }
- }
- }
-
- //2.取消所有的服务订阅
- Map
> destroySubscribed = new HashMap(this.getSubscribed()); - if (!destroySubscribed.isEmpty()) {
- Iterator var12 = destroySubscribed.entrySet().iterator();
-
- while(var12.hasNext()) {
- Map.Entry
> entry = (Map.Entry)var12.next(); - URL url = (URL)entry.getKey();
- Iterator var6 = ((Set)entry.getValue()).iterator();
-
- while(var6.hasNext()) {
- NotifyListener listener = (NotifyListener)var6.next();
-
- try {
- this.unsubscribe(url, listener);
- if (this.logger.isInfoEnabled()) {
- this.logger.info("Destroy unsubscribe url " + url);
- }
- } catch (Throwable var9) {
- this.logger.warn("Failed to unsubscribe url " + url + " to registry " + this.getUrl() + " on destroy, cause: " + var9.getMessage(), var9);
- }
- }
- }
- }
- }
1.移除内存中已经注册的服务
- // 1.移除内存中已经注册的服务
- Set
destroyRegistered = new HashSet(this.getRegistered()); - if (!destroyRegistered.isEmpty()) {
- Iterator var2 = (new HashSet(this.getRegistered())).iterator();
-
- while(var2.hasNext()) {
- URL url = (URL)var2.next();
- if (url.getParameter("dynamic", true)) {
- try {
- this.unregister(url);
- if (this.logger.isInfoEnabled()) {
- this.logger.info("Destroy unregister url " + url);
- }
- } catch (Throwable var10) {
- this.logger.warn("Failed to unregister url " + url + " to registry " + this.getUrl() + " on destroy, cause: " + var10.getMessage(), var10);
- }
- }
- }
- }
这部分代码主要是将内存当中的注册信息移除,这部分缓存记录,是在容器启动时,当向注册中心订阅成功后,会同步缓存一份到内存当中。可见,若注册中心挂掉了,Dubbo仍然可以通过缓存获取到远程RPC服务,但是无法获取到新增的RPC服务。
这里主要分析两个方法:this.getRegistered()和 this.unregister(url)。
this.getRegistered()——
- private final Set
registered = new ConcurrentHashSet(); -
- public Set
getRegistered() { - return this.registered;
- }
这是获取缓存URL的集合。
this.unregister(url)——
- public void unregister(URL url) {
- if (url == null) {
- throw new IllegalArgumentException("unregister url == null");
- } else {
- if (this.logger.isInfoEnabled()) {
- this.logger.info("Unregister: " + url);
- }
- this.registered.remove(url);
- }
- }
这是将URL从Set集合当中移除的操作。这部分代码其实我有点想明白,为何还需要从Set获取到所有URL,然后再通过迭代器方式一个一个取出去进行移除,直接将Set置空不是更好些吗?当然,这里面应该还有一些我没有考虑到的细节,还有待进一步进行研究。
2.取消所有服务订阅
- //2.取消所有的服务订阅
- Map
> destroySubscribed = new HashMap(this.getSubscribed()); - if (!destroySubscribed.isEmpty()) {
- Iterator var12 = destroySubscribed.entrySet().iterator();
-
- while(var12.hasNext()) {
- Map.Entry
> entry = (Map.Entry)var12.next(); - URL url = (URL)entry.getKey();
- Iterator var6 = ((Set)entry.getValue()).iterator();
-
- while(var6.hasNext()) {
- NotifyListener listener = (NotifyListener)var6.next();
-
- try {
- this.unsubscribe(url, listener);
- if (this.logger.isInfoEnabled()) {
- this.logger.info("Destroy unsubscribe url " + url);
- }
- } catch (Throwable var9) {
- this.logger.warn("Failed to unsubscribe url " + url + " to registry " + this.getUrl() + " on destroy, cause: " + var9.getMessage(), var9);
- }
- }
- }
- }
这部分逻辑与移除内存url都很类型,都是先从缓存里把所有订阅信息都取出来,然后再跌代移除。
这部分个关闭,主要是关闭provider和consumer,即对应前边提到的,服务提供方会先标记不再接受新请求,新请求过来直接报错,然后,检查线程池中的线程是否还在运行,如果有,等待线程完成,若超时,则强制关闭;服务消费者则不再发起新请求,同时检测看还有没有请求的响应没有返回,若有,等待返回,若超时,则强制关闭。
下面大概分析一下其源码逻辑。
protocol.destroy(),其方法在接口里定义,具体实现是在RegistryProtocol当中。
- @SPI("dubbo")
- public interface Protocol {
- int getDefaultPort();
- @Adaptive
-
Exporter export(Invoker var1) throws RpcException; - @Adaptive
-
Invoker refer(Class var1, URL var2) throws RpcException; - void destroy();
- }
RegistryProtocol的具体实现如下:
- public void destroy() {
- List
> exporters = new ArrayList(this.bounds.values()); - Iterator var2 = exporters.iterator();
- while(var2.hasNext()) {
- Exporter> exporter = (Exporter)var2.next();
- exporter.unexport();
- }
- this.bounds.clear();
- }
这里的核心方法是exporter.unexport(),根据命名就可以推测出,大概就是说不暴露对外接口协议的方法,也就是关闭那些对外暴露的服务。
该exporter.unexport()方法具体实现有两类,一个是DubboExporter,一个是AbstractExporter,这里主要分析下AbstractExporter里面的逻辑。
AbstractExporter内部关于unexport()的方法如下:
- public void unexport() {
- if (!this.unexported) {
- this.unexported = true;
- this.getInvoker().destroy();
- }
- }
this.getInvoker().destroy()的实现如下:
- public void destroy() {
- Iterator var1 = (new ArrayList(this.serverMap.keySet())).iterator();
-
- String key;
- //关停所有的Server,provider不再接收新的请求
- while(var1.hasNext()) {
- key = (String)var1.next();
- ExchangeServer server = (ExchangeServer)this.serverMap.remove(key);
- if (server != null) {
- try {
- if (this.logger.isInfoEnabled()) {
- this.logger.info("Close dubbo server: " + server.getLocalAddress());
- }
- // HeaderExchangeServer中会停止发送心态的任务,关闭channel
- server.close(getServerShutdownTimeout());
- } catch (Throwable var7) {
- this.logger.warn(var7.getMessage(), var7);
- }
- }
- }
-
- var1 = (new ArrayList(this.referenceClientMap.keySet())).iterator();
-
- ExchangeClient client;
- //关停所有Client,consumer将不再发送新的请求
- while(var1.hasNext()) {
- key = (String)var1.next();
- client = (ExchangeClient)this.referenceClientMap.remove(key);
- if (client != null) {
- try {
- if (this.logger.isInfoEnabled()) {
- this.logger.info("Close dubbo connect: " + client.getLocalAddress() + "-->" + client.getRemoteAddress());
- }
- // HeaderExchangeClient中会停止发送心态的任务,关闭channel
- client.close();
- } catch (Throwable var6) {
- this.logger.warn(var6.getMessage(), var6);
- }
- }
- }
- ......
- }
总结一下,Dubbo的优雅下线,若是通过JDK的shutdownHook来完成优雅停机的,这时当用户对该Dubbo进行执行kill pid后,在关闭JVM时会发起一个线程执行ShutdownHook,进而执行 ProtocolConfig.destroyAll()方法,该方法在关掉进行前,主要做了以下一些清理工作:
1、关闭zk客户端
2、 客户端断开ZK连接后,ZK会自动删除临时注册节点
3、 取消重试机制
4 、清除内存中已经注册的服务
5、 取消所有的服务订阅
6、关闭provider和consumer,停止新的请求
后面还有一步没分析到,是若仍有在执行的线程,会等待其执行完成。
最后,在清理完一系列工作后,就可以关闭该进程了。
这就是Dubbo的优雅下线大概的原理。