Token认证机制详解与实战应用指南
优化前:卡得不行
上个月上线一个新功能,用户反馈“点一下要等好几秒”,我一开始以为是后端慢,结果自己本地试了下,发现每次刷新页面或者切换路由,前端都要卡 3~5 秒——这哪能忍?
查了下,问题出在 Token 认证流程上。我们用的是 JWT,每次请求前都要从 localStorage 拿 token,塞进 header 里发给后端。但问题不在这里,而是在 token 过期处理逻辑太重了。一旦 token 过期,前端会自动发起 refresh 请求,拿到新 token 后再重试原请求。听起来很合理,对吧?但实际一测,发现:
- refresh 请求本身要 800ms ~ 1.2s(因为要查数据库、签发新 token)
- 如果用户同时发了 5 个 API 请求,每个都触发 refresh,那就会并发 5 次 refresh 请求!
- 更糟的是,有些请求在 refresh 完成前就失败了,页面直接白屏
优化前,首页加载时间稳定在 4.5s 左右,其中 3s 花在无效的 token 重试和重复 refresh 上。用户点个按钮,等半天没反应,最后还报错——这体验确实烂透了。
找到瓶颈了!
我先用 Chrome DevTools 的 Network 面板录了一次完整加载过程,发现有 4 个请求返回了 401,然后紧接着 4 个 /auth/refresh 请求,而且它们几乎同时发出,响应时间都在 1s 左右。这明显是并发 refresh 导致的资源浪费。
再打开 Performance 面板跑一次,JS 执行时间里,大量时间花在 handleTokenExpired 和 retryOriginalRequest 这两个函数上。尤其是 retry 逻辑,每次都要重新构造请求、解析参数、重建 AbortController……完全没必要。
折腾了半天发现,核心问题不是 token 本身,而是 refresh 机制没做防重。多个请求同时检测到 token 过期,各自去 refresh,结果后端压力大,前端也乱成一锅粥。
核心优化:单例 refresh + 请求队列
我试了三种方案:
- 加个 flag,refresh 期间其他请求直接 reject —— 不行,用户体验差,很多请求会莫名其妙失败
- 用 localStorage 存 refresh 状态,靠轮询判断 —— 太 hack,而且有竞态风险
- 用 Promise 缓存当前 refresh 任务,所有待重试的请求都 await 同一个 Promise —— 亲测有效!
最后这个方案最稳。思路很简单:当第一个请求发现 token 过期,它就启动 refresh 并把返回的 Promise 缓存起来;后续请求看到有正在进行的 refresh,就直接 await 这个 Promise,等它 resolve 后再用自己的参数重试。
这里注意我踩过好几次坑:Promise 不能缓存 resolved 值,否则下次 token 过期时会复用旧结果。所以必须每次过期都新建 Promise,但同一时间段内只允许一个。
下面是优化前后的关键代码对比:
优化前:乱发 refresh
// 伪代码,简化版
async function request(url, options) {
const token = localStorage.getItem('token');
const res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: Bearer ${token} }
});
if (res.status === 401) {
// 直接 refresh,不管有没有人在 refresh
const newToken = await refreshToken();
localStorage.setItem('token', newToken);
// 重试原请求
return request(url, options); // 递归重试
}
return res;
}
优化后:单例 refresh + 请求队列
let refreshPromise = null;
async function refreshToken() {
const res = await fetch('https://jztheme.com/api/auth/refresh', {
method: 'POST',
credentials: 'include' // 假设用 cookie 带 refresh token
});
const data = await res.json();
localStorage.setItem('token', data.token);
return data.token;
}
async function requestWithAuth(url, options) {
const token = localStorage.getItem('token');
const res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: Bearer ${token} }
});
if (res.status === 401) {
// 如果已有 refresh 任务,直接等它
if (!refreshPromise) {
refreshPromise = refreshToken().finally(() => {
// 重置,避免缓存 resolved 状态
refreshPromise = null;
});
}
// 等待 refresh 完成
await refreshPromise;
// 用新 token 重试
const newToken = localStorage.getItem('token');
return fetch(url, {
...options,
headers: { ...options.headers, Authorization: Bearer ${newToken} }
});
}
return res;
}
这段代码的核心就是 refreshPromise 这个变量。它保证了同一时间只有一个 refresh 请求在跑,其他 401 请求都会排队等它完成。而且 finally 里重置为 null,确保下一次过期时能重新触发。
另外,我还在请求拦截器里加了 token 过期时间预判(比如提前 30 秒主动 refresh),避免等到 401 才处理。不过这个属于锦上添花,主干还是上面的队列机制。
性能数据对比
改完后压测了一下,效果非常明显:
- 首页加载时间从平均 4.5s 降到 800ms 左右
- Network 面板里,refresh 请求从 4~5 个减少到 1 个
- 用户操作响应速度提升 5 倍以上,基本无感知等待
- 后端 refresh 接口 QPS 下降 70%,服务器压力小了很多
特别提一下,这个优化对弱网环境提升更大。之前在 3G 模拟下,refresh 要 2s+,并发 5 个直接超时;现在只要 1 个,成功率 100%。
当然,也不是完美无缺。比如如果 refresh 本身失败了(比如 refresh token 也过期),那所有排队的请求都会失败。不过这种情况本来就需要跳登录页,所以我在 refreshToken 里加了全局错误处理,直接跳转,问题不大。
其他小优化(带过)
除了主干逻辑,我还顺手做了两件事:
- 把 token 存到内存(比如 Vuex/Pinia 的 state)而不是每次都读 localStorage,减少 I/O
- 在 service worker 里缓存一些非敏感的 GET 请求,配合 token 有效性判断,进一步减少请求量
不过这些提升有限,大概就省了 50~100ms,远不如 refresh 队列带来的收益。
总结
这次优化让我深刻体会到:Token 认证的性能瓶颈,往往不在加密或传输,而在 异常处理流程的并发控制。一个简单的 Promise 缓存,就能解决大问题。
以上是我踩坑后的总结,希望对你有帮助。如果有更优的实现方式——比如用 RxJS 或者 Web Worker 处理 refresh 队列——欢迎评论区交流!

暂无评论