Spring下dubbo应用停机时序的问题和解决方案

dubbo的优雅停机依赖jvm hook,在spring工程下使用时,如果业务中有使用到hook机制进行处理,由于jvm钩子函数的执行是并发执行,存在如下问题:
1.dubbo服务先于应用自定义的hook逻辑关闭,导致部分数据处理失败
2.dubbo服务晚于spring 关闭执行,导致dubbo优雅关停时抛出IllegalStateException异常
本文主要针对 dubbo的2.6.2,2.6.x 2.7.4.1,2.7.5几个版本的关停服务进行分析,并提供基于Spring的通用的时序控制方案
本文主要从业务场景及存在的问题、 dubbo各个版本优雅停机的进阶、Spring项目下dubbo停机时序自控三个方面介绍。

业务场景及存在问题

Spring下dubbo应用停机时序的问题和解决方案_第1张图片

先简要的介绍下架构的场景,当前业务从中间件消费数据,采用的是 batch模式 + spring-kafka自动提交,消费数据时将数据加入到本地缓存即认为消费成功。(基于spring-kafka消费方式的优化解决思路不在本次讨论范围内)业务处理线程会并发的调用dubbo 服务进行数据处理、落库等。在模块重启、关停时,由于pipe缓存中仍有任务数据,需使用钩子,等缓存中数据消费完后,再进行dubbo服务的关停。这样就会牵扯到优雅停服的时序问题,按照要求,关闭的顺序应该是

Kafka 关闭消费端 ——> 剩余数据消费——>dubbo服务关停——>Spring关停及bean销毁。dubbo的服务关停也是依赖jvm hook

Spring下dubbo应用停机时序的问题和解决方案_第2张图片

参照 ApplicationShutdownHooks中hook线程的执行逻辑:是对于每一个注册的钩子都分配一个线程,并发执行,即jvm的多个hook执行时没有时序的,业务中对hook的时序要自行控制。

dubbo 优雅关停的进阶

本节主要对 dubbo的关停进阶的过程进行介绍

dubbo2.6.2

dubbo2.6.2提供的关停服务仅使用了 jvm hook,未依赖Spring中的ApplicationListener,相关的关停服务可以参考 AbstractConfig

static {
        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            @Override
            public void run() {
                if (logger.isInfoEnabled()) {
                    logger.info("Run shutdown hook now.");
                }
                ProtocolConfig.destroyAll();
            }
        }, "DubboShutdownHook"));
    }

关停的入口方法是 ProtocolConfig.destroyAll()

dubbo2.6.x

dubbo2.6.x 支持监听 Spring ApplicationListener ContextClosedEvent和jvm hook两种优雅关停的方式,并通过cas对关停操作进行同步,保证只会被执行一次。关停服务参考AbstractConfig和SpringExtensionFactory

static {
        // this is only for compatibility
        Runtime.getRuntime().addShutdownHook(DubboShutdownHook.getDubboShutdownHook());
    }
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();
            }
        }
    }

可以看到在2.6.x版本中,jvm hook保留了下来,但标记为 this is only for compatibility(只是为了兼容)。并且在2.6.x中还有一个细节需要注意的是,如果dubbo所依赖的Spring项目没有显示或者隐式的注册shutdownhook,那ShutdownHookListener是不会执行的,只能通过所谓的兼容jvm hook进行优雅关停。并且在dubbo2.6.x开始废弃掉了ProtocolConfig.destroyAll()进行服务关停的入口,这个在升级的时候,如果有手动控制关停服务的逻辑,可以改成DubboShutdownHook.getDubboShutdownHook().doDestroy();

dubbo2.7.4.1

dubbo在2.7版本之后在服务关停方面,主要的优化是在判断Spring上下文存在的情况下,移除了jvm hook中的兼容性调用,统一使用ApplicationListener监听机制实现优雅关停。同时针对2.6.x中Spring项目未注册shutdownhook的情况((ConfigurableApplicationContext) context).registerShutdownHook() 即使原spring工程没有注册,此处也要给注册监听closeevent。2.7版本之后做了很多优化,如客户端线程池的改造、DefaultFuture消除锁同步引入CompletableFuture。性能提升非常明显,感兴趣的同学可以仔细研究下。如下代码参照SpringExtensionFactory

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

dubbo2.7.5

由于dubbo2.7.5尚存在一系列问题,开发及维护团队也不建议直接在线上使用该版本,而是建议使用低一级别的2.7.4.1。参见1

Spring下dubbo应用停机时序的问题和解决方案_第3张图片

在2.7.5版本中,dubbo首次针对JVM hook机制的时序问题提供了解决方案(撒花~~),具体可以参考下 参见2

2.7.5提供了一个 ShutdownHookCallback的回调接口,在进行destory之前被调用来执行相关逻辑,可以用来做时序上的同步。实现上非常简单,dubbo只是提供了接口,具体实现则需要开发人员实现。

private final ShutdownHookCallbacks callbacks = ShutdownHookCallbacks.INSTANCE;
@Override
    public void run() {
        if (logger.isInfoEnabled()) {
            logger.info("Run shutdown hook now.");
        }
        callback();
        doDestroy();
    }
public void callback() {
        getCallbacks().forEach(callback -> execute(callback::callback));
    }

