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

前言

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

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


Token 刷新的三个常见陷阱

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

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

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

陷阱 1:并发雪崩

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

typescript 复制代码
// 每个请求都触发一次刷新
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 的逻辑,容易漏细节。


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

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

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

完整实现

状态与队列定义

typescript 复制代码
let isRefreshing = false;        // 单例锁
const failedQueue: QueueItem[] = [];  // 请求队列

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

队列处理函数

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

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

响应拦截器

这是核心部分:

typescript 复制代码
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 提取方式、失败跳转等)。要封装成通用方案,需要把这些行为全部抽成参数。

参数设计

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

typescript 复制代码
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

typescript 复制代码
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 过期

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

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

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

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

场景 3:Refresh Token 也过期

bash 复制代码
请求 /api/user → 401 → 尝试刷新 → 刷新也失败 → onRefreshFailure 被调用 ✅

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


在不同框架中的集成方式

Vue 3 + Pinia

typescript 复制代码
// 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

typescript 复制代码
// 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 适配)

typescript 复制代码
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' });
  },
});

安全注意事项

Token 类型 推荐存储 不推荐 原因
Access Token 内存 / sessionStorage localStorage 避免持久化泄露
Refresh Token HttpOnly Cookie 任何前端可读位置 防 XSS 窃取

本文的方案假设 Refresh Token 存在后端下发的 HttpOnly Cookie 中,前端通过调用 /auth/refresh 自动携带。前端永远不应该直接接触 Refresh Token。

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


总结

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

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

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

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

相关推荐
yingyima1 小时前
Git 实战:你必须掌握的 7 个常用命令
前端
次次皮1 小时前
代理启动前端dist包
java·前端·vue
星恒随风2 小时前
四天学完前端基础三件套(JavaScript篇)
开发语言·前端·javascript·笔记
guslegend2 小时前
第9节:前端工程与一键启动
前端·大模型·状态模式·ai编程
南囝coding3 小时前
Anthropic 内部数百个 Claude Code Skills,他们总结的这套方法值得看
前端·后端
Dxy12393102163 小时前
如何使用jQuery获取一类元素并遍历它们
前端·javascript·jquery
csdn小瓯3 小时前
AI质量评估体系:LLM-as-a-Judge实现与自动化测试实战
前端·网络·人工智能
jiayong233 小时前
第 43 课:任务详情抽屉里的批量处理闭环与删除联动
java·开发语言·前端
刀法如飞4 小时前
JavaScript 数组去重的 20 种实现方式,学会用不同思路解决问题
前端·javascript·算法