Dubbo 優雅停機演進之路

一、前言


https://studyidea.cn/articles/2019/09/03/1567504427330.html 一文中我們聊到了 Java 實現優雅停機原理。接下來我們就跟根據上面知識點,深入 Dubbo 內部,去了解一下 Dubbo 如何實現優雅停機。

二、Dubbo 優雅停機待解決的問題

為了實現優雅停機,Dubbo 需要解決一些問題:

  1. 新的請求不能再發往正在停機的 Dubbo 服務提供者。
  2. 若關閉服務提供者,已經接收到服務請求,需要處理完畢才能下線服務。
  3. 若關閉服務消費者,已經發出的服務請求,需要等待響應返回。

解決以上三個問題,才能使停機對業務影響降低到最低,做到優雅停機。

三、2.5.X

Dubbo 優雅停機在 2.5.X 版本實現比較完整,這個版本的實現相對簡單,比較容易理解。所以我們先以 Dubbo 2.5.X 版本源碼為基礎,先來看一下 Dubbo 如何實現優雅停機。

3.1、優雅停機總體實現方案

優雅停機入口類位於 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.destroyAll() 源碼如下:

public static void destroyAll() {
 // 防止併發調用
 if (!destroyed.compareAndSet(false, true)) {
 return;
 }
 // 先註銷註冊中心
 AbstractRegistryFactory.destroyAll();
 // Wait for registry notification
 try {
 Thread.sleep(ConfigUtils.getServerShutdownTimeout());
 } catch (InterruptedException e) {
 logger.warn("Interrupted unexpectedly when waiting for registry notification during shutdown process!");
 }
 ExtensionLoader loader = ExtensionLoader.getExtensionLoader(Protocol.class);
 // 再註銷 Protocol
 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 優雅停機主要分為兩步:

  1. 註銷註冊中心
  2. 註銷所有 Protocol

3.2、註銷註冊中心

註銷註冊中心源碼如下:

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

這個方法將會將會註銷內部生成註冊中心服務。註銷註冊中心內部邏輯比較簡單,這裡就不再深入源碼,直接用圖片展示。

Dubbo 優雅停機演進之路

ps: 源碼位於:AbstractRegistry

以 ZK 為例,Dubbo 將會刪除其對應服務節點,然後取消訂閱。由於 ZK 節點信息變更,ZK 服務端將會通知 dubbo 消費者下線該服務節點,最後再關閉服務與 ZK 連接。

通過註冊中心,Dubbo 可以及時通知消費者下線服務,新的請求也不再發往下線的節點,也就解決上面提到的第一個問題:新的請求不能再發往正在停機的 Dubbo 服務提供者。

但是這裡還是存在一些弊端,由於網絡的隔離,ZK 服務端與 Dubbo 連接可能存在一定延遲,ZK 通知可能不能在第一時間通知消費端。考慮到這種情況,在註銷註冊中心之後,加入等待進制,代碼如下:

// Wait for registry notification
try {
 Thread.sleep(ConfigUtils.getServerShutdownTimeout());
} catch (InterruptedException e) {
 logger.warn("Interrupted unexpectedly when waiting for registry notification during shutdown process!");
}

默認等待時間為 10000ms,可以通過設置
dubbo.service.shutdown.wait 覆蓋默認參數。10s 只是一個經驗值,可以根據實際情設置。不過這個等待時間設置比較講究,不能設置成太短,太短將會導致消費端還未收到 ZK 通知,提供者就停機了。也不能設置太長,太長又會導致關停應用時間邊長,影響發佈體驗。

3.3、註銷 Protocol

ExtensionLoader 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#getLoadedExtensions 將會返回兩種 Protocol 子類,分別為 DubboProtocol 與 InjvmProtocol。

DubboProtocol 用與服務端請求交互,而 InjvmProtocol 用於內部請求交互。如果應用調用自己提供 Dubbo 服務,不會再執行網絡調用,直接執行內部方法。

這裡我們主要來分析一下 DubboProtocol 內部邏輯。

DubboProtocol#destroy 源碼:

public void destroy() {
 // 關閉 Server
 for (String key : new ArrayList(serverMap.keySet())) {
 ExchangeServer server = serverMap.remove(key);
 if (server != null) {
 try {
 if (logger.isInfoEnabled()) {
 logger.info("Close dubbo server: " + server.getLocalAddress());
 }
 server.close(ConfigUtils.getServerShutdownTimeout());
 } catch (Throwable t) {
 logger.warn(t.getMessage(), t);
 }
 }
 }
 // 關閉 Client
 for (String key : new ArrayList(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(ConfigUtils.getServerShutdownTimeout());
 } catch (Throwable t) {
 logger.warn(t.getMessage(), t);
 }
 }
 }
 for (String key : new ArrayList(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(ConfigUtils.getServerShutdownTimeout());
 } catch (Throwable t) {
 logger.warn(t.getMessage(), t);
 }
 }
 }
 stubServiceMethodsMap.clear();
 super.destroy();
}

Dubbo 默認使用 Netty 作為其底層的通訊框架,分為 Server 與 Client。Server 用於接收其他消費者 Client 發出的請求。

