最近发生了一个apollo带宽被打满的问题,因此看了一下apollo的部分设计和源码,本文针对发生的apollo带宽问题,聊聊apollo部分设计的理解。

问题现象

如下图所示:问题当天的15:20—16:20接近一个小时的时间,一直有db网络带宽的抖动,到了16:20网络带宽彻底打满,导致触发阿里云限流,导致apollo服务端整体不可用。

apollo配置是会缓存在客户端应用本地,因此服务端db的带宽增长肯定不是查询的apollo查询导致的,而是变更导致的,是什么配置变更会导致服务端db带宽增长如此巨大呢?

图片

apollo设计

客户端设计图

下面是一张官方的apollo客户端设计图,从设计图里面可以看出用户新增或者修改配置的流程如下:

  • 用户新增或者修改配置,将配置内容在apollo服务端进行更新
  • apollo客户端有两种方式进行配置的更新(推拉结合):主动进行配置更新的推送、定时拉取配置更新(兜底)
  • apollo客户端会将服务端的配置缓存在内存中
  • apollo客户端会将配置更新通知给应用程序
  • apollo客户端会将配置缓存到本地文件中(以便后续异常后从本地文件恢复)

图片

流程问题分析

从上面设计图可以看到,客户端更新配置到内存是服务器内部的内存写入,客户端从内存写入本地缓存是客户端服务内部IO,因此客户端配置更新不会导致服务端的db带宽抖动。因此问题出现在服务端的配置更新。下面我们就来看apollo服务端更新配置的逻辑。

1.apollo推拉结合推送
apollo将数据同步到客户端是通过推拉结合的方式,核心是两个类(RemoteConfigRepository、RemoteConfigLongPollService)。

推:即服务端将变更的配置主动推送给客户端(保障实时性)。而apollo的推送,则是通过长轮询实现的,核心的实现类为RemoteConfigLongPollService。

拉:即客户端定时访问服务端配置,检测配置是否更新,若更新,则拉取服务端最新配置(可理解为推送失败的兜底逻辑)。定时拉取则是通过job定时(五分钟)去查询配置是否变更。核心实现类为RemoteConfigRepository。

2.长轮询
长轮询流程可以看下图所示,即apollo客户端在启动后,会发起一个http的长轮询,而apollo服务端会将该长轮询挂起,直到该长轮询对应的配置出现了变更,则会通知给客户端,让客户端进行最新的配置拉取。

图片

具体源码如下所示:
RemoteConfigLongPollService类加载完后会执行startLongPolling。

以下是去除部分代码的的startLongPolling方法源码,可以看到startLongPolling调用了doLongPollingRefresh进行长轮询,而该方法执行了什么呢?

private void startLongPolling() {
    try {
      m_longPollingService.submit(new Runnable() {
        @Override
        public void run() {
          //调用长轮询方法
          doLongPollingRefresh(appId, cluster, dataCenter);
        }
      });
    } catch (Throwable ex) {
      m_longPollStarted.set(false);
      ApolloConfigException exception =
          new ApolloConfigException("Schedule long polling refresh failed", ex);
      Tracer.logError(exception);
      logger.warn(ExceptionUtil.getDetailMessage(exception));
    }
  }

以下是去除了部分代码的doLongPollingRefresh源码,可以看到:

  • 首先doLongPollingRefresh进行了一次http的长轮询
  • 如果服务端长轮询返回200,并且有数据,则代表服务端代码进行了更新,则调用notify方法进行客户端的配置更新
  • 如果服务端长轮询返回304,或者无数据,则代表没有更新
  • 若代码存在异常,则最外层的while循环会不断的进行重试,而重试的逻辑在com.ctrip.framework.apollo.core.schedule.ExponentialSchedulePolicy的fail方法中,可以看到按照2的倍数进行重试,即2秒,4秒,8秒,16秒以此类推,直到达到最大的重试时间120秒,后续重试间隔不再变大,按照120秒间隔进行不断重试
