想象一下,你正忙着在后台管理系统里处理一堆紧急数据,突然屏幕中央弹出一个登录框,提示你登录已过期。你赶紧输入密码重新登录,结果刚点确定,又弹出一个同样的框,紧接着第三个、第四个接连出现,仿佛陷入了某种死循环,原本顺畅的工作流瞬间被打断,这种体验不仅让人崩溃,更是产品体验上的重大失分。这在采用 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 过期问题,我们不能仅仅满足于能刷新,更要追求刷新过程的优雅与稳定。通过引入单飞模式和请求队列机制,我们可以有效避免并发刷新导致的弹窗风暴和状态混乱。虽然实现起来需要处理一些异步细节和边界情况,但相比用户抱怨系统难用,这点开发成本是完全值得的。记住,好的技术实现应该是隐形的,让用户在流畅的操作中感知不到背后的复杂逻辑,这才是我们追求的目标。系统的稳定性不仅仅体现在代码不报错,更体现在极端情况下的容错能力。希望这篇文章能帮你解决这个困扰已久的难题,让你的系统登录态管理更加健壮,为用户提供一个真正专业、可靠的应用体验。
评论
围绕“RESTful无状态登录态下Token过期与刷新并发触发,重新登录反复弹窗,怎么构建安全顺滑的续期闭环”参与讨论