上面源碼中首先關閉 Server ,停止接收新的請求,然後再關閉 Client。這樣做就降低服務被消費者調用的可能性。

3.4、關閉 Server

首先將會調用 HeaderExchangeServer#close,源碼如下:

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)) {
 // 發送 READ_ONLY 事件
 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 void doClose() {
 if (!closed.compareAndSet(false, true)) {
 return;
 }
 stopHeartbeatTimer();
 try {
 scheduled.shutdown();
 } catch (Throwable t) {
 logger.warn(t.getMessage(), t);
 }
}

這裡將會向服務消費者發送 READ_ONLY 事件。消費者接受之後,主動排除這個節點,將請求發往其他正常節點。這樣又進一步降低了註冊中心通知延遲帶來的影響。

接下來將會關閉心跳檢測,關閉底層通訊框架 NettyServer。這裡將會調用 NettyServer#close 方法,這個方法實際在 AbstractServer 處實現。

AbstractServer#close 源碼如下:

public void close(int timeout) {
 ExecutorUtil.gracefulShutdown(executor, timeout);
 close();
}

這裡首先關閉業務線程池,這個過程將會盡可能將線程池中的任務執行完畢,再關閉線程池,最後在再關閉 Netty 通訊底層 Server。

Dubbo 默認將會把請求/心跳等請求派發到業務線程池中處理。

關閉 Server,優雅等待線程池關閉,解決了上面提到的第二個問題:若關閉服務提供者,已經接收到服務請求,需要處理完畢才能下線服務。

Dubbo 服務提供者關閉流程如圖:

Dubbo 優雅停機演進之路

ps:為了方便調試源碼,附上 Server 關閉調用聯。

DubboProtocol#destroy
 ->HeaderExchangeServer#close
 ->AbstractServer#close
 ->NettyServer#doClose 

3.5 關閉 Client

Client 關閉方式大致同 Server,這裡主要介紹一下處理已經發出請求邏輯,代碼位於HeaderExchangeChannel#close。

// graceful close
public void close(int timeout) {
 if (closed) {
 return;
 }
 closed = true;
 if (timeout > 0) {
 long start = System.currentTimeMillis();
 // 等待發送的請求響應信息
 while (DefaultFuture.hasFuture(channel)
 && System.currentTimeMillis() - start < timeout) {
 try {
 Thread.sleep(10);
 } catch (InterruptedException e) {
 logger.warn(e.getMessage(), e);
 }
 }
 }
 close();
}

關閉 Client 的時候,如果還存在未收到響應的信息請求,將會等待一定時間,直到確認所有請求都收到響應,或者等待時間超過超時時間。

ps:Dubbo 請求會暫存在 DefaultFuture Map 中,所以只要簡單判斷一下 Map 就能知道請求是否都收到響應。

通過這一點我們就解決了第三個問題:若關閉服務消費者,已經發出的服務請求,需要等待響應返回。

Dubbo 優雅停機總體流程如圖所示。

Dubbo 優雅停機演進之路

ps: Client 關閉調用鏈如下所示:

DubboProtocol#close
 ->ReferenceCountExchangeClient#close
 ->HeaderExchangeChannel#close
 ->AbstractClient#close

2.7.X

Dubbo 一般與 Spring 框架一起使用,2.5.X 版本的停機過程可能導致優雅停機失效。這是因為 Spring 框架關閉時也會觸發相應的 ShutdownHook 事件,註銷相關 Bean。這個過程若 Spring 率先執行停機,註銷相關 Bean。而這時 Dubbo 關閉事件中引用到 Spring 中 Bean,這就將會使停機過程中發生異常,導致優雅停機失效。

為了解決該問題,Dubbo 在 2.6.X 版本開始重構這部分邏輯,並且不斷迭代,直到 2.7.X 版本。

新版本新增 ShutdownHookListener,繼承 Spring ApplicationListener 接口,用以監聽 Spring 相關事件。這裡 ShutdownHookListener 僅僅監聽 Spring 關閉事件,當 Spring 開始關閉,將會觸發 ShutdownHookListener 內部邏輯。

public class SpringExtensionFactory implements ExtensionFactory {
 private static final Logger logger = LoggerFactory.getLogger(SpringExtensionFactory.class);
 private static final Set CONTEXTS = new ConcurrentHashSet();
 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();
 }
 }
 }
}

當 Spring 框架開始初始化之後,將會觸發 SpringExtensionFactory 邏輯,之後將會註銷 AbstractConfig 註冊 ShutdownHook,然後增加 ShutdownHookListener。這樣就完美解決上面『雙 hook』 問題。

最後

優雅停機看起來實現不難,但是裡面設計細枝末節卻非常多,一個點實現有問題,就會導致優雅停機失效。如果你也正在實現優雅停機,不妨參考一下 Dubbo 的實現邏輯。

幫助文章

1、
https://www.cnkirito.moe/dubbo-gracefully-shutdown/

本文由博客一文多發平臺 https://openwrite.cn?from=article_bottom 發佈!


分享到:


相關文章: