令牌活多久,谁说了算
写在前面
有一类 bug,几乎每个做过 OAuth 客户端的人都遇到过,但很少有人真正定位过它:
用户用得好好的,突然被要求重新登录。
它的特征非常一致——偶发、无法稳定复现、日志里只有一行
invalid_grant、用户重新登录一次就好了。于是它通常被归档成「网络问题」或者「服务端抽风」,然后在下一个版本里继续出现。但它多半既不是网络问题,也不是服务端抽风。它是一个并发问题,藏在一个大多数人以为不需要考虑并发的地方:令牌刷新。
这篇文章把这条链路从头到尾拆一遍——为什么令牌是一对而不是一个、轮换式刷新令牌带来了什么约束、竞态究竟怎么发生、三种解法各自的代价、落盘顺序为什么反直觉、时钟偏移会怎么放大问题、以及最关键的一步:如何把「刷新失败」和「凭据真的失效」分开。
全文约一万字,含 3 张原理示意图。读完你应该能自己判断,手里那个「偶发掉登录」到底出在哪一环。
本文是《Claude Code 的「账号」到底是什么》第一章的展开。那篇讲的是凭据有哪几类,这篇讲其中一类怎么活下去。
目录
- 第一章 为什么是一对令牌,而不是一个
- 第二章 轮换式:一次性的那种刷新令牌
- 第三章 竞态的解剖
- 第四章 三种解法与各自的代价
- 第五章 落盘顺序:一个反直觉的细节
- 第六章 时钟:过期判断没你想的那么简单
- 第七章 失败分类:唯一该打扰用户的情况
- 第八章 多客户端协调
- 第九章 怎么主动复现:把偶发变成必现
- 第十章 观测与排障
- 第十一章 一份检查清单
- 结语
第一章 为什么是一对令牌,而不是一个
1.1 短命的那个负责干活
访问令牌(access token)是真正被带在每个请求上的那一个。它的生命周期通常很短——几十分钟到几小时。
短命是刻意的。带在请求上的东西,暴露面最大:它会经过日志、经过中间层、经过各种可能被记录的地方。一旦泄露,短命意味着攻击窗口有限。
代价是它会频繁过期,所以必须有一个续命机制。
1.2 长命的那个负责续命
刷新令牌(refresh token)的生命周期长得多——数周到数月,有些实现干脆不设过期。
它和访问令牌的关键差别在于暴露面:它只在刷新那一刻被发出去,一次,对着一个固定的端点。平时它就躺在本地,不参与任何业务请求。
所以这一对的分工是清楚的:
- 高频使用、高暴露、短命 → 访问令牌
- 低频使用、低暴露、长命 → 刷新令牌
1.3 这个设计买到了什么
三件事。
第一,泄露的代价被压缩了。 拿到访问令牌的攻击者,最多能用几十分钟。
第二,撤销变得可行。 如果只有一个长命令牌,撤销它意味着服务端必须为每个请求查一次撤销列表——成本很高。有了这一对,服务端只需要在刷新那一刻检查一次:刷新令牌还有效吗?无效就不再签发。访问令牌自己会过期。这把一个高频检查变成了低频检查。
第三,用户不必频繁登录。 这是用户唯一能直接感知的那一件——只要刷新链路不断,登录状态就一直续下去。
注意第三条:这个设计的全部用户价值,就是「不用反复登录」。所以刷新链路断了、用户被要求重新登录,等于这套机制在它唯一的可见承诺上失败了。这就是为什么本文讨论的问题值得认真对待——它不是一个边角 bug,它打在这套设计的正中间。
1.4 一个容易被忽略的后果:状态变成了可变的
还有一件事值得先点出来,因为后面每一章都建立在它上面。
固定式凭据(比如一串 API Key)有个很舒服的性质:它是不可变的。你把它写进配置,之后所有读取都拿到同一个值,读的人再多也不会互相影响。
令牌对不是这样。刷新会改变它。于是配置文件从"只读的常量"变成了"多个进程共享的可变状态"——而这正是所有并发问题的经典配方:
共享 + 可变 + 多个写者 = 需要同步 很多客户端的令牌管理代码是从"读一个 API Key"的形态演化过来的,读写路径依然按不可变常量来写。它们不是忘了加锁,而是从没意识到这里已经变成了共享可变状态。
这也解释了为什么这类 bug 常常出现在一次"从 API Key 迁移到 OAuth"的重构之后——业务逻辑改对了,并发模型没跟上。
第二章 轮换式:一次性的那种刷新令牌
2.1 两种设计的取舍
刷新令牌有两种做法,差别在于用过之后旧的还有效吗:
| 类型 | 刷新后旧令牌 | 安全性 | 并发友好度 |
|---|---|---|---|
| 固定式 | 仍然有效,可反复使用 | 较低——泄露后可长期使用,且难以察觉 | 高,随便并发 |
| 轮换式(rotating) | 立即作废,换发新的一份 | 高——泄露窗口被压到一次 | 低,并发即冲突 |
轮换式的安全性优势是实打实的:一份被偷走的刷新令牌,只要正主先用了一次,它就作废了。反过来如果攻击者先用了,正主下次刷新会失败——失败本身成了泄露的信号。
正因如此,主流实现越来越倾向轮换式。
2.2 轮换带来的硬约束
但轮换式给客户端塞了一个硬约束:
同一份刷新令牌,在任意时刻只能有一个刷新动作在飞。
固定式下这个约束不存在——你并发刷十次,十次都成功,拿到十份等价的访问令牌,浪费点配额而已。轮换式下,第一个成功,其余全部拿到 invalid_grant。
这个约束听起来容易满足,直到你意识到:客户端往往不止一个进程。
2.3 重放检测:为什么服务端会连坐
还有一层更严厉的机制值得知道。
部分实现会做重放检测:如果一份已经作废的刷新令牌被再次使用,服务端不只是拒绝这一次,而是把整条令牌链全部作废——包括刚刚签发出去的那份新的。
逻辑是这样的:一份作废的令牌被重复使用,只有两种解释——要么是客户端有 bug,要么是令牌泄露了、有人在用旧的那份。既然分不清,就按最坏情况处理,把整条链掐掉,逼真正的用户重新登录。
从安全角度这完全合理。但它意味着:
在有重放检测的服务端上,一次刷新竞态不只是让一个客户端失败,而是可能把所有客户端一起打下线。
这就解释了一个特别恼人的现象——用户明明只是切了个窗口,结果 CLI、编辑器、桌面端同时要求重新登录。
第三章 竞态的解剖
3.1 时间线
把上图写成时间线:
T0 进程 A 发现 access token 快过期,读到 refresh_1,发起刷新
T0+ε 进程 B 也发现快过期,也读到 refresh_1,发起刷新
T1 服务端处理 A:签发 access_2 / refresh_2,refresh_1 作废
T2 服务端处理 B:refresh_1 已作废 → 400 invalid_grant
T3 进程 B 认为「凭据失效了」,触发重新登录流程 注意 T0+ε 那一步:B 读到的是 refresh_1,因为 A 还没把新的写回去。两个进程读到了同一份,这就是竞态的全部。
3.2 为什么这么容易撞上
一个自然的疑问:两个进程恰好在同一瞬间刷新,概率有多低?
比想象中高得多,原因有三个。
第一,它们的触发条件是相同的。 所有进程判断"要不要刷新"用的都是同一个 expires_at。这不是独立随机事件——它们被同一个时间点同步了。就像所有人的闹钟都定在同一分钟,"恰好同时"是必然而不是巧合。
第二,进程数比你以为的多。 命令行开着、编辑器插件的语言服务在后台跑着、桌面端最小化着——三个进程读同一份配置很常见。再加上有些客户端自己就是多进程的。
第三,唤醒时刻天然聚集。 笔记本合盖再打开,所有进程几乎同时恢复,同时发现令牌过期,同时刷新。这是竞态最容易发生的时刻,也是"合上盖子再打开就要重新登录"这种用户反馈的来源。
3.3 为什么极难复现
因为窗口极窄。
竞态只发生在「A 已发起刷新,但尚未写回新令牌」这段时间里。这段时间等于一次网络往返——通常几十到几百毫秒。要复现它,你得让两个进程在这个窗口内同时发起刷新。
手工测试基本不可能撞上。而且它在开发环境更难出现:开发时往往只开一个客户端,配置文件也是新鲜的。
结论是:这类 bug 不可能靠"多测几次"发现,只能靠推理找出来。 这也是为什么它在很多产品里能存活很久——没人能复现,就没人能修。
3.4 还有一类触发源:401 反向触发
前面讨论的都是"主动发现快过期了"这一条触发路径。还有一条更隐蔽的:被 401 反向触发。
流程是这样的:客户端带着自认为还有效的访问令牌发请求,服务端返回 401,客户端意识到"哦它过期了",于是发起刷新。
这条路径的问题在于它天然是并发的:如果客户端同时有多个请求在飞(很常见——一个补全、一个诊断、一个索引),那么它们会同时收到 401,然后同时发起刷新。
而且这条路径经常绕过主动刷新那套逻辑——很多实现里,主动刷新走的是一个带锁的调度器,而 401 触发走的是拦截器里一段独立的代码。两条路径各写各的,锁只加在其中一条上。
排查时值得专门确认一句:你的两条触发路径,用的是同一段刷新代码吗? 如果不是,锁很可能只保护了一半。
第四章 三种解法与各自的代价
4.1 单飞:只管得住进程内
单飞(singleflight) 是最常见的第一反应:进程内维护一个"正在刷新"的标记,并发的调用方合并成一次请求,其余等待并复用结果。
实现简单,开销极低,而且能消除同一进程内的并发刷新。
但它的边界很清楚:跨进程无效。而第三章说过,真实场景里多进程才是常态。所以单飞是必要的,但远远不够。
4.2 跨进程锁:超时的两难
真正能解决问题的是跨进程锁——用文件锁或类似机制,保证同一时刻只有一个刷新在跑。其余进程等锁,拿到锁后先重读配置:如果已经有新令牌了,直接用,不必再刷。
这条路是对的,但有一个必须处理的问题:持锁的进程崩了怎么办?
如果不设超时,锁就永远不会释放,所有进程一起卡死。所以必须有超时。但超时值本身是个两难:
| 超时设置 | 后果 |
|---|---|
| 太短 | 一次网络慢就会导致锁提前释放,第二个进程冲进去 —— 等于没锁 |
| 太长 | 持锁进程崩溃后,其余进程要干等这么久 |
比较稳妥的做法是把超时定在远大于一次正常刷新耗时、但用户还能忍受的量级——通常几秒到十几秒。同时,拿到锁之后一定要重读一次配置,而不是直接刷新:因为很可能上一个持锁者已经刷好了。
这一步经常被忘。忘了它,跨进程锁就退化成了"排队刷新"——每个进程都刷一次,只是排着队刷,轮换式下照样从第二个开始全部失败。
4.3 宽限期:最优雅,但你说了不算
第三种解法在服务端:旧刷新令牌作废后,保留一个很短的宽限窗口(通常几十秒),窗口内用旧令牌发来的重复请求,返回同一份新令牌,而不是报错。
这是最优雅的解法——它从根上消除了竞态的后果,客户端什么都不用做。
但对客户端开发者来说,它有个致命属性:决定权不在你手里。服务端做了,你就享受到;没做,你只能靠前两种。而且服务端有没有做、宽限期多长,通常不在文档里。
所以正确的态度是:假设没有宽限期来设计,如果实际上有,那是白赚的。
4.4 现实中的组合
综合下来,稳妥的策略是跨进程锁做主,单飞做进程内优化:
需要刷新?
├─ 进程内已有刷新在飞 → 等它,复用结果 (单飞)
└─ 否 → 抢跨进程锁
├─ 抢到 → 重读配置
│ ├─ 已经是新的 → 直接用,不刷 ← 这一步最容易漏
│ └─ 还是旧的 → 刷新,落盘,释放锁
└─ 没抢到 → 等锁(带超时)→ 重读配置 → 用新的 再加上第七章要讲的失败分类,这套组合能覆盖绝大多数情况。
4.5 锁拿不到怎么办
最后一个实际问题:如果这个环境根本没法加跨进程锁呢?
这不是假设。几种常见情况:
- 配置目录在只读文件系统上,或挂载在不支持文件锁的网络文件系统上;
- 客户端跑在受限沙箱里,拿不到锁所需的系统调用;
- 多个客户端根本不在同一台机器上(比如配置目录被同步到了多台设备)。
最后一种尤其麻烦——文件同步工具会把配置文件复制到别的机器,而那台机器上的进程会拿着同一份刷新令牌去刷新。 你在本机加再严的锁也管不到它。
这种情况下能做的只有降级,按可靠性排序:
| 降级手段 | 效果 | 局限 |
|---|---|---|
| 加大随机抖动 | 显著降低撞车概率 | 只是降低,不是消除 |
| 收紧提前刷新窗口 | 缩短"都觉得该刷了"的时间段 | 太紧会导致刷新赶不上过期 |
| 强化失败分类 | 撞了也能自愈,用户无感 | 这一条最关键 |
注意第三条:当你无法阻止竞态发生时,唯一的出路是让竞态发生了也没关系。 第七章那套"重读配置判断是否撞车"的逻辑,在没有锁的环境里从"优化"变成了"必需品"。
这引出一个值得记住的工程判断:防止和恢复,是两条独立的防线,不能互相替代。 锁是防止,失败分类是恢复。只做防止的实现,在锁失效的环境里会彻底崩掉;只做恢复的实现,会有大量本可避免的多余刷新。两条都要有。
第五章 落盘顺序:一个反直觉的细节
5.1 先用后存,会丢掉唯一一份
即使解决了并发,还有一个只在崩溃时暴露的问题:新令牌是先拿去用,还是先写到磁盘?
直觉是"先用,确认能用了再存"——毕竟万一新令牌是坏的呢?
在轮换式模型下,这个直觉是错的。
左边那条路的问题在于:refresh_1 在服务端签发新令牌的那一刻就作废了,与你有没有存下新的无关。所以崩溃之后,磁盘上那份 refresh_1 已经是一张废纸,而唯一有效的 refresh_2 从来没被写下来过。
用户唯一的出路是重新登录。
反过来,先存后用的最坏情况是什么?新令牌是坏的,你存了它——但你顶多再刷新一次。代价是一次多余的网络请求,而不是一次掉登录。
5.2 原子写:写临时文件再重命名
"存"这个动作本身也有讲究。
如果直接覆写配置文件,那么在写到一半时崩溃,磁盘上就是一个半新半旧的损坏文件。对客户端来说这比没有文件更糟——没有文件它知道要重新登录,损坏文件它可能直接解析失败、崩溃、或者读出乱七八糟的值。
标准做法是:
① 写入临时文件(同一目录,避免跨文件系统)
② fsync(确保内容真的落盘,不是躺在页缓存里)
③ 原子重命名,覆盖目标文件 文件系统保证 rename 是原子的:任何时刻,其他进程看到的要么是完整的旧文件,要么是完整的新文件,不存在中间态。
第 ② 步经常被省略。省了它,在断电场景下 rename 可能已经生效但内容还没落盘,结果是一个长度正确、内容为零的文件。日常崩溃(进程被 kill)不会触发这个问题,只有断电和内核崩溃会——所以它极其罕见,也极难排查。
5.3 一条通用准则
把这一章和前面的合起来,可以提炼出一条准则:
在凭据管理里,重复操作的代价永远低于丢失的代价。
- 多刷新一次?浪费一次请求。
- 丢了刷新令牌?用户重新登录。
两者根本不在一个量级。所以所有设计上的犹豫,都应该向"宁可多做一次"倾斜:宁可多存一次,宁可多刷一次,宁可留个残余文件,也不要冒丢失的风险。
第六章 时钟:过期判断没你想的那么简单
6.1 时钟偏移
服务端返回的过期信息通常有两种形式:expires_in(还有多少秒)或 expires_at(绝对时刻)。
expires_at 看起来更方便,但它埋着一个坑:它是服务端时钟的时刻,而你在用本地时钟去比较。
如果本地时钟快了五分钟,你会认为令牌已经过期而提前刷新——浪费一次,但无害。如果本地时钟慢了五分钟,你会认为令牌还有效而继续使用——然后收到 401,而你的代码可能没准备好处理"我以为没过期但服务端说过期了"这种情况。
稳妥做法是:优先用 expires_in,收到响应时立刻换算成本地时钟的绝对时刻。这样只依赖本地时钟的流逝(相对量),不依赖它的绝对值。相对量的误差远小于绝对值。
6.2 提前刷新窗口
不要等到令牌真的过期才刷新。理由:
- 网络往返需要时间。掐着过期时刻刷新,请求发出时可能已经过期了。
- 排队、重试都需要余量。
通常的做法是留一个提前量——在过期前若干时间就开始刷新。
但这个提前量有个副作用,正好放大第三章的问题:提前量越大,多个进程"同时发现该刷新了"的窗口就越宽。所有进程都在过期前 5 分钟醒来,比都在过期前 30 秒醒来,撞车概率高得多。
缓解办法是给提前量加一点随机抖动——每个进程的提前量在一个范围内随机取值,把它们的唤醒时刻打散。这是分布式系统里的老办法,用在这里同样有效。
抖动不能替代锁,但它能显著降低抢锁的频率。两者是互补的。
6.3 休眠与挂起
最后一个时钟相关的坑:笔记本合盖。
进程被挂起时,它设的定时器行为在不同平台上不一致——有的照常走,有的暂停。所以恢复之后,不能相信任何基于定时器的判断,必须重新用当前时刻算一次。
而且如第三章所说,恢复时刻是所有进程一起醒来的时刻,是竞态的高发点。所以恢复路径上的刷新,尤其需要走完整的锁流程,不能图省事走快路径。
第七章 失败分类:唯一该打扰用户的情况
这一章是全文最关键的一章。前面所有的机制,都是为了减少失败;而这一章讲的是失败之后怎么办——即使机制再完备,失败也一定会发生。
7.1 三类失败
刷新请求失败,至少要分成三类,因为它们的正确应对完全不同:
| 类别 | 表现 | 凭据状态 | 正确应对 |
|---|---|---|---|
| 网络 / 服务端 | 连不上、超时、5xx | 完好 | 退避重试 |
| 刷新撞车 | invalid_grant,但本地刚发生过刷新 | 完好(只是换了新的) | 重读配置,用新的 |
| 凭据真失效 | invalid_grant,重读后仍是同一份 | 已失效 | 重新授权 |
把它们混成一类的后果非常具体:
- 全按"重新授权"处理 → 网络抖一下用户就被踢下线。
- 全按"重试"处理 → 凭据真失效时无限重试,用户看到的是一直转圈,不知道该做什么。
7.2 区分中间那一类的方法
第一类和后两类容易分——看是不是 invalid_grant 就行。难的是后两类:它们的错误码是一样的。
区分方法其实很直接:重读一次配置,看令牌变没变。
收到 invalid_grant
→ 重读配置文件
├─ 令牌和我刚才用的不一样 → 别人已经刷好了,撞车,用新的重试
└─ 令牌还是同一份 → 没人刷过,是真失效 这个判断的依据是:如果是撞车,说明有另一个进程成功刷新过,它一定已经把新令牌写回去了(因为第五章说了要先存后用)。所以**"重读后令牌变了"就是撞车的证据**。
这里能看出第五章那条"先存后用"的另一层价值:它不只是防丢失,它还让撞车变得可检测。如果实现是先用后存,那么撞车时另一个进程可能还没写回,你重读到的还是旧的,就会误判成真失效。
这是一个很好的例子——看似无关的两个设计决策,其实互相支撑。
还有一个补强:如果重读后令牌没变,但距离上次成功刷新很近(比如几秒内),仍然值得再等一小会儿重读一次。因为可能另一个进程刚拿到响应、还没来得及落盘。
7.3 重新授权是最后手段
最后强调一点态度问题。
要求用户重新登录,是这套机制能做出的最糟糕的事。 它意味着:中断当前工作、打开浏览器、走完整个授权流程,运气不好还要在几台设备上各做一遍。
所以重新授权必须是穷尽所有其他可能之后的最后手段,而不是遇到 invalid_grant 的默认反应。
一个实用的自查:在你的代码里搜索触发重新授权的地方,看看有几处。 如果超过一处,很可能有某处是过于草率的。理想情况下,整个客户端应该只有一个地方能决定"必须重新授权",而且它的入参应该是一个明确的、已经排除了前两类的结论。
第八章 多客户端协调
8.1 谁持有令牌
前面几章默认了一个前提:多个进程读写同一份配置文件。这是最常见的形态,但不是唯一的。
大致有两种架构,取舍不同。
8.2 集中式:一个守护进程
由一个常驻进程独占持有令牌,其余客户端向它索取。
优点很明显:只有一个进程会刷新,竞态从根上消失了;锁、重读、抖动这些全都不需要。
代价也很明显:
- 多了一个必须常驻的进程,它的生命周期管理本身就是工程量(怎么启动、怎么保活、崩了谁拉起、怎么升级)。
- 它成了单点。它挂了,所有客户端一起不可用。
- 客户端和它之间需要一套通信机制,而这套机制本身也要考虑并发和错误处理。
有意思的是:集中式并没有消灭复杂度,只是把它从"令牌刷新"搬到了"进程管理"。 后者更成熟、更容易复用现成方案,所以这个搬运通常是划算的——但它不是免费的。
8.3 分散式:各存各的
每个客户端独立走一次授权,持有各自的令牌。
这就是上一篇第八章讲的按设备签发在进程层面的对应物。优点是彻底没有共享状态,也就没有竞态;每个客户端的问题被限制在自己身上。
代价是用户要授权多次,而且授权本身有次数或数量限制时会撞上限。
一个折中是按"客户端家族"分组:命令行和编辑器插件共用一份(它们通常来自同一个安装),桌面端单独一份。这样既减少了共享的范围,又不至于让用户授权太多次。
第九章 怎么主动复现:把偶发变成必现
第三章说过,这类 bug 手工测试基本撞不上。但它是可以被主动构造出来的——而且一旦能构造,验证修复就从"上线观察两周"变成"跑一次测试"。
这一章讲怎么造。
9.1 核心思路:把窗口撑开
竞态的窗口,等于「A 发起刷新」到「A 写回新令牌」之间的时间——正常情况下是一次网络往返,几十到几百毫秒。
要复现它,不需要精确的时序控制,只需要把这个窗口人为撑大。撑到几秒,你就有充裕的时间手工触发第二个进程。
撑开的办法有三种,按侵入性从低到高:
办法一:在刷新端点前放一个可控延迟。 把客户端指向一个本地代理,代理转发到真实端点,但在返回响应之前故意等 5 秒。这样 A 的刷新会挂起 5 秒,你有充足时间去戳 B。
这个办法最干净——不改客户端一行代码,测的是真实二进制的真实行为。
办法二:在客户端里加一个测试钩子。 在"拿到响应"和"写回配置"之间插一个可配置的 sleep,仅在测试构建或某个环境变量下生效。
比办法一侵入性高,但能精确控制窗口的位置——你可以单独撑开「拿到响应后、落盘前」这一段,从而验证第五章讲的落盘顺序问题。
办法三:直接构造脏状态。 不跑真实流程,而是手工把配置文件改成"一个已经作废的刷新令牌",然后启动客户端看它怎么反应。
这个办法最快,适合验证第七章的失败分类——它不复现竞态本身,但能复现竞态的后果。
9.2 一份可以照做的复现步骤
以办法一为例:
① 起一个本地代理,转发刷新端点,响应前延迟 5s
② 客户端指向该代理
③ 把配置里的过期时刻改成「即将过期」
④ 同时启动两个客户端进程(或一个进程 + 手工触发第二次)
⑤ 观察:
A → 5s 后拿到新令牌
B → 拿到 invalid_grant
⑥ 检查 B 的行为:
✓ 重读配置、用新令牌继续 → 修复有效
✗ 触发重新授权 → 就是这个 bug 第 ③ 步值得单独说:直接改配置里的过期时刻,比等真实过期高效得多。令牌的实际有效期可能是几十分钟,你不可能每次测试都等那么久。而"提前多久算过期"是客户端本地的判断,改配置就能操纵。
9.3 把它变成回归测试
手工复现一次是不够的——你需要它变成每次改动都会跑的测试,否则半年后有人重构刷新逻辑,这个 bug 会原样回来。
要变成自动化测试,需要三个部件:
| 部件 | 作用 |
|---|---|
| 假的授权服务端 | 完整实现轮换语义:签发新的、作废旧的、旧的再来就返回 invalid_grant |
| 可控延迟 | 撑开窗口,让并发确定性地发生 |
| 断言 | 检查最终状态,而不是中间过程 |
假服务端那一条是关键。用真实服务端做自动化测试不现实——你不能反复消耗真实令牌,也不能要求它按你的时序配合。而轮换语义本身很简单,几十行就能实现一个够用的假服务端。
断言应该检查最终状态,比如:
- 两个进程最后是否都持有有效令牌?
- 配置文件里最终那份令牌是不是最新的那份?
- 有没有触发重新授权?(这一条最重要——它应该是 0 次)
不要断言中间过程(比如"B 应该收到 invalid_grant"),因为一个足够好的实现可能通过锁完全避免 B 发出那次请求。断言结果,不要断言路径——否则你的测试会阻止更好的实现。
9.4 顺带能测出来的其他问题
搭好这套装置之后,很多相关问题都能顺手验证:
- 杀进程测落盘顺序:在延迟窗口里 kill 掉 A,重启后看它能不能继续——这直接验证第五章。
- 改本地时钟测偏移:把系统时间往前/往后拨,看过期判断会不会出错——验证第六章。
- 断开网络测失败分类:让代理直接不响应,看客户端是退避重试还是触发重新授权——验证第七章。
- 同时起十个进程测锁:看最终有几次真实刷新发生。理想情况是 1 次,如果是 10 次说明锁没生效,如果是 2-3 次说明锁生效了但有重读缺失。
最后一条那个数字特别有用:真实刷新次数是一个单一指标,直接反映锁和重读做得对不对。把它打进日志或测试断言,比读代码判断可靠得多。
第十章 观测与排障
9.1 应该记什么日志
刷新链路的日志有个特殊要求:它必须能在事后还原出竞态。而竞态是多进程的,所以单进程视角的日志不够。
至少要记这些:
| 字段 | 为什么 |
|---|---|
| 进程标识 | 没有它就分不清是哪个进程干的 |
| 刷新令牌的指纹(不是明文) | 用来判断两次刷新用的是不是同一份 |
| 触发原因 | 主动到期、401 触发、恢复后检查——分布很说明问题 |
| 是否抢到锁、等了多久 | 锁没生效的话这里会一眼看出来 |
| 结果与错误码 | 区分三类失败的原始依据 |
| 落盘是否成功 | 定位"刷成功了但没存下来" |
指纹这一条要强调:绝不要记明文令牌。取个短哈希前缀就够用了——你需要的只是"两次是不是同一份"这个判断,不需要值本身。
9.2 一份排查顺序
如果你手上正好有一个"偶发掉登录",可以按这个顺序查:
第一步:确认错误码。 是不是 invalid_grant?如果是 401/403 或者网络错误被当成了凭据失效,那问题在分类逻辑(第七章),不在并发。
第二步:看有没有并发刷新。 从日志里找时间接近的两次刷新,看它们用的令牌指纹是否相同。相同 = 竞态确认。
第三步:检查锁。 有没有锁?拿到锁之后有没有重读配置?重读这一步的缺失是最常见的。
第四步:检查落盘顺序。 是先存后用还是先用后存?有没有原子写?
第五步:检查唤醒时刻的分布。 如果掉登录集中在"合盖再打开"之后,重点看恢复路径(第六章 6.3)。
第六步:检查提前量与抖动。 提前量很大又没有抖动,会显著推高撞车率。
第十一章 一份检查清单
把全文压成可以逐条对照的清单:
并发
持久化
时钟
失败处理
可观测
结语
回到开头那个问题:为什么用着用着突然要求重新登录?
现在可以给出一份完整的答案链:
- 轮换式刷新令牌要求同一时刻只能有一个刷新在飞;
- 多个客户端进程共享同一份令牌,且被同一个过期时刻同步唤醒;
- 于是它们同时读到同一份刷新令牌,同时发起刷新;
- 第一个成功,其余收到
invalid_grant; - 客户端把
invalid_grant一律当成"凭据失效",触发重新授权; - 如果服务端还有重放检测,整条令牌链被作废,所有客户端一起掉线。
每一环单独看都很合理,合在一起就是一个几乎无法复现的偶发故障。
而修复它不需要什么高深技术——跨进程锁、抢到锁后重读、先存后用、失败分类,四件事而已。难的不是实现,是意识到这里存在并发。
这也是我写这篇的原因:令牌刷新看起来像一个纯粹的串行流程——过期了,去换一个新的,多简单。正是这种"看起来简单",让它在无数产品里成为一个长期存在、反复出现、却从没人真正修好的问题。
如果你手上正好有这么一个 bug,第十章那份清单可以直接拿去对照。
本文只讨论 OAuth 2.0 及其常见扩展的公开机制与通用客户端工程实践,不涉及任何特定厂商的内部实现。文中的错误码与行为描述以 RFC 6749 及主流实现的共性为准,具体服务端可能存在差异,实现时请以其文档为准。
