深入理解 Token 无感刷新:从并发雪崩到单例锁 + 请求队列的完整实现

79 阅读10分钟

前言

最近在项目中遇到一个线上问题:用户反馈"使用过程中突然被踢到登录页"。排查后发现,页面加载时同时发出了多个 API 请求,恰逢 Access Token 过期,导致刷新逻辑被重复触发,Refresh Token 被提前消耗。

这个问题并不罕见,但网上的方案大多存在隐患。今天就来系统梳理一下 Token 无感刷新的完整实现思路。


Token 刷新的三个常见陷阱

现代 Web 应用普遍采用 JWT 双 Token 机制:

  • Access Token —— 短期有效(15~30 分钟)
  • Refresh Token —— 长期有效(7 天左右)

理想状态:Access Token 过期后,前端自动刷新并重试原请求,用户全程无感知。但实际实现中容易踩三个坑。

陷阱 1:并发雪崩

页面加载时 10 个接口同时发出,Token 恰好过期,10 个请求全部返回 401。如果拦截器直接刷新:

// 每个请求都触发一次刷新
axios.interceptors.response.use(null, async (error) => {
  if (error.response.status === 401) {
    const newToken = await refreshToken();
    error.config.headers['Authorization'] = `Bearer ${newToken}`;
    return axios(error.config);
  }
});

10 个 401 → 10 次 refreshToken() → 后端收到 10 个刷新请求。

轻则后端拒绝重复刷新,重则 Refresh Token 被提前消耗,用户反而被踢下线。

陷阱 2:无限循环

如果 refreshToken() 本身也返回 401(Refresh Token 过期),拦截器会再次触发 → 刷新 → 又 401 → 再重试……浏览器直接卡死。

陷阱 3:到处复制粘贴

就算写对了,每个新项目都得重复那一套 isRefreshing + failedQueue + processQueue 的逻辑,容易漏细节。


正确方案:单例锁 + 请求队列 + 重试标记

核心思路可以概括为三个字:锁、排、重

请求 A ──→ 401 ──→ 加锁,发起刷新 ──→ 成功 ──→ 重试 A ──→ 200 ✅
请求 B ──→ 401 ──→ 已加锁,加入队列 ──┘
请求 C ──→ 401 ──→ 已加锁,加入队列 ──┘
                                       └──→ 拿到新 Token,重试 B、C ──→ 200
  • isRefreshing):第一个 401 负责刷新,后续 401 全部排队
  • failedQueue):将等待中的请求存入队列,刷新完成后统一处理
  • _retry 标记):已重试的请求不再进入刷新逻辑,防止死循环

完整实现

状态与队列定义

let isRefreshing = false;        // 单例锁
const failedQueue: QueueItem[] = [];  // 请求队列

只需要两个变量,一个锁一个队列。

队列处理函数

const processQueue = (error: any, token: string | null) => {
  failedQueue.forEach(({ resolve, reject }) => {
    error ? reject(error) : resolve(token!);
  });
  failedQueue.length = 0;  // 清空队列
};

刷新成功时统一 resolve(token),失败时统一 reject(error),然后清空队列。

响应拦截器

这是核心部分:

axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalConfig = error.config;

    // 1. 非 401 或已重试过 → 直接拒绝
    if (!shouldRefresh(status) || retryCount >= maxRetryCount) {
      return Promise.reject(error);
    }

    // 2. 已经在刷新中 → 加入队列等待
    if (isRefreshing) {
      return new Promise<string>((resolve, reject) => {
        failedQueue.push({ resolve, reject, config: originalConfig });
      }).then((token) => {
        setAuthHeader(originalConfig, token);
        return axios(originalConfig);  // 拿到新 Token 后重试
      });
    }

    // 3. 首次触发 → 加锁、刷新
    originalConfig._retry = true;
    isRefreshing = true;

    try {
      const response = await refreshApiCall();
      const newToken = extractToken(response);

      processQueue(null, newToken);    // 通知队列中所有等待的请求
      setAuthHeader(originalConfig, newToken);
      return axios(originalConfig);    // 重试当前请求
    } catch (refreshError) {
      processQueue(refreshError, null);  // 刷新失败,通知所有排队请求
      clearAuth();
      window.location.href = '/login';
      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;  // 无论成败,释放锁
    }
  }
);