如果定义了多个ShutdownHookCallback,这里也是会并行执行的,需要注意

目前工程中dubbo停服时序控制方案

首先介绍下当前项目中dubbo 停机时序的控制方法,基于(dubbo2.6.2) + 非web Spring 项目
目前项目中使用dubbo2.6.2,从上文中可以知道,这个版本的dubbo没有注册ApplicationListener,仅依赖jvm hook。
所以之前的实现方式是 :手动移除注册在ApplicationShutdownHooks中的 DubboShutdownHook钩子线程,并在业务钩子中完成数据处理后,手动关停dubbo。

ApplicationContext context = new ClassPathXmlApplicationContext("application-context.xml");
removeDubboShutdownHook();  // 移除dubbo注册的钩子
 Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                threadpool.shutdown();  // 关闭业务线程池
                for (MessageListenerContainer container : kafkaRegistry.getListenerContainers()) {
                    container.stop();  
                    log.info("***** kafka consumer shutdown. ***** ");
                }
                // 消化pipe缓存数据
                for (Integer key : getPipe()) {
                    Pipe target = getPipeMap().get(key);
                    while (target.size() > 0) {
                        log.info(String.format("targetid=%s, pipe size=%s, wainting...", key, target.size()));
                        DateTimeUtil.sleepQuietly(1000);
                    }
                }
                DateTimeUtil.sleepQuietly(2000);// 等待日志刷到磁盘或批量数据的插入
                log.info("***** start shutdown dubbo. ***** ");
                ProtocolConfig.destroyAll();
                log.info("ShutDownThread sucess.")
            }
        });

dubbo应用优雅停机实现方案

显然,随着业务需求和 dubbo服务的升级,原有的停服时序控制方法无法满足这方面需求。我们需要的是一个可以针对dubbo 多个版本兼容的自定义停机逻辑。

实现思路

1.获取Spring ApplicationContext应用上下文,如果是非web spring项目,显示调用registerShutdownHook方法,支持applicationListener
2.从jvm hook 中移除 dubbo服务注册的钩子(兼容2.6.x及以下版本)
3.获取ApplicationEventMulticaster的bean对象
4.反射获取DubboShutdownHookListener,并从ApplicationEventMulticaster中移除。
5.实现ApplicationListener,自定义停机逻辑,并在处理完后手动关停dubbo服务。

实现代码

ApplicationContext context = new ClassPathXmlApplicationContext("application-context.xml");
((ClassPathXmlApplicationContext) context).registerShutdownHook();
// 从jvm钩子中移除DubboShutdownHook
 removeDubboShutdownHook();
// 从context中移除dubbolistener
 AbstractApplicationEventMulticaster multicaster = context.getBean(
AbstractApplicationContext.APPLICATION_EVENT_MULTICASTER_BEAN_NAME,
AbstractApplicationEventMulticaster.class);
ApplicationListener dub = getDubboApplicationListener();
multicaster.removeApplicationListener(dub);
private static void removeDubboShutdownHook() {
        try {
            Class clazz = Class.forName("java.lang.ApplicationShutdownHooks");
            Field field = clazz.getDeclaredField("hooks");
            field.setAccessible(true);
            Object hooks = field.get(null);
            Map hooksMap = (Map) hooks;
            Iterator> it = hooksMap.entrySet().iterator();
            while (it.hasNext()) {
                Map.Entry entry = it.next();
                Thread t = entry.getKey();
                if (StringUtils.equals("DubboShutdownHook", t.getName())) {
                    it.remove();
                    log.info("remove DubboShutdownHook sucess.");
                    break;
                }
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
    }
private static ApplicationListener getDubboApplicationListener() {
        ApplicationListener dubboShutdownHook = null;
        try {
            Class clazz = Class.forName("org.apache.dubbo.config.spring.extension.SpringExtensionFactory");
            Field field = clazz.getDeclaredField("SHUTDOWN_HOOK_LISTENER");
            field.setAccessible(true);
            Object listener = field.get(null);
            dubboShutdownHook = (ApplicationListener) listener;
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
        return dubboShutdownHook;
    }

时序控制进阶

在使用手动控制 hook时序问题时,建议将有严格时序要求的业务尽量放在一个 hook线程或者一个ApplicationListener中执行,如果涉及到第三方服务的调用,保险起见,建议手动输出下 当前的ApplicationListener,hooks等看下是否存在时序问题。
例如在上文的场景中有使用到kafkaRegistry,那就要考虑kafka存不存在并发的监听容器关闭事件,以及时序问题。在本项目场景中因为只有消费,没有生产场景,kafka的时序无需关系。但多考虑些总归是好的


Spring下dubbo应用停机时序的问题和解决方案_第4张图片
微信截图_20200518105228.png

KafkaListenerEndpointRegistry 只监听refresh事件,不存在关停时序问题

@Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        if (event.getApplicationContext().equals(this.applicationContext)) {
            this.contextRefreshed = true;
        }
    }

思考及其他

在基于ApplicationListener事件,由于ApplicationListener 的Order 默认是 Integer.Max_Value,造成无序。在自定义相关事件和监听器时,建议使用SmartApplicationListener,并初始化order。

你可能感兴趣的:(Spring下dubbo应用停机时序的问题和解决方案)