Dubbo支持自适应等待无损下线

原创 吴就业 132 0 2022-04-20

本文为博主原创文章,未经博主允许不得转载。

本文链接:https://wujiuye.com/article/5f6bd6a3de0848a28d774d9ed4ec7cc1

作者:吴就业
链接:https://wujiuye.com/article/5f6bd6a3de0848a28d774d9ed4ec7cc1
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。

本篇文章写于2022年04月20日,从公众号|掘金|CSDN手工同步过来(博客搬家),本篇为原创文章。

无损上下线是服务治理不可忽视的问题,在应⽤上下线发布过程中,如果上下线不平滑,就会出现短时间的服务调⽤报错,如连接被拒绝(Connection refused)、请求超时或请求异常。

请求超时发生在服务提供者上线时,由于过早暴露服务,请求进来时,可能应用还未初始化完成,如中间件的初始化、IOC容器的初始化。连接被拒绝、请求异常发生在服务下线,如果中间件的销毁早于Dubbo就会出现请求异常;如果请求未写入IO通道就关闭连接,就会导致服务消费者接收IO异常;如果服务消费者感知提供者下线有延迟,就会导致延迟的这段时间内,被路由到已下线提供者节点的请求都抛连接被拒绝异常。

对于无损上线(平滑上线),Dubbo提供了延迟注册的解决方案,可以结合延迟初始化使用。在Spring容器初始化阶段,我们先将服务提供者扫描出来,不影响Spring对提供者实现Bean的生命周期处理,等待Spring容器初始化完成之后,通过监听Spring容器初始化完成事件,再将扫描出来的服务提供者注册到注册中心,此时还可以结合Dubbo的延迟注册功能使用,避免一些中间件组件也是在这个时机才初始化。

对于无损下线(平滑下线),Dubbo也提供了ShutdownHook的支持,但这个实现比较简陋。如果使用Dubbo的ShutdownHook,会导致正在处理中的请求(处理ing)无法正常完成响应。

为解决此问题,我们可实现自适应等待无损下线,移除Dubbo注册的ShutdownHook,自己注册一个ShutdownHook,在这个ShutdownHook中,先是将此服务提供者节点从注册中心摘除,此时还是能够继续接收请求的,然后休眠等待所有正在处理中的请求都完成,并且响应给消费者后,再销毁协议(如http协议的jetty容器),不再接收和处理请求。

这个过程是:先从注册中心注销->继续接收和处理请求->处理完最后的请求后销毁协议->不再接收请求和处理请求。当服务提供者处于“继续接收和处理请求”这个阶段时,消费者会陆续感知到此节点已经下线,后续不再发请求。

移除Dubbo的ShutdownHook需要确保在Dubbo调用addShutdownHook之后。移除的代码如下。

// 移除dubbo的钩子,实现无损下线需要,避免接收到kill信号量就把协议销毁了
// 注册的地方@see com.alibaba.dubbo.config.AbstractConfig#static{}
Runtime.getRuntime().removeShutdownHook(DubboShutdownHook.getDubboShutdownHook());

然后注册自己的ShutdownHook,例如。

static {
    Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
        @Override
        public void run() {
            shutdownIfNeed();
        }
    }));
}

private synchronized static void shutdownIfNeed() {
    //......
    // 先从注册中心摘除
    for (ServiceConfig<Object> serviceConfig : SERVICE_MAP.values()) {
        serviceConfig.unexport();
    }
    // 无损下线等待
    LosslessOfflineSupper.losslessOffline();
    // double unexport,销毁协议
    DubboShutdownHook.getDubboShutdownHook().destroyAll();
}

实现自适应等待,可通过Filter扩展点,添加一个负责统计正在处理的请求数的Filter,例如。

@Activate(group = Constants.PROVIDER)
public class LosslessOfflineProviderFilter implements Filter {
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        LosslessOfflineSupper.incRequest();
        try {
            return invoker.invoke(invocation);
        } finally {
            LosslessOfflineSupper.decRequest();
        }
    }
}

统计逻辑的实现很简单,使用AtomicLong统计即可,在invoker.invoke调用之前自增处理中的请求数,在invoker.invoke调用之后自减处理中的请求数。

等待逻辑的实现如下。

public static void losslessOffline() {
    while (REQ_CNT.get() > 0) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            //
        }
    }
    // 响应给服务消费者可能还需要点时间
    try {
        Thread.sleep(5000);
    } catch (InterruptedException e) {
        //
    }
}

最后再休眠几秒,是为了避免响应没写给消费者就关闭了连接。

需要注意,此方案依然解决不了中间件自己通过ShutdownHook在Dubbo销毁之前先销毁的问题。由于ShutdownHook的无序异步特性,如果中间件组件也注册了ShutdownHook,且这些ShutdownHook在Dubbo的ShutdownHook之前已经执行完了,如果还有请求进来,这些请求就无法被正常处理,可能还会导致产生脏数据。如发送kafka成功后,写mysql失败,因为mysql连接池这时候已经销毁,那么请求处理失败了,但已经发送kafka成功的消息却无法撤回。

当然,如果是将Dubbo整合到Spring项目中,建议是使用Spring的事件监听完成shutdown操作,避免Spring在dubbo之前shutdown,导致一些bean的销毁方法被调用,无法再处理业务逻辑。

