想象一下,你正忙着在后台管理系统里处理一堆紧急数据,突然屏幕中央弹出一个登录框,提示你登录已过期。你赶紧输入密码重新登录,结果刚点确定,又弹出一个同样的框,紧接着第三个、第四个接连出现,仿佛陷入了某种死循环,原本顺畅的工作流瞬间被打断,这种体验不仅让人崩溃,更是产品体验上的重大失分。这在采用 RESTful 风格接口并且使用无状态认证机制的现代 Web 应用中,其实是一个非常经典且棘手的技术难题。

一、应用场景:为什么会出现这种情况

1.1 现代 Web 认证的基本形态

现在的很多系统,尤其是前后端分离的架构,很少再使用传统的 Session 登录方式了,取而代之的是基于 JWT 或者类似 Token 的无状态认证。这种模式的好处在于,服务器不需要存储用户的登录状态,所有的身份验证信息都打包在 Token 里面,前端带着这个 Token 去请求数据,服务器校验通过就放行,不通过就拒绝。这种方式大大减轻了服务器的压力,也让系统更容易扩展,支持水平扩容,不再受限于单台服务器的内存。对于大型互联网应用来说,这是标配的技术方案。

1.2 并发请求下的脆弱性

问题往往出在用户操作频率较高的时候。比如一个列表页面,打开的时候可能需要同时发起五个接口请求,分别加载用户信息、菜单权限、待办事项、数据图表和系统通知。如果这个时候,用户的访问 Token 刚好到期,那么这五个请求几乎会同时收到服务器返回的 401 未授权错误。按照传统的逻辑,前端拦截器发现 401,就会调用刷新 Token 的接口。可是,因为五个请求是并发的,它们可能同时触发了刷新逻辑,导致瞬间发起了五次刷新请求。

二、技术原理:无状态认证到底是怎么玩的

2.1 Token 的生命周期

在这种机制下,Token 通常分为两种,一种是 Access Token,用于访问业务接口,有效期很短,比如十五分钟;另一种是 Refresh Token,用于换取新的 Access Token,有效期较长,比如七天。当 Access Token 过期后,前端需要拿着 Refresh Token 去请求刷新接口,服务器验证通过后,返回一个新的 Access Token,前端用新 Token 重试刚才失败的请求。这个过程叫做续期。这种双 Token 机制兼顾了安全性与便捷性,既保证了业务访问的高频安全性,又避免了用户频繁输入密码的麻烦。

2.2 为什么刷新会打架

理想情况下,一个请求失败,刷新一次,重试成功,皆大欢喜。但现实是残酷的,当多个请求同时失败时,如果没有控制机制,它们都会去执行刷新逻辑。第一个请求刷新成功了,拿到了新 Token,但第二个请求可能还在用旧 Token 或者也在同时刷新,这时候如果服务端没有做好并发控制,可能会因为 Refresh Token 被重复使用而报错,或者导致 Token 版本混乱。更糟糕的是,前端本地存储的 Token 被更新后,其他还没完成重试的请求如果拿到的还是旧 Token,就会继续报错,导致弹窗反复出现。

三、核心难点:并发请求导致的刷新风暴

3.1 刷新风暴的定义

所谓的刷新风暴,就是指在极短的时间内,前端发起了大量无意义的刷新 Token 请求。这不仅增加了服务器的负载,更重要的是,它破坏了用户体验。用户看到满屏的弹窗,不知道点哪个,甚至有时候点了一个,后面又弹出一个,感觉系统完全失控了。

3.2 状态不一致的噩梦

除了并发刷新,还有一个问题是状态不一致。假设前端发起了一次刷新请求,正在等待服务器返回,这时候另一个请求也发现 Token 过期了,它也发起了一次刷新。如果服务器端允许 Refresh Token 复用,那么两次刷新都会成功,但第二次刷新会覆盖第一次生成的 Token。如果服务器端不允许复用,第二次就会失败,导致用户被踢下线。无论哪种情况,前端都需要处理这种复杂的异步状态,确保所有的请求都能有序地使用最新的有效 Token。

四、解决方案:构建平滑的续期闭环

4.1 核心思路:单飞模式与请求队列

要解决这个问题,核心思路其实很简单,就是确保在 Token 过期时,全局只触发一次刷新逻辑。这就好比一个办公室的门禁,如果刷卡发现卡过期了,只需要一个人去前台换卡,换好之后大家都能进,没必要每个人都跑去前台排队换卡。技术上,我们通常使用一个锁机制,比如一个布尔变量或者一个 Promise 对象,来表示当前是否正在刷新中。

4.2 请求排队与重试机制

当第一个请求触发刷新时,其他遇到的 401 错误请求不能直接失败,也不能直接重试,而应该被挂起,放入一个队列中。等到第一个请求刷新成功,拿到新 Token 之后,再统一更新本地的 Token 存储,然后把队列里所有挂起的请求依次重试。如果刷新失败,比如 Refresh Token 也过期了,那么整个队列里的请求都直接失败,并引导用户重新登录。这样就能保证用户体验的顺滑,不会出现弹窗地狱。

五、代码实战:前端拦截器与锁机制

5.1 技术栈说明

下面的示例代码使用 JavaScript 配合 Axios 库来实现,这是目前前端开发中最常见的技术组合。代码中包含了请求拦截器、响应拦截器以及核心的刷新锁逻辑。

