搜狐技术产品

浅谈Nacos获取配置两次调优经历

01

Nacos简介

‌Nacos‌(Dynamic Naming and Configuration Service)是一个开源的动态服务发现、配置管理和服务管理平台,由阿里巴巴集团开发并维护。Nacos致力于帮助用户发现、配置和管理微服务,通过它提供的简单易用的特性集,能够快速实现动态服务发现、服务配置、服务元数据及流量管理。‌例如Nacos支持配置的动态更新,服务无需重新部署上线即可获取最新配置,提升了系统的灵活性和响应速度;在多环境配置方面,它提供的Namespace和Group机制,便于管理不同的生产环境(例如测试、预上线和生产环境,每一个环境可通过Namespace和Group机制对同一配置项进行不同的配置)的配置、简化多环境配置管理;Nacos还支持多种配置格式(如Properties、YAML、JSON等)和流量控制,满足不同的开发需求。Nacos凭借如上这些优势,在配置管理领域是一种理想的选择。

02

Nacos配置中心在实际项目中的应用

Nacos目前广泛的应用在我们的项目中,并且多数是作为配置管理来应用的,例如管理黑白名单的变更、灰度上线、一些开关的设置等。例如,在2023年北显机房下线、金山云替换北显机房的过程中,Nacos在我们的服务中发挥了巨大的作用。因历史和架构原因,我们的多个服务存在共用同一redis实例的情况,为最大程度避免redis数据不一致的情况,我们在共用redis的服务中引入同一配置项作为切换的开关(前提是北显机房的redis数据已“实时地”向金山云机房同步),当开关关闭时,所有服务读写北显机房的redis。当开关打开后,所有服务读写金山云机房的redis。这相当于做到了所有服务“同时”从读写北显机房的redis切换到读写金山云机房的redis,观察服务没有问题后,移除开关,实现了机房平稳替换。

Nacos作为配置中心并且实际应用的过程中,我们共发现了两次问题,这两个问题都是在流量比较大的时候出现的,下面我们分别来讲述两个问题的表现以及我们是如何针对这两个问题进行调优的。希望通过这两次配置调优的经历,为大家提供一些可选的配置调优方案,以期未来大家在遇到类似问题时,可以尝试我们的解决方案。

NacosClient获取Config的常规流程

优化前客户端获取配置的流程图:

Image

03

两次调优经历

为了避免因服务器迁移导致的一些文件路径问题,我们未配置Nacos本地文件路径(即我们没有指定user.home属性),故而在起初,我们获取某个dataId配置的时候,都是实时从Server端拉取,也就是在上图中我们没有本地缓存的文件。

Image
Image

3.1 第一次调优

从流程图可以看到,我们服务获取配置都是实时从Nacos Server端拉取,当服务的请求量较大(请求量大,但未被Nacos拦截器拦截住)时,虽然可以获取到配置,但是服务报了很多超时,因为获取配置这一步的耗时就已经接近或者超过程序里设定的超时时间(400毫秒)。相关代码如下所示:

public static String getConfig(String dataId) {
   try {
return configService.getConfig(dataId, NACOS_GROUP, 400);
   } catch (Throwable e) {
     LOGGER.error("getConfig happens error, dataId = {}, group = {} ", dataId, NACOS_GROUP, e);
   }
return null;
 }

我们的一个数字气泡计算服务灰度上线后,发现了很多关于"getConfig happens error, "的超时错误日志。经排查和讨论,我们认为,每一个服务的配置项并不是很多,并且配置项多数不会频繁的变更,即便配置项发生改变,延迟几秒(Nacos配置变化生效时间几乎都是毫秒级)对服务也无影响,综上,我们用机器内存缓存配置来解决这一超时问题。针对还可能出现的超时情况,我们采用了兜底的方式(兜底的代码几乎不会执行到,但为了安全起见,我们还是为每一个配置项设置了一个兜底的“值”)。

▲ 第一次优化:内存缓存+兜底配置

我们在服务中为各个配置项注册监听器,当配置项发生变更时,我们将已获取到的新的配置configInfo放入内存Map中,如下图所示:

Image

通过这种方式,服务在实际获取配置的时候,优先从内存缓存中获取,如果获取不到再从服务器拉取:

public static String getConfig(String dataId) {
    try {
        // 优先从map中获取
        String value = cacheMap.get(dataId);
if (StringUtils.isBlank(cacheMap.get(dataId))) {
            // 如果map中没有此配置项,则从服务端拉取,拉取后再放入map中
            value = configService.getConfig(dataId, NACOS_GROUP, 100);
            cacheMap.put(dataId, value);
        }

return value;
    } catch (Throwable e) {
        LOGGER.error("getConfig happens error, dataId = {}, group = {} ", dataId, NACOS_GROUP, e);
    }
return null;
}

