浅谈Dubbo框架踩坑记之优雅重启问题

一、背景

最近生产环境引入Dubbo服务,每次上线重启服务,都会有超时报警,诡异的是,客户端和服务端重启都会有影响,量大了报警就愈发明显了。

大致报警信息如下:

cause: org.apache.dubbo.remoting.TimeoutException: Waiting server-side response timeout by scan timer. start time: 2021-09-09 11:59:56.822, end time: 2021-09-09 11:59:58.828, client elapsed: 0 ms, server elapsed: 2006 ms, timeout: 2000 ms, request: Request [id=307463, version=2.0.2, twoway=true, event=false, broken=false, data=null], channel: /XXXXXX:52149 -> /XXXXXX:20880] with root cause]

会是什么原因呢?

  1. 没有优雅停机?

  2. 重启瞬间,请求量太大,没有预热?

  3. Dubbo启动成功后,SpringBoot还未启动成功,没有延迟暴露?

  4. 是否有参数配置不合理?

以上都有可能,经过将近半个月时间的阅读Dubbo框架源码、 验证,终于找全了答案,特此呕心整理采坑记录。

二、说明

  1. 版本

组件 版本
Dubbo 2.7.7
Netty 4.0.36.Final
Zookeeper 3.4.9

  1. 基本情况

对于读请求,幂等的,我们是默认重试的,但是写请求,默认是不重试的。

默认超时时间2000ms。

服务都是docker容器,Dubbo客户端数量远大于服务提供端, 比例大概是10: 1

  1. 提示 本文重点阐述服务重启相关的技术点和原理, 对Dubbo框架基础,Netty基础,以及版本之前的区别不会展开讲解。

三、优雅重启关键技术点

针对上面问题,Dubbo框架也提供了解决方案,下面我们依次看下。

  1. Dubbo优雅停机机制

Dubbo是通过JDK的ShutdownHook来完成优雅停机的, Dubbo 中实现的优雅停机机制主要包含6个步骤: 

(1)收到 kill PID 进程退出信号,Spring 容器会触发容器销毁事件。

(2)provider 端会注销服务元数据信息(删除ZK节点)。

(3)consumer 会拉取最新服务提供者列表。

(4)provider 会发送 readonly 事件报文通知 consumer 服务不可用。

(5)服务端等待已经执行的任务结束并拒绝新任务执行。

优雅退出

核心代码:

  @Override
    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)) {
                //发送 readonly 事件报文通知 consumer 服务不可用
                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);
    }

相关配置

dubbo:
  application:
        shutwait: 10000 # 优雅退出等待时间,单位毫秒 默认等待 10s
  1. Dubbo预热机制

Dubbo服务默认权重是100,Dubbo实际上是提供了一种伪预热机制,根据服务提供者运行时间计算权重,再使用负载均衡策略实现流量从小到大。下面我们就从 Dubbo 源码出发,观察服务预热具体实现方式,具体源码位于 AbstractLoadBalance#getWeight

 /**
     * Get the weight of the invoker's invocation which takes warmup time into account
     * if the uptime is within the warmup time, the weight will be reduce proportionally
     *
     * @param invoker    the invoker
     * @param invocation the invocation of this invoker
     * @return weight
     */
    int getWeight(Invoker<?> invoker, Invocation invocation) {
        int weight;
        URL url = invoker.getUrl();
        // Multiple registry scenario, load balance among multiple registries.
        if (REGISTRY_SERVICE_REFERENCE_PATH.equals(url.getServiceInterface())) {
            weight = url.getParameter(REGISTRY_KEY + "." + WEIGHT_KEY, DEFAULT_WEIGHT);
        } else {
            weight = url.getMethodParameter(invocation.getMethodName(), WEIGHT_KEY, DEFAULT_WEIGHT);
            if (weight > 0) {
                //获取服务启动时间 timestamp
                long timestamp = invoker.getUrl().getParameter(TIMESTAMP_KEY, 0L);
                if (timestamp > 0L) {
                    //使用当前时间减去服务提供者启动时间,计算服务提供者已运行时间 `uptime`
                    long uptime = System.currentTimeMillis() - timestamp;
                    if (uptime < 0) {
                        return 1;
                    }
                    //获取服务预热时间基数,默认是10分钟
                    int warmup = invoker.getUrl().getParameter(WARMUP_KEY, DEFAULT_WARMUP);
                    //如果服务启动时间 小于 warmup 则重新计算权重
                    if (uptime > 0 && uptime < warmup) {
                        //根据已运行时间动态计算服务预热过程的权重
                        weight = calculateWarmupWeight((int)uptime, warmup, weight);
                    }
                }
            }
        }
        return Math.max(weight, 0);
    }

