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,然后同时去刷新。

后果有三层,一层比一层糟:

  1. 浪费:N 个请求换来 N 次刷新,每次都是一次网络往返。
  2. 互相覆盖:后返回的刷新结果覆盖先返回的。如果服务端做了 Refresh Token 轮换(每次刷新都作废旧的那个),先完成的那个新 Access Token 立刻作废,客户端却还在用它——下一次请求又 401。
  3. 死循环:第 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 清理
  • 只在后端明确判定失效时清登录态,网络异常保留会话
  • 原请求只重放一次,避免在异常下变成循环