apollo线上问题的分析

最近发生了一个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进行长轮询,而该方法执行了什么呢?

csharp 复制代码
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秒间隔进行不断重试
scss 复制代码
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();
        }
    }
}
ini 复制代码
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服务端的最新配置。

scss 复制代码
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次,如果再重试失败则打印异常日志
ini 复制代码
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进行判断,不再直接拉取全量数据?

(本文作者:柳健强)

关注公众号「哈啰技术」,第一时间收到最新技术推文。

相关推荐
lucifer3112 小时前
线程池与最佳实践
java·后端
程序员大金3 小时前
基于SSM+Vue+MySQL的酒店管理系统
前端·vue.js·后端·mysql·spring·tomcat·mybatis
程序员大金3 小时前
基于SpringBoot的旅游管理系统
java·vue.js·spring boot·后端·mysql·spring·旅游
Pandaconda3 小时前
【计算机网络 - 基础问题】每日 3 题(十)
开发语言·经验分享·笔记·后端·计算机网络·面试·职场和发展
程序员大金4 小时前
基于SpringBoot+Vue+MySQL的养老院管理系统
java·vue.js·spring boot·vscode·后端·mysql·vim
customer084 小时前
【开源免费】基于SpringBoot+Vue.JS网上购物商城(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
Ylucius4 小时前
JavaScript 与 Java 的继承有何区别?-----原型继承,单继承有何联系?
java·开发语言·前端·javascript·后端·学习
ღ᭄ꦿ࿐Never say never꧂5 小时前
微服务架构中的负载均衡与服务注册中心(Nacos)
java·spring boot·后端·spring cloud·微服务·架构·负载均衡
.生产的驴5 小时前
SpringBoot 消息队列RabbitMQ 消息确认机制确保消息发送成功和失败 生产者确认
java·javascript·spring boot·后端·rabbitmq·负载均衡·java-rabbitmq