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停机时序自控三个方面介绍。

业务场景及存在问题


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

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


参照 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


在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<BaseContent> 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<Thread, Thread> hooksMap = (Map<Thread, Thread>) hooks;
            Iterator<Map.Entry<Thread, Thread>> it = hooksMap.entrySet().iterator();
            while (it.hasNext()) {
                Map.Entry<Thread, Thread> 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的时序无需关系。但多考虑些总归是好的


微信截图_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。

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