// 技术栈:JavaScript + Axios
import axios from 'axios';

// 创建一个 axios 实例
const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 5000
});

// 定义一个标志位,表示当前是否正在刷新 token
let isRefreshing = false;
// 定义一个数组,存储所有等待重试的请求
let requestsQueue = [];

// 请求拦截器,自动带上 token
service.interceptors.request.use(
  config => {
    const token = localStorage.getItem('access_token');
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截器,处理错误码
service.interceptors.response.use(
  response => {
    return response;
  },
  error => {
    const { response } = error;
    if (response && response.status === 401) {
      // 如果已经是刷新请求,避免递归
      if (error.config.url.includes('/auth/refresh')) {
        localStorage.removeItem('access_token');
        window.location.href = '/login';
        return Promise.reject(error);
      }

      // 如果当前没有正在刷新,则开始刷新
      if (!isRefreshing) {
        isRefreshing = true;
        return refreshAccessToken().then(newToken => {
          // 刷新成功,更新本地 token
          localStorage.setItem('access_token', newToken);
          // 执行队列中所有等待的请求
          requestsQueue.forEach(cb => cb(newToken));
          requestsQueue = [];
          // 重试当前失败的请求
          error.config.headers['Authorization'] = `Bearer ${newToken}`;
          return service(error.config);
        }).catch(err => {
          // 刷新失败,清空队列并跳转登录
          requestsQueue = [];
          window.location.href = '/login';
          return Promise.reject(err);
        }).finally(() => {
          isRefreshing = false;
        });
      } else {
        // 如果正在刷新,将当前请求挂起加入队列
        return new Promise((resolve) => {
          requestsQueue.push((newToken) => {
            error.config.headers['Authorization'] = `Bearer ${newToken}`;
            resolve(service(error.config));
          });
        });
      }
    }
    return Promise.reject(error);
  }
);

// 刷新 token 的函数
function refreshAccessToken() {
  return new Promise((resolve, reject) => {
    // 调用刷新接口,这里简化处理
    axios.post('/auth/refresh', { refresh_token: localStorage.getItem('refresh_token') })
      .then(res => resolve(res.data.access_token))
      .catch(err => reject(err));
  });
}

export default service;

5.2 代码逻辑解析

在这段代码中,我们定义了一个 isRefreshing 变量作为锁。当第一个请求遇到 401 时,检查这个锁,发现没被占用,就把它设为 true,然后调用刷新接口。如果刷新成功,就遍历 requestsQueue 队列,把队列里所有之前挂起的请求都执行一遍。注意,队列里存的是回调函数,每个回调函数负责用新 Token 重试自己的请求。如果后续又有请求遇到 401,这时候 isRefreshing 已经是 true 了,它们就不会再去调用刷新接口,而是把重试逻辑作为一个回调函数 push 进队列,等待第一个刷新任务完成。

六、技术优缺点与注意事项

6.1 方案的优点

这种方案最大的优点就是用户体验极好,用户几乎感觉不到登录态过期的存在,除非是 Refresh Token 也真的过期了。同时,它极大地减少了向服务器发起的刷新请求数量,保护了后端资源。代码结构清晰,逻辑封装在拦截器中,业务代码不需要做任何改动,耦合度低。

6.2 方案的缺点

缺点主要在于复杂度增加。前端需要维护一个全局的状态锁和一个请求队列,这在全局变量管理上需要小心,避免内存泄漏或者状态污染。另外,如果服务器端的刷新接口响应很慢,那么所有挂起的请求都会等待很长时间,这可能导致页面暂时无法交互,需要做好超时控制。

6.3 关键注意事项

首先,一定要防止刷新接口本身被拦截器再次拦截,否则会导致死循环,代码中已经做了 URL 判断。其次,队列中的请求重试时,必须使用最新的 Token,不要沿用旧的 config。最后,当刷新失败时,务必清空队列,直接跳转登录页,不要让用户在一个无效的登录态下无限重试。此外,还要考虑多标签页的情况,如果一个标签页刷新了 Token,其他标签页需要通过 Storage Event 监听本地存储的变化,同步更新自己的 Token,否则其他标签页的请求依然会失败。这个问题在很多团队协作开发中被忽视,导致用户在一个窗口登录了,另一个窗口却登出了,体验极差。还有,要注意网络异常的情况,如果刷新接口因为网络超时失败,前端应该提示用户检查网络,而不是直接登出,给予用户重试的机会,这样更加人性化。

七、文章总结

面对 RESTful 无状态登录下的 Token 过期问题,我们不能仅仅满足于能刷新,更要追求刷新过程的优雅与稳定。通过引入单飞模式和请求队列机制,我们可以有效避免并发刷新导致的弹窗风暴和状态混乱。虽然实现起来需要处理一些异步细节和边界情况,但相比用户抱怨系统难用,这点开发成本是完全值得的。记住,好的技术实现应该是隐形的,让用户在流畅的操作中感知不到背后的复杂逻辑,这才是我们追求的目标。系统的稳定性不仅仅体现在代码不报错,更体现在极端情况下的容错能力。希望这篇文章能帮你解决这个困扰已久的难题,让你的系统登录态管理更加健壮,为用户提供一个真正专业、可靠的应用体验。