Nacos token 续签故障记录
Nacos token 续签故障记录
出了什么问题
10 月 30 日排查时,消费端有下面这段报错。项目名、地址、命名空间等标识已经替换,时间和错误保留,重复日志和其余堆栈省略。
2026-10-30T10:13:57.650+08:00 ERROR 1 --- [APP] [ncesChangeEvent] o.a.d.r.client.metadata.MetadataUtils : [DUBBO] Failed to get app metadata for revision <REVISION> for type remote from instance <ADDRESS>, dubbo version: <DUBBO_VERSION>, current host: <ADDRESS>
java.lang.IllegalStateException: http error, code=403,msg=token expired!,dataId=<PROVIDER>,group=<REVISION>,tenant=<NAMESPACE>
at org.apache.dubbo.metadata.store.nacos.NacosMetadataReport.getAppMetadata(NacosMetadataReport.java:172) ~[dubbo-<DUBBO_VERSION>.jar!/:<DUBBO_VERSION>]
at org.apache.dubbo.registry.client.metadata.MetadataUtils.getMetadata(MetadataUtils.java:205) ~[dubbo-<DUBBO_VERSION>.jar!/:<DUBBO_VERSION>]
at org.apache.dubbo.registry.client.metadata.MetadataUtils.getRemoteMetadata(MetadataUtils.java:164) ~[dubbo-<DUBBO_VERSION>.jar!/:<DUBBO_VERSION>]
页面提示依赖服务不可用,日志先报 Dubbo 元数据获取失败,再往下看,是 Nacos 返回了 403 token expired!。服务端也有对应的拒绝记录:
2026-10-30 10:59:03,864 DEBUG access denied, request: ConfigQueryRequest, reason: token expired!
2026-10-30 10:59:04,866 DEBUG access denied, request: ConfigQueryRequest, reason: token expired!
2026-10-30 10:59:15,868 DEBUG access denied, request: ConfigQueryRequest, reason: token expired!
当时 Pod 还在运行,健康检查正常,CPU 也不高。消费端能发现新实例,却读不到它的 Dubbo 元数据,调用还是失败了。应用刚启动时正常,运行一段时间后才出错;不改账号密码,只重启消费端,页面又能打开。
排查时对比过旧、新 ReplicaSet:Nacos 凭据一致,当时能正常登录,没有发现被轮换或漏配。但这只说明凭据可用,不能证明旧进程里的客户端刷新成功。403 仍要结合消息、命名空间、分组和权限看,HTTP 状态码和 Nacos RPC 错误码也要分开;这次关键的消息就是 token expired!。
下面先顺着客户端源码把建池、登录、刷新和监听接起来,再看有效期和过期后的处理,最后用当时的线程栈核对。
顺着报错,看客户端怎么连接 Nacos
下面的代码按 3.0.3 来看。中文注释是补的,省略部分已标出,文件和行号列在文末。
从元数据入口找到配置客户端
异常落在 NacosMetadataReport.getAppMetadata(),先沿着这个组件往前找。初始化时,NacosMetadataReport.buildConfigService() 调用 NacosFactory.createConfigService(properties),再由 ConfigFactory.createConfigService(Properties) 构造出 NacosConfigService。
这份配置客户端接着创建 ClientWorker。从这里往下看,就能找到它和 Nacos 交互时用的线程池,以及登录、刷新和监听任务是怎么安排的。
ClientWorker 先建池,再启动客户端
ClientWorker 构造时创建 ConfigRpcTransportClient 和调度池,把池交给 agent,再调用 agent.start(),随后启动模糊监听:
// ClientWorker,538—545 行。
agent = new ConfigRpcTransportClient(properties, serverListManager);
configFuzzyWatchGroupKeyHolder = new ConfigFuzzyWatchGroupKeyHolder(agent, uuid);
ScheduledExecutorService executorService = Executors.newScheduledThreadPool(
initWorkerThreadCount(properties),
new NameThreadFactory("com.alibaba.nacos.client.Worker"));
agent.setExecutor(executorService);
agent.start();
configFuzzyWatchGroupKeyHolder.start();
线程数由 initWorkerThreadCount() 计算:
// ClientWorker,554—562 行;THREAD_MULTIPLE = 1,MIN_THREAD_NUM = 2。
private int initWorkerThreadCount(NacosClientProperties properties) {
int count = ThreadUtils.getSuitableThreadCount(THREAD_MULTIPLE);
if (properties == null) {
return count;
}
count = Math.min(count, properties.getInteger(PropertyKeyConst.CLIENT_WORKER_MAX_THREAD_COUNT, count));
count = Math.max(count, MIN_THREAD_NUM);
return properties.getInteger(PropertyKeyConst.CLIENT_WORKER_THREAD_COUNT, count);
}
默认先取 CPU 数:优先读环境变量 NACOS_COMMON_PROCESSORS,其次读系统属性 nacos.common.processors,值无效或不大于 0 时用 JVM 的 availableProcessors()。ThreadUtils.getSuitableThreadCount(1) 再取不小于 CPU 数的最小 2 的幂,例如 2 → 2、3 → 4、16 → 16。
然后应用 clientWorkerMaxThreadCount 上限,至少保留 2 个线程,最后读显式的 clientWorkerThreadCount。最后这个值直接覆盖前面的结果,后面不再保底到 2;上限参数则不能拿来扩容。
也就是说,没有覆盖线程数、JVM 又只看到 2 核时,每份配置客户端的池里就只有 2 个 Worker。后面再用运行中的 JVM 信息确认现场是不是这个情况。
首次登录同步执行,后续刷新交给池调度
agent.start() 进入 ConfigTransportClient.start() 后,先直接登录一次,再提交周期检查:
// ConfigTransportClient,137—142 行。
public void start() throws NacosException {
securityProxy.login(this.properties);
this.executor.scheduleWithFixedDelay(() -> securityProxy.login(properties), 0,
this.securityInfoRefreshIntervalMills, TimeUnit.MILLISECONDS);
startInternal();
}
这里的 this.executor,就是前面 ClientWorker 交给 agent 的池,认证没有单独的执行线程。
检查间隔在第 63 行定义为 5 秒。scheduleWithFixedDelay 是上一次执行完,再等 5 秒执行下一次;没有空闲线程,到时间也只能等。首次登录同步执行,所以应用刚启动时能拿到 token,后续检查才依赖这个池。
两个监听也用这个池,但任务一直不退出
start() 接着调用 startInternal(),普通监听在这里提交:
// 844—864 行节选,省略异常处理。
ScheduledExecutorService executor = getExecutor();
executor.schedule(() -> {
while (!executor.isShutdown() && !executor.isTerminated()) {
try {
listenExecutebell.poll(5L, TimeUnit.SECONDS);
if (executor.isShutdown() || executor.isTerminated()) {
continue;
}
executeConfigListen();
} catch (Throwable e) {
// 省略日志、短暂休眠及再次通知逻辑。
}
}
}, 0L, TimeUnit.MILLISECONDS);
再回到 ClientWorker 的构造过程,configFuzzyWatchGroupKeyHolder.start() 启动模糊监听,它也从 agent 取同一个池:
// 108—128 行节选,省略异常处理。
ScheduledExecutorService agentExecutor = agent.getExecutor();
agentExecutor.submit(() -> {
while (!agentExecutor.isShutdown() && !agentExecutor.isTerminated()) {
try {
fuzzyListenExecuteBell.poll(5L, TimeUnit.SECONDS);
if (agentExecutor.isShutdown() || agentExecutor.isTerminated()) {
continue;
}
executeConfigFuzzyListen();
} catch (Throwable e) {
// 省略日志、短暂休眠及再次通知逻辑。
}
}
});
两个 while 都写在提交的 Runnable 内部。poll() 等待结束后继续下一轮,整个任务不会因此退出。 线程虽然大部分时间在等消息,但一直还在执行这个监听任务,不能再接别的任务。
模糊监听在构造时就会启动,所以业务没用模糊订阅,也不代表它不会占线程。如果每池只有 2 个 Worker,普通监听、模糊监听各占一个,剩下就是 2 − 1 − 1 = 0,周期认证就没有线程可用。
这是线程池饥饿,不需要 CPU 跑满,也不需要出现死锁。初始延迟为 0 的检查即使在启动时执行过一次,也不代表后面还能执行。
再看有效期:为什么会过期,过期后又没恢复
token 有效期是 5 小时,正常会提前刷新
SecurityProxy.login() 调用认证插件,NacosClientAuthServiceImpl 先判断时间:
// login() 的非强制重新登录分支,84—86 行。
if ((System.currentTimeMillis() - lastRefreshTime) < TimeUnit.SECONDS
.toMillis(tokenTtl - tokenRefreshWindow)) {
return true;
}
没到时间就返回,到了才通过 HttpLoginProcessor 重新登录。成功后更新 tokenTtl、tokenRefreshWindow、lastRefreshTime,并替换 loginIdentityContext。这里说的“续签”,就是重新登录、换 token;普通业务请求不会延长有效期。
这次服务端返回的 tokenTtl 是 18000 秒,也就是 5 小时。generateTokenRefreshWindow() 在 tokenTtl / 15 到 tokenTtl / 10 附近取随机窗口,提前量约 1200—1800 秒,也就是拿到 token 后约 4 小时 30 分到 4 小时 40 分开始尝试刷新。每 5 秒执行的是检查,不是每 5 秒都换 token。
再和故障时间对一下:旧 Pod 大约在 2026 年 10 月 29 日 11:14 启动,16:12:09 还有成功的配置请求,16:15:01 开始报过期,和 5 小时基本吻合。如果检查任务一直没有线程,启动时拿到的旧 token 就会用到过期;业务什么时候报错,还取决于什么时候再次发起配置请求。
到期以后,重新登录为什么也没跑起来
requestProxy() 发请求前通过 getSecurityHeaders() 写入认证信息,收到 NO_RIGHT 后调用:
// ClientWorker,1301—1305 行节选。
if (response.getErrorCode() == ConfigQueryResponse.NO_RIGHT) {
reLogin();
}
return response;
追到 SecurityProxy.reLogin(),它只是写入 reLoginFlag = "true",没有马上发起 HTTP 登录,也没有拿新 token 重试当前请求。要等下一次 login() 处理这个标记,这个分支还有 60 秒的重新登录保护窗口。
续签检查和报错后的重新登录都依赖同一个池。池里没有线程,设置标记也无法换到新 token,后续请求就会继续失败。
本地快照也没兜住:NacosConfigService.getConfigInner() 遇到 NO_RIGHT 会直接抛异常,不走后面的快照回退。更前面的 failover 文件是另一条路径,不能因为以前读过配置,就认为快照一定能处理认证错误。
到这里,源码和有效期能解释这组现象,但还不能仅凭没有登录日志就断定任务没跑。接下来用 JVM 信息和线程栈核对:池是不是只有两个线程,它们是不是都被监听占着。
最后用 JVM 和线程栈核对现场
JVM 实际只看到 2 核
当时容器里的 Java 进程号是 1。下面把命名空间和 Pod 名换成变量;换环境时要确认进程号,容器里需要有 jcmd,本地用 rg 筛选输出。
NS='<namespace>'
POD='<pod-name>'
# 看 JVM 实际识别到的 CPU 数。
kubectl -n "$NS" exec "$POD" -- jcmd 1 VM.info | rg 'CPUs:|active_processor_count:'
当时输出是:
CPUs: 16 total, 2 available
active_processor_count: 2
宿主机总共 16 核,JVM 实际只看到 2 核。当时也没有找到 clientWorkerThreadCount 的覆盖值,和前面的计算逻辑对上,每池默认只有 2 个 Worker。这是每个池的大小,不是整个 Java 进程只能建 2 个线程。
线程栈里,6 个 Worker 全在监听任务里
抓完整线程栈,再找 Nacos Worker。当时是直接接管道筛选的,下面整理成先存本地文件,方便回看:
# 抓完整线程栈,文件保存在本地。
kubectl -n "$NS" exec "$POD" -- jcmd 1 Thread.print > threads.txt
# 找到 Nacos Worker,并查看后面的调用栈。
rg -A 20 '^"com\.alibaba\.nacos\.client\.Worker\.' threads.txt
下面这组线程是在 2026 年 10 月 30 日约 11:27 抓到的。那时已经临时重启过,JVM 参数还没改,新 token 也没过期:
"com.alibaba.nacos.client.Worker.0" #29 cpu=27.59ms elapsed=1619.98s waiting on condition
at com.alibaba.nacos.client.config.impl.ClientWorker$ConfigRpcTransportClient.lambda$startInternal$3(ClientWorker.java:849)
"com.alibaba.nacos.client.Worker.1" #30 cpu=11.65ms elapsed=1619.98s waiting on condition
at com.alibaba.nacos.client.config.impl.ConfigFuzzyWatchGroupKeyHolder.lambda$start$0(ConfigFuzzyWatchGroupKeyHolder.java:113)
两个线程存在了约 27 分钟,累计 CPU 时间只有 27.59 毫秒和 11.65 毫秒。一个停在 ClientWorker.java:849,另一个停在 ConfigFuzzyWatchGroupKeyHolder.java:113,正好对应前面两个监听循环里的 poll()。
等消息时 CPU 可以很低,但任务没结束,线程也没有还给池子。 如果调用栈回到了 getTask()、DelayedWorkQueue.take() 等线程池取任务路径,才是在等池里的任务。不能只看 WAITING、TIMED_WAITING 就把它当作空闲线程;这里的 cpu 也是累计时间,不是瞬时 CPU 百分比。
再看全部 Worker,一共 6 个,全在这两类监听里,没有找到回到池里等任务的线程:
| 线程编号 | 数量 | 在做什么 |
|---|---|---|
#29、#58、#68 |
3 | 普通监听,停在 ClientWorker.java:849。 |
#30、#59、#69 |
3 | 模糊监听,停在 ConfigFuzzyWatchGroupKeyHolder.java:113。 |
和前面的创建逻辑结合起来,对应的是 3 组独立池 × 每组 2 个线程 = 6 个 Worker。每组两个线程都被监听占着,三个池又不能互相借线程,所以续签仍然没有线程可用。
线程名会重复,要看 #29 这样的编号,不能只凭 Worker.0 给线程分组。旧记录没有客户端创建栈或对象关联信息,也不能把 #29、#58、#68 逐一对应到具体配置入口。三组池可能来自哪些入口,放在后面的补充里说明。
当时没有保存调度队列明细,没有直接看到某个续签任务排队。这里的判断来自全部 Worker 的占用和源码里的同池调度,再结合到期后持续报错。线程栈虽然是在重启后抓的,但新 token 有效时,线程被占满的条件就已经存在。
再和正常实例对一下
当时还对照了其他正常实例:同样限为 2 核,同样用 3.0.3,但 JVM 带了 -XX:-UseContainerSupport,实际看到了宿主机的 16 核。它们各有 3 组配置线程池,每组 16 个 Worker,两个监听没有占满整个池。
源码和现场到这里能对上:故障实例每池只有两个线程,都被长期监听占着;正常实例的池更大,两个监听没有占满它。
后来怎么处理,验证到了哪一步
10 月 30 日约 11:00,先滚动重启恢复业务。重新登录以后页面能打开,但 11:27 的线程栈说明,监听仍然占满池,重启只是重新开始一个 token 有效期。
约 13:46,保持原镜像不变,在应用的 JAVA_OPTS 里加入下面的参数,再做滚动更新:
-XX:-UseContainerSupport
关闭容器资源感知后,JVM 可见 CPU 从 2 变成宿主机的 16,每池默认大小也从 2 变成 16。两个监听还在,但不再占满整个池。用前面的 VM.info 检查,输出变成了:
CPUs: 16 total, 16 available
滚动更新后,还确认了这些结果:
- Deployment 的 updated、ready、available 都是
1;新 Pod 为1/1 Running,0 次重启。 - 实际 JVM 命令行里有
-XX:-UseContainerSupport,Pod 的 CPU 限额仍是 2 核。 - 当时已创建 36 个 Nacos Worker:3 个普通监听、3 个模糊监听,另外 30 个在调度队列等待任务。线程按需创建,不要求每组一开始就建满 16 个。
- 就绪探针返回
UP,新 Pod 启动日志没有看到 Nacos 403 或 token 过期。
Pod 的实际 CPU 限额仍是 2 核,没有因为 JVM 看到 16 核就增加配额。关闭容器资源感知还会影响 JVM 对 CPU、内存等资源的判断,堆大小和容器内存限额也要一起核对。
这些都是刚更新后的结果,但现有记录还没有证明新 Pod 跑过 5 小时后续签成功。重启后立刻能用,只能说明当时恢复了,还需要继续确认:
- 到正常刷新窗口时,是否重新登录成功,而且是实际访问元数据的那个客户端。
- 连续运行超过 5 小时后,主动读配置、读元数据,再调用原来失败的业务接口。没有请求时看不到 403,说明不了续签是否正常。
- 在受控验证中触发需要重新读取实例元数据的访问,排除只是旧缓存还能用。条件允许,再多观察一个完整周期。
补充:线程池数量和部署差异
为什么一个进程里会有三组配置线程池
同一个应用可能不止一份配置客户端。核对当前配置和本地依赖,还能找到这些创建入口:
| 入口 | 用途 | 创建客户端的方法 |
|---|---|---|
spring.cloud.nacos.config |
读取 Spring 应用配置 | NacosConfigManager.createConfigService() |
dubbo.config-center |
读取 Dubbo 动态配置 | NacosDynamicConfiguration.buildConfigService() |
dubbo.metadata-report |
读写 Dubbo 元数据 | NacosMetadataReport.buildConfigService() |
它们分别调用 NacosFactory.createConfigService(properties)。Spring 缓存自己的实例,Dubbo 的另外两条路径不会因此共用它;连接同一个 Nacos 地址,也不会自动合成一份客户端。三条路径各持有一份时,就有三份 ClientWorker,但数量不是 Nacos 固定规定的,接入方式变了也会不同。
上表第三条是元数据中心。dubbo.registry、spring.cloud.nacos.discovery 的服务发现入口使用 NamingService,不能拿这些配置项的数量直接算配置线程池。
为什么直接部署正常,Pod 里却可能出问题
直接部署和 Pod 里可能表现不同,差别还是 JVM 实际看到的 CPU 数。容器有自己的 CPU 限制,JVM 开启容器资源感知时,会据此识别可用 CPU;直接在服务器上运行,如果没有额外限制,通常能看到更多核:
| 部署情况 | JVM 可见 CPU | 每池默认线程数 | 两个监听占用以后 |
|---|---|---|---|
| 直接部署,假设 JVM 看到 16 核 | 16 | 16 | 还有容量执行续签等任务。 |
| 本次 Pod 内的实际情况 | 2 | 2 | 两个线程都被占着,续签没线程可用。 |
表里的独立部署 16 核只是说明原理的例子,不是另采的运行数据。两个监听始终存在,池大时还能留出容量,到了每池 2 个线程时就全部占完了。关键还是 JVM 可见 CPU 和实际池大小,不是所有 Pod 都有问题;直接部署如果每池也只有 2 个线程,同样可能出问题。
补充:其他修复办法
下面是待验证的替代方案,这次线上没有实施。示例里的 4 只说明配置方式,最终线程数要结合负载验证。
更直接的办法是单独指定配置池大小。3.0.3 的客户端支持从 JVM 系统属性读取,可以在 Java 启动参数里加:
-DclientWorkerThreadCount=4
默认查找顺序是客户端传入的 Properties、JVM 系统属性、环境变量。如果接入路径已经在 Properties 里写了 2,默认情况下会优先用它,不能只加启动参数就当作所有池都改好了。
直接使用 Java API 时,在创建客户端前设置:
// 先设置属性,再创建配置客户端。
properties.setProperty(PropertyKeyConst.CLIENT_WORKER_THREAD_COUNT, "4");
ConfigService configService = NacosFactory.createConfigService(properties);
Spring 配置、Dubbo 配置中心、Dubbo 元数据中心都要确认实际用了新值;修改后要重建客户端或重启应用。线程数要给两个监听之外的任务留容量,clientWorkerMaxThreadCount 不能替代显式线程数来扩容。
其他办法的影响范围不同,可以按需要选择,不用叠着加:
| 办法 | 怎么设置 | 要注意什么 |
|---|---|---|
| 只调整 Nacos 用来计算线程数的 CPU 数 | -Dnacos.common.processors=4,或者环境变量 NACOS_COMMON_PROCESSORS=4 |
保留 JVM 容器资源感知,但会影响其他使用这个值的 Nacos 线程池。环境变量优先于这个系统属性;显式线程数和上限仍要核对。 |
| 只覆盖 JVM 可见 CPU 数 | -XX:ActiveProcessorCount=4 |
不用关闭容器资源感知,但会影响 GC 等按 CPU 数计算线程的组件。Pod 的实际 CPU 配额不会因此增加。 |
| 实际增加容器 CPU 配额 | 例如把容器 CPU limit 从 2 核调到 4 核 | JVM 实际识别到更多 CPU 后,默认池也可能变大;需要额外资源,也要确认没有其他配置限制池大小。 |
从实现上,也可以让认证刷新和长期监听分开用执行资源,或让监听执行一轮就退出,再由调度器安排下一轮。如果选择升级,要确认目标版本确实改了这段逻辑、兼容现有组件;这里没有验证过具体升级版本。
不管用哪种方式,都要确认参数确实传给了进程、各组池实际生效,再完成前面的跨有效期验证。只延长 token 有效期、增加重试或定时重启,没有解决监听长期占满线程的问题。
补充:时间线和源码位置
当时的时间线
下面均为北京时间,按当时留下的日志和线程记录整理。
| 时间 | 记录 |
|---|---|
| 2026-10-29 约 11:14 | 旧 Pod 启动并取得 Nacos token。 |
| 2026-10-29 16:12:09 | Nacos 请求日志中,该 Pod 最后一次配置请求成功。 |
| 2026-10-29 16:15:01 | 首次出现该 Pod 的 Nacos 403 token expired!,此后持续发生。 |
| 2026-10-30 约 11:00 | 滚动重启应用后重新取得 token,刷新业务页面确认恢复;这只是临时恢复。 |
| 2026-10-30 约 11:27 | 临时重启后、调整 JVM 参数前,抓取 Worker 线程信息,确认监听任务仍占着线程。 |
| 2026-10-30 约 13:46 | 给应用 Deployment 增加 -XX:-UseContainerSupport;随后新 Pod 就绪。 |
源码位置
Spring 和 Dubbo 的创建入口用本地依赖 JAR 的 javap -c -p 核对过,具体方法见前文。
源码来自本地 Maven 缓存中的 com.alibaba.nacos:nacos-client:3.0.3:sources 和 com.alibaba.nacos:nacos-client-basic:3.0.3:sources。下表行号对应 source JAR,包名省略 com.alibaba.nacos. 前缀:
文件(包名省略共同前缀 com.alibaba.nacos.) |
关键位置 | 对应问题 |
|---|---|---|
client.config.NacosConfigService |
构造方法 74—85 行;getConfigInner() 206—260 行 |
谁创建配置客户端;403 是否回退快照 |
client.config.impl.ClientWorker |
构造方法 538—545 行;initWorkerThreadCount() 554—562 行 |
线程池由谁创建、大小如何计算 |
common.utils.ThreadUtils |
getSuitableThreadCount(int) 99—105 行 |
CPU 数如何换算成线程数 |
common.utils.PropertyUtils |
29—40、56—68 行 | CPU 数的覆盖项及 JVM 默认值 |
client.env.SearchableProperties、client.env.JvmArgsPropertySource |
init()、search()、getProperty() |
clientWorkerThreadCount 如何从 JVM 系统属性读取,以及默认配置优先级 |
client.config.impl.ConfigTransportClient |
start() 137—142 行 |
初次登录与定时刷新如何执行 |
client.config.impl.ClientWorker 内部类 ConfigRpcTransportClient |
startInternal() 844—864 行;requestProxy() 1279—1305 行 |
普通监听为何占线程;403 如何处理 |
client.config.impl.ConfigFuzzyWatchGroupKeyHolder |
start() 108—128 行 |
第二个长期监听任务从哪里来 |
client.security.SecurityProxy |
login() 78—84 行;reLogin() 111—124 行 |
登录委托及重新登录标记 |
client.auth.impl.NacosClientAuthServiceImpl(在 nacos-client-basic 中) |
login() 76—118 行;generateTokenRefreshWindow() 135—138 行 |
什么时候真正重新获取 token |

浙公网安备 33010602011771号