下面看下计算权重算法

 /**
     * Calculate the weight according to the uptime proportion of warmup time
     * the new weight will be within 1(inclusive) to weight(inclusive)
     *
     * @param uptime the uptime in milliseconds
     * @param warmup the warmup time in milliseconds
     * @param weight the weight of an invoker
     * @return weight which takes warmup into account
     */
    static int calculateWarmupWeight(int uptime, int warmup, int weight) {
        int ww = (int) ( uptime / ((float) warmup / weight));
        return ww < 1 ? 1 : (Math.min(ww, weight));
    }

这里计算方式其实很简单, 简单来说服务运行时间越久,权重越高,直到uptime = warmup时,恢复正常权重weight.

在默认情况下(Dubbo服务默认权重100, 预热时间10分钟)

假如服务提供者已运行 1 分钟,那么 weight 最终结果为 10 。

假如服务提供者已运行 5 分钟,那么 weight 最终结果为 50 。

假如服务提供者已运行 11 分钟,超过默认预热时间的阈值 10分 钟,那么将不会再计算,直接返回 weight 默认权重。

温馨提示: 负载均衡策略 consistenthash(一致性Hash) 不支持服务预热 。

相关配置

dubbo:
    provider:
         warmup: 600000 # 单位毫秒 默认10分钟
  1. 延迟暴露

某些外部容器(比如tomcat)在未完全启动完毕之前,对于dubbo service的调用会存在阻塞,导致consumer端timeout,这种情况在发布的时候有一定概率会发生。为了避免这个问题,设置一定的延时时间(保证在tomcat启动完毕之后)就可以做到平滑发布。

dubbo延迟暴露在源码中主要体现在ServiceBean类和它的父类ServiceConfig# export中。

 public synchronized void export() {
        //是否已经暴露
        if (!shouldExport()) {
            return;
        }

        if (bootstrap == null) {
            bootstrap = DubboBootstrap.getInstance();
            bootstrap.init();
        }

        checkAndUpdateSubConfigs();

        //init serviceMetadata
        serviceMetadata.setVersion(version);
        serviceMetadata.setGroup(group);
        serviceMetadata.setDefaultGroup(group);
        serviceMetadata.setServiceType(getInterfaceClass());
        serviceMetadata.setServiceInterfaceName(getInterface());
        serviceMetadata.setTarget(getRef());

        if (shouldDelay()) { //是否需要延迟暴露
            DELAY_EXPORT_EXECUTOR.schedule(this::doExport, getDelay(), TimeUnit.MILLISECONDS);
        } else {
            //真正执行服务暴露的方法
            doExport();
        }

        exported();
    }

从以上代码可以看出, Dubbo是使用了一个 schedule delay task ,延迟执行 doExport。

延迟暴露时序图如下:

相关配置

dubbo:
    provider:
         delay: 5000 # 默认null不延迟, 单位毫秒
  1. 其它

解决完这些,重启服务还是有大量超时,通过排查客户端日志发现。

/XXX:57330 -> /XXXX:20880 is established., dubbo version: 2.7.7, current host: XXXX
2021-09-07 15:01:07.748 [NettyClientWorker-1-16] INFO  o.a.d.r.t.netty4.NettyClientHandler   -  [DUBBO] The connection of /XXXX:57332 -> /XXXX:20880 is established., dubbo version: 2.7.7, current host: XXXX

# 简单统计一下发现 客户端启动时建立了3600个长连接
$ less /u01/logs/order-service-api_XXX/dubbo.log  | grep NettyClientWorker- |grep  '2021-09-07 15' | wc -l
3600

带着这个疑问,查看源码发现。

DubboProtocol#getClients

