2026.06.18
双 Token 认证里最容易被忽略的一行:Single-flight 刷新去重
双 Token 方案本身不难,难的是「多处请求同时 401」的那一瞬间。这篇记录做鉴权与会话体系时,怎么让令牌续期收敛在请求层,业务代码完全无感。
负责 App 客户端的鉴权与会话体系。双 Token(Access + Refresh)是成熟的方案,但真正上线后出问题的从来不是方案本身,而是并发下的时序。
双 Token 的常规做法#
- Access Token 短命(分钟级),每次请求带上
- Refresh Token 长命(天级),只用来换新的 Access Token
- 两者都写进设备的加密存储(SecureStore)
请求层拦截 401,刷新令牌,然后重放一次原请求。目的很明确:业务代码永远不需要知道令牌刷新这回事。
async function request(input: RequestInfo, init?: RequestInit) {
const res = await fetch(withAuth(input, init));
if (res.status !== 401) return res;
await refreshToken();
return fetch(withAuth(input, init)); // 只重放一次
}看起来没问题。问题在并发。
「刷新风暴」是怎么发生的#
应用启动时通常有几个请求同时发出:拉用户资料、拉通知、拉首页数据。如果此刻 Access Token 恰好过期,它们会同时收到 401,然后同时去刷新。
后果有三层,一层比一层糟:
- 浪费:N 个请求换来 N 次刷新,每次都是一次网络往返。
- 互相覆盖:后返回的刷新结果覆盖先返回的。如果服务端做了 Refresh Token 轮换(每次刷新都作废旧的那个),先完成的那个新 Access Token 立刻作废,客户端却还在用它——下一次请求又 401。
- 死循环:第 2 点叠加"重放一次",就是刷新 → 401 → 刷新 → 401。
第 2 点是真正致命的,因为它会周期性复现,而不是偶发。
用单例 Promise 去重#
解法是把"正在刷新"这件事本身变成一个共享的 Promise:
let inFlight: Promise<string> | null = null;
export function refreshToken(): Promise<string> {
// 已经有人在刷新了 —— 复用它,不要另起一次
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const refresh = await SecureStore.getItemAsync("refreshToken");
if (!refresh) throw new AuthError("NO_REFRESH_TOKEN");
const res = await fetch(`${BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: refresh }),
});
const body = await res.json();
if (!res.ok) throw toAuthError(body);
if (typeof body?.data?.accessToken !== "string") {
throw new AuthError("MALFORMED_RESPONSE");
}
await SecureStore.setItemAsync("accessToken", body.data.accessToken);
if (body.data.refreshToken) {
await SecureStore.setItemAsync("refreshToken", body.data.refreshToken);
}
return body.data.accessToken;
} finally {
// 关键:无论成功失败都要清空,否则一次失败会永久卡住后续刷新
inFlight = null;
}
})();
return inFlight;
}inFlight 的赋值和 finally 里的清空是这段代码的全部要点:
- 第一个到达的调用者创建 Promise,其他人直接拿同一个引用
- 多个并发 401 只产生一次网络请求,且复用同一个结果
finally保证失败后不会留下一个永远 pending 的 Promise
漏掉 finally 那一句,症状是:令牌刷新失败一次之后,所有后续请求都卡在等一个永远不会 resolve 的 Promise 上,应用看起来是"网络一直转圈"。
什么情况下才该清登录态#
还有一个容易做错的判断:401 不等于登录失效。
网络抖动、网关超时、上游 5xx 都可能让刷新请求失败,但这些情况不应该把用户踢下线——用户重试一下就通了。
只有后端明确判定 Refresh Token 本身失效时,才清登录态。我们约定用一个专用业务码区分:
const REFRESH_TOKEN_INVALID = 4002;
if (body?.code === REFRESH_TOKEN_INVALID) {
await clearSession(); // 明确失效 → 清登录态,跳登录页
throw new AuthError("SESSION_EXPIRED");
}
// 其余情况保留本地会话,把错误抛给调用方重试
throw new AuthError("REFRESH_TRANSIENT");区分"明确失效"和"暂时失败",是这段逻辑里最值钱的一行判断。不做这个区分,用户在地铁里断网两秒就会被登出——而他从头到尾什么都没做错。
小结#
- 令牌续期收敛在请求层,业务代码不感知
- 并发刷新用单例 Promise 去重,注意
finally清理 - 只在后端明确判定失效时清登录态,网络异常保留会话
- 原请求只重放一次,避免在异常下变成循环