Dubbo 优雅停机

优雅停机特性是所有 RPC 框架中非常重要的特性之一,因为核心业务在服务器中正在执行时突然中断可能会出现严重后果,接下来我们消息探讨 Dubbo 框架内部实现优雅停机原理。


Dubbo 优雅停机原理

Dubbo 中实现的优雅停机机制主要包含6个步骤:
(1)收到 kill PID 进程退出信号,Spring 容器会触发容器销毁事件。
(2)provider 端会注销服务元数据信息(删除ZK节点)。
(3)consumer 会拉取最新服务提供者列表。
(4)provider 会发送 readonly 事件报文通知 consumer 服务不可用。
(5)服务端等待已经执行的任务结束并拒绝新任务执行。

可能读者会有疑问,既然注册中心已经通知了最新的服务列表,为什么还要发送 readonly 报文呢?这里主要考虑注册中心推送服务有网络延迟,以及客户端计算服务列表可能占用一些时间。provider 发送 readonly 报文时,consumer 端会设置相应的 provider 为不可用状态,下次负载均衡就不会调用下线的机器。

在应用停机时,可能还存在执行到了一半的任务,试想这样一个场景:一个 Dubbo 请求刚到达提供者,服务端正在处理请求,收到停机指令后,提供者直接停机,留给消费者的只会是一个没有处理完毕的超时请求。

结合上述的案例,我们总结出 Dubbo 优雅停机需要满足两点基本诉求:

  1. 服务消费者不应该请求到已经下线的服务提供者
  2. 处理中请求需要处理完毕,不能被停机指令中断

注意:Dubbo 是通过 JDK 的 ShutdownHook 来完成优雅停机的,所以如果用户使用 kill -9 PID 等强制关闭指令,是不会执行优雅停机的,只有通过 kill PID 时,才会执行。

1、优雅停机初始方案 — 2.6.3 之前版本

为了让读者对 Dubbo 的优雅停机有一个最基础的理解,我们首先研究下 Dubbo 2.6.3 之前的版本,这个版本实现优雅停机的方案相对简单,容易理解。

1.1 入口类 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的静态块中,Dubbo 注册了一个 shutdownHook(本质上是一个线程),用于执行 Dubbo 预设的一些停机逻辑,继续跟进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定义了暴露、订阅、销毁三个方法:

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中执行的优雅停机逻辑分成了两部分,其中第一部分和注册中心(Registry)相关,第二部分和协议/流程(Protocol)相关。

1.3 注册中心注销

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

大致的逻辑就是删除掉注册中心中本节点对应的服务提供者地址。此时,注册中心就会通知消费端服务器节点删除事件,进而拉取最新的服务提供者列表。

1.4 协议注销
        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)这段代码会加载到两个协议 :DubboProtocolInjvm。后者Injvm由于是直接清空本地内存,没啥好讲的。主要来分析一下DubboProtocol的逻辑。

DubboProtocol实现了我们前面提到的Protocol接口,它的destory方法是我们重点要看的。

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

主要分成了两部分注销逻辑:server 和 client。由于 server 和 client 的流程类似,所以我只选取了 server 部分来分析具体的注销逻辑。

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

在关闭过程中,如果发现有正在进行中的任务,即没有接收到服务端返回值的任务,就 Thread.sleep 10 毫秒,在超时时间内(默认10秒)等待任务执行完毕。然后关闭心跳检测,关闭 NettyServer。

2. Spring 容器下 Dubbo 的优雅停机

上述的方案在不使用 Spring 时的确是无懈可击的,但由于现在大多数开发者选择使用 Spring 构建 Dubbo 应用,上述的方案会存在一些缺陷。

由于 Spring 框架本身也依赖于 shutdown hook 执行优雅停机,并且与 Dubbo 的优雅停机会并发执行,而 Dubbo 的一些 Bean 受 Spring 托管,当 Spring 容器优先关闭时,会导致 Dubbo 的优雅停机流程无法获取相关的 Bean 而报错,从而优雅停机失效。

Dubbo 开发者们迅速意识到了 shutdown hook 并发执行的问题,开始了一系列的补救措施。

2.1 增加 ShutdownHookListener

Spring 如此受欢迎的原因之一便是它的扩展点非常丰富,例如它提供了ApplicationListener接口,开发者可以实现这个接口监听到 Spring 容器的关闭事件,为解决 shutdown hook 并发执行的问题,在 Dubbo 2.6.3 中新增了ShutdownHookListener类,用作 Spring 容器下的关闭 Dubbo 应用的钩子。这样保证了先关闭 Dubbo 的应用钩子再去关闭 Spring 的应用钩子。

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 执行关闭钩子时,会发布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 先发布ContextClosedEvent事件,调用关闭 Dubbo 应用的钩子,然后再关闭自身的 Spring 应用。从而解决了上述因 Spring 钩子早于 Dubbo 钩子执行导致 Dubbo 优雅停机失效的问题。

3. Dubbo 2.7 最终方案

dubbo 2.6.3 版本,也有缺点,因为它仍然保留了原先的 Dubbo 注册 JVM 关闭钩子,只是这个钩子的报错不会影响 Spring 钩子中关闭 Dubbo 应用的执行,因为它们是两个独立的线程。但是 Dubbo 注册 JVM 关闭钩子的操作难免有点多余,于是在 dubbo 2.7.x 版本中,通过SpringExtensionFactory类移除了该操作。

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

该方案完美的解决了上述并发钩子问题,直接取消掉 Dubbo 的 JVM 的钩子。
同时如果担心当前 Spring 容器没有注册 Spring 钩子(SpringBoot 会自动注册)?那就显示调用 registerShutdownHook进行注册。

4. 完善优雅停机

上述的优雅停机,只是针对 Dubbo 本身的机制来说的,但是实际情况仅靠 Dubbo 自身的机制是不行的。因为 Dubbo 无法控制数据库持久层框架 ,如 Mybatis 的启停时机。即使 Dubbo 能够控制自身程序有10s(可配置)的执行时间,但是由于无法控制 Mybatis 的关闭时机,所以数据库的连接会在第一时间被关闭,依然无法做到项目程度的优雅停机。

解决上述问题的思路:执行 Kill PID 命令之前,先注销 zk 上的服务,待程序完全执行完之后,再执行 Kill PID 命令。

第一种实现方式,我们可以通知 Dubbo Admin 后台,先批量注销本机器的服务:



待程序完全执行完之后,大概30s左右,就可以发布项目了。发布完成之后,再批量启用本机器的服务。

该种方式在机器数量较少的场景,还能勉强接受,机器数量一多,手动操作量就会很大。于是我们迫切需要一种方式,来替换手动的操作。查阅资料发现,Dubbo 在 2.5.8 新版本增加了 QOS 模块,它允许运维可以通过命令来启停本机器的服务。默认情况,qos 是开启的,并且默认端口为 22222,我们可以通知dubbo.application.qosPort来修改端口。执行telnet ip port(比如 telnet localhost 22222)命令就可以连接上 qos 平台 。


我们可以利用其offline命令,提前下线本机器的服务。于是我在启动脚本中,先去连接qos平台,然后下线本机器的服务,最后 sleep 30 秒,再去执行启动操作,确保你的机器可以执行 telnet 命令。

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

sleep 30
...

由于Dubbo在启动过程中会自动暴露服务,于是我们不用执行online命令去开启服务。至此,我们实现了基于项目维度的优雅停机。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,287评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,346评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,277评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,132评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,147评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,106评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,019评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,862评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,301评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,521评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,682评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,405评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,996评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,651评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,803评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,674评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,563评论 2 352