在实际开发里,Apollo配置中心几乎是标配。但很多朋友遇到配置修改后,服务重启才生效,或者日志里监听器的回调被打印了好几遍,甚至老年代内存慢慢涨上去。这篇文章想带大家从Apollo客户端的源码角度,把“监听注册”这件事彻底讲透:它是怎么工作的,为什么会出现重复订阅,又怎么会内存泄漏。
一、从一次“诡异”的配置不更新说起
有一天,同事小张跑过来跟我说,他的服务改了配置,等了一分钟都没生效。后来发现他把Apollo的Config对象当成了临时变量,在每次请求里都去获取一次,然后又注册一遍监听器。这样一来,监听器越加越多,老的那份Config对象还没被回收,因为监听器列表里还持有它们的引用。改配置的时候,这些监听器全都跑一遍,但很多已经无效了,白白占用资源。这就是一个典型的重复订阅加内存泄漏的组合问题。
可能你会说,我平时也没这么写呀,为什么还是感觉Apollo的配置有时候不生效?别急,咱们从底层的存储结构慢慢讲。
二、Apollo客户端里监听注册到底干了啥
要搞懂这个问题,得先知道Apollo客户端在收到配置后,是怎么通知到我们的。
2.1 核心链路
Apollo客户端的Config对象是从ConfigService里拿的。每个namespace对应一个Config实例。你调用config.addChangeListener(...)的时候,它会把监听器放到一个内部集合里。当远程配置有变化,或者本地缓存文件被修改,Apollo的定时任务会拉取到变更,然后遍历这个集合,逐个回调监听器的onChange方法。
2.2 源码走读:从ConfigService到AbstractConfig
我们平时用的配置对象,实际是DefaultConfig的实例,它继承自AbstractConfig。AbstractConfig里面有一个成员变量:
// 保存所有监听器的集合
private CopyOnWriteArraySet<ConfigChangeListener> m_listeners = new CopyOnWriteArraySet<>();
注意这里用的是CopyOnWriteArraySet,它保证了线程安全,又不会在遍历时抛ConcurrentModificationException。addChangeListener的源码逻辑大概是这样的:
@Override
public void addChangeListener(ConfigChangeListener listener) {
// 如果监听器是空,直接不管
if (listener == null) {
return;
}
// 关键点:这里没有判断是否重复,直接往集合里塞
m_listeners.add(listener);
}
因为Set本身会去重,同一个监听器对象重复添加不会增加数量。但是,如果你每次new一个监听器对象,那每次都是新的引用,都会被塞进去。而且注意,DefaultConfig内部还会维护一个属性改变时的缓存,为了触发监听器,它会把Config本身也作为参数传给监听器。如果这个Config对象不是单例,监听器又持有Config的引用,那Config就无法被回收。
再往深处看,配置变更的触发是从Repository拿到的。当远程配置变了,Apollo客户端会调用DefaultConfig的fireConfigChange方法。这个方法会先取得当前发生变化的key和值,打包成一个ConfigChangeEvent,然后遍历m_listeners,挨个调用onChange。整个过程是同步的,也就是说一个监听器如果执行很慢,后面的监听器都会等着。
三、重复订阅是怎么发生的
3.1 最常见的错误写法
很多人把获取Config和注册监听放在了业务方法里,比如每次处理请求的时候都来一遍:
// 错误示范:每次调用都会创建新的Config和监听器
public void handleRequest() {
// 每次都从ConfigService获取Config
Config config = ConfigService.getAppConfig();
// 每次new一个监听器,重复注册
config.addChangeListener(new ConfigChangeListener() {
@Override
public void onChange(ConfigChangeEvent changeEvent) {
// 这里处理配置变更
System.out.println("配置变了:" + changeEvent.changedKeys());
}
});
// 业务逻辑...
}
上面的代码执行一次,就产生一个匿名内部类监听器,并且对应一个Config引用。虽然ConfigService本身有缓存,同一namespace返回的Config实例其实同一个,但监听器不是同一个对象,所以CopyOnWriteArraySet会一直膨胀。
3.2 Set能去重为什么还会膨胀
因为CopyOnWriteArraySet的add方法底层是调用CopyOnWriteArrayList的addIfAbsent,判断依据是equals。而匿名监听器类没有重写equals,所以每个new出来的对象都不相等,都会被认为新元素。于是,监听器列表越来越大。你可以把CopyOnWriteArraySet想象成一个排队买东西的队列,只要你换了张脸去排队,哪怕你穿一模一样的衣服,它都把你当成新顾客。
3.3 重复订阅带来的隐藏问题
最直接的问题就是回调重复执行。假设你注册了1000个同样逻辑的监听器,改一次配置,你的代码就执行1000次。如果里面有发送邮件、刷新数据库连接池这种操作,服务直接就卡死或者数据库连接被打满。更隐蔽的是内存泄漏:每个监听器都引用着外部的业务对象,比如某个Service、某个HttpClient,那这些业务对象也永远无法被回收,时间一长,堆内存使用率直线上升。
举个例子,我们曾经在生产环境遇到过,某几个配置项一改,整个集群的CPU立刻飙到100%。后来一看堆栈,全是同一个监听器的回调在反复执行。原因就是一个定时任务里反复调用了一个注册方法,监听器数量涨到了好几千。把注册逻辑挪到启动阶段之后,问题马上消失。
四、内存泄漏真相与排查方法
4.1 是谁一直拽着对象不放手
Apollo的监听器集合就放在Config对象里,Config对象被ConfigService缓存着,应用不退出,它就不会消失。只要监听器还引用着业务对象,那业务对象就跟着一起“常住”在老年代。我们做过一次压测,每秒调用一次错误写法,运行一小时,监听器数量从0涨到3600个,堆内存涨了200多MB。压测结束后,垃圾回收也救不回来,因为所有监听器都是可达的。
4.2 怎么排查
排查思路很简单:查一下那个监听器集合的大小,或者用堆转储工具看对象引用链。
- 先用jstack看看有没有线程长期阻塞在监听器回调里。
- 再用jmap dump出堆文件,用MAT或者VisualVM打开。
- 搜索你的业务类实例,看它的引用链里有没有DefaultConfig里的m_listeners。
- 如果能看到类似
java.util.concurrent.CopyOnWriteArrayList的元素数量特别多,并且每个元素都是你那个匿名监听器类,那基本实锤了。
假如你有一段代码反复注册监听器,可以简单打印数量:
// 临时诊断代码:通过反射查看监听器数量
Field field = AbstractConfig.class.getDeclaredField("m_listeners");
field.setAccessible(true);
Object listeners = field.get(config);
// 这里直接把集合的toString或者size打出来
System.out.println("当前监听器数量:" +
((java.util.concurrent.CopyOnWriteArraySet<?>) listeners).size());
不过正式代码里不建议这么干,反射只是为了排查问题。
4.3 顺手排查Spring环境里的重复Bean
如果你用了Spring,还得注意一下监听器是不是被重复代理了。有时候我们会在同一个类里既写了@ApolloConfigChangeListener,又手动在构造方法里调用了addChangeListener,那这个监听器就会有两个引用,虽然逻辑一样,但对象不同,也是一个隐患。所以写代码的时候,最好统一一种方式,不要混着来。
五、怎么优雅地订阅与清理
5.1 全局共享一个Config,监听器只加一次
最正确的方式是把Config变成单例,或者至少在应用启动时只获取一次。比如写一个配置管理类:
/**
* 配置管理类,整个应用只保留一个Config实例
* 技术栈:Java + Apollo客户端
*/
public class AppConfigHolder {
// 静态单例:只初始化一次
private static final Config APP_CONFIG = ConfigService.getAppConfig();
// 注册监听器,只在启动时调用一次
public static void init() {
APP_CONFIG.addChangeListener(new ConfigChangeListener() {
@Override
public void onChange(ConfigChangeEvent changeEvent) {
// 只关心自己关心的key
if (changeEvent.isChanged("switch.enable")) {
System.out.println("开关变更,新值=" +
changeEvent.getChange("switch.enable").getNewValue());
}
}
});
}
// 对外提供获取配置的方法
public static String get(String key, String defaultValue) {
return APP_CONFIG.getProperty(key, defaultValue);
}
}
在main方法里调用一次AppConfigHolder.init(),以后所有地方都直接通过AppConfigHolder访问配置,不要再去ConfigService拿。这样既保证了Config单例,也保证了监听器数量是1。
5.2 使用Spring容器管理生命周期
如果你用的是Spring Boot,更简单。可以借助Apollo提供的@ApolloConfigChangeListener注解,让Spring帮你管理监听器的生命周期。这个注解可以标注在任意方法上,Apollo的Spring集成会自动把方法包装成监听器,并且只会注册一次。比如:
@Component
public class DynamicConfigListener {
/**
* 使用注解注册监听器,Spring确保这个方法对应的监听器只被注册一次
* 技术栈:Java + Spring Boot + Apollo
*/
@ApolloConfigChangeListener("application")
public void onConfigChange(ConfigChangeEvent changeEvent) {
// 每次配置变更都会进到这里
System.out.println("监听到变更的key:" + String.join(",", changeEvent.changedKeys()));
// 根据key做对应的刷新逻辑
for (String key : changeEvent.changedKeys()) {
if ("timeout".equals(key)) {
// 假设这里刷新一个超时时间
refreshTimeout(changeEvent.getChange(key).getNewValue());
}
}
}
private void refreshTimeout(String newValue) {
// 模拟刷新操作
System.out.println("新的超时时间:" + newValue);
}
}
5.3 手动移除监听器
Apollo也提供了removeChangeListener方法。如果某个监听器是动态创建的,用完之后一定要记得移除。比如你在一个临时任务里订阅配置,任务结束时要这么干:
// 技术栈:Java + Apollo客户端
Config config = ConfigService.getConfig("someNamespace");
ConfigChangeListener listener = new ConfigChangeListener() {
@Override
public void onChange(ConfigChangeEvent changeEvent) {
// 临时任务需要处理的变化
handleTempConfig(changeEvent);
}
};
// 注册
config.addChangeListener(listener);
try {
// 执行临时任务
doSomething();
} finally {
// 一定记得移除,否则监听器会一直留在Config里
config.removeChangeListener(listener);
}
移除的时候,传入的必须是同一个监听器对象。这样才能保证从集合里删掉。如果你new了另一个对象去remove,是没用的。
5.4 总结一下优雅姿势
- 非Spring项目:用一个静态holder持有Config,注册一次。
- Spring项目:用@ApolloConfigChangeListener,不要手动注册。
- 动态临时监听:用try-finally确保移除。
- 不要在监听器里做重活,如果要做,放到异步线程池里。
六、关联技术:Spring的事件机制与Apollo的集成
Apollo的Spring集成其实利用了Spring的事件发布机制。它的核心思想是:把Apollo配置变更当成一个Spring事件,然后广播给所有注册的监听器。这样能更好地和Spring的生命周期结合。
6.1 集成原理
Apollo的ApolloApplicationContextInitializer会加载属性源,同时把ConfigChangeListener包装成Spring的ApplicationListener。当配置变更时,Apollo客户端内部触发监听器,然后Spring容器再发出一个EnvironmentChangeEvent。这样,我们也可以用@EventListener注解来监听配置变化。这个机制的好处是,监听器的注册和销毁都交给Spring管理,不会漏掉,也不会重复。
6.2 一个容易踩的坑
有同学在@PostConstruct方法里用@Value读取配置,发现配置改了一直不更新。这里要区分:@Value只在Bean创建时注入一次,不会自动刷新。你需要配合@RefreshScope或者自己注册监听器来重新设置值。Apollo官方推荐的是在监听器里手动更新值,或者用@ApolloConfigChangeListener配合重建Bean。注意不要同时用两种方式,否则可能重复刷新。
再补充一个细节,Spring的事件广播器默认是同步的。如果你用@EventListener监听EnvironmentChangeEvent,并且方法里执行了一个很慢的操作,它会阻塞Apollo的配置变更通知线程。所以建议监听器方法里尽量快,或者加上@Async注解。
七、应用场景、优缺点与注意事项
7.1 应用场景
这套监听机制主要适用于需要动态调整配置的场景,比如:
- 开关切换:功能灰度、流量控制。
- 参数调整:数据库连接池大小、超时时间。
- 业务规则变更:黑名单列表、限流阈值。
只要配置一变,应用无需重启,立刻感知。
7.2 优点
- 实时性高:客户端有长轮询,秒级感知变更。
- 使用简单:一个回调方法就能拿到变更key和值。
- 去重机制好:Set结构避免相同对象重复注册。
7.3 缺点
- 理解成本高:如果不知道底层是集合存储,容易写出重复订阅的代码。
- 内存泄漏风险:监听器持有外部引用,或者动态监听器不注销,都会泄漏。
- 调试成本大:重复监听器导致回调次数暴增时,日志很难定位。
7.4 注意事项
- 不要在监听器里做耗时操作,比如RPC调用、大循环。Apollo的回调是同步的,会阻塞后续监听器执行。
- 监听器里如果抛出异常,最好捕获,不要影响其他监听器。
- 动态创建的监听器必须手动移除,建议用try-finally包裹。
- 尽量使用Apollo提供的Spring注解,而不是手动addChangeListener,除非你是非Spring项目。
- 多环境、多namespace时,要明确监听的是哪个namespace,不要在回调里无脑刷新全部缓存。
八、总结
Apollo客户端的监听注册机制,本质上是在一个Config实例的内部Set里保存监听器,配置变更时遍历执行。重复订阅的根源在于每次创建新的监听器对象,而内存泄漏的根源在于监听器集合被ConfigService持有,且监听器又持有外部对象。解决思路很简单:保证Config单例、监听器只注册一次、动态监听器用后即焚。对于Spring项目,优先用@ApolloConfigChangeListener;对于普通Java项目,用一个静态初始化块来注册。最后,遇到诡异的配置不更新或者内存暴涨,先检查监听器列表是不是失控了。
评论
围绕“从Apollo客户端源码看配置监听注册机制,重复订阅与内存泄漏的隐藏陷阱排查”参与讨论