private void doLongPollingRefresh(String appId, String cluster, String dataCenter) {
    while (!m_longPollingStopped.get() && !Thread.currentThread().isInterrupted()) {
        Transaction transaction = Tracer.newTransaction("Apollo.ConfigService", "pollNotification");
        String url = null;
        try {
            //执行长轮询
            url =
            assembleLongPollRefreshUrl(lastServiceDto.getHomepageUrl(), appId, cluster, dataCenter,
                                       m_notifications);
            HttpRequest request = new HttpRequest(url);
            request.setReadTimeout(LONG_POLLING_READ_TIMEOUT);

            transaction.addData("Url", url);

            final HttpResponse<List<ApolloConfigNotification>> response =
            m_httpUtil.doGet(request, m_responseType);

            logger.debug("Long polling response: {}, url: {}", response.getStatusCode(), url);
            if (response.getStatusCode() == 200 && response.getBody() != null) {
                updateNotifications(response.getBody());
                updateRemoteNotifications(response.getBody());
                transaction.addData("Result", response.getBody().toString());
                notify(lastServiceDto, response.getBody());
            }

            //try to load balance
            if (response.getStatusCode() == 304 && random.nextBoolean()) {
                lastServiceDto = null;
            }

            m_longPollFailSchedulePolicyInSecond.success();
            transaction.addData("StatusCode", response.getStatusCode());
            transaction.setStatus(Transaction.SUCCESS);
        } catch (Throwable ex) {
            long sleepTimeInSecond = m_longPollFailSchedulePolicyInSecond.fail();
            try {
                TimeUnit.SECONDS.sleep(sleepTimeInSecond);
            } catch (InterruptedException ie) {
                //ignore
            }
        } finally {
            transaction.complete();
        }
    }
}
public long fail() {
    long delayTime = this.lastDelayTime;
    if (delayTime == 0L) {
        delayTime = this.delayTimeLowerBound;
    } else {
        //delayTimeUpperBound为120
        delayTime = Math.min(this.lastDelayTime << 1, this.delayTimeUpperBound);
    }

    this.lastDelayTime = delayTime;
    return delayTime;
}

长轮询整体流程

长轮询的整体流程如下所示:

  • apollo客户端启动后会通过RemoteConfigLongPollService类发起一个长轮询(超时90秒),调用apollo服务端的notifications/v2接口,apollo服务端会将长轮询挂起
  • 如果有配置变更,apollo服务端会通知客户端存在配置变更
  • apollo客户端的RemoteConfigLongPollService类接收到变更通知,会调用RemoteConfigRepository进行配置变更的同步

图片

1.定时拉取
具体源码如下所示:
RemoteConfigRepository加载完毕后会执行schedulePeriodicRefresh方法,该方法设置可定时任务的间隔为5分钟执行同步数据的trySync()方法。trySync方法会执行sync()逻辑,然后sync()方法会执行loadApolloConfig()方法加载apollo服务端的最新配置。

private void schedulePeriodicRefresh() {
    //定时拉取,间隔时间为5分钟
    m_executorService.scheduleAtFixedRate(
        new Runnable() {
          @Override
          public void run() {
            trySync();
          }
        }, m_configUtil.getRefreshInterval(), m_configUtil.getRefreshInterval(),
        m_configUtil.getRefreshIntervalTimeUnit());
  }

方法的省略代码如下所示:

  • 首先loadApolloConfig进行了一次的请求
  • 如果服务端长轮询返回304,或者无数据,则代表没有更新,则直接返回
  • 如果服务端不是返回304,则代表有更新,则返回更新的appID,namespace、cluster等信息
  • 若代码存在异常,则最外层的for循环会进行间隔1秒的重试,重试的逻辑为重试2次,如果再重试失败则打印异常日志
