ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

无感刷新Token失效:Axios请求队列与单飞机制完整实现

无感刷新Token失效:Axios请求队列与单飞机制完整实现 不知道你遇到过没有在后台管理系统里录了一长串数据点了保存页面一顿直接被弹回登录页。再登录进去刚才填的内容全没了。技术排查到最后往往就是一行日志——token过期。这类问题在内部系统里尤其常见token有效期设太短用户频繁被踢下线体验很差设太长安全隐患又扛不住。两边都是矛盾。其实这个问题业内已经有一套成熟的解法就是标题里说的“无感刷新”。核心思路是给用户发两把钥匙一把短期有效负责日常开门一把长期有效专门的职责是在短期钥匙失效时去换新的短期钥匙。用户全程无感知没有跳登录页没有数据丢失请求照常成功。这篇文章把我的完整实现思路、踩过的坑和一些并发场景下的处理细节写出来希望能帮你少走弯路。适合谁来读正在做前端登录鉴权、经历过“token过期弹登录页”被用户骂、或者想升级现有认证方案的技术同学。我会从设计思路到代码实现再到问题排查都过一遍尽量讲透。1. 为什么业务里绕不开无感刷新1.1 从Session到Token便利背后的过期魔咒传统的Session认证是一把“服务端保管的锁”用户登录后服务端把会话信息存进内存或缓存给浏览器发一个SessionId浏览器每次请求带上这个Id服务端去查一下查到就放行。这种方式简单可靠但缺点也很明显服务端要保存会话状态多机部署时得额外引入共享存储会话管理复杂度随并发量直线上升。后来Token认证成了主流。JWT就是一个典型代表用户登录后服务端签发一个Token这个Token自带有效期exp服务端不再存储会话每次请求只要验签和解码就能确认请求者身份。整套流程是无状态的特别适合分布式和前后端分离的场景。但问题也随之而来Token里带了过期时间怎么让用户在免登录的前提下持续使用系统如果把Token有效期设成7天甚至30天那一旦Token泄露相当于把门户钥匙直接交给攻击者安全风险很大。如果把有效期设在15分钟左右安全是安全了用户用着用着就超时体验变成灾难。于是“短期Token 长期刷新Token”的组合方案诞生了这就是无感刷新的底层逻辑。短期TokenAccess Token负责实际业务接口的鉴权有效期短泄露损失有限长期TokenRefresh Token只在一个特定接口上使用专门用于换取新的短期Token有效期可以很长。用户完全感知不到这个过程就像你手机App的登录状态能保持好几个月靠的也是类似的机制。1.2 无感刷新到底解决了什么一句话概括无感刷新解决的是“安全策略”和“用户体验”之间的矛盾。没有它你只能在两个坏选择里挑一个要么Token有效期设短用户频繁重新登录要么设得很长安全隐患像定时炸弹。用上无感刷新之后体验上用户永远处于登录状态但对服务端来说真正使用的访问凭证有效期依然很短。即便攻击者拿到Access Token最多在几分钟内有效而Refresh Token因为只用于刷新接口泄露面非常小。这套组合拳兼顾了短时效的安全性和长登录的体验。另外还有一个团队协作上的价值。现实中很多系统的接口是多个后端各写各的有的接口鉴权逻辑严格一些有的宽松一些。如果你没有统一的刷新机制每个接口校验Fail后的行为就得在前端各自处理代码里到处是“状态码401就跳登录页”的逻辑维护成本很高。在请求层统一实现无感刷新所有接口一视同仁体验是一致的代码也收敛在一个文件里。1.3 无感刷新的整体架构拆解无感刷新的整体架构通常由三部分协作完成请求/响应拦截器统一在请求头里携带Access Token统一拦截响应中的401状态码刷新队列在当前Access Token失效时只启动一次刷新请求其余并发请求排队等待存储层妥善管理Access Token和Refresh Token的生命周期并保证多个标签页之间的同步这里面最核心也最容易出错的是并发情况下的处理。试想一个页面同时发5个请求服务端同时返回5个401如果你的代码不做任何并发控制就会发出5次刷新请求。刷新接口的Token通常有有效期并发刷新不仅浪费还可能导致一个刷新完成后其他刷新请求带着旧Token被拒最终出现“明明刷新成功了某个请求却还是失败”的诡异现象。解决办法就是下文要展开的单飞模式和请求队列。2. 前置设计拦截器链路与Token存储2.1 拦截器的职责划分无感刷新的代码基本都挂在Axios或Fetch封装层的拦截器里。很多同学把请求拦截器和响应拦截器的职责搞混写出来的代码一团乱麻这里理一下。请求拦截器的职责只有一个把Access Token从存储中取出来塞进请求头。它不应该承担任何业务判断逻辑更不应该去做“Token快过期了我先刷新一下”这种操作——那会让每个请求都背上额外的异步逻辑并发场景下会放大竞态问题。响应拦截器的职责是核心检查返回状态码。如果接口返回401先判断是不是真正的Token失效——也可能是账号密码错了、权限被删了。如果确认是Access Token过期就走刷新流程刷新成功后把当前请求用新Token重发如果刷新失败才跳转登录页。这里有个关键点刷新接口本身也必须走同一个拦截器链路否则你怎么保证刷新请求能正常发出但又不能让刷新接口触发“再次刷新”的逻辑否则就死循环了。我的做法是给刷新请求打一个明确的标记或者用独立的Axios实例让它在响应拦截器里直接返回不进入刷新逻辑。二选一一定要明确。2.2 Token存储localStorage、sessionStorage还是CookieToken存在哪里直接决定了方案的安全边界和跨标签页表现。以下是我在实际项目中的对比存储位置优点缺点使用建议localStorage存储容量大跨标签页共享刷新页面不丢失易被XSS攻击读取生命周期长中小型项目最常用需配合严格的XSS防护sessionStorage关闭标签页后失效泄露面较小刷新页面会丢多标签页不共享适合对会话隔离要求较高的场景CookiehttpOnlyJS拿不到TokenXSS读取风险低需要处理CSRF防护跨域配置复杂配合后端设置httpOnly和SameSite安全性最高多数前端团队的第一选择都是localStorage因为它实现简单刷新页面后Token还在多标签页共享也方便。缺点也明显——只要页面上有一个XSS漏洞攻击者就能把Token捞走。所以如果你用了localStorage一定要在安全策略上补强比如严格过滤富文本、禁用eval、做好CSP。如果你的系统对安全性要求很高比如金融、医疗类系统我更推荐把Refresh Token放在httpOnly Cookie里Access Token仍放localStorage或内存中。Refresh Token本身不参与业务接口鉴权只在后端刷新接口之间传递用它被拿走的概率控制到最低。代价是实现复杂一些——需要处理Cookie跨域、CSRF防护等。具体取舍看团队安全水位。2.3 刷新接口的“免拦截”设计这里单独强调一下因为它是一个很隐蔽的坑。假设你没有独立的Axios实例所有请求都走同一个service那么在请求拦截器里就会给刷新请求也挂上旧的Access Token。这在大多数时候没问题Refresh Token如果放在Header里另说但当刷新请求返回401时响应拦截器会再次进入刷新逻辑于是你在刷新过程中又调了一次刷新就会递归死循环。解决办法有两种第一种给刷新接口的配置加一个自定义标记比如config._isRefreshRequest true在请求拦截器里跳过它在响应拦截器里判断这个标记如果是刷新请求且返回401直接走登出逻辑。第二种更干净创建一个独立的axios实例只服务于刷新接口不走主拦截器。代码如下// 刷新接口专用实例不走业务拦截器 const refreshAxios axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000 }) refreshAxios.interceptors.response.use( (response) response.data, (error) { // 刷新接口返回401意味着Refresh Token也失效了 if (error.response error.response.status 401) { handleLogout() } return Promise.reject(error) } ) async function doRefreshToken() { const refreshToken getRefreshToken() const response await refreshAxios.post(/auth/refresh, { refreshToken }) setTokens(response.accessToken, response.refreshToken) return response.accessToken }这样主拦截器的逻辑就简单了只有非刷新接口返回401时才进入排队和刷新逻辑刷新接口自己的401在独立实例里处理不会串味。3. 核心实现请求队列与单飞刷新3.1 并发401场景的问题重述先把场景说透用户在一个页面上一次性触发了多个请求比如进入工作台时同时拉取用户信息、菜单列表、待办事项、消息通知。这几个请求同时发出此时恰逢Access Token过期后端会对每一个请求都返回401。如果你的代码是这样的收到401就立即调用刷新接口然后重发当前请求那就会出现5个并发请求各自触发一次刷新。Refresh Token虽然通常有效期较长但并行刷新依然有严重问题后端一般会在刷新时废弃旧的Refresh Token并签发新的多个并发刷新请求同时到达只有第一个能成功旧RefresToken已被标记使用/作废后面的请求都会被判定为无效刷新。结果就是有的请求重试成功有的请求却因刷新失败被踢回登录页。正确做法是无论同一时刻有多少个请求遇到401全系统最多只有一个刷新请求在跑。其余请求进入等待队列等刷新完成后再用新Token重发。这就是“单飞模式”本质是把多个并发的刷新动作合并成一次。3.2 请求队列为什么是解决“单飞”的关键请求队列在实现上确实不难但理解它为什么是这个形态比抄代码更重要。想象一下现实场景办公室门禁到期了十几个员工同时刷卡开门。如果每个人都各自去人事处办新卡门口就会挤成一团人事处的服务也会被重复调用。正确的做法是派一个人去办新卡其余人在原地等着等新卡办回来了大家再顺序刷卡进门。代码里的实现就是三个数据结构isRefreshing布尔值标记当前是否已经有人去办新卡了pendingRequests数组记录所有等待新卡的“员工”每个等待元素的resolve和reject相当于每个人在等待时的“手机号”办成后通知他办砸了也通知他核心逻辑是响应拦截器发现401后先看isRefreshing。如果为true说明已经有人去刷新了自己就push到队列里等着。如果为false自己来当这个“去办新卡的人”把标志位置为true然后发起刷新请求。刷新成功后遍历队列把所有等待请求用新Token重发resolve它们的结果刷新失败则逐个reject然后跳转登录页。3.3 基于Axios的完整实现下面是我实际项目中使用的核心代码我重新整理过去掉了业务噪音保留了完整逻辑import axios from axios import { getAccessToken, getRefreshToken, setTokens, clearTokens } from /utils/auth import { refreshTokenApi } from /api/auth import router from /router const service axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 15000 }) // 刷新状态锁 let isRefreshing false // 等待队列 let pendingQueue [] // 请求拦截器只负责携带token service.interceptors.request.use((config) { const token getAccessToken() if (token) { config.headers[Authorization] Bearer ${token} } return config }) // 响应拦截器核心刷新逻辑 service.interceptors.response.use( (response) response.data, async (error) { const { response, config } error // 没有响应或非401不处理 if (!response || response.status ! 401) { return Promise.reject(error) } // 如果是刷新请求自身返回401说明Refresh Token也失效了直接登出 if (config.url.includes(/auth/refresh)) { clearTokens() router.replace({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }) return Promise.reject(error) } // 尝试刷新避免在响应拦截器里调用service导致循环用新实例 if (isRefreshing) { // 已有刷新请求在跑当前请求挂起 return new Promise((resolve, reject) { pendingQueue.push({ config, resolve, reject }) }) } isRefreshing true try { const newToken await refreshTokenApi() // 刷新成功先处理等待队列中的所有请求 const retryRequests pendingQueue.map(({ config: pendingConfig, resolve, reject }) { return service({ ...pendingConfig, headers: { ...pendingConfig.headers, Authorization: Bearer ${newToken} }, _retried: true }).then(resolve, reject) }) // 当前触发了刷新的请求也重试 const currentRequest service({ ...config, headers: { ...config.headers, Authorization: Bearer ${newToken} }, _retried: true }) const results await Promise.allSettled([currentRequest, ...retryRequests]) return results[0].status fulfilled ? results[0].value : Promise.reject(results[0].reason) } catch (refreshError) { // 刷新失败通知所有等待的请求刷新失败 pendingQueue.forEach(({ reject }) reject(refreshError)) clearTokens() router.replace({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }) return Promise.reject(refreshError) } finally { pendingQueue [] isRefreshing false } } ) export default service有几个细节值得单独标注第一重试标记_retried。它防止一个请求在刷新成功后重试结果新Token还是无效比如后端签发的新Token本身有问题时再次进入刷新逻辑造成死循环。理论上第一次重试就能成功但如果后端时钟不同步或签发异常重试后仍可能401这时应当直接reject而不是再刷一次。第二用Promise.allSettled等待所有重试请求是因为结果之间互不依赖。当前触发了刷新的请求作为主请求返回其他队列请求的结果各自通过resolve/reject分发调用方拿到的仍是各自的Promise结果互不干扰。第三登录页跳转时携带了redirect参数。这个参数记录用户当前所在路由登录成功后再跳回来。没有这一步的用户体验是登录回来后又得自己手动导航到之前所在的页面很影响情绪。3.4 刷新失败后的降级策略刷新失败不等于就要立刻踢用户下线有些场景值得更精细地处理。我的经验是按失败原因区分网络异常断网、服务端宕机导致的刷新失败不应当马上跳登录页。用户此刻还持有Refresh Token只是刷新接口暂时不通直接跳登录等于把用户来回折腾。这种情况我给用户一个轻量提示“网络异常请稍后重试”保留在登录状态即可。Refresh Token过期或无效导致的刷新失败才真正跳到登录页。账号被禁用、权限被删导致的401属于业务层面的登录失效就算刷新也没意义同样直接跳登录。靠什么区分不能只看Axios的error对象得看后端返回的业务码。所以前端和后端约定清理的“401响应体结构”非常关键。我习惯让后端在401时统一返回{ code, message }其中code区分TOKEN_EXPIREDAccess Token过期可刷新、REFRESH_TOKEN_INVALIDRefresh Token失效不可刷新、ACCOUNT_DISABLED账号禁用等。前端拿到401后先读code再决定走哪个分支。4. 进阶细节多标签页、提前刷新与竞态条件4.1 多标签页Token互相覆盖怎么办无感刷新还有一个经典陷阱用户开两个标签页都在操作同一个系统标签页A的请求触发刷新拿到了新Token写回localStorage此时标签页B持有的还是旧Token它的请求自然401于是它也触发刷新结果用的是它内存里那份旧的Refresh Token。问题的根源在于两个标签页持有的Token状态不同步。解决思路有几个方向第一个思路是标签页同步。监听localStorage的storage事件当某个标签页写入新Token时其他标签页感知到变化主动更新自己内存中的Token。优点是简单缺点是只能监听同源页面且如果两个标签页同时发起刷新同时写入localStorage后写入的覆盖前者仍然存在竞态窗口。第二个思路是浏览器层面加锁。用navigator.locks.request或BroadcastChannel广播“开始刷新”事件先到先得其他标签页收到广播后进入等待不再发起自己的刷新请求。这个方案更严谨代价是代码量增加。我的建议是如果你的用户群体主要是在浏览器单标签页里操作的场景比如B端后台用storage事件同步就够用了如果是C端用户多标签页使用很频繁建议上BroadcastChannel。第三个思路也是更釜底抽薪的Refresh Token轮换机制规定一次性使用后端颁发新Refresh Token时记录其family关系并返回给前端。一旦检测到旧Refresh Token被再次使用说明可能泄露后端直接作废该family下所有Token强制重新登录。这属于后端策略前端配合实现Token更新即可但从安全视角值得了解。4.2 提前刷新的定时器方案有些团队的方案不是“等到401再刷新”而是主动在Token过期前提前刷新。思路是解析Access Token的载荷拿到exp字段在过期前的某个时间点比如剩余30秒或5分钟主动触发一个后台刷新任务让Token永不过期。这个方案的好处是绝大多数请求根本不会遇到401刷新动作发生在用户毫无感知的时间切片里交互层更顺滑。风险也有如果用户一直在操作定时器触发了一次刷新但用户又持续操作到Token再次过期还是会有401所以它不能完全替代“401时刷新”的兜底逻辑。如果页面在后台运行或手机锁屏定时器被浏览器冻结到点不触发。等用户回到页面Token已经过期。所以需要监听visibilitychange和focus事件在页面恢复时立刻检查Token剩余时间如果不足则马上刷新。代码骨架如下const TOKEN_REFRESH_THRESHOLD 60 * 1000 // 过期前1分钟刷新 function getTokenRemainingTime(token) { const decoded JSON.parse(atob(token.split(.)[1])) return Number(decoded.exp) * 1000 - Date.now() } function scheduleTokenPreRefresh() { const token getAccessToken() if (!token) return const remain getTokenRemainingTime(token) if (remain TOKEN_REFRESH_THRESHOLD) { refreshTokenApi().catch(() {}) return } setTimeout(() refreshTokenApi().catch(() {}), remain - TOKEN_REFRESH_THRESHOLD) } document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { scheduleTokenPreRefresh() } })我要提醒的是这个方案要和401刷新方案配合使用不能替代。因为定时器方案覆盖不了“定时器没触发但请求已经发出去”的极端边界比如网络延迟把请求卡了几分钟。生产环境里我没见过纯靠定时器的方案基本都是两者互补。4.3 刷新接口自身的防重入“防重入”和“单飞”是两个层面的东西。单飞解决的是业务请求之间并发401时合并刷新防重入解决的是刷新请求本身被重复调用的情况。比如上面第4.2节那种提前刷新和401刷新都用到了refreshTokenApi()如果没有防重入在某个边界场景下定时器刚触发刷新另一个请求也恰好401两个刷新请求又同时发出去了。防重入的实现很简单核心是缓存Promiselet refreshPromise null function refreshTokenApi() { if (refreshPromise) { return refreshPromise } refreshPromise refreshAxios .post(/auth/refresh, { refreshToken: getRefreshToken() }) .then((res) { setTokens(res.accessToken, res.refreshToken) return res.accessToken }) .finally(() { refreshPromise null }) return refreshPromise }这样不管多少处调用refreshTokenApi()实际发出去的刷新请求永远只有一个。等它resolve之后refreshPromise置空下次再调用才会重新发起。这个细节重要到什么程度我见过线上事故——多个标签页同时在刷新后端限流直接把刷新接口拦了结果是一个正常的Token刷新动作引发了大面积401用户集体掉线。加了这个防重入拥堵面能小一大半再配合上面说的广播锁基本就稳了。4.4 刷新成功后的业务状态恢复无感刷新有个隐性要求对于用户来说整个刷新过程不能打断他正在进行的操作。举个例子用户在表单页填了一大堆内容正要提交此时Token刚好过期提交请求遇到401走刷新流程刷新成功请求自动重发用户看到的只是提交按钮转了两下圈。这个体验是符合预期的。但如果刷新失败跳了登录页用户填的内容就没了。要改善这个体验可以在跳登录页前把当前页面的关键业务状态存到sessionStorage注意不能用localStorage否则下次登录覆盖。比如草稿内容、分页页码、筛选条件等。登录回来后从sessionStorage里恢复让用户回到之前的操作位置。这个属于业务层面的状态恢复技术上不难难的是提升到“无感刷新也是产品体验一环”这个认知层面。无感刷新不仅仅是技术上的Token自动续期更是一个完整的“会话永不中断”方案从请求发送、刷新、重试、失败兜底到状态恢复每一环都要闭环。顺便提一个性能侧的小建议如果页面在刷新Token期间发出的请求本来就很少队列等待时间很短用户基本无感但如果突然有大量请求并发比如报表页面一次加载20个图表数据刷新本身如果耗时超过两秒页面上会出现明显卡顿。这时候建议在刷新期间做一个加载态兜底别让用户以为页面死了。5. 踩坑实录与排查手册5.1 几个真实出现的严重问题先说一个死循环问题。第一版实现时我没有给刷新接口单独建实例所有请求都走同一个Axios。刷新接口返回401后响应拦截器里又触发了刷新逻辑刷新接口又请求一次又401循环往复。浏览器控制台刷出一大片红色报错接口被刷了上百次最后被服务端限流。后来加了独立实例把“刷新接口自身401”和“业务请求401”完全隔离问题才解决。第二个问题更隐蔽。我用localStorage存Token在响应拦截器里刷新成功后直接localStorage.setItem(accessToken, newToken)。看起来没问题但多个标签页同时操作时A标签页写入的新Token会被B标签页在几毫秒后写入的旧Token覆盖B标签页后续请求全变401再刷新又覆盖回来表现就是“Token在反复横跳”。解决办法就是上加BroadcastChannel或storage监听把Token更新广播到所有标签页。第三个问题是关于重试死循环的。有个老接口本身就有Bug某次数据库查询超时后端兜底返回了一个401。前端不明所以觉得是Token过期就刷新重试刷新成功后重试还是401又刷新再次重试。虽然我加了_retried标记但当时漏了“这个标记只在非刷新请求上生效”的判断导致业务逻辑上的401也被当成Token过期来刷新。后来加上了后端业务码的区分业务码不是TOKEN_EXPIRED的401一律直接reject不触发刷新。5.2 常见问题速查表问题现象可能原因解决方案刷新接口被反复调用没有用防重入缓存Promise用refreshPromise缓存刷新Promisefinally中置空刷新成功但部分请求仍失败并发刷新时Refresh Token被提前轮换后续刷新请求失败单飞模式合并刷新请求或后端精简Refresh Token轮换为每次刷新只换Access Token页面频繁跳登录刷新失败后无条件跳转没有区分错误类型按业务码区分只有Refresh Token无效时才跳登录多标签页Token互相覆盖各标签页独立刷新写入localStorage互相覆盖storage事件或BroadcastChannel同步TokenToken在浏览器后台挂掉定时器被浏览器冻结监听visibilitychange页面恢复时立即检查Token剩余时间401重试死循环业务性401权限不足被当作Token过期处理结合后端业务码判断只有TOKEN_EXPIRED才触发刷新登录后回不到原页面跳登录页时未携带redirect参数跳转时拼接redirect当前路由登录成功后回跳排查这类问题我的建议是先看响应拦截器里打印的日志。把每次401的config.url、业务码、Token剩余时间清晰地打出来问题多半一眼就能定位。不要上来就搜代码先看报错入口在哪一层——是请求层、刷新层还是业务层。这个分层排查的思路能帮你省下一整天。5.3 无感刷新的几点实操心得踩坑踩多了我总结出几个实践中很受用的原则。第一前端再怎么说安全安全边界也要交给后端。Refresh Token的存储、轮换、过期策略是后端制定规则前端只是执行。前端的刷新请求要带上Refresh Token但不要把它放在每个业务接口的Header里——那样它就退化成第二个Access Token了泄露面变大。第二刷新逻辑必须集中在一个文件里不要分散到每个业务页面。如果页面代码里到处都在写“401了你自己刷新”那一旦刷新逻辑调整你就要改几十个文件。拦在请求层一个文件搞定。第三先上线“401时刷新”的兜底版本再考虑“提前刷新”的优化版。两步走的原因很简单兜底版逻辑清晰容易验证提前刷新的定时器涉及生命周期、性能、后台恢复坑更多。很多团队上来就做提前刷新结果定时器一冻结就掉链子反而做砸了。第四联调阶段务必让后端配合把401的业务码定义清楚。前后端对“哪些401需要刷新哪些401不需要”达成一致前端才能精准分流。我在项目里会把这套约定直接写进接口文档作为鉴权规范的一部分后面新同事接手也不会跑偏。我个人在实践中还有一个偏好统一在每个业务请求的Header里带X-Token-Expires-At等信息让后端可以在业务处理前判断Token是否快到过期临界点配合后端做一个“主动更新Token”的响应头。这样做的好处是刷新时机可以前移减少实际遇到401的频率让“无感”的程度再高一层。不过这个方案要求后端配合不适合所有团队了解即可。如果你正在为Token过期弹登录页的问题头疼照着这篇文章把单飞刷新和后端业务码分流做扎实了绝大多数场景都能扛住。先把最核心的并发刷新逻辑跑通再去优化多标签页和定时器一步一步来你的用户会明显感觉到“这些系统怎么突然不掉线了”。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进