几个关键细节:

  1. 队列中的请求通过 Promise 挂起 —— 调用方拿到的是一个未 resolved 的 Promise,刷新成功时统一 resolve,调用方的 .then() 自然触发重试
  2. finally 释放锁 —— 即使刷新异常也不会死锁
  3. _retry 标记 —— 重试后的请求不会再次进入刷新流程,切断死循环

封装成通用方案的思路

上面的代码和业务耦合在一起(硬编码了刷新接口、Token 提取方式、失败跳转等)。要封装成通用方案,需要把这些行为全部抽成参数。

参数设计

经过梳理,真正需要外部传入的只有以下几项:

interface TokenRefreshOptions {
  /** 必填:刷新 Token 的 API 请求函数 */
  refreshApiCall: () => Promise<AxiosResponse>;

  /** 必填:从刷新响应中提取新的 Access Token */
  extractToken: (response: AxiosResponse) => string;

  /** 可选:自定义设置请求头,默认 Bearer */
  setAuthHeader?: (config: AxiosRequestConfig, token: string) => void;

  /** 可选:触发刷新的状态码,默认 [401] */
  statusCodes?: number[];

  /** 可选:最大重试次数,默认 1 */
  maxRetryCount?: number;

  /** 可选:刷新成功回调 */
  onRefreshSuccess?: (token: string, response: AxiosResponse) => void;

  /** 可选:刷新失败回调 */
  onRefreshFailure?: (error: any) => void;
}

为什么这么设计?

refreshApiCall 为什么是函数而不是 URL 字符串?

因为刷新请求的发起方式千差万别:有的需要额外 Header,有的需要加签名,有的要走独立的 Axios 实例。给一个函数,调用方完全掌控怎么发请求。

extractToken 为什么是函数而不是字段名?

后端返回格式不统一:有的 res.data.token,有的 res.data.access_token,有的嵌套在 res.data.data.accessToken。直接给 response,怎么取由调用方决定。

onRefreshSuccess / onRefreshFailure 为什么是回调?

Token 存在哪、刷新失败跳哪个页面,这些和业务强绑定。通用的方案不应该替调用方做这些决定,而是在正确的时机回调出去。

最终 API

import { createTokenRefreshInterceptor } from 'token-refresh-lock';
import axios from 'axios';

const instance = axios.create({ baseURL: '/api' });

const controller = createTokenRefreshInterceptor(instance, {
  refreshApiCall: () => instance.post('/auth/refresh'),
  extractToken: (res) => res.data.accessToken,
  onRefreshSuccess: (token) => {
    sessionStorage.setItem('access_token', token);
  },
  onRefreshFailure: () => {
    sessionStorage.removeItem('access_token');
    window.location.href = '/login';
  },
});

// 登出时可以手动卸载拦截器
controller.eject();

效果验证

用 Mock 数据模拟了三个典型场景:

场景 1:单个请求 Token 过期

请求 /api/user → 401 → 自动刷新 → 重试 → 200 ✅
刷新调用次数:1

场景 2:5 个并发请求同时 401

请求 1~5 全部 401 → 只刷新 1 次 → 全部重试成功 ✅
刷新调用次数:1(不是 5)

这是单例锁最核心的价值 —— 无论多少个请求同时 401,刷新接口永远只被调用一次。

场景 3:Refresh Token 也过期

请求 /api/user → 401 → 尝试刷新 → 刷新也失败 → onRefreshFailure 被调用 ✅

