这个 bug 很有节奏感:服务平稳跑两小时,忽然几秒钟内几十个请求一起 401,然后自己好了。再过两小时,再来一次。
第一次看到的时候我以为是上游抽风,翻了对端的公告,没有故障。后来在日志里发现 401 前面有一串 invalid_grant------去换 token 被拒了。
可 token 明明刚换过。
想通这一层,问题就清楚了
我去翻了协议文档里那句一直被我跳过的话:换新 token 时,旧的立即失效。
这句话的意思是:同一个 refresh_token,在同一时刻只能被使用一次。
所以我们那五秒钟里发生的事是这样的:缓存的 access_token 到期,五个并发的业务请求同时检查,同时发现「过期了」,同时发起刷新。
第一个成功了,拿到新 token,服务端顺手把旧 token 作废。
后面四个还在路上,它们手里拿的是刚被作废的 refresh_token,全部 invalid_grant。
如果代码把刷新失败直接抛给上层,就是五个请求一起 401。
更难看的是另一版:失败的那四个把空值写回了缓存,覆盖掉刚拿到的有效 token。故障从「一次 401」变成「持续 401,直到下一次刷新成功」。
还有个放大器。刷新接口的限频通常比业务接口严格得多,有的平台给到每分钟 5 次。五个并发刷新直接撞 429,然后重试逻辑介入,越刷越限,越限越刷------刷新风暴。
所以问题不是「加个锁」
我第一反应是加锁,加完发现没用。因为真正的题目是:让一段时间内只有一个刷新动作,并且让等着的那批请求用上它的结果。
这两件事缺一不可。
解法一:单飞加双检
python
import threading
import time
class TokenHolder:
"""进程内凭证持有者:并发请求只触发一次刷新。"""
def __init__(self, refresh_fn, skew_seconds: int = 60):
self._lock = threading.Lock()
self._refresh_fn = refresh_fn # 返回 (token, ttl_seconds)
self._token = None
self._expire_at = 0.0
self._skew = skew_seconds
def get(self) -> str:
if self._token and time.monotonic() < self._expire_at - self._skew:
return self._token
with self._lock:
# 双检:等锁期间可能已经被别人换过了
if self._token and time.monotonic() < self._expire_at - self._skew:
return self._token
token, ttl = self._refresh_fn()
self._token = token
self._expire_at = time.monotonic() + ttl
return token
两个细节,一个写错就等于没写。
锁里必须再判一次。 只判锁外那一层的写法,五个线程排队进锁,前一个刷完出来,后四个照样各刷一遍。并发变串行,invalid_grant 一个没少。
过期时间要用 time.monotonic()。 用 time.time() 的话,NTP 校时往回拨一下,预留的提前量瞬间归零,本该提前刷的判断直接失效。
skew_seconds 也别省。把 token 用到最后一秒,等于把刷新和使用压在同一瞬间,并发窗口放到最大。
解法二:提前后台刷
请求量稳定的服务,可以让业务路径永远碰不到过期 token。
python
import threading
import time
class BackgroundRefresher:
"""后台按有效期比例续期,业务线程不参与刷新。"""
def __init__(self, refresh_fn, refresh_ratio: float = 0.7):
self._refresh_fn = refresh_fn
self._ratio = refresh_ratio
self._token = None
self._started = False
self._lock = threading.Lock()
def _loop(self):
while True:
try:
token, ttl = self._refresh_fn()
with self._lock:
self._token = token
except Exception:
time.sleep(5) # 失败别退出循环
continue
time.sleep(max(5, int(ttl * self._ratio)))
def start(self):
if not self._started:
self._started = True
threading.Thread(target=self._loop, daemon=True).start()
def get(self) -> str:
with self._lock:
if self._token is None:
raise RuntimeError("凭证尚未就绪")
return self._token
业务代码变干净了,没有锁竞争。但它有两个躲不开的短板:进程刚起来时第一次调用还是要等刷新;进程重启,缓存空了,照样现场刷。
所以它适合常驻服务,不适合随时冷启动的那种部署。
解法三:集中托管
只要是多实例,前两个方案就都不够了。
五个 Pod 各有各的进程内锁,五个锁互相不知道对方存在,五次刷新照旧。
凭证得搬出进程,放到所有实例共享的地方:
python
import time
def get_token(redis, refresh_fn, skew: int = 60) -> str:
"""多实例共享凭证:抢到锁的实例刷新,其余实例等结果。"""
cached = redis.hgetall("upstream:token")
if cached and float(cached["expire_at"]) - time.time() > skew:
return cached["token"].decode()
lock_key = "upstream:token:lock"
if redis.set(lock_key, "1", nx=True, ex=10):
try:
cached = redis.hgetall("upstream:token")
if cached and float(cached["expire_at"]) - time.time() > skew:
return cached["token"].decode()
token, ttl = refresh_fn()
redis.hset("upstream:token", mapping={
"token": token,
"expire_at": time.time() + ttl,
})
return token
finally:
redis.delete(lock_key)
# 没抢到锁:等结果,绝不自己发刷新请求
for _ in range(20):
time.sleep(0.05)
cached = redis.hgetall("upstream:token")
if cached:
return cached["token"].decode()
raise RuntimeError("等待凭证刷新超时")
三个要点,第三个最容易写错。
没抢到锁的实例只能等。 只要有一个实例忍不住自己刷,并发就回来了。这是整个方案的生命线。
等待要有上限。 上面最多等 1 秒,超时抛异常走降级,不能让请求无限挂着。
锁的释放要校验持有者。 我上面为了短直接 delete 了,严格写应该把随机值写进锁,释放时用 Lua 比对再删。不然慢请求把别人的锁删掉,锁就形同虚设。
三种怎么选
| 解法 | 适用 | 代价 | 哪里会失效 |
|---|---|---|---|
| 单飞+双检 | 单实例 | 代码最少 | 多实例各刷各的 |
| 提前后台刷 | 常驻服务 | 业务路径零竞争 | 冷启动要现场刷 |
| 集中托管 | 多实例 | 依赖共享存储 | 存储故障 |
实践里最稳的是组合:集中托管保证跨实例一致,进程内再套一层单飞减少共享存储访问。两层都要,缺一层就有洞。
刷新失败必须分类
invalid_grant 和网络超时完全是两件事。
refresh_token 失效,重试没有意义,只能重新授权,这时候要告警到人,不是在代码里循环。
网络错误或 5xx 可以重试,但要退避,不能连累其他请求。
429 说明已经在并发刷新了,该退避并回头检查锁是不是没生效。
把这三类混成一个 except Exception: retry(),就是刷新风暴的起点。我踩过。
检查清单
- 搞清对端是旋转式还是固定式续期(旋转式才会互踢)
- 过期判断有提前量
- 锁内做了第二次判断
- 过期时间基于单调时钟
- 多实例时凭证放共享存储
- 没抢到锁只等结果,不自己刷
- 等待有超时上限
- 锁释放校验持有者
- 失败按凭证失效/网络错误/限频分类
- 刷新次数有监控
最后一条挺好用:正常情况下每天刷新次数约等于「实例数 × 24 ÷ 有效期」。这个数字突然翻几倍,就是并发收敛失效了。
最后
凭证续期看着像「到期就换」,本质是把一个非幂等的操作,在并发下收敛成一次。
想清楚「刷新不是幂等的」这一点,剩下的只是决定在哪一层收敛。