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 小时后续签成功。重启后立刻能用,只能说明当时恢复了,还需要继续确认:

  1. 到正常刷新窗口时,是否重新登录成功,而且是实际访问元数据的那个客户端。
  2. 连续运行超过 5 小时后,主动读配置、读元数据,再调用原来失败的业务接口。没有请求时看不到 403,说明不了续签是否正常。
  3. 在受控验证中触发需要重新读取实例元数据的访问,排除只是旧缓存还能用。条件允许,再多观察一个完整周期。

补充:线程池数量和部署差异

为什么一个进程里会有三组配置线程池

同一个应用可能不止一份配置客户端。核对当前配置和本地依赖,还能找到这些创建入口:

入口 用途 创建客户端的方法
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
posted @ 2026-11-02 23:20  fitch_liu  阅读(4)  评论(0)    收藏  举报