1. 背景

最近在当牛马的时候,线上遇到一个 看似很常见、但实际很坑的 Bug

场景

用户登录小程序后,正常使用一段时间,会突然被要求重新登录。

经过排查发现:

  • 用户登录使用的是 token 机制
  • token 有效期为 1 小时
  • token 过期后会被 用户中心直接销毁
  • 当前端携带一个 已过期 / 已销毁的 token 请求接口时,后端会直接返回 404
  • 前端识别到 404 后,就会触发 重新登录流程

从用户视角来看:

用户体验问题

用着用着突然掉线,非常影响体验 😓

为了解决这个问题,我们决定引入 token 刷新机制

  • 在 token 即将过期时
  • 后端主动调用 用户中心接口
  • 使用旧 token 换取新 token
  • 返回新 token 给前端继续使用

2. 初始实现思路

这个需求落到我身上,实现思路其实很直接:

  1. 每次请求先校验 token
  2. 如果 token 快过期(比如剩余 5 分钟)
  3. 调用用户中心接口,用 旧 token 换新 token
  4. 用户中心逻辑:
    • 换新 token 成功
    • 旧 token 立即失效

伪代码大概是这样:

1
2
3
4
5
6
public Token validateAndRefresh(Token oldToken) {
if (oldToken.willExpireSoon()) {
return userCenter.refresh(oldToken);
}
return oldToken;
}

本地自测、Postman 测试一切正常,看起来问题已经解决。

然而——

一上环境就翻车了。


3. 线上问题现象

在测试环境发现:

  • 有时候可以正常刷新
  • 有时候还是会被要求重新登录
  • Postman 单次请求 几乎必现成功
  • 前端页面操作却是 时好时坏

进一步抓日志 + 抓请求后发现一个关键点:

关键现象

前端一次点击,会并发发起多次请求

也就是说:

  • 第一次请求:
    • 使用旧 token
    • 成功刷新为新 token
    • 旧 token 被用户中心销毁
  • 第二、第三次请求:
    • 仍然携带 已经被销毁的旧 token
    • 直接 404

这就解释了为什么:

  • “有时成功,有时失败”
  • “一次点三次,只成功一次”

4. 第一版尝试方案(失败)

4.1 方案思路

我当时的第一反应是:

初始误判

既然只有第一次请求能换到新 token,那我把 新 token 和旧 token 的映射缓存起来 不就行了?

具体想法:

  • 第一次请求刷新 token 成功后
  • 在缓存中记录:
1
oldToken -> newToken
  • 后续请求如果还带着 oldToken
  • 就直接从缓存中拿 newToken

伪代码类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Token getToken(Token oldToken) {
Token cached = cache.get(oldToken);
if (cached != null) {
return cached;
}

if (oldToken.willExpireSoon()) {
Token newToken = userCenter.refresh(oldToken);
cache.put(oldToken, newToken);
return newToken;
}

return oldToken;
}

4.2 实际效果

改完后再测:

  • 原来:一次点击 3 个请求,只成功 1 个
  • 现在:一次点击 3 个请求,能成功 2 个

问题缓解了,但没有完全解决。


5. 关键问题定位

冷静下来重新分析后,发现这个方案本质上有一个致命问题:

致命问题

在第一个请求完成 token 刷新并写入缓存之前,其他并发请求已经进来了。

也就是说:

  • 并发请求 A / B / C
  • A 进入 refresh 流程
  • B、C 在 A 写缓存之前就已经校验 token
  • 此时:
    • 旧 token 已经被用户中心销毁
    • 缓存里还没有新 token
  • 结果:B、C 直接失败
核心结论

这是一个典型的并发 + 线程安全问题,而不是单纯的缓存问题。


6. 最终解决方案

最终方案

通过分布式锁控制并发刷新,结合 L1 + L2 二级缓存共享最新 token,彻底解决并发请求下 token 被提前销毁的问题。

6.1 核心目标

  • 同一时间 只允许一个请求刷新 token
  • 其他并发请求:
    • 不再直接刷新
    • 而是等待 / 读取最新 token

6.2 方案设计

1. 分布式锁

  • 使用 oldToken 作为锁 key
  • 刷新 token 前先抢锁
  • 保证只有一个请求会调用用户中心

2. 二级缓存

  • L1 本地缓存(如 Caffeine / Guava)
    • 极快
    • 减少 Redis 压力
  • L2 分布式缓存(如 Redis)
    • 跨实例共享
    • 存储最新 token

6.3 完整伪代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public Token getOrRefreshToken(Token oldToken) {
// 1. 先查 L1 缓存
Token token = l1Cache.get(oldToken);
if (token != null) {
return token;
}

// 2. 再查 L2 缓存
token = redis.get(oldToken);
if (token != null) {
l1Cache.put(oldToken, token);
return token;
}

// 3. 尝试获取分布式锁
String lockKey = "token:refresh:" + oldToken;
if (redisLock.tryLock(lockKey)) {
try {
// Double Check,防止重复刷新
Token again = redis.get(oldToken);
if (again != null) {
l1Cache.put(oldToken, again);
return again;
}

// 4. 真正刷新 token
Token newToken = userCenter.refresh(oldToken);

// 5. 写入二级缓存
redis.set(oldToken, newToken, 1, TimeUnit.HOURS);
l1Cache.put(oldToken, newToken);

return newToken;
} finally {
redisLock.unlock(lockKey);
}
}

// 6. 没抢到锁的请求,短暂等待后重试
sleep(50);
return getOrRefreshToken(oldToken);
}

7. 总结

这个 Bug 表面上看是:

表象问题

token 过期导致重新登录

但本质上是:

  • 前端并发请求
  • 后端 token 刷新非线程安全
  • 外部系统(用户中心)对旧 token 立即失效

最终的经验总结:

  1. 凡是“刷新 / 更新 / 置换”类操作,一定要先想并发问题
  2. Postman 测不出的问题,前端并发一上来就原形毕露
  3. 分布式系统里:
    • Double Check
    • 缓存层级

往往缺一不可。

希望这次踩坑记录,能帮到以后再踩类似坑的自己 🙃

吐槽

明明这个幂等应该用户中心实现的,结果还得我们做,离谱的很