title: "Apollo 与 Nacos 配置中心:一次回调线程被业务阻塞 8 秒,灰度下发全排队的复盘"
tags: [apollo, nacos, 配置中心, 微服务, 分布式]
categories: [后端, 中间件]
大促前 20 分钟,我们把下单接口的限流阈值从 8000 QPS 调到 12000,点完发布,监控里只有一半机器的阈值变了。另一半机器还在用旧值跑,结果这半边先被打满、线程池耗尽,报错一路蔓延到上游。那天我们才真正去读 Apollo 和 Nacos 的推送代码,发现两家的"配置下发"根本不是一回事。
下面按一次真实事故讲清楚:配置到底是怎么到你代码里的、为什么回调里写业务会把全站配置刷新堵死、以及 Nacos 和 Apollo 在刷新链路上的本质分歧。
事故现场:改一个阈值,只有一半机器生效
我们当时用 Apollo。发布配置后,Apollo 控制台显示"已通知",但订单集群 12 台机器里,只有 6 台在 1 秒内把阈值改了,另外 6 台过了 8 秒才变。那 8 秒里,旧阈值这半边扛着 8000 上限,新流量一冲就拒绝,触发上游重试风暴。
排查发现,问题不在 Apollo 服务端的推送,而在我们自己的监听器写法。当年为了"配置一变就同步给风控",有人在 ConfigChangeListener 的回调里直接做了两件重活:
// 错误示范:在 Apollo 回调线程里做阻塞业务
@PostConstruct
public void init() {
Config config = ConfigService.getAppConfig();
config.addChangeListener(changeEvent -> {
for (String key : changeEvent.changedKeys()) {
if ("order.rateLimit".equals(key)) {
// ① 直接查数据库拿最新规则
Rule rule = ruleMapper.selectByKey(key);
// ② 同步发一条 Kafka 通知风控
kafkaTemplate.send("rate-limit-topic", rule.toJson());
// ③ 更新本地内存
rateLimitHolder.update(rule);
}
}
});
}
逐行看这段"看起来没问题"的代码:第 6 行 addChangeListener 注册了一个回调;第 9 行拿到变更 key 后,第 11 行 ruleMapper.selectByKey 是一次 DB 查询,在我们那台慢库上平均 120ms,偶发会到 2 秒;第 13 行 kafkaTemplate.send 是同步发送, broker 抖动时阻塞 500ms 到几秒;第 15 行才更新内存。
问题症结在:Apollo 的客户端回调是单线程串行的。一个 key 的回调没跑完,下一个 key 的回调只能排队。那次我们同时改了 4 个配置项(限流、降级开关、灰度比例、日志级别),前两个 key 的回调各被 DB + Kafka 拖了 4 秒,后面两个 key 包括真正的限流阈值,硬生生等了 8 秒才执行。一半机器因为节点启动时间不同、拿到通知的时序错开,反而"侥幸"先执行了,另一半就卡在队列里。
Apollo 的推送链路:长轮询 + 单线程回调
Apollo 客户端(RemoteConfigRepository)拉配置的模型是:客户端定时向服务端发起一个带 notificationId 的长轮询(默认 90 秒超时),服务端有变更才立即返回,没变更就 hold 住连接直到超时或变更。客户端拿到变更后,在一个专门的单线程池(默认 1 个线程)里遍历所有注册的 RepositoryChangeListener,再触发业务侧的 ConfigChangeListener。
这也是为什么回调里不能阻塞——它和"拉取下一次通知"用的是同一批线程资源。阻塞回调 = 阻塞后续所有配置的处理。正确的写法是回调里只做内存更新,重活丢到业务线程池:
// 正确示范:回调里只更新内存,重活异步化
@PostConstruct
public void init() {
Config config = ConfigService.getAppConfig();
config.addChangeListener(changeEvent -> {
if (changeEvent.isChanged("order.rateLimit")) {
// 只更新内存,立刻返回,不碰 DB / MQ
String newValue = config.getProperty("order.rateLimit", "8000");
rateLimitHolder.update(Integer.parseInt(newValue));
// 需要同步风控?丢到独立线程池,别占 Apollo 的回调线程
bizExecutor.submit(() -> syncToRiskControl(newValue));
}
});
}
第 7 行把"更新内存"做成纯内存操作,几十微秒结束;第 10 行把同步风控这种不可控耗时的动作丢进 bizExecutor(我们自己建的 ThreadPoolExecutor),彻底和 Apollo 回调线程解耦。改完后我们压测过:即使风控同步偶发卡 3 秒,配置刷新本身稳定在 50ms 内完成。
Nacos 的推送链路:长轮询 + 独立监听器线程
Nacos 客户端(ClientWorker)也是长轮询,但它在设计上把"接收变更"和"通知业务"分得更开。ConfigService.addListener 注册时,你同时传一个 Executor(不传就用默认的,但每个 listener 的回调在它自己的线程里跑,不会互相阻塞):
// Nacos:为每个监听器指定独立线程,长轮询回调彼此隔离
ConfigService configService = NacosFactory.createConfigService(properties);
configService.addListener("order-config", "DEFAULT_GROUP",
new AbstractListener() {
@Override
public void receiveConfigInfo(String configInfo) {
// 这里的执行线程由 Nacos 管理,多个 listener 之间不串行
int limit = parseLimit(configInfo);
rateLimitHolder.update(limit);
}
});
逐行看:第 3 行拿到 ConfigService;第 4 行 addListener 的第二个参数是 dataId、第三个是 group;AbstractListener 的 receiveConfigInfo 在 Nacos 内部线程里执行。Nacos 服务端通过 UDP 推送 + 客户端长轮询双通道保障实时性,变更到达后由 ClientWorker 的 executor 线程触发你的回调。
关键差异点:Apollo 的回调在客户端侧是单线程串行的,Nacos 的监听器回调在客户端侧是多线程并发的。所以同样的"回调里写重活"错误,在 Nacos 上可能只是拖慢那一个 listener,在 Apollo 上会拖慢所有配置。这是我们当年踩完坑才分清的。
对比:Apollo 和 Nacos 在刷新链路上的 5 处分歧
| 客户端回调线程 | 单线程串行(默认 1 线程) | 每 listener 独立线程,互不阻塞 |
| 推送通道 | 长轮询 + 定时拉取兜底 | 长轮询 + UDP 推送 |
| 配置粒度 | namespace + app + cluster + env 多维 | dataId + group + tenant |
| 灰度能力 | 内置按 IP/应用实例灰度,较成熟 | 1.x 靠 tag,2.x 起支持更细灰度 |
| 配置格式 | 以 properties/yml 文本为主 | 文本/JSON/YAML,原生支持监听整段 |
这张表不是"谁更好"的结论,而是告诉你两家的坑长得不一样:Apollo 的坑在"回调串行",Nacos 的坑在"listener 线程池默认可能被你自己的重活占满,要主动给 Executor"。
复盘真实数字
那次事故我们量过:
– 阻塞回调导致最慢一台机器的配置生效延迟 8.2 秒,P99 配置生效延迟从预期 200ms 涨到 8 秒。
– 这 8 秒里旧阈值半边拒绝了 约 1.7 万次请求,触发上游重试 约 5.3 万次,把订单服务线程池打满 40 秒。
– 改成"回调只更新内存 + 重活异步"后,配置生效 P99 降到 53ms,连续两周灰度发布再没出现一半生效。
– 我们同时把 Apollo 客户端回调线程池调到 2(仍保守,因为串行语义要避免竞态),Nacos 侧给每个热配置 listener 显式传 Executor,线程数按 key 数量配 4。
我的取舍判断
我不建议把"配置热更新"当成免费的能力直接用。两个具体判断:
第一,回调里只做内存写,任何 IO(DB/MQ/HTTP)一律异步。 这不是偏好,是 Apollo 单线程串行的物理约束逼出来的。哪怕你用 Nacos,也别假设"它线程多就不会出问题"——你那个 Executor 一旦被慢调用占满,该 listener 的下次刷新照样排队。
第二,Apollo 和 Nacos 没必要二选一撕。 我们后来是"配置下发用 Apollo(看中成熟的灰度),服务发现用 Nacos",边界清晰反而少踩坑。如果你的场景只是简单的开关/阈值热更新,Nacos 更轻;如果要按机器维度灰度、要配置审计回滚的完整闭环,Apollo 更省心。别为了"统一技术栈"强行只留一个而牺牲另一边的强项。
一个常被忽略的坑:@RefreshScope 的代理失效
Spring Cloud 体系下还有个独立坑:用 @RefreshScope + @Value 注入的配置,刷新后会生成新代理对象,但你如果把配置值在 @PostConstruct 里拷贝到了一个 static 字段或本地缓存, refresh 后的新值根本进不去:
@RefreshScope
@Component
public class RateLimitConfig {
@Value("${order.rateLimit:8000}")
private int limit;
// 错误:把值固化到 static,refresh 后读不到新值
private static int cachedLimit;
@PostConstruct
void init() { cachedLimit = limit; } // 只在启动时跑一次
public static int getLimit() { return cachedLimit; } // 永远是旧值
}
第 9 行把 limit 拷进 static cachedLimit,@PostConstruct 只在 Bean 初始化时跑一次;@RefreshScope 刷新的是这个 Bean 的代理实例,但 getLimit() 读的是类级 static,跟代理对象无关,于是新阈值永远到不了调用方。正确做法是别缓存,直接读注入字段或走 Environment 活查。
思考题
你现在的项目里,配置变更回调是在哪个线程执行的?如果有人在回调里加了一行"发 MQ 通知",在 Apollo 和 Nacos 上分别会怎样?欢迎在评论区说下你踩过的配置中心坑。
如果这篇对你排查配置下发延迟有帮助,点个赞或收藏,下一篇我写分布式 Session 在 Redis 连接池静默断开时的登录态雪崩。