如果内存缓存里没有此配置项、并且请求服务端获取此配置项时超时的话,就用兜底的配置值(这里需要注意的是,兜底的配置值可能需要不断的调整以备不时之需),相关代码如下所示:

public static boolean getSwitch() {
    try {
        // 优先从内存缓存里或者服务端拉取配置
        String config = getConfig(SWITCH_DATAID);
if (StringUtils.isNotBlank(config)) {
return"1".equalsIgnoreCase(config);
        }
    } catch (Exception e) {
        LOGGER.error("Error", e);
    }

    // 兜底值
returnfalse;
}

这样通过内存缓存和兜底的方式,解决了请求Nacos服务端获取配置超时这一问题。

第一次优化后客户端获取配置的流程图总结如下:

Image

3.2 第二次调优

经过第一次的调优,Nacos作为配置在我们服务中一直运行的很好。但有一次我们的一个服务在灰度上线重启时报了一些关于Nacos的错误日志,等这台灰度上线的机器“稳定”后,相关的Nacos报错日志也就停止了。初步排查我们发现,这些报错导致其对应请求在获取Nacos配置时走了兜底,虽然并未造成请求错误,但依赖于兜底,在未来可预见的时间内可能“引发”一些未知的问题,所以我们决定继续排查并解决此问题。

经过各种排查和分析,我们发现服务刚启动后有并发的流量进来,导致部分请求流量在获取配置的时候被Nacos拦截器拦截住(获取配置的qps超过了server端的限流阈值),以至获取不到最新的配置,报错日志如下:

15:47:49.708 [nioEventLoopGroup-3-7] ERROR [traceId:0f9bb096-fb01-4251-85f0-e5eb9d747f55] (c.a.n.c.c.i.Limiter:79) - access_key_id:4396fea7a8e259f09e8022caf4105fbd limited

15:47:49.709 [nioEventLoopGroup-3-6] ERROR [traceId:d4d2e03d-0267-4a47-b9b0-7416aa1c0e5c] (c.a.n.c.c.i.Limiter:79) - access_key_id:4396fea7a8e259f09e8022caf4105fbd limited

15:47:49.710 [nioEventLoopGroup-3-7] ERROR [traceId:0f9bb096-fb01-4251-85f0-e5eb9d747f55] (c.a.n.c.c.i.ClientWorker:242) - [fixed-nacos01.recom.mrd.sohuno.com_8848-nacos02.recom.mrd.sohuno.com_8848-nacos03.recom.mrd.sohuno.com_8848-e9010bc6-8adc-41ee-a3ad-222d08892325] [sub-server-error] dataId=comment_aggregation_switch, group=online, tenant=e9010bc6-8adc-41ee-a3ad-222d08892325, code=-503

15:47:49.711 [nioEventLoopGroup-3-4] ERROR [traceId:085d14da-1b6e-4379-8297-3fb425795519] (c.a.n.c.c.i.Limiter:79) - access_key_id:4396fea7a8e259f09e8022caf4105fbd limited

15:47:49.711 [nioEventLoopGroup-3-4] ERROR [traceId:085d14da-1b6e-4379-8297-3fb425795519] (c.a.n.c.c.i.ClientWorker:242) - [fixed-nacos01.recom.mrd.sohuno.com_8848-nacos02.recom.mrd.sohuno.com_8848-nacos03.recom.mrd.sohuno.com_8848-e9010bc6-8adc-41ee-a3ad-222d08892325] [sub-server-error] dataId=comment_aggregation_switch, group=online, tenant=e9010bc6-8adc-41ee-a3ad-222d08892325, code=-503

15:47:49.711 [nioEventLoopGroup-3-6] ERROR [traceId:d4d2e03d-0267-4a47-b9b0-7416aa1c0e5c] (c.a.n.c.c.i.ClientWorker:242) - [fixed-nacos01.recom.mrd.sohuno.com_8848-nacos02.recom.mrd.sohuno.com_8848-nacos03.recom.mrd.sohuno.com_8848-e9010bc6-8adc-41ee-a3ad-222d08892325] [sub-server-error] dataId=comment_aggregation_switch, group=online, tenant=e9010bc6-8adc-41ee-a3ad-222d08892325, code=-503

原因分析:

根据报错日志,初步判定是从远端服务器拉取comment_aggregation_switch这个dataId配置的时候报错,我们阅读了Nacos获取配置相关的源码,从源码一步一步地追踪到了错误日志出现的地方,相关分析过程如下:

首先,我们通过getConfig方法获取comment_aggregation_switch这个dataId的配置值:

public static boolean getCommentAggregationSwitch() {
    try {
        String config = getConfig(COMMENT_AGGREGATION_SWITCH);
if (StringUtils.isNotBlank(config)) {
return"1".equalsIgnoreCase(config);
        }
    } catch (Exception e) {
        LOGGER.error("getCommentAggregationSwitch error", e);
    }
returnfalse;
}

在获取comment_aggregation_switch这个dataId配置时,因为机器内存中没有此配置,所以通过configService对象的getConfig接口远程拉取:

public static String getConfig(String dataId) {
    try {
        String value = cacheMap.get(dataId);
if (StringUtils.isBlank(cacheMap.get(dataId))) {
            value = configService.getConfig(dataId, NACOS_GROUP, 100);
            cacheMap.put(dataId, value);
        }

return value;
    } catch (Throwable e) {
        LOGGER.error("getConfig happens error, dataId = {}, group = {} ", dataId, NACOS_GROUP, e);
    }
return null;
}

@Override
public String getConfig(String dataId, String group, long timeoutMs) throws NacosException {
return getConfigInner(namespace, dataId, group, timeoutMs);
}

而configService对象的getConfig接口会调用getConfigInner方法,在getConfigInner方法种,我们找到了出错的地方。因为我们没有为Nacos配置LOCAL_SNAPSHOT_PATH(即我们没有指定user.home属性),所以跳过本地检查那一步,也就是不会优先使用本地配置。那么不使用本地缓存配置,或缓存已过期,这些都会向Nacos服务端发起请求来获取配置:

private String getConfigInner(String tenant, String dataId, String group, long timeoutMs) throws NacosException {
    group = blank2defaultGroup(group);
    ParamUtils.checkKeyParam(dataId, group);
    ConfigResponse cr = new ConfigResponse();

    cr.setDataId(dataId);
    cr.setTenant(tenant);
    cr.setGroup(group);

    // 优先使用本地配置
    String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant);
if (content != null) {
        LOGGER.warn("[{}] [get-config] get failover ok, dataId={}, group={}, tenant={}, config={}", agent.getName(),
                dataId, group, tenant, ContentUtils.truncateContent(content));
        cr.setContent(content);
        String encryptedDataKey = LocalEncryptedDataKeyProcessor
                .getEncryptDataKeyFailover(agent.getName(), dataId, group, tenant);
        cr.setEncryptedDataKey(encryptedDataKey);
        configFilterChainManager.doFilter(null, cr);
        content = cr.getContent();
return content;
    }

    try {
        ConfigResponse response = worker.getServerConfig(dataId, group, tenant, timeoutMs);
        cr.setContent(response.getContent());
        cr.setEncryptedDataKey(response.getEncryptedDataKey());

        configFilterChainManager.doFilter(null, cr);
        content = cr.getContent();

return content;
    } catch (NacosException ioe) {
if (NacosException.NO_RIGHT == ioe.getErrCode()) {
            throw ioe;
        }
        LOGGER.warn("[{}] [get-config] get from server error, dataId={}, group={}, tenant={}, msg={}",
                agent.getName(), dataId, group, tenant, ioe.toString());
    }

    LOGGER.warn("[{}] [get-config] get snapshot ok, dataId={}, group={}, tenant={}, config={}", agent.getName(),
            dataId, group, tenant, ContentUtils.truncateContent(content));
    content = LocalConfigInfoProcessor.getSnapshot(agent.getName(), dataId, group, tenant);
    cr.setContent(content);
    String encryptedDataKey = LocalEncryptedDataKeyProcessor
            .getEncryptDataKeyFailover(agent.getName(), dataId, group, tenant);
    cr.setEncryptedDataKey(encryptedDataKey);
    configFilterChainManager.doFilter(null, cr);
    content = cr.getContent();
return content;
}

在ConfigResponse response = worker.getServerConfig(dataId, group, tenant, timeoutMs);这一步,执行getServerConfig方法、http请求Nacos服务端获取具体的配置值,相关截图如下:

Image

在getServerConfig方法中,会调用agent(ServerHttpAgent)的httpGet方法去获取配置结果:

Image

在agent(ServerHttpAgent)的httpGet方法中,会继续调用NACOS_RESTTEMPLATE的get方法:

Image

在NACOS_RESTTEMPLATE的get方法中,执行execute方法,这里即将和Nacos的拦截器打交道:

Image

执行this.requestClient()的execute方法、在经过Nacos服务端拦截器的时候,判断是否被拦截器拦截住时返回了:

Image

在isIntercept方法中,Nacos拦截器里判断qps是否超过了限流器阈值,即Limiter.isLimit方法返回true:

