Nacos 可观测性建设

此代码更新已被合并进 alibaba/nacos 主分支,详见:

https://github.com/alibaba/nacos/commit/03345fd923c79ba2ca749076ce9a8dc38a496594

统一指标注册中心

Nacos 原有的指标均注册于 micrometer 提供的 Metrics.globalRegistry 中,考虑到未来可能有对指标分为不同的监控级别暴露以及分类管理的需求,创建统一指标注册中心 NacosMeterRegistryCenter,在其中用一个 ConcurrentHashMap 管理多个 micrometer 中定义的 CompositeMeterRegistry,对指标进行分模块管理以及统一注册。

由于 TopN 等类型指标会频繁出现注册后再清除,重新注册新指标的情况,所以将此类动态指标独立于其余指标注册,方便清理和修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public final class NacosMeterRegistryCenter {

// stable registries.

public static final String CORE_STABLE_REGISTRY = "CORE_STABLE_REGISTRY";

public static final String CONFIG_STABLE_REGISTRY = "CONFIG_STABLE_REGISTRY";

public static final String NAMING_STABLE_REGISTRY = "NAMING_STABLE_REGISTRY";

// dynamic registries.

public static final String TOPN_CONFIG_CHANGE_REGISTRY = "TOPN_CONFIG_CHANGE_REGISTRY";

public static final String TOPN_SERVICE_CHANGE_REGISTRY = "TOPN_SERVICE_CHANGE_REGISTRY";

private static final ConcurrentHashMap<String, CompositeMeterRegistry> METER_REGISTRIES = new ConcurrentHashMap<>();

private static PrometheusMeterRegistry PROMETHEUS_METER_REGISTRY = null;

static {
try {
PROMETHEUS_METER_REGISTRY = ApplicationUtils.getBean(PrometheusMeterRegistry.class);
} catch (Throwable t) {
Loggers.CORE.warn("Metrics init failed :", t);
}
// ...
// init all MeterRegistry by PROMETHEUS_METER_REGISTRY
}

...

}

PrometheusMeterRegistry 是由 Spring 自动创建的 MeterRegistry 类,注册到其中的指标会被自动暴露到 /nacos/actuator/prometheus 中。只需要将 Spring 容器中的 PrometheusMeterRegistry 注册到一个 CompositeRegistry 下,以后注册到此 CompositeRegistry 就会暴露出来。

micrometer 的 Timer,Counter,DistributionSummary,Gauge 等类型的指标也统一经过 NacosMeterRegistryCenter 的方法来注册。

新增指标汇总

指标 含义
nacos_timer_seconds_count{module=”config”,name=”readConfigRt”} 读配置的次数
nacos_timer_seconds_sum{module=”config”,name=”readConfigRt”} 读配置的总时间
nacos_timer_seconds_count{module=”config”,name=”writeConfigRt”} 写配置的次数
nacos_timer_seconds_sum{module=”config”,name=”writeConfigRt”} 写配置的总时间
nacos_timer_seconds_count{module=”config”,name=”readConfigRpcRt”} 通过 Rpc 读配置的次数
nacos_timer_seconds_sum{module=”config”,name=”readConfigRpcRt”} 通过 Rpc 读配置的总时间
nacos_timer_seconds_count{module=”config”,name=”writeConfigRpcRt”} 通过 Rpc 写配置的次数
nacos_timer_seconds_sum{module=”config”,name=”writeConfigRpcRt”} 通过 Rpc 写配置的总时间
nacos_monitor{module=”naming”,name=”serviceSubscribedEventQueueSize”} 服务订阅事件的消息队列大小
nacos_monitor{module=”naming”,name=”serviceChangedEventQueueSize”} 服务变更事件的消息队列大小
nacos_monitor{module=”naming”,name=”pushPendingTaskCount”} 服务推送的消息队列大小
nacos_monitor{module=”naming”,name=”emptyPush”} 服务推送的推空(推送的服务没有有效实例)次数
nacos_config_subscriber{version=”v1”} Nacos 1.X 版本的配置订阅者数量
nacos_config_subscriber{version=”v2”} Nacos 2.X 版本的配置订阅者数量
nacos_naming_publisher{version=”v1”} Nacos 1.X 版本的服务发布者数量
nacos_naming_publisher{version=”v2”} Nacos 2.X 版本的服务发布者数量
nacos_naming_subscriber{version=”v1”} Nacos 1.X 版本的服务订阅者数量
nacos_naming_subscriber{version=”v2”} Nacos 2.X 版本的服务订阅者数量
nacos_monitor{module=”config”,name=”fuzzySearch”} 配置中心模糊查询次数
config_change_count{config=”@com.alibaba.nacos@nacos.example“} 配置 @@ 的变更次数(一周内)
service_change_count{config=”@com.alibaba.nacos@nacos.example“} 服务 @@ 的变更次数(一周内)
nacos_monitor{module=”core”,name=”longConnection”} Nacos 长连接数(此指标非新增,原 module 为 config,但实际为所有长连接数,包括 config 和 naming 模块,故修改为 core)

Grafana 效果展示(大盘仅供参考)

展示的 TopN 的 N 在代码中设置为 10。配置/服务数少于 N 时,展示所有配置/服务的变更次数。

image-20220824192317076

image-20220823235753600

image-20220824193248743

配置中心新增指标

配置订阅者版本分布

在 com.alibaba.nacos.config.server.monitor 中新建了 collector 包,新建 ConfigSubscriberMetricsCollector 类,从统一的线程池中发起每隔 DELAY_SECONDS(设置为5s)进行一次版本分布统计的定时任务,更新到暴露给 Prometheus 的指标中。

其中 v1 版本的配置订阅者直接从 LongPollingService 中的 allSubs 获取数量,v2 版本的配置订阅者则从 ConfigChangeListenContext 中为不同连接对象保存的 map 中获取连接数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class ConfigSubscriberMetricsCollector {

private static final long DELAY_SECONDS = 5;

@Autowired
public ConfigSubscriberMetricsCollector(LongPollingService longPollingService, ConfigChangeListenContext configChangeListenContext) {
ConfigExecutor.scheduleConfigTask(() -> {
MetricsMonitor.getConfigSubscriberMonitor("v1").set(longPollingService.getSubscriberCount());
MetricsMonitor.getConfigSubscriberMonitor("v2").set(configChangeListenContext.getConnectionCount());
}, DELAY_SECONDS, DELAY_SECONDS, TimeUnit.SECONDS);
}
}

配置中心模糊查询数量

在 config 的 MetricsMonitor 中新增 fuzzySearch 计数指标。在 com.alibaba.nacos.config.server.controller.ConfigController 中的 fuzzySearchConfig 处埋点,递增 fuzzySearch 指标。

仿照配置发布次数等指标,在 com.alibaba.nacos.config.server.monitor.MemoryMonitor 中也为其配置定时清零任务(每天)。

配置中心读写 RT

读 RT 做的是 get 配置的计时,写 RT 做的是 publish 配置的计时。

在 com.alibaba.nacos.config.server.monitor.MetricsMonitor 中新增了 readConfigRt, readConfigRpcRt, writeConfigRt, writeConfigRpcRt 四个指标,类型均为 Timer 。

在 com.alibaba.nacos.config.server.aspect.RequestLogAspect 的 AOP 切面中找到对应的切面,增加计时代码,记录到 Timer 指标中。

服务注册中心新增指标

服务提供/订阅者版本分布

类似配置中心订阅者版本分布,在 com.alibaba.nacos.naming.monitor 中新建了 collector 包,新建 NamingSubAndPubMetricsCollector 类,从统一的线程池中发起每隔 DELAY_SECONDS (设置为5s) 进行一次版本分布统计的定时任务,更新到暴露给 Prometheus 的指标中。

其中 v1 版本的服务提供/订阅者从 EphemeralIpPortClientManager 和 PersistentIpPortClientManager 中存储的 clients 中获取,v2 版本的服务提供/订阅者则从 ConnectionBasedClientManager 中存储的 clients 中获取。

服务订阅/变更消息队列大小

每个事件对应 NotifyCenter 中的一个 publisher 和一个消息队列,这里统计了 ServiceEvent.ServiceChangedEvent 和 ServiceEvent.ServiceSubscribedEvent 的消息队列大小,这两个事件发生后会向订阅的 client 推送新的服务信息。

暴露给 Prometheus 的指标采集策略同样是在 com.alibaba.nacos.naming.monitor.collector 包内新建一个 collector 服务,设置为每隔 2s 进行一次 NotifyCenter 中对应消息队列的大小采集。

服务更新推空次数

每次 push 结束,会调用所有 PushResultHook 进行处理。在 com.alibaba.nacos.naming.push.v2.hook.NacosMonitorPushResultHook 的 pushSuccess 中,判断本次 push 的服务信息是否有效实例数为0,以此决定是否递增推空次数。

1
2
3
4
5
6
7
8
9
10
@Override
public void pushSuccess(PushResult result) {
MetricsMonitor.incrementPush();
MetricsMonitor.incrementPushCost(result.getAllCost());
MetricsMonitor.compareAndSetMaxPushCost(result.getAllCost());
if (null == result.getData().getHosts() || !result.getData().validate()) {
MetricsMonitor.incrementEmptyPush();
}
...
}

服务更新推送任务消息队列大小

在 PushDelayTaskExecuteEngine 中,继承自父类 NacosDelayTaskExecuteEngine 的 tasks 表即为当前等待被处理的 push 任务的队列,对其创建守护线程进行定期(每2s)收集即可。

动态注册指标(TopN)

TopN 计数指标容器

尽管 Prometheus 为我们提供了 topk 的函数,但是将可能达到几百万项的指标全部提供给 Prometheus 并让其实时通过 HTTP 读取、排序,对监控系统和网络的压力是比较大的。为此提出一个类似于 LinkedHashMap 的结构,来为计数类型的指标提供时间复杂度为 O(1) 的更新操作,并能让指标存储保持有序,方便快速取出新的 TopN 项,再暴露到给 Prometheus 的接口中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class TopnCounterMetricsContainer {

/**
* dataId -> count.
*/
private ConcurrentHashMap<String, AtomicInteger> dataCount;

/**
* count -> node.
*/
private ConcurrentHashMap<Integer, DoublyLinkedNode> specifiedCountDataIdSets;

private DoublyLinkedNode dummyHead;

...

private class DoublyLinkedNode {

public DoublyLinkedNode next;

public DoublyLinkedNode prev;

public ConcurrentHashSet<String> dataSet;

public int count;

public DoublyLinkedNode(DoublyLinkedNode next, DoublyLinkedNode prev, ConcurrentHashSet<String> dataSet, int count) {
this.next = next;
this.prev = prev;
this.dataSet = dataSet;
this.count = count;
}
}
}

TopnCounterMetricsContainer (0)

如图,当前计数指标为 {G:3, D:1, E:1, F:1, A:0, B:0, C:0},下面将指标 A 递增两次。

每次递增执行:

  • 从 dataCount 这个 map 中找到要递增的指标当前值。
  • 利用指标当前值从 specifiedCountDataIdSets 这个 map 中找到要递增的指标在双向链表中的节点。
  • 从节点内的 set 中删除此指标名。(若此节点指标值非0且节点的 set 为空,则删除此节点)
  • 向此节点的前一个节点(若两节点差值非 1,则插入新节点)插入此指标名。
  • 在 dataCount 中递增此指标计数。

TopnCounterMetricsContainer (1)

TopnCounterMetricsContainer (2)

此结构实现了任一指标的 increment 操作为 O(1) 并保持所有容器内指标在链表内有序。

取 TopN 指标只需要从链表头开始,从每个节点的 set 内取指标直到满 N 个即可。

变更最频繁的 TopN 个配置

在 config 模块的 MetricsMonitor 中创建一个 TopnCounterMetricsContainer ,用于管理所有配置的变化次数。

每次配置变化会触发异步事件 ConfigDataChangedEvent,对此记录某项配置的变更次数递增。

创建 ConfigDynamicMeterRefreshService ,用于创建 config 模块中所有动态指标注册的刷新服务。创建定时任务每 30s 清空 TopN 频繁变更配置指标注册,重新从 TopnCounterMetricsContainer 中获取最新的 TopN 项,暴露到给 Prometheus 的服务接口中。另外创建定时任务,每周清空配置变更次数,避免发生整型溢出。

