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 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的时序无需关系。但多考虑些总归是好的
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。