cover_image

apollo线上问题的分析

柳健强 哈啰技术
2023年11月09日 09:30

图片

最近发生了一个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()方法的省略代码如下所示:

  • 首先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秒钟的长轮询会有两个交互

  1. 先通过notifications/v2的接口,判断是否存在配置变更,该长轮询接口只返回存在变更的namespace,不返回具体的配置信息

  2. 如果存在配置变更,则进行namespace维度的配置同步

  3. 长轮询的失败会按照即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进行判断,不再直接拉取全量数据?



The End

轻轻点手指,

1「在看」

2

👍滴。

图片

继续滑动看下一个
哈啰技术
向上滑动看下一个