Dubbo 優(yōu)雅停機(jī)

優(yōu)雅停機(jī)特性是所有 RPC 框架中非常重要的特性之一,因?yàn)楹诵臉I(yè)務(wù)在服務(wù)器中正在執(zhí)行時(shí)突然中斷可能會(huì)出現(xiàn)嚴(yán)重后果,接下來(lái)我們消息探討 Dubbo 框架內(nèi)部實(shí)現(xiàn)優(yōu)雅停機(jī)原理。


Dubbo 優(yōu)雅停機(jī)原理

Dubbo 中實(shí)現(xiàn)的優(yōu)雅停機(jī)機(jī)制主要包含6個(gè)步驟:
(1)收到 kill PID 進(jìn)程退出信號(hào),Spring 容器會(huì)觸發(fā)容器銷(xiāo)毀事件。
(2)provider 端會(huì)注銷(xiāo)服務(wù)元數(shù)據(jù)信息(刪除ZK節(jié)點(diǎn))。
(3)consumer 會(huì)拉取最新服務(wù)提供者列表。
(4)provider 會(huì)發(fā)送 readonly 事件報(bào)文通知 consumer 服務(wù)不可用。
(5)服務(wù)端等待已經(jīng)執(zhí)行的任務(wù)結(jié)束并拒絕新任務(wù)執(zhí)行。

可能讀者會(huì)有疑問(wèn),既然注冊(cè)中心已經(jīng)通知了最新的服務(wù)列表,為什么還要發(fā)送 readonly 報(bào)文呢?這里主要考慮注冊(cè)中心推送服務(wù)有網(wǎng)絡(luò)延遲,以及客戶端計(jì)算服務(wù)列表可能占用一些時(shí)間。provider 發(fā)送 readonly 報(bào)文時(shí),consumer 端會(huì)設(shè)置相應(yīng)的 provider 為不可用狀態(tài),下次負(fù)載均衡就不會(huì)調(diào)用下線的機(jī)器。

在應(yīng)用停機(jī)時(shí),可能還存在執(zhí)行到了一半的任務(wù),試想這樣一個(gè)場(chǎng)景:一個(gè) Dubbo 請(qǐng)求剛到達(dá)提供者,服務(wù)端正在處理請(qǐng)求,收到停機(jī)指令后,提供者直接停機(jī),留給消費(fèi)者的只會(huì)是一個(gè)沒(méi)有處理完畢的超時(shí)請(qǐng)求。

結(jié)合上述的案例,我們總結(jié)出 Dubbo 優(yōu)雅停機(jī)需要滿足兩點(diǎn)基本訴求:

  1. 服務(wù)消費(fèi)者不應(yīng)該請(qǐng)求到已經(jīng)下線的服務(wù)提供者
  2. 處理中請(qǐng)求需要處理完畢,不能被停機(jī)指令中斷

注意:Dubbo 是通過(guò) JDK 的 ShutdownHook 來(lái)完成優(yōu)雅停機(jī)的,所以如果用戶使用 kill -9 PID 等強(qiáng)制關(guān)閉指令,是不會(huì)執(zhí)行優(yōu)雅停機(jī)的,只有通過(guò) kill PID 時(shí),才會(huì)執(zhí)行。

1、優(yōu)雅停機(jī)初始方案 — 2.6.3 之前版本

為了讓讀者對(duì) Dubbo 的優(yōu)雅停機(jī)有一個(gè)最基礎(chǔ)的理解,我們首先研究下 Dubbo 2.6.3 之前的版本,這個(gè)版本實(shí)現(xiàn)優(yōu)雅停機(jī)的方案相對(duì)簡(jiǎn)單,容易理解。

1.1 入口類(lèi) AbstractConfig
public abstract class AbstractConfig implements Serializable {
    static {
        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            public void run() {
                if (logger.isInfoEnabled()) {
                    logger.info("Run shutdown hook now.");
                }
                ProtocolConfig.destroyAll();
            }
        }, "DubboShutdownHook"));
    }

}

AbstractConfig的靜態(tài)塊中,Dubbo 注冊(cè)了一個(gè) shutdownHook(本質(zhì)上是一個(gè)線程),用于執(zhí)行 Dubbo 預(yù)設(shè)的一些停機(jī)邏輯,繼續(xù)跟進(jìn)ProtocolConfig.destroyAll()

1.2 ProtocolConfig
    public static void destroyAll() {
        if (!destroyed.compareAndSet(false, true)) {
            return;
        }
        AbstractRegistryFactory.destroyAll();
        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);
            }
        }

Dubbo 中的Protocol定義了暴露、訂閱、銷(xiāo)毀三個(gè)方法:

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();
}

