Dubbo的優雅下線原理分析

朱季謙發表於2021-07-27

文/朱季謙

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<Protocol> 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()裡邊主要做了兩件事:

  1. 首先登出註冊中心,即斷開與註冊中心的連線,Dubbo註冊到ZK的是臨時節點,故而當連線斷開後,臨時節點及底下的資料就會被自動刪除;
  2. 關閉provider和consumer暴露的協議介面,這樣,新的請求就無法再繼續進行;

下面主要按照這兩個模組大體介紹下其底層邏輯:

一、登出註冊中心

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類中具體實現:
image

進入到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<URL> 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<URL, Set<NotifyListener>> destroySubscribed = new HashMap(this.getSubscribed());
    if (!destroySubscribed.isEmpty()) {
        Iterator var12 = destroySubscribed.entrySet().iterator();

        while(var12.hasNext()) {
            Map.Entry<URL, Set<NotifyListener>> 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<URL> 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<URL> registered = new ConcurrentHashSet();

public Set<URL> 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<URL, Set<NotifyListener>> destroySubscribed = new HashMap(this.getSubscribed());
    if (!destroySubscribed.isEmpty()) {
        Iterator var12 = destroySubscribed.entrySet().iterator();

        while(var12.hasNext()) {
            Map.Entry<URL, Set<NotifyListener>> 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都很型別,都是先從快取裡把所有訂閱資訊都取出來,然後再跌代移除。


二、關閉protocol協議

這部分個關閉,主要是關閉provider和consumer,即對應前邊提到的,服務提供方會先標記不再接受新請求,新請求過來直接報錯,然後,檢查執行緒池中的執行緒是否還在執行,如果有,等待執行緒完成,若超時,則強制關閉;服務消費者則不再發起新請求,同時檢測看還有沒有請求的響應沒有返回,若有,等待返回,若超時,則強制關閉。

下面大概分析一下其原始碼邏輯。

protocol.destroy(),其方法在介面裡定義,具體實現是在RegistryProtocol當中。

@SPI("dubbo")
public interface Protocol {
    int getDefaultPort();
    @Adaptive
    <T> Exporter<T> export(Invoker<T> var1) throws RpcException;
    @Adaptive
    <T> Invoker<T> refer(Class<T> var1, URL var2) throws RpcException;
    void destroy();
}

RegistryProtocol的具體實現如下:

public void destroy() {
    List<Exporter<?>> 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的優雅下線大概的原理。

相關文章