private ApolloConfig loadApolloConfig() {
   
    int maxRetries = m_configNeedForceRefresh.get() ? 2 : 1;
    long onErrorSleepTime = 0; // 0 means no sleep
    Throwable exception = null;

    List<ServiceDTO> configServices = getConfigServices();
    String url = null;
    //异常的重试,最多2次,
    for (int i = 0; i < maxRetries; i++) {
      List<ServiceDTO> randomConfigServices = Lists.newLinkedList(configServices);
      Collections.shuffle(randomConfigServices);
      if (m_longPollServiceDto.get() != null) {
        randomConfigServices.add(0, m_longPollServiceDto.getAndSet(null));
      }

      for (ServiceDTO configService : randomConfigServices) {
        if (onErrorSleepTime > 0) {
          try {
            m_configUtil.getOnErrorRetryIntervalTimeUnit().sleep(onErrorSleepTime);
          } catch (InterruptedException e) {
            //ignore
          }
        url = assembleQueryConfigUrl(configService.getHomepageUrl(), appId, cluster, m_namespace,
                dataCenter, m_remoteMessages.get(), m_configCache.get());

        HttpRequest request = new HttpRequest(url);

        try {
          //请求apollo服务端是否存在变更
          HttpResponse<ApolloConfig> response = m_httpUtil.doGet(request, ApolloConfig.class);
          m_configNeedForceRefresh.set(false);
          m_loadConfigFailSchedulePolicy.success();

          transaction.addData("StatusCode", response.getStatusCode());
          transaction.setStatus(Transaction.SUCCESS);

          if (response.getStatusCode() == 304) {
            logger.debug("Config server responds with 304 HTTP status code.");
            return m_configCache.get();
          }

          ApolloConfig result = response.getBody();
          //返回变更配置的namespace、cluster、appID等信息
          return result;
        } catch (ApolloConfigStatusCodeException ex) {
          ApolloConfigStatusCodeException statusCodeException = ex;
          transaction.setStatus(statusCodeException);
        } catch (Throwable ex) {
          transaction.setStatus(ex);
        } finally {
          transaction.complete();
        }
        //异常重试间隔,1秒钟
        onErrorSleepTime = m_configNeedForceRefresh.get() ? m_configUtil.getOnErrorRetryInterval() :
        m_loadConfigFailSchedulePolicy.fail();
      }

    }

  }

2.定时任务流程

图片

定时任务流程较为简单,apollo客户端的定时任务每隔五分钟会进行一次调用,拉取最新变化的配置进行更新。

3.总结

  • 配置更新有两种方式,定时任务每隔五分钟的拉取和90秒钟的长轮询
  • 每隔五分钟的拉取会按照namespace维度,拉取变化的kv对应的整个namespace配置,其失败重试机制为失败重试两次
  • 90秒钟的长轮询会有两个交互
    a. 先通过notifications/v2的接口,判断是否存在配置变更,该长轮询接口只返回存在变更的namespace,不返回具体的配置信息
    b. 如果存在配置变更,则进行namespace维度的配置同步
    c. 长轮询的失败会按照即2秒,4秒,8秒,16秒.......120秒,120秒进行重试

问题点

根据上述总结,可以基本得出问题点:

  • 首先,配置的更新会按照namespace维度去apollo服务端拉取,而每次apollo服务端会从db拉取namespace的数据,若单个namespace有1000个key,每个key有1K,则一个namespace的大小为1M左右。若Apollo客户端有200台机器,则每次配置更新会有200MB的db带宽访问
  • 5分钟的定时拉取虽然只有两次重试,但是每隔五分钟就会按照namespace维度请求全量配置
  • 90秒的长轮询失败会一直进行重试

结合上诉问题点和数据库慢查询,以及apollo的变更情况,基本可以得出问题出现的原因:

  • 之前完成了大促的最后一次压测,大家都对应用进行了扩容
  • 每台机器相当于一个apollo的客户端,由于扩容导致apollo客户端数量大大增加
  • 当天15:20-16:20这段时间,部分机器较多的应用,同时更新了apollo配置,且部分apollo配置的namespace较大,则会出现db带宽的异常升高
  • 长时间的db带宽抖动,加上更多的大namespace配置变更,则会引起db带宽限流,导致长轮询和定时拉取逻辑失败
  • 长轮询失败会不断进行重试,定时任务也会不断进行同步,导致整个apollo服务端宕机

优化

针对apollo的上述问题,是否存在优化点?以下是我的部分想法:

  • 如:长轮询和定时任务加上失败重试次数,如果一定时间内超过一定次数,则认为服务端宕机,不再请求?
  • 如:配置的变更同步不按照namespace维度进行同步,按照key维度进行同步?
  • 如:数据库新增md5字段,定时任务判断配置是否变化可以根据服务端缓存文件的md5和数据库的md5进行判断,不再直接拉取全量数据?

(本文作者:柳健强)

image.png


哈啰技术
89 声望53 粉丝

哈啰官方技术号,不定期分享哈啰的相关技术产出。