最近发生了一个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进行判断,不再直接拉取全量数据?
(本文作者:柳健强)
关注公众号「哈啰技术」,第一时间收到最新技术推文。