Image

我们看到,Nacos服务端设置某个accessKeyId默认访问的qps最大不能超过5(limit=5):

Image

因为limit=5这个阈值限制,加上我们服务在启动后就有大量的流量进来,存在qps>5的情况,所以在此处qps>5的并发访问流量被限制住,isLimit方法返回true:

Image
Image
Image

因isLimit方法返回true,以及LimitResponse的statusCode是-503,所以agent(ServerHttpAgent)的httpGet在如下这一步返回(没有走进isFail方法里):

Image

从而getServerConfig实现类里,在判断请求结果时,因result的getCode()返回是-503,所以default处打印了错误日志(我们服务报错的日志),并抛了异常:

Image

在经过如上的分析后,针对被Nacos拦截器拦截、超过server端限流器阈值这一问题,我们初步制定出了两种解决方案,如下所示:

▲ 解决方案1:增大server端限流器阈值

我们第一时间能想到的解决方案就是增大limit的值,也就是要增加limitTime这一配置或者更改这一配置的值,然后Nacos在读取该值的时候就会用新的限流阈值(Nacos获取阈值是通过System.getProperty("limitTime", String.valueOf(limit))来获取的)。但是我们想到了一些潜在的问题,首先问题一:限流阈值增加到多少合适;问题二:Nacos服务器是否可以抗住;问题三:增大这个阈值是否会带来额外的问题?

我们初步想将limitTime的值设置为10,也就是翻一倍。但是如果并发流量要是大于10怎么办,这个值需要再增加到多少合适?再增加后Nacos服务器是否可以抗的住,即便抗的住是否会有一些未知的其它问题。最后,经过组内的评测,我们觉得此种更改可能风险比较大,我们需要一种更适合的解决方案。

▲ 解决方案2:服务启动后、流量进来前先获取配置到内存以及增加监听器

在经过第一次调优后,我们的服务是在服务启动后、流量进来前只监听,并没有先获取配置,代码如下:

public static void addListener(String dataId, String group) {
    // 监听配置
    try {
        configService.addListener(dataId, group, new PropertiesListener() {
            @Override
            public void innerReceive(Properties properties) {
            }
            @Override
            public void receiveConfigInfo(String configInfo) {
            }
        });
    } catch (Throwable e) {
        LOGGER.error("addListener happens error, dataId = {}, group = {} ", dataId, group, e);
    }
}

我们发现Nacos在作为配置管理时,有自带的getConfigAndSignListener方法,此方法是可以先获取配置再去注册监听的,我们尝试使用这一方法,修改后代码如下:

String config = configService.getConfigAndSignListener(dataId, group, 1000,
   new PropertiesListener() {
     @Override
     public void innerReceive(Properties properties) {
     }

     @Override
     public void receiveConfigInfo(String configInfo) {
       cacheMap.put(dataId, configInfo);
     }
   });
if (StringUtils.isNotEmpty(config)) {
   cacheMap.put(dataId, config);
 }

这样在服务启动时首先会拉取一次配置并放到内存,之后如果有并发的流量进来,都可以从内存中获取。为了验证这一改动是否有效,我们在UAT环境上模拟报问题那台机器上的请求,也就是使并发请求Nacos获取配置的qps>5,经过了多次验证,我们没有再发现报错。此种解法相较于第一种解决方案,对于我们来讲算是近似最优解。变更代码、测试验证、灰度、上线后,再未发现此问题。

第二次优化后获取配置相当于拆分为两个“步骤”,步骤一是在应用程序初始化时:

Image

步骤二是在程序运行时需要获取配置:

Image

04

总结

Nacos作为配置中心在我们项目中发挥了重要作用,在实际使用的过程中,我们共遇到了两个问题,本篇文章介绍了两个问题的表现并简单从源码层面分析了第二个问题出现的原因,之后给出了对应的解决方案。我们希望通过这两次配置调优的经历,为大家提供一些可选的配置调优方案,以期未来大家在遇到类似问题时,可以尝试应用这两种解决方法来解决实际的问题。

在SpringBoot的项目中,可以通过以下方式配置Nacos:

Image

也可以不通过配置方式直接在项目中初始化:

Image

综上,目前我们认为引入Nacos组件最佳的实践方式是:

1.服务启动时首先获取配置并且监听配置的改变;

2.将获取到的配置缓存到本地内存中;

3.可根据需要适当增大限流器阈值limitTime,但不建议更改此参数;

4.可根据需要适当增大从服务器拉取配置的超时时间,目前内网环境下我们设置的是100毫秒,可以重试;

5.做好相关错误日志的打印及报警工作;

6.为每一个配置项增加一个兜底值。