五个线程同时去换 token,四个被踢下线:刷新接口其实不是幂等的

这个 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 ÷ 有效期」。这个数字突然翻几倍,就是并发收敛失效了。

最后

凭证续期看着像「到期就换」,本质是把一个非幂等的操作,在并发下收敛成一次。

想清楚「刷新不是幂等的」这一点,剩下的只是决定在哪一层收敛。

相关推荐
第404号工位41 分钟前
插上充电宝真能偷光相册?我拆解了一台改装充电宝,实测“恶意充电”的真实攻击链
linux·python·架构
十二42 分钟前
【洛谷题解】P1115 最大子段和(前缀和 + 维护最小前缀)
数据结构·c++·算法·蓝桥杯
数据库小学妹43 分钟前
MySQL表碎片与 OPTIMIZE TABLE:空间回收的代价与边界
数据库·后端·mysql
故图1 小时前
全字段 INSERT IGNORE:大约 70 行零日期,把证照列表加载打崩
后端
Json____1 小时前
用 FastAPI + Vue3 打造一套健身私教课预约管理系统
后端·fastapi·it学习·wwwoop.com
aibo8603081 小时前
基于 Spring Boot + Vue3 的博物馆藏品管理系统设计与实现
java·spring boot·后端
苍何1 小时前
Codex+Obsidian,多平台同步,一键分发,太香了!
后端
幻智星科技1 小时前
FPGA不同品牌开发平台约束文件迁移难题解析
fpga开发·架构·硬件架构
renhongxia11 小时前
RAG检索增强生成:打通大模型与外部知识的通道
大数据·人工智能·算法