private ExchangeClient[] getClients(URL url) {
        boolean useShareConnect = false;

        //获取配置连接数, 如果没有配置默认0
        int connections = url.getParameter(CONNECTIONS_KEY, 0);
        List<ReferenceCountExchangeClient> shareClients = null;
        // if not configured, connection is shared, otherwise, one connection for one service
        if (connections == 0) {
            //注意: 如果Provider 配置了connections, 就不会使用共享连接,Consumer就算配置了shareConnections也不会生效
            useShareConnect = true;

            /*
             * The xml configuration should have a higher priority than properties.
             */
            String shareConnectionsStr = url.getParameter(SHARE_CONNECTIONS_KEY, (String) null);
            connections = Integer.parseInt(StringUtils.isBlank(shareConnectionsStr) ? ConfigUtils.getProperty(SHARE_CONNECTIONS_KEY,
                    DEFAULT_SHARE_CONNECTIONS) : shareConnectionsStr);
            shareClients = getSharedClient(url, connections);
        }

        ExchangeClient[] clients = new ExchangeClient[connections];
        for (int i = 0; i < clients.length; i++) {
            if (useShareConnect) {
                clients[i] = shareClients.get(i);

            } else {
                //初始化创连接
                clients[i] = initClient(url);
            }
        }

        return clients;
    }

问题就在于,我们服务端配置了

dubbo:
  provider:
    connections: 200

解释下上面代码,如果没有配置 connections,  就会使用共享连接, 共享连接个数由Consumer 配置 shareconnections 个数决定,默认 1个,  反之, 如果配置了connections, 就会给每一个service 建立 connections个数长连接。

下面我们再看看 initClient 过程

initClient(URL url) {

        // client type setting.
        String str = url.getParameter(CLIENT_KEY, url.getParameter(SERVER_KEY, DEFAULT_REMOTING_CLIENT));

        url = url.addParameter(CODEC_KEY, DubboCodec.NAME);
        // enable heartbeat by default
        url = url.addParameterIfAbsent(HEARTBEAT_KEY, String.valueOf(DEFAULT_HEARTBEAT));

        // BIO is not allowed since it has severe performance issue.
        if (str != null && str.length() > 0 && !ExtensionLoader.getExtensionLoader(Transporter.class).hasExtension(str)) {
            throw new RpcException("Unsupported client type: " + str + "," +
                    " supported client type is " + StringUtils.join(ExtensionLoader.getExtensionLoader(Transporter.class).getSupportedExtensions(), " "));
        }

        ExchangeClient client;
        try {
            // 是否配置了懒加载
            if (url.getParameter(LAZY_CONNECT_KEY, false)) {
                client = new LazyConnectExchangeClient(url, requestHandler);

            } else {
                //没有配置懒加载会初始化长连接
                client = Exchangers.connect(url, requestHandler);
            }

        } catch (RemotingException e) {
            throw new RpcException("Fail to create remoting client for service(" + url + "): " + e.getMessage(), e);
        }

        return client;
    }

从以上代码可以看出, 如果没有配置懒加载,会直接初始化长连接。也就是说,每当我们消费端重启,会建立 Service个数 * 200 * 服务端docker服务数 个长连接。我们service个数是3, docker服务个数6  刚好是3600个长连接。

那么,服务端重启呢,服务端重启 ZK 会通知到消费端(大概60台docker服务), 都会和新启动的docker服务建立连接,一个消费端建立 200 * 3, 那么总共会建立 36000 个长连接。

由此可知,每次服务重启,都需要建立大量长连接,导致建连耗时时间特别长(大致计算了下,大概10s)。

优化:  将连接池数量改小,经过压测, 配置 2 就够用了。

dubbo:
  provider:
    connections: 2

当然也可以服务端默认不配置,由消费端决定长连接个数。当需要长连接较多时,可以使用懒加载。服务端重启瞬间建立长连接总数建议不超过500。解决以上问题之后,重启超时问题终于算是解决了。

总结

Dubbo优雅重启问题,算是踩了个大坑,同时也说明了参数配置要知其所以然的重要性,不然可能导致不可预料的问题。

另外我们还踩了个线程池的坑,这个下篇文章再做介绍。

关注我,不迷路,欢迎点赞收藏。

猜你喜欢

转载自blog.csdn.net/weixin_38130500/article/details/120279023