TopN 的 N 暂定为 10,可以在代码中修改,未来也可以作为配置文件中的配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* dynamic meter refresh service.
*
* @author <a href="mailto:liuyixiao0821@gmail.com">liuyixiao</a>
*/
@Service
public class ConfigDynamicMeterRefreshService {

private static final String TOPN_CONFIG_CHANGE_REGISTRY = NacosMeterRegistryCenter.TOPN_CONFIG_CHANGE_REGISTRY;

private static final int CONFIG_CHANGE_N = 10;

/**
* refresh config change count top n per 30s.
*/
@Scheduled(cron = "0/30 * * * * *")
public void refreshTopnConfigChangeCount() {
NacosMeterRegistryCenter.clear(TOPN_CONFIG_CHANGE_REGISTRY);
List<Pair<String, AtomicInteger>> topnConfigChangeCount = MetricsMonitor.getConfigChangeCount()
.getTopNCounter(CONFIG_CHANGE_N);
for (Pair<String, AtomicInteger> configChangeCount : topnConfigChangeCount) {
List<Tag> tags = new ArrayList<>();
tags.add(new ImmutableTag("config", configChangeCount.getFirst()));
NacosMeterRegistryCenter.gauge(TOPN_CONFIG_CHANGE_REGISTRY, "config_change_count", tags, configChangeCount.getSecond());
}
}

/**
* reset config change count to 0 every week.
*/
@Scheduled(cron = "0 0 0 ? * 1")
public void resetTopnConfigChangeCount() {
MetricsMonitor.getConfigChangeCount().removeAll();
}
}

变更最频繁的 TopN 个服务

TopN 容器与上述变更最频繁的 TopN 个配置相同。

每次服务变化会触发异步事件 ServiceEvent.ServiceChangedEvent ,对此记录某项服务的变更次数递增。

对 TopN 指标的测试

TopN 容器涉及到链表中节点的新建和删除,频繁操作时需要保证被删除的节点被垃圾回收,不发生内存泄漏。

软硬件情况

阿里云 云服务器ECS 2核8GB Ubuntu 22.04 64位 openjdk8。

在此机器上启动单机模式的 nacos 服务,设置 JVM 启动参数为:-Xms4g -Xmx4g -Xmn2g

观察 JVM 堆内存使用情况以及修改配置请求 RT。

内存

image-20220824154635950

在 15:15 - 15:30 期间对其进行了约 10000 次配置修改请求,此后无请求,安静运行。可以看到堆内存以及 minor GC 正常,没有发生内存泄漏情况。

image-20220824173231911

此后陆续又进行了几万次配置修改,堆内存健康。TopN 指标容器中被抛弃的链表节点在新生代正常回收。

image-20220824184850156

写配置 RT

image-20220824185214372

因为指标更新任务是通过 NotifyCenter 发布事件后异步处理,所以 RT 保持稳定。

TopN 指标容器更新时间测试

对含有 k 个计数指标项的容器,每次对随机指标更新,更新(递增)k * k 次,计算平均时间,得到如下结果:

指标项数 k 每次更新(递增)平均时间(ns)
1000 281.00
2000 262.75
3000 233.89
10000 243.82

我们可以看到,每次更新的平均时间与指标项数基本无关,证实时间复杂度为 O(1)。

附容器类性能测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Main {

public static void main(String[] args) {
int num = 1000;
TopnCounterMetricsContainer topnCounterMetricsContainer = new TopnCounterMetricsContainer();
for (int i = 0; i < num; i++) {
topnCounterMetricsContainer.put(Integer.toString(i));
}
Random random = new Random();
long start = System.currentTimeMillis();
for (int i = 0; i < num; i++) {
for (int k = 0; k < num; k++) {
int j = random.nextInt(num);
topnCounterMetricsContainer.increment(Integer.toString(j));
}
}
long end = System.currentTimeMillis();
System.out.println("Average time: " + (double) (end - start) * 1000000 / (num * num) + "ns");
System.out.println("Top N items:");
List<Pair<String, AtomicInteger>> result = topnCounterMetricsContainer.getTopNCounter(10);
for (Pair<String, AtomicInteger> item : result) {
System.out.println(item.getFirst() + ": " + item.getSecond());
}
}
}