優(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 中實(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)基本訴求:
- 服務(wù)消費(fèi)者不應(yīng)該請(qǐng)求到已經(jīng)下線的服務(wù)提供者
- 處理中請(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é)議 :DubboProtocol和Injvm。后者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ī)。