架构&系统设计

Dubbo 优雅停机

2020-06-06  本文已影响0人  habit_learning

优雅停机特性是所有 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命令去开启服务。至此,我们实现了基于项目维度的优雅停机。

上一篇下一篇

猜你喜欢

热点阅读