只需要添加一个Bean,实现ApplicationListener接口,并指定泛型参数类型为ContextClosedEvent即可监听Spring的Shutdown事件。并且ApplicationListener是支持使用Spring的注解排序的,这样能指定将Dubbo的ApplicationListener排在最前面,例如。

@Configuration
public class DubboApplicationListener implements ApplicationListener<ContextClosedEvent>, Ordered{
    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE;
    }
    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        // do 
    }
}

Spring的ContextClosedEvent事件实际也是通过注册JVM ShutdownHook,然后调用ApplicationContext的doClose方法,在doClose方法中发出的事件。

Spring保证了在调用ApplicationListener的onApplicationEvent方法之后,才会执行销毁bean的逻辑。但Spring不会默认add这个ShutdownHook,需要我们手动调用registerShutdownHook方法才会生效。

org.springframework.context.support.AbstractApplicationContext#registerShutdownHook

如果是SpringBoot应用,无需手动调用,SpringBoot已经做了封装,在SpringApplication的refreshContext方法中调用了ApplicationContext的registerShutdownHook方法。

无论是使用Runtime.getRuntime().addShutdownHook,还是Spring的ContextClosedEvent事件,要实现应用平滑下线,单Dubbo是不够的,需要整个技术栈提供支持,比如,约定都通过Spring的ContextClosedEvent实现shutdown操作,并且约定shutdown顺序。

还有另外一种取巧的方法,通过反射注册一个更高优先级的Hook,可以让该Hook在所有调用Runtime.getRuntime().addShutdownHook方法注册的ShutdownHook之前执行。

/**
 * @see java.io.Console使用了slot=0,要确保没有地方用到java.io.Console
 */
static {
    try {
        Class<?> sc = Class.forName("java.lang.Shutdown");
        Method method = sc.getDeclaredMethod("add", int.class, boolean.class, Runnable.class);
        method.setAccessible(true);
        // 插入在ApplicationShutdownHooks之前,前提是slot=0没被占用
        method.invoke(null, 0, false, new Runnable() {
            @Override
            public void run() {
                // do 
            }
        });
    } catch (Throwable e) {
    }
}

需要注意的是,Shutdown的add方法,传递的slot只能是0~9,并且1已经被JDK实现Runtime.getRuntime().addShutdownHook使用了,0和2也被使用了,其中,只要没有地方触发java.io.Console类初始化,0就可以使用,否则会导致进程启动不起来。

这种方法注册的Hook是会阻塞后面的Hook的执行的,而Runtime.getRuntime().addShutdownHook注册的ShutdownHook不仅无法控制排序,每个ShutdownHook都是一个线程,也无法控制ShutdownHook-A执行完之后再到ShutdownHook-B的执行顺序。

另外,如果通过反射调用Shutdown的add方法这个方案行不通,笔者在线上容器环境中验证过,JDK版本1.8.0.202,确实反射调用失败,说明slot=0的坑位还是被用了。于是想到了通过反射修改字段值的方案,经验证是成功的,实现代码如下。

// 实现优于Runtime.getRuntime().addShutdownHook之前执行
static {
    try {
        // 只是确保ApplicationShutdownHooks已经初始化
        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            @Override
            public void run() {
            }
        }));
        // 拿到字段
        Class<?> sc = Class.forName("java.lang.Shutdown");
        Field hooks = sc.getDeclaredField("hooks");
        hooks.setAccessible(true);
        // 替换元素
        Runnable[] runnables = ((Runnable[]) hooks.get(null));
        final Runnable r = runnables[1];
        runnables[1] = new Runnable() {
            @Override
            public void run() {
                trigger();
                r.run();
            }
        };
        logger.info("java.lang.Shutdown#hooks modify success");
    } catch (Exception ex) {
        ex.printStackTrace();
        logger.info("java.lang.Shutdown#hooks modify fail");
    }
}
#中间件

声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。

文章推荐

被开源组件坑惨了,文件上传到MFS后MD5不一致

moosefs没有关于底层自定义二进制通信协议的文档,且各大版本api上差异很大。我们在github上找到一个go语言实现api操作moosefs的开源库,但不幸的是,这个组件实现的api版本很低,在测试阶段,我们发现上传文件到moosefs后,moosefs上存储的文件的md5与本地原文件md5不一致,如果是图片,能很明显的看出少了一块像素。

基于dubbo-go二次开发荔枝RPC框架

本文介绍如何基于dubbo-go的扩展点,二次开发支持公司内部rpc协议,支持java项目和go项目的互相调用。

如何开发一个Java微服务项目脚手架

如果没有脚手架,每当需要创建一个新的project,我们通常会选择基于现有的project复制一份,然后修改修改。

Xxl-job SDK引发的OOM

由于输出的错误日记字符串长度过长,导致xxl-job-admin处理callback请求无法将日记入库。sdk会将失败的callback写入一个重试文件(xxl-job-callback.log),sdk有一个后台线程,定时每几秒会全量load重试文件到内存中...

Dubbo之HTTP RPC vs Dubbo RPC性能压测

此次性能测试对比的是我们基于Dubbo扩展点自实现的Http rpc协议,与Dubbo原生Dubbo rpc协议的单次请求响应平均耗时、吞吐量。

Dubbo为什么会提供泛化调用这个功能

Dubbo的泛化调用功能就类似于Java语言提供的泛型功能,目的都是通用。那为什么需要泛化调用功能呢?