回到ProtocolConfig的源碼中,我把ProtocolConfig中執(zhí)行的優(yōu)雅停機(jī)邏輯分成了兩部分,其中第一部分和注冊(cè)中心(Registry)相關(guān),第二部分和協(xié)議/流程(Protocol)相關(guān)。

1.3 注冊(cè)中心注銷(xiāo)

AbstractRegistryFactory.destroyAll():

public static void destroyAll() {
        if (LOGGER.isInfoEnabled()) {
            LOGGER.info("Close all registries " + getRegistries());
        }
        // Lock up the registry shutdown process
        LOCK.lock();
        try {
            for (Registry registry : getRegistries()) {
                try {
                    registry.destroy();
                } catch (Throwable e) {
                    LOGGER.error(e.getMessage(), e);
                }
            }
            REGISTRIES.clear();
        } finally {
            // Release the lock
            LOCK.unlock();
        }
    }

大致的邏輯就是刪除掉注冊(cè)中心中本節(jié)點(diǎn)對(duì)應(yīng)的服務(wù)提供者地址。此時(shí),注冊(cè)中心就會(huì)通知消費(fèi)端服務(wù)器節(jié)點(diǎn)刪除事件,進(jìn)而拉取最新的服務(wù)提供者列表。

1.4 協(xié)議注銷(xiāo)
        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);
            }
        }

loader.getLoadedExtension(protocolName)這段代碼會(huì)加載到兩個(gè)協(xié)議 :DubboProtocolInjvm。后者Injvm由于是直接清空本地內(nèi)存,沒(méi)啥好講的。主要來(lái)分析一下DubboProtocol的邏輯。

DubboProtocol實(shí)現(xiàn)了我們前面提到的Protocol接口,它的destory方法是我們重點(diǎn)要看的。

public class DubboProtocol extends AbstractProtocol {

    public void destroy() {
        for (String key : new ArrayList<String>(serverMap.keySet())) {
            ExchangeServer server = serverMap.remove(key);
            if (server != null) {
                server.close(ConfigUtils.getServerShutdownTimeout());
            }
        }

        for (String key : new ArrayList<String>(referenceClientMap.keySet())) {
            ExchangeClient client = referenceClientMap.remove(key);
            if (client != null) {
                client.close(ConfigUtils.getServerShutdownTimeout());
            }
        }

        for (String key : new ArrayList<String>(ghostClientMap.keySet())) {
            ExchangeClient client = ghostClientMap.remove(key);
            if (client != null) {
                client.close(ConfigUtils.getServerShutdownTimeout());
            }
        }
        stubServiceMethodsMap.clear();
        super.destroy();
    }
}

主要分成了兩部分注銷(xiāo)邏輯:server 和 client。由于 server 和 client 的流程類(lèi)似,所以我只選取了 server 部分來(lái)分析具體的注銷(xiāo)邏輯。

  public void close(final int timeout) {
        startClose();
        if (timeout > 0) {
            final long max = (long) timeout;
            final long start = System.currentTimeMillis();
            if (getUrl().getParameter(Constants.CHANNEL_SEND_READONLYEVENT_KEY, true)) {
                sendChannelReadOnlyEvent();
            }
            while (HeaderExchangeServer.this.isRunning()
                    && System.currentTimeMillis() - start < max) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    logger.warn(e.getMessage(), e);
                }
            }
        }
        doClose();
        server.close(timeout);
    }

    private boolean isRunning() {
        Collection<Channel> channels = getChannels();
        for (Channel channel : channels) {
            if (DefaultFuture.hasFuture(channel)) {
                return true;
            }
        }
        return false;
    }

    private void doClose() {
        if (!closed.compareAndSet(false, true)) {
            return;
        }
        stopHeartbeatTimer();
        try {
            scheduled.shutdown();
        } catch (Throwable t) {
            logger.warn(t.getMessage(), t);
        }
    }

在關(guān)閉過(guò)程中,如果發(fā)現(xiàn)有正在進(jìn)行中的任務(wù),即沒(méi)有接收到服務(wù)端返回值的任務(wù),就 Thread.sleep 10 毫秒,在超時(shí)時(shí)間內(nèi)(默認(rèn)10秒)等待任務(wù)執(zhí)行完畢。然后關(guān)閉心跳檢測(cè),關(guān)閉 NettyServer。

2. Spring 容器下 Dubbo 的優(yōu)雅停機(jī)

上述的方案在不使用 Spring 時(shí)的確是無(wú)懈可擊的,但由于現(xiàn)在大多數(shù)開(kāi)發(fā)者選擇使用 Spring 構(gòu)建 Dubbo 應(yīng)用,上述的方案會(huì)存在一些缺陷。

由于 Spring 框架本身也依賴(lài)于 shutdown hook 執(zhí)行優(yōu)雅停機(jī),并且與 Dubbo 的優(yōu)雅停機(jī)會(huì)并發(fā)執(zhí)行,而 Dubbo 的一些 Bean 受 Spring 托管,當(dāng) Spring 容器優(yōu)先關(guān)閉時(shí),會(huì)導(dǎo)致 Dubbo 的優(yōu)雅停機(jī)流程無(wú)法獲取相關(guān)的 Bean 而報(bào)錯(cuò),從而優(yōu)雅停機(jī)失效。

Dubbo 開(kāi)發(fā)者們迅速意識(shí)到了 shutdown hook 并發(fā)執(zhí)行的問(wèn)題,開(kāi)始了一系列的補(bǔ)救措施。

2.1 增加 ShutdownHookListener

Spring 如此受歡迎的原因之一便是它的擴(kuò)展點(diǎn)非常豐富,例如它提供了ApplicationListener接口,開(kāi)發(fā)者可以實(shí)現(xiàn)這個(gè)接口監(jiān)聽(tīng)到 Spring 容器的關(guān)閉事件,為解決 shutdown hook 并發(fā)執(zhí)行的問(wèn)題,在 Dubbo 2.6.3 中新增了ShutdownHookListener類(lèi),用作 Spring 容器下的關(guān)閉 Dubbo 應(yīng)用的鉤子。這樣保證了先關(guān)閉 Dubbo 的應(yīng)用鉤子再去關(guān)閉 Spring 的應(yīng)用鉤子。

private static class ShutdownHookListener implements ApplicationListener {
        @Override
        public void onApplicationEvent(ApplicationEvent event) {
            if (event instanceof ContextClosedEvent) {
                // we call it anyway since dubbo shutdown hook make sure its destroyAll() is re-entrant.
                // pls. note we should not remove dubbo shutdown hook when spring framework is present, this is because
                // its shutdown hook may not be installed.
                DubboShutdownHook shutdownHook = DubboShutdownHook.getDubboShutdownHook();
                shutdownHook.destroyAll();
            }
        }
    }

在 Spring 執(zhí)行關(guān)閉鉤子時(shí),會(huì)發(fā)布ContextClosedEvent事件:
AbstractApplicationContext#registerShutdownHook:

    public void registerShutdownHook() {
        if (this.shutdownHook == null) {
            // No shutdown hook registered yet.
            this.shutdownHook = new Thread() {
                @Override
                public void run() {
                    synchronized (startupShutdownMonitor) {
                        doClose();
                    }
                }
            };
            Runtime.getRuntime().addShutdownHook(this.shutdownHook);
        }
    }

    protected void doClose() {
        // Check whether an actual close attempt is necessary...
        if (this.active.get() && this.closed.compareAndSet(false, true)) {
            if (logger.isDebugEnabled()) {
                logger.debug("Closing " + this);
            }

            LiveBeansView.unregisterApplicationContext(this);

            try {
                // Publish shutdown event.
                publishEvent(new ContextClosedEvent(this));
            }
            catch (Throwable ex) {
                logger.warn("Exception thrown from ApplicationListener handling ContextClosedEvent", ex);
            }

            // Stop all Lifecycle beans, to avoid delays during individual destruction.
            if (this.lifecycleProcessor != null) {
                try {
                    this.lifecycleProcessor.onClose();
                }
                catch (Throwable ex) {
                    logger.warn("Exception thrown from LifecycleProcessor on context close", ex);
                }
            }

            // Destroy all cached singletons in the context's BeanFactory.
            destroyBeans();

            // Close the state of this context itself.
            closeBeanFactory();

            // Let subclasses do some final clean-up if they wish...
            onClose();

            // Reset local application listeners to pre-refresh state.
            if (this.earlyApplicationListeners != null) {
                this.applicationListeners.clear();
                this.applicationListeners.addAll(this.earlyApplicationListeners);
            }

            // Switch to inactive.
            this.active.set(false);
        }
    }

Spring 先發(fā)布ContextClosedEvent事件,調(diào)用關(guān)閉 Dubbo 應(yīng)用的鉤子,然后再關(guān)閉自身的 Spring 應(yīng)用。從而解決了上述因 Spring 鉤子早于 Dubbo 鉤子執(zhí)行導(dǎo)致 Dubbo 優(yōu)雅停機(jī)失效的問(wèn)題。

3. Dubbo 2.7 最終方案

dubbo 2.6.3 版本,也有缺點(diǎn),因?yàn)樗匀槐A袅嗽鹊?Dubbo 注冊(cè) JVM 關(guān)閉鉤子,只是這個(gè)鉤子的報(bào)錯(cuò)不會(huì)影響 Spring 鉤子中關(guān)閉 Dubbo 應(yīng)用的執(zhí)行,因?yàn)樗鼈兪莾蓚€(gè)獨(dú)立的線程。但是 Dubbo 注冊(cè) JVM 關(guān)閉鉤子的操作難免有點(diǎn)多余,于是在 dubbo 2.7.x 版本中,通過(guò)SpringExtensionFactory類(lèi)移除了該操作。

public class SpringExtensionFactory implements ExtensionFactory {
    public static void addApplicationContext(ApplicationContext context) {
        CONTEXTS.add(context);
        if (context instanceof ConfigurableApplicationContext) {
            ((ConfigurableApplicationContext) context).registerShutdownHook();
            DubboShutdownHook.getDubboShutdownHook().unregister();
        }
        BeanFactoryUtils.addApplicationListener(context, SHUTDOWN_HOOK_LISTENER);
    }
}

該方案完美的解決了上述并發(fā)鉤子問(wèn)題,直接取消掉 Dubbo 的 JVM 的鉤子。
同時(shí)如果擔(dān)心當(dāng)前 Spring 容器沒(méi)有注冊(cè) Spring 鉤子(SpringBoot 會(huì)自動(dòng)注冊(cè))?那就顯示調(diào)用 registerShutdownHook進(jìn)行注冊(cè)。

4. 完善優(yōu)雅停機(jī)

上述的優(yōu)雅停機(jī),只是針對(duì) Dubbo 本身的機(jī)制來(lái)說(shuō)的,但是實(shí)際情況僅靠 Dubbo 自身的機(jī)制是不行的。因?yàn)?Dubbo 無(wú)法控制數(shù)據(jù)庫(kù)持久層框架 ,如 Mybatis 的啟停時(shí)機(jī)。即使 Dubbo 能夠控制自身程序有10s(可配置)的執(zhí)行時(shí)間,但是由于無(wú)法控制 Mybatis 的關(guān)閉時(shí)機(jī),所以數(shù)據(jù)庫(kù)的連接會(huì)在第一時(shí)間被關(guān)閉,依然無(wú)法做到項(xiàng)目程度的優(yōu)雅停機(jī)。

解決上述問(wèn)題的思路:執(zhí)行 Kill PID 命令之前,先注銷(xiāo) zk 上的服務(wù),待程序完全執(zhí)行完之后,再執(zhí)行 Kill PID 命令。

第一種實(shí)現(xiàn)方式,我們可以通知 Dubbo Admin 后臺(tái),先批量注銷(xiāo)本機(jī)器的服務(wù):



待程序完全執(zhí)行完之后,大概30s左右,就可以發(fā)布項(xiàng)目了。發(fā)布完成之后,再批量啟用本機(jī)器的服務(wù)。

該種方式在機(jī)器數(shù)量較少的場(chǎng)景,還能勉強(qiáng)接受,機(jī)器數(shù)量一多,手動(dòng)操作量就會(huì)很大。于是我們迫切需要一種方式,來(lái)替換手動(dòng)的操作。查閱資料發(fā)現(xiàn),Dubbo 在 2.5.8 新版本增加了 QOS 模塊,它允許運(yùn)維可以通過(guò)命令來(lái)啟停本機(jī)器的服務(wù)。默認(rèn)情況,qos 是開(kāi)啟的,并且默認(rèn)端口為 22222,我們可以通知dubbo.application.qosPort來(lái)修改端口。執(zhí)行telnet ip port(比如 telnet localhost 22222)命令就可以連接上 qos 平臺(tái) 。


我們可以利用其offline命令,提前下線本機(jī)器的服務(wù)。于是我在啟動(dòng)腳本中,先去連接qos平臺(tái),然后下線本機(jī)器的服務(wù),最后 sleep 30 秒,再去執(zhí)行啟動(dòng)操作,確保你的機(jī)器可以執(zhí)行 telnet 命令。

#!/bin/bash
(sleep 1;
echo "offline"
sleep 2;
#echo quit
)|telnet localhost 22222

sleep 30
...

由于Dubbo在啟動(dòng)過(guò)程中會(huì)自動(dòng)暴露服務(wù),于是我們不用執(zhí)行online命令去開(kāi)啟服務(wù)。至此,我們實(shí)現(xiàn)了基于項(xiàng)目維度的優(yōu)雅停機(jī)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容