正常时无感刷新,异常时优雅降级。这才是生产环境需要的行为。


在不同框架中的集成方式

Vue 3 + Pinia

// stores/auth.ts
import { defineStore } from 'pinia';
import axios from 'axios';
import { createTokenRefreshInterceptor } from 'token-refresh-lock';

const api = axios.create({ baseURL: '/api' });

export const useAuthStore = defineStore('auth', () => {
  const token = ref('');

  createTokenRefreshInterceptor(api, {
    refreshApiCall: () => api.post('/auth/refresh'),
    extractToken: (res) => res.data.accessToken,
    onRefreshSuccess: (newToken) => { token.value = newToken; },
    onRefreshFailure: () => {
      token.value = '';
      router.push('/login');
    },
  });

  return { token };
});

React

// lib/request.ts
import axios from 'axios';
import { createTokenRefreshInterceptor } from 'token-refresh-lock';

const api = axios.create({ baseURL: '/api' });

createTokenRefreshInterceptor(api, {
  refreshApiCall: () => api.post('/auth/refresh'),
  extractToken: (res) => res.data.accessToken,
  onRefreshSuccess: (token) => {
    sessionStorage.setItem('access_token', token);
  },
  onRefreshFailure: () => {
    sessionStorage.removeItem('access_token');
    window.location.href = '/login';
  },
});

export default api;

小程序(uni-app + axios 适配)

import axios from 'axios';
import { createTokenRefreshInterceptor } from 'token-refresh-lock';

const api = axios.create({ baseURL: 'https://your-api.com' });

createTokenRefreshInterceptor(api, {
  refreshApiCall: () => api.post('/auth/refresh'),
  extractToken: (res) => res.data.accessToken,
  onRefreshFailure: () => {
    uni.clearStorageSync();
    uni.reLaunch({ url: '/pages/login/index' });
  },
});

需要后端配合:HttpOnly Cookie 是推荐但非必须

前面的方案只讲了前端怎么做,但无感刷新的安全运行,强烈推荐后端将 Refresh Token 存储在 HttpOnly Cookie 中。

需要说明的是:本方案对后端 Refresh Token 的传递方式没有硬性要求。无论你的后端是 HttpOnly Cookie、请求体传递、还是自定义 Header,都可以通过 refreshApiCall 参数自行控制。下面先讲推荐方案,再给出替代写法。

为什么必须是 HttpOnly Cookie?

HttpOnly 是一个 Cookie 属性,设置后 JavaScript 无法通过 document.cookie 读取该 Cookie,但浏览器会在后续匹配的请求中自动携带。

// 后端登录成功后下发
Set-Cookie: refreshToken=abc123; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh

这带来一个关键特性:前端永远获取不到 Refresh Token 的值

  • XSS 攻击注入了恶意脚本 → 读不到 refreshToken Cookie → 无法窃取
  • 前端代码里根本不存在 Refresh Token 的变量 → 从源头消除了泄露风险
  • 浏览器调用 /auth/refresh 时自动携带 → 前端不需要手动传递

如果不用 HttpOnly 会怎样?

很多项目图方便,把 Refresh Token 存在 localStorage 里,前端手动读取后拼到请求体中发送。这样做有一个致命隐患:

存储方式JS 可读XSS 可窃取后果
localStorage攻击者拿到 Refresh Token → 7 天内随意冒充用户
sessionStorage同上,仅关闭标签页后失效
JS 内存变量同上,刷新页面后失效
HttpOnly Cookie攻击者读不到,从根本上杜绝窃取

Refresh Token 有效期通常 7 天,一旦被 XSS 窃取,攻击者可以在 7 天内随意刷新 Access Token,等同于长期盗用账号。这不是理论风险,是真实发生过的事故。

替代方案:请求体传递 Refresh Token

如果你的后端暂时无法使用 HttpOnly Cookie(如跨域场景、老项目改造等),也可以在 refreshApiCall 中手动传递:

