1. 先从一场凌晨告警说起:Token 过期为什么能把接口打崩
1.1 那晚发生了什么
接手老项目的第三周,凌晨两点的告警把我从床上薅了起来。监控屏上连续弹出三条消息:"外部支付渠道接口失败率超过 30%""订单查询超时率上升""鉴权接口响应时间突增"。打开日志中心,满屏都是同一种异常:
code复制com.example.api.AuthException: access_token expired
at com.example.thirdparty.WechatClient.doPost(WechatClient.java:88)
at com.example.order.service.OrderQueryService.query(OrderQueryService.java:132)
把时间轴拉出来一看,规律非常明显:没有网络超时,没有参数错误,全是 token 过期。这个系统的访问令牌有效期是两小时,而业务高峰期恰好横跨了过期点,客户端持有的旧 token 在那一瞬间集体失效。第一波请求进来,业务代码 catch 到异常后,各自执行了一次 refreshToken(),问题就出在这里——上千条业务线程同时去刷新 token,第三方鉴权接口 QPS 本来就有配额,瞬间被打到限流。刷新失败的线程继续重试原请求,继续拿着旧 token 打,继续 401,形成一个恶性循环。
这个场景在对接第三方开放平台、内部微服务网关、或者任何采用短时 access_token 鉴权的系统里都极易出现。它暴露出来的问题不是"token 会过期"这种常识,而是"过期之后怎么恢复"这个相当容易被低估的技术环节。
1.2 为什么"catch 一下重新调"不是一个好方案
从单次请求的角度看,catch 住 TokenExpiredException、刷新 token、重新调用业务接口,逻辑上没有任何问题。但从系统整体来看,这个简单的方案至少埋着三个雷:
- 刷新风暴(refresh stampede):没有全局唯一的刷新入口时,同一瞬间可能有成千上万个并发请求各自触发刷新。鉴权接口不是自家服务,通常有 QPS 限制,一旦被打满,轻则限流重则封禁 IP,恢复周期比 token 过期本身长得多。
- 重试次数没有上限:业务侧如果直接用 while 循环重试,遇到鉴权服务短暂不可用,请求会在循环里空转,耗尽线程池资源,把本来没受影响的接口也拖垮。这是典型的雪崩前兆。
- 刷新后的 token 传播不及时:某些实现里,刷新后的新 token 只存在于当前线程的局部变量中,其他线程依旧持有旧值。下一个请求进来,拿着旧 token 再打一次,继续 401,刷新代码被反复执行,但整体成功率上不去。
TokenRetryHelper 就是在这种背景下被设计出来的。它把"判断 token 是否过期 → 触发刷新 → 重试原调用"这套逻辑收敛到一个统一的执行器里,业务方不再关心 token 从哪里来、怎么续期、最多重试几次,只负责把自己的调用动作当作一个函数传进去。
1.3 它解决的问题,用一句话概括
TokenRetryHelper 的本质是一个带重试能力的执行器,它承担了三件事:限制重试次数、保证同一时刻只有一个线程(或一个实例)发起刷新、让后续请求尽快用上最新 token。它不关心 token 本身长什么样,也不关心你调的是 REST 接口还是 RPC 接口,它只负责把"重试"这件很容易做错的事情做对。
这篇文章会从老版本的设计讲起,分析它为什么在 Spring Boot 迁移时不能直接搬,然后给出完整的重构方案和踩坑记录。如果你也在维护一个类似的"带 token 鉴权的调用层",或者正在把老项目往 Spring Boot 上挪,下面这些内容应该能帮你省下不少弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. TokenRetryHelper 老版本的设计拆解
2.1 核心接口与主流程
老项目用的是传统 SSM 架构,没有 Spring 容器管理,TokenRetryHelper 就是一个普通的工具类,配合三个函数式接口工作。初次接手时它的代码比我预想的要克制,主要逻辑如下:
java复制public class TokenRetryHelper {
private static final int DEFAULT_MAX_RETRY = 3;
private volatile String cachedToken;
private final Object refreshLock = new Object();
public <T> T execute(TokenSupplier tokenSupplier,
TokenRefresher tokenRefresher,
ApiCaller<T> apiCaller) {
String token = tokenSupplier.get();
int attempt = 0;
while (true) {
try {
return apiCaller.call(token);
} catch (TokenExpiredException ex) {
attempt++;
if (attempt >= DEFAULT_MAX_RETRY) {
throw ex;
}
token = refreshToken(token, tokenRefresher);
}
}
}
private String refreshToken(String oldToken, TokenRefresher tokenRefresher) {
// 双检锁:避免同一 JVM 内多个线程重复刷新
String current = cachedToken;
if (current != null && !current.equals(oldToken)) {
return current;
}
synchronized (refreshLock) {
current = cachedToken;
if (current != null && !current.equals(oldToken)) {
return current;
}
cachedToken = tokenRefresher.refresh(oldToken);
return cachedToken;
}
}
}
调用方式大概是这样的:
java复制String result = tokenRetryHelper.execute(
() -> tokenCache.getAccessToken(),
oldToken -> authClient.refreshToken(oldToken),
token -> thirdPartyClient.queryOrder(token, orderId)
);
从接口签名可以看出,TokenSupplier 负责提供 token,TokenRefresher 负责拿旧 token 换新 token,ApiCaller 是真正的业务调用,它抛出的 TokenExpiredException 是触发重试的唯一信号。整个执行器内部就是一个受控的 while 循环,外部拿到的永远只是最后的结果或者最终异常。
2.2 双检锁刷新:避免 JVM 内的刷新风暴
这里最值得学习的是 refreshToken 里的双检锁写法。为什么需要它?因为在高并发场景下,第一波请求发现 token 过期时,会有大量线程同时进入 catch 块。如果不加控制,它们会各自执行 tokenRefresher.refresh(oldToken),把同一个 refreshToken 凭证拿到鉴权服务上去换新。问题在于,不少第三方平台的设计是 refresh_token 只能使用一次,第二次用就标记失效,后续所有刷新都会失败。
双检锁的思路是:
- 第一次检查 cachedToken 是否已经变化。如果另一个线程刚刚完成了刷新,当前线程直接使用新值,连锁都不需要竞争。
- 如果第一次检查发现还是旧值,说明刷新尚未完成,进入 synchronized 块。
- 进入锁后做第二次检查。因为第一个拿到锁的线程可能已经完成了刷新,此时 cachedToken 已经更新,当前线程就不必再执行刷新,直接返回新值即可。
这个模式在单机场景下是有效的,也确实解决了刷新风暴的问题。但它有个隐含前提:cachedToken 是静态字段在单 JVM 内共享。一旦服务水平扩展成多个实例,这套锁就只锁得住一台机器,别的机器的线程照样自己在刷新。
2.3 老代码的隐藏问题:表面能用,其实有三处硬伤
老版本在线上跑了很久,功能上没出过太大问题,但我在评估迁移工作量时,发现了三个埋得非常深的问题:
- 静态状态与单实例绑定:cachedToken 是类级别的静态字段,多实例部署时各自为政,一台机器刷新了新 token,其他机器的缓存还是旧值,整个集群的 token 更新节奏是乱的。
- 配置全部硬编码:最大重试次数 3、锁对象、token 缓存位置都写死在代码里。想调参数必须改代码重新发版,这在运维侧非常被动。
- 依赖隐藏且不可测试:tokenRefresher 的实现直接依赖第三方 SDK 的静态方法,无法在单元测试里 mock,遇到非 token 过期的鉴权异常(比如签名错误、权限不足)也会被当成可重试异常,白白消耗重试次数。
这些问题在单机、低频、非关键路径上都不致命,但一旦要迁移到 Spring Boot 并接受更高的流量和更严格的运维要求,它们就必须被重新设计。
3. 迁移 Spring Boot:看着简单,实际要解决三个层面的问题
3.1 容器管理:从静态工具类到受管 Bean 的本质变化
很多人拿到"把老代码迁到 Spring Boot"这个任务,第一反应是把类上直接加 @Component,然后跑起来试试。这种加法本身没有错,但它掩盖了容器管理带来的本质变化。
工具类时代,TokenRetryHelper 是 self-contained 的,所有依赖都通过静态方法或方法参数传入。成为 Spring Bean 之后,它的依赖关系被反转了:token 来源、刷新器、分布式锁、配置对象全部应该通过构造器注入。这意味着,这个类不再是"我给你参数你来执行",而是"我声明我依赖什么,由容器在运行时组装"。
这个转变带来的好处是配置外置。重试次数、锁的 key、token 的缓存 TTL 都可以放到 application.yml 里,运维改一行配置就能调整行为,不需要重新打包。如果你迁移的项目里还有 springfox 3.0.0 这类与 Spring Boot 2.6+ 存在路径匹配冲突的老组件,这个过程会更痛苦,需要先解决框架层面的兼容问题,再谈业务组件的迁移。
3.2 从单机到多实例:synchronized 的语义完全变了
老代码里的双检锁,在单机部署时是可靠的。但 Spring Boot 服务默认就是多实例部署,一个服务至少两三个节点,同步锁只能锁住当前 JVM 内的线程,跨实例的刷新竞争完全失控。
我有一次在测试环境遇到过真实案例:两个实例同时发现 token 过期,各自发起刷新,其中一台拿到的 refresh_token 因为并发刷新被第三方标记为已失效,导致该实例在接下来的十分钟里所有请求全部鉴权失败。这个问题的根源不是第三方接口有 bug,而是分布式场景下的共享状态没有被妥善处理。
所以迁移方案里,锁的语义必须从"进程内互斥"升级为"分布式互斥"。最常用的载体就是 Redis,用 SETNX 指令实现互斥锁,配合过期时间防止死锁。锁的范围也从"静态字段"变成"Redis 中的一个 key",所有实例对同一个 key 竞争。
3.3 Spring 生态里的现成轮子,别重复造
老代码里很多手写的逻辑,在 Spring Boot 生态里都有成熟组件可以直接替换。我在重构前列了一张对照表:
| 手写逻辑 | Spring Boot 生态替代 | 引入成本 |
|---|---|---|
| while 循环重试 | Spring Retry 的 RetryTemplate | 低,一个注解搞定 |
| token 本地缓存 | Caffeine + Redis 二级缓存 | 低,starter 自带 |
| 状态字段 | @ConfigurationProperties 绑定外部配置 | 低,原生支持 |
| 监控统计 | Micrometer + Actuator | 低,指标挂载即可 |
| 分布式锁 | RedisTemplate + Lua 脚本 | 中,需要自己封装 |
这不是说 Spring 生态的组件一定比手写代码好,而是说它们经过了大量生产环境验证,边界条件处理得更完善。比如 Spring Retry 提供的退避策略(BackOffPolicy)、异常分类(retryFor / noRetryFor)、有状态重试,都是手写代码很难在一晚上就做扎实的东西。既然迁移的目标是降低维护成本,那就应该把这些现成的能力用起来,而不是把老代码的手写逻辑原封不动地搬到新框架里。
4. 落地重构:从工具类到 Spring Boot 组件的完整实现
4.1 第一步:配置项全部外置
我为迁移后的组件设计了一个独立的配置类,用 @ConfigurationProperties 绑定 yml 里的参数。这样做的直接好处是:调整重试策略不用改代码,测试环境、生产环境可以通过不同 profile 使用不同参数。
java复制@ConfigurationProperties(prefix = "app.token")
public class TokenProperties {
/** 最大重试次数 */
private int maxAttempts = 3;
/** 分布式锁过期时间 */
private Duration refreshTimeout = Duration.ofSeconds(5);
/** token 在 Redis 中的 key */
private String tokenKey = "app:token:cache";
/** 刷新锁的 key */
private String refreshLockKey = "app:token:refresh:lock";
/** token 有效期,用于刷新后写入 Redis */
private Duration tokenTtl = Duration.ofMinutes(90);
// getters / setters 省略
}
application.yml 里的配置:
yaml复制app:
token:
max-attempts: 3
refresh-timeout: 5s
token-key: "app:token:cache"
refresh-lock-key: "app:token:refresh:lock"
token-ttl: 90m
为了避免遗漏,建议在启动类上加上 @EnableConfigurationProperties(TokenProperties.class) 或者在配置类上加 @ConfigurationPropertiesScan,让 IDEA 能够在编译期帮你检查配置项的拼写。这一步成本极低,收益立竿见影。
4.2 第二步:拆分职责,面向接口
老代码把 token 的来源、刷新、存储全部混在工具类的参数里,迁移后我按单一职责拆成了几个接口:
- TokenStore:负责 token 的读写,屏蔽底层是 Redis、Caffeine 还是数据库。
- TokenRefresher:负责用旧 token 换新 token,封装第三方鉴权 SDK。
- TokenRetryTemplate:负责整个重试与刷新协调流程,是组件的门面。
核心执行器的实现如下:
java复制@Component
public class TokenRetryTemplate {
private final TokenStore tokenStore;
private final TokenRefresher tokenRefresher;
private final TokenProperties properties;
private final StringRedisTemplate redisTemplate;
public TokenRetryTemplate(TokenStore tokenStore,
TokenRefresher tokenRefresher,
TokenProperties properties,
StringRedisTemplate redisTemplate) {
this.tokenStore = tokenStore;
this.tokenRefresher = tokenRefresher;
this.properties = properties;
this.redisTemplate = redisTemplate;
}
public <T> T execute(TokenAction<T> action) {
int attempt = 0;
while (true) {
String token = tokenStore.get();
try {
return action.call(token);
} catch (TokenExpiredException ex) {
attempt++;
if (attempt >= properties.getMaxAttempts()) {
throw ex;
}
refreshTokenIfNeeded(token);
}
}
}
private void refreshTokenIfNeeded(String oldToken) {
String latest = tokenStore.get();
if (latest != null && !latest.equals(oldToken)) {
return;
}
String lockKey = properties.getRefreshLockKey();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, UUID.randomUUID().toString(),
properties.getRefreshTimeout());
if (!Boolean.TRUE.equals(locked)) {
// 拿不到锁,说明其他实例正在刷新,短暂等待后重新读取
sleepQuietly(100);
return;
}
try {
// 获取锁后二次检查,避免重复刷新
String current = tokenStore.get();
if (current != null && !current.equals(oldToken)) {
return;
}
String newToken = tokenRefresher.refresh(oldToken);
tokenStore.set(newToken);
} finally {
redisTemplate.delete(lockKey);
}
}
private void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
这个实现的核心逻辑和老版本一脉相承,但有几个关键变化:锁变成了 Redis 分布式锁、token 的读取统一走 TokenStore、配置全部来自外部绑定。双检锁的模式保留了下来,只是作用范围从 JVM 扩展到了整个集群。
4.3 第三步:TokenStore 的 Redis 实现
TokenStore 我选择了 Redis 作为主存储,理由很简单:所有实例共享同一份 token 缓存,谁刷新了,其他人立刻能读到新值,不需要额外做广播通知。
java复制@Component
public class RedisTokenStore implements TokenStore {
private final StringRedisTemplate redisTemplate;
private final TokenProperties properties;
public RedisTokenStore(StringRedisTemplate redisTemplate,
TokenProperties properties) {
this.redisTemplate = redisTemplate;
this.properties = properties;
}
@Override
public String get() {
return redisTemplate.opsForValue().get(properties.getTokenKey());
}
@Override
public void set(String token) {
redisTemplate.opsForValue().set(
properties.getTokenKey(), token, properties.getTokenTtl());
}
}
如果想进一步降低对 Redis 的访问压力,可以在本地加一层 Caffeine 缓存,配合一个极短的过期时间(比如 10 秒),让大部分读请求打到本地内存。这属于性能优化,不属于功能必需项,我建议先上最简单的版本,等压测数据出来再决定要不要加这一层。
4.4 第四步:业务侧怎么接
迁移后,业务代码不再需要传三个函数式接口,只需注入 TokenRetryTemplate,把真正的调用动作写进 lambda 即可:
java复制@Service
public class OrderQueryService {
private final TokenRetryTemplate retryTemplate;
public OrderQueryService(TokenRetryTemplate retryTemplate) {
this.retryTemplate = retryTemplate;
}
public OrderResult query(String orderId) {
return retryTemplate.execute(token -> {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<OrderResult> response = restTemplate.exchange(
endpointUrl, HttpMethod.GET, entity, OrderResult.class);
return response.getBody();
});
}
}
这样设计的最大好处是关注点分离。OrderQueryService 只关心"查订单",token 的来源、过期后的刷新、重试次数全部由 TokenRetryTemplate 负责。测试的时候,mock 掉 TokenRetryTemplate,业务逻辑的快测一点不受影响;反过来,单独给 TokenRetryTemplate 写单元测试,也完全不需要启动 Spring 容器。
4.5 第五步:把可观测性做进去
老版本组件跑得像黑盒,token 到底刷了多少次、失败了多少次、重试了几轮,完全看不到。迁移时我直接用 Micrometer 的 Counter 和 Timer 把指标暴露出来,接入 Prometheus + Grafana:
java复制@Configuration
public class TokenMetricsConfiguration {
private final MeterRegistry meterRegistry;
public TokenMetricsConfiguration(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
public void recordRefreshSuccess() {
Counter.builder("app.token.refresh.success")
.register(meterRegistry)
.increment();
}
public void recordRefreshFailure() {
Counter.builder("app.token.refresh.failure")
.register(meterRegistry)
.increment();
}
}
指标不在多,关键在于在告警时能回答三个问题:现在 token 是不是有效的?最近刷新有没有失败?重试有没有异常放大?有这三个指标,配合 Actuator 的健康检查,线上排障时间至少能缩短一半。
5. 迁移中的深坑与对策
5.1 @Retryable 的自调用陷阱
我最初想在业务方法上直接用 Spring Retry 的 @Retryable 注解,让框架自动重试,代码看起来会简洁很多:
java复制@Service
public class OrderClientService {
@Retryable(retryFor = TokenExpiredException.class, maxAttempts = 3)
public OrderResult fetchOrder(String orderId) {
// 调用第三方接口
}
}
但很快踩到了 Spring AOP 的经典坑:注解在同类内部调用时不会生效。因为 @Retryable 是基于代理实现的,外部调用走代理,内部 this.fetchOrder() 直接调用原始对象,注解被完全绕过。如果你在 fetchOrderWithRefresh 方法里 try-catch 到异常后又调用 fetchOrder,你会发现重试根本没有发生。
解决方案有三个:
- 把重试方法放到独立的 Service 类中,通过注入代理对象调用。
- 在类里注入自身的代理(@Lazy 注入自己)。
- 放弃注解,直接用 RetryTemplate 编程式重试。
我最终选择了方案三。原因很直接:TokenRetryTemplate 本身就承担了重试和刷新协调的职责,业务方法再叠加 @Retryable 反而职责重复,而且注解方式在定制退避策略和异常分类时不如编程式灵活。
5.2 重试与幂等的边界:有些请求真的不能重试
TokenRetryTemplate 的通用逻辑是:遇到 TokenExpiredException 就刷新并重试。但业务层必须自己判断请求是否幂等。如果是查询操作,重试没有任何问题;如果是下单、支付、退款这类写操作,重试可能导致重复扣款或重复创建订单。
我的建议是:
- 在 TokenAction 回调里,业务方通过捕获异常自行判断当前请求是否已经发出,是否收到了明确的失败响应。
- 对于非幂等的写操作,在重试前校验请求幂等键,或者使用第三方接口自带的请求流水号。
- 在 TokenRetryTemplate 上提供 executeWithIdempotentKey 的重载方法,让业务方显式传入幂等键,避免滥用重试能力。
这个问题不是 TokenRetryHelper 特有的,但既然组件把重试逻辑藏了起来,业务方很容易以为"反正它会重试",从而放松了对幂等性的警惕。这一点我在代码注释里反复强调,也在 Review 时专门提示过所有使用方。
5.3 Redis 锁的过期时间与安全删除
分布式锁最常见的坑是:持有锁的实例处理时间超过了锁的过期时间,锁被 Redis 自动释放,此时另一个实例获得了锁,前一个实例处理完后执行 delete,把别人的锁删掉了。
我在代码里用 setIfAbsent(lockKey, uuid, timeout) 获取锁,value 里存了一个 UUID。释放锁时不能简单调 delete,而是要先比对 value,确认是自己的锁才删除。Spring Data Redis 里可以用 execute 传入一个 Lua 脚本实现原子操作:
java复制private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) " +
"else return 0 end";
public void releaseLock(String lockKey, String lockValue) {
redisTemplate.execute(
new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
Collections.singletonList(lockKey),
lockValue
);
}
锁的过期时间也要设置合理。token 刷新通常是一个网络调用,正常情况下 1 秒内能完成,但遇到第三方服务慢的时候,5 秒的过期时间可能不够。我建议把刷新动作单独用带超时的 HTTP client 执行,超时时间小于锁过期时间,从根上避免持有锁时间过长的问题。
5.4 不是所有 401 都等于 token 过期
这个坑在迁移后的联调阶段才暴露出来。有些第三方接口在签名错误、权限不足、请求被网关拦截时也会返回 401,但响应体里的错误码各不相同。如果 TokenRetryTemplate 只认 HTTP 状态码,就会把签名错误当成 token 过期,触发刷新和重试,白白浪费刷新凭证。
正确的做法是:在解析异常时检查响应体中的错误码或错误类型,只对明确的 token_expired / invalid_token 错误做刷新重试,其他 401 直接上抛。我为此定义了一个异常分类器:
java复制public class TokenExpiredPredicate {
private final Set<String> tokenExpiredCodes =
Set.of("TOKEN_EXPIRED", "INVALID_TOKEN", "ACCESS_TOKEN_EXPIRED");
public boolean matches(AuthException ex) {
return ex.getErrorCode() != null
&& tokenExpiredCodes.contains(ex.getErrorCode());
}
}
TokenRetryTemplate 的 catch 块改为先走 Predicate 判断,再决定是否重试。这也回答了"为什么不能用 Spring Retry 的简单 retryFor 直接指定异常类型"——异常类型不够精细,需要业务语义层面的判断。
5.5 刷新接口本身失败时,不要继续烧重试次数
老代码还有一个坏习惯:刷新 token 失败后,外层循环当成普通异常继续重试,结果第三方鉴权服务已经熔断,业务线程还在不断尝试刷新。迁移后的 refreshTokenIfNeeded 方法里,我在刷新失败时做了快速失败处理——直接抛出不可重试的异常,让上层走降级逻辑,而不是默默再进循环。
这个决策的依据是:如果刷新接口都失败了,说明鉴权链路本身有问题,此时重试业务请求毫无意义,不如快速失败,把压力留给熔断器去保护。
6. 压测验证与上线后的指标观察
6.1 怎么在测试环境模拟 token 过期
迁移完成后,最关键的验证就是模拟一次真实的 token 过期,观察整个组件是否符合预期。我推荐用 MockWebServer 或 WireMock 搭建一个模拟的第三方接口,把 token 的 TTL 调到 1 分钟,然后在临界点用压测工具打并发。
更省事的方案是直接在测试里控制 TokenStore 的值:先在 Redis 里写入一个有效 token,然后写一个脚本在指定时间点手动删除或者替换成无效值,同时跑并发请求。观察点有三个:
- 所有请求最终是否成功。
- 整个集群触发的刷新次数是否为 1(通过 Redis 锁保证)。
- 重试次数是否在配置上限之内。
6.2 压测场景与阈值
我做了三个场景的压测:
| 场景 | 并发数 | 预期结果 |
|---|---|---|
| token 正常有效期 | 500 QPS | 无刷新,无重试,RT 平稳 |
| token 过期瞬间 | 500 QPS | 少量请求触发一次刷新,随后所有请求成功 |
| 刷新接口不可用 | 300 QPS | 快速失败,不阻塞,线程池不被打满 |
第三个场景最容易出问题。如果刷新接口直接返回 500,TokenRetryTemplate 应该立刻抛错,而不是继续重试。我把 maxAttempts 配成 3,实际观察到的行为是:每个请求最多触发一次刷新尝试,失败后立即上抛,线程池占用率保持在安全水位。
6.3 上线后盯哪些指标
新组件上线后,我在 Grafana 里加了三个面板:
- token_refresh_total:刷新次数,正常情况下一个 token 生命周期内集群级刷新次数等于到期次数,不随 QPS 放大。
- token_refresh_failure_total:刷新失败次数,这个指标变为 0 才能说明分布式锁和刷新逻辑正常工作。
- retry_total:重试次数,正常情况下只出现在过期瞬间的少数请求上,如果持续走高,说明有其他异常被误判成了 token 过期。
另外,我把第三方鉴权接口的 RT 和调用量也放在同一个看板里。肉眼对比"业务失败率曲线"和"刷新调用曲线"重叠程度,是最直观的判断方式。如果刷新调用量突然放大到请求量的数量级,说明锁失效了,需要立刻检查 Redis key 的 TTL 设置和 lock 释放逻辑。
从老版本的静态工具类到 Spring Boot 受管组件,TokenRetryHelper 的迁移不只是加一个 @Component 那么简单。容器管理、分布式锁、配置外置、可观测性、异常分类,每一层都需要重新审视。我最大的体会是:任何"在单机场景下看起来没问题"的并发控制,一旦放到多实例环境,都必须重新考虑它的语义边界。现在这套方案已经在我这边的生产环境跑了一段时间,token 过期瞬间的请求成功率保持在 99.9% 以上,之前的刷新风暴彻底消失。如果你也在迁老代码,建议先把这个组件的重试边界画清楚,再去动业务代码,顺序反过来容易两头不讨好。