createTokenRefreshInterceptor(instance, {
  // 手动从存储中读取 Refresh Token 并放入请求体
  refreshApiCall: () => {
    const refreshToken = sessionStorage.getItem('refresh_token');
    return instance.post('/auth/refresh', { refreshToken });
  },
  extractToken: (res) => res.data.accessToken,
});

这种方式能正常工作,但 Refresh Token 暴露给了 JavaScript,存在 XSS 窃取风险。仅建议作为过渡方案,长期仍应迁移到 HttpOnly Cookie。

前后端协作流程(HttpOnly Cookie 方案)

整个无感刷新需要前后端配合完成,流程如下:

1. 用户登录
   前端 → POST /auth/login { username, password }
   后端 → Set-Cookie: refreshToken=xxx; HttpOnly; Secure; SameSite=Strict
   后端 → 响应体: { "accessToken": "eyJhbG..." }
   前端 → 将 accessToken 存入内存或 sessionStorage

2. 正常业务请求
   前端 → Authorization: Bearer <accessToken>    (手动设置请求头)
   前端 → Cookie: refreshToken=xxx               (浏览器自动携带)

3. Access Token 过期(返回 401)
   前端 → POST /auth/refresh                     (SDK 自动调用)
   浏览器 → 自动携带 HttpOnly Cookie 中的 refreshToken(前端无感知)
   后端 → 校验 refreshToken,签发新的 accessToken
   后端 → 响应体: { "accessToken": "new_eyJhbG..." }
   前端 → SDK 用新 accessToken 重试原请求         (用户无感知)

4. Refresh Token 也过期
   前端 → POST /auth/refresh
   后端 → 401 Unauthorized
   前端 → onRefreshFailure 回调,跳转登录页

注意第 3 步:前端调用 /auth/refresh 时,不需要也不可能在请求体中传递 Refresh Token —— 它由浏览器通过 Cookie 自动携带,这就是 HttpOnly 的意义。

后端需要做什么?

后端的配合非常简单,只需要两件事:

1. 登录接口下发 HttpOnly Cookie:

POST /auth/login
→ 200 OK
  Set-Cookie: refreshToken=xxx; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh
  Body: { "accessToken": "eyJhbG..." }

2. 提供刷新接口:

POST /auth/refresh
Cookie: refreshToken=xxx  (浏览器自动携带)

→ 成功: { "accessToken": "new_eyJhbG..." }
→ 失败: 401 Unauthorized(Refresh Token 也过期或无效)

刷新接口从 Cookie 中读取 Refresh Token 并校验,合法则签发新的 Access Token 返回,非法则返回 401。前端不需要关心 Refresh Token 怎么传递的,refreshApiCall 只需发起一个 POST 请求即可。

Access Token 存储建议

Token 类型推荐存储方式不推荐原因
Access Token内存变量 / sessionStoragelocalStorage短期有效,避免持久化泄露
Refresh TokenHttpOnly Cookie(后端设置)任何前端可读位置前端不可读,防 XSS 窃取

切勿将任何 Token 存入 localStorage,这是 XSS 攻击的高价值目标。


总结

Token 无感刷新看似简单,实则暗藏并发雪崩、无限循环、安全存储三大陷阱。本文梳理了一套经过验证的方案:

  1. 单例锁 —— 同一时间只允许一次刷新请求,杜绝并发雪崩
  2. 请求队列 —— 利用 Promise 机制将等待中的请求挂起,刷新成功后批量重试
  3. 重试标记 —— 防止重试请求再次进入刷新流程,切断死循环

这个方案不复杂,但需要每个细节都考虑到位。把核心逻辑理解清楚后,无论是手写还是基于现有封装,都能避免常见的那些坑。

希望这篇文章能帮到正在实现 Token 刷新方案的同学。如果有更好的思路,欢迎交流讨论。