---
title: 并发 401 下的 Token 刷新竞态：一个被低估的 Bug
description: 多个请求同时收到 401 时，如何用单次刷新与等待队列避免 refresh token 轮换竞态，并确保刷新失败时所有请求都能正确结束。
image: "https://zengliangyi.com/og/blog/token-refresh-race-condition.png"
---

**TL;DR：** 当多个请求同时遇到 401 时，朴素实现会触发多次 Token 刷新，形成竞态。一个 `isRefreshing` 标志加订阅者队列就能把刷新合并为一次；但刷新失败时必须通知队列中的每个请求，否则它们返回的 Promise 会永久停在 pending 状态。

> 本文假设你熟悉 `async/await`、HTTP 拦截器（Axios 或 Fetch）和 JWT 认证基础。

## 问题：并发 401 不止一个

Token 刷新的第一版代码通常像这样：



```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);  
    }  
    return   Promise  .  reject  (error);  
  });
```



单个请求失效时，这段代码足够用。但真实页面经常会同时请求用户信息、通知数量和业务数据。当 Token 在这些请求飞行途中失效时，每个 401 都会发起一次刷新：



```text
Request A → 401 → refreshToken() ─┐  
  Request B → 401 → refreshToken()  │  
  Request C → 401 → refreshToken()  │← 同时触发多次刷新  
  Request D → 401 → refreshToken() ─┘
```



如果服务端采用 refresh token rotation，每次刷新都会使上一个 refresh token 失效。第一个请求刷新成功后，其余请求继续使用旧 Token，可能全部失败，最后把用户送回登录页。

## 心理模型：收银台排队

可以把并发请求想象成超市收银台：

- 朴素实现中，每位顾客都单独去找店长。

- 队列实现中，第一位顾客负责找店长，其他顾客原地排队；店长带回新 Token 后，所有人再继续结账。

实现这个模型只需要一个状态和一个队列：



```typescript
let   isRefreshing   =   false  ;  
  let   subscribers  :   Subscriber  []   =   [];
```



## 实现带队列的刷新机制

### 1. 定义订阅者



```typescript
// 字符串表示刷新成功，null 表示刷新失败  
  type   Subscriber   =   (  newToken  :   string   |   null  )   =>   void  ;  
  
  let   isRefreshing   =   false  ;  
  let   subscribers  :   Subscriber  []   =   [];
```



`string | null` 很重要：队列不仅需要接收成功结果，也必须收到失败结果。

### 2. 管理队列



```typescript
function   addSubscriber  (  callback  :   Subscriber  ) {  
    subscribers.  push  (callback);  
  }  
  
  function   notifySubscribers  (  newToken  :   string   |   null  ) {  
    subscribers.  forEach  ((  callback  )   =>   callback  (newToken));  
    subscribers   =   [];  
  }
```



### 3. 调度刷新与重试



```typescript
export   async   function   handleUnauthorized  <  T  >(  
    doRefresh  :   ()   =>   Promise  <  string   |   null  >,  
    doRetry  :   (  newToken  :   string  )   =>   Promise  <  T  >,  
    onFailure  :   ()   =>   void  
  )  :   Promise  <  T  > {  
    // 已有刷新在进行：加入队列，等待同一次刷新结果  
    if   (isRefreshing) {  
      return   new   Promise  <  T  >((  resolve  ,   reject  )   =>   {  
        addSubscriber  ((  newToken  )   =>   {  
          if   (newToken) {  
            doRetry  (newToken).  then  (resolve).  catch  (reject);  
          }   else   {  
            reject  (  new   Error  (  'Token refresh failed'  ));  
          }  
        });  
      });  
    }  
  
    isRefreshing   =   true  ;  
  
    // 约定：doRefresh 捕获网络异常，并在失败时返回 null  
    const   newToken   =   await   doRefresh  ();  
  
    if   (newToken) {  
      notifySubscribers  (newToken);  
      isRefreshing   =   false  ;  
      return   doRetry  (newToken);  
    }  
  
    notifySubscribers  (  null  );  
    isRefreshing   =   false  ;  
    onFailure  ();  
    return   Promise  .  reject  (  new   Error  (  'Token refresh failed'  ));  
  }
```



这里的 `doRefresh` 应当把请求异常归一化为 `null`。如果它可能直接抛出异常，就要在调用处捕获，否则 `isRefreshing` 无法复位，等待队列也收不到结果：



```typescript
const   doRefresh   =   async   ()   =>   {  
    try   {  
      return   await   fetchNewToken  ();  
    }   catch   {  
      return   null  ;  
    }  
  };
```



### 4. 接入 Axios 拦截器



```typescript
axios.interceptors.response.  use  (  null  , (  error  )   =>   {  
    const   {   response  ,   config   }   =   error;  
  
    if   (response?.status   !==   401  )   return   Promise  .  reject  (error);  
    if   (config?.url?.  includes  (  '/auth/login'  ))   return   Promise  .  reject  (error);  
  
    // 刷新接口本身失败时不能再次进入刷新流程  
    if   (config?.url?.  includes  (  '/auth/refresh'  )) {  
      clearStorage  ();  
      window.location.href   =   '/login'  ;  
      return   Promise  .  reject  (error);  
    }  
  
    return   handleUnauthorized  (  
      ()   =>   doRefresh  (),  
      (  newToken  )   =>   {  
        config.headers.Authorization   =   `Bearer ${  newToken  }`  ;  
        return   axios  (config);  
      },  
      ()   =>   {  
        clearStorage  ();  
        window.location.href   =   '/login'  ;  
      }  
    );  
  });
```



现在，同一批 401 只会触发一次刷新：



```text
Request A → 401 → isRefreshing=false → 发起刷新  
  Request B → 401 → isRefreshing=true  → 加入队列  
  Request C → 401 → isRefreshing=true  → 加入队列  
  Request D → 401 → isRefreshing=true  → 加入队列  
    
  刷新成功 → notifySubscribers(newToken) → B、C、D 使用新 Token 重试
```



## 隐藏问题：永久 pending 的 Promise

不少实现会在刷新失败时直接清空队列：



```typescript
// ❌ 队列中的 Promise 没有收到结果  
  isRefreshing   =   false  ;  
  subscribers   =   [];  
  onFailure  ();
```



队列里保存的是各个 Promise 的完成回调。直接丢弃这些回调，会让调用者拿到的 Promise 既不 resolve，也不 reject。

一个未决 Promise 并不必然永远占用内存；是否能被回收取决于外部是否仍持有引用。但从业务生命周期看，这些请求已经泄漏：加载状态无法结束，等待它们的组合 Promise 无法完成，调用方还可能继续保留相关闭包和资源。

正确做法是把失败也广播给所有订阅者：



```typescript
// ✅ 每个等待请求都会进入 reject 分支  
  notifySubscribers  (  null  );  
  isRefreshing   =   false  ;  
  onFailure  ();
```



这也是 `Subscriber` 使用 `string | null`，而不是只接受 `string` 的原因。

## 需要处理的边界情况

### 刷新接口自身的 401

刷新请求不能再次进入 `handleUnauthorized()`，否则会无限递归：



```text
refreshToken() → 401 → handleUnauthorized() → refreshToken() → ...
```



登录和刷新端点应当在拦截器入口处直接排除。

### 多标签页

`isRefreshing` 是当前 JavaScript 上下文中的模块级状态，不能跨标签页或 iframe 自动同步。如果多个页面共享同一组凭证，可以使用 `BroadcastChannel` 或 `SharedWorker` 协调刷新结果。

### 状态复位顺序

在这套实现中，先通知当前队列，再把 `isRefreshing` 设回 `false`。这样通知过程仍属于当前刷新周期，不会在队列尚未分发完成时开启另一轮刷新。

### 刷新超时

队列没有内建超时。如果刷新请求可能长时间无响应，可以在 `doRefresh` 中加入超时，并统一返回 `null`：



```typescript
const   doRefresh   =   ()   =>  
    Promise  .  race  ([  
      fetchNewToken  (),  
      new   Promise  <  null  >((  resolve  )   =>   {  
        setTimeout  (()   =>   resolve  (  null  ),   10_000  );  
      }),  
    ]);
```



## 取舍

| 优点 | 局限 |

| --- | --- |

| 无额外依赖，与 HTTP 客户端解耦 | 模块级状态不能跨标签页 |

| 同一时刻最多一次刷新 | 需要明确异常和超时契约 |

| 刷新成功与失败都能结束等待请求 | 重试仍需防止业务级无限循环 |

这套模式的重点不是“排队”本身，而是让一次刷新拥有完整的生命周期：成功时唤醒全部请求，失败时也结束全部请求。

## 相关代码与资料

- [TIL 原文](https://github.com/ZengLiangYi/til/issues/1)

- [完整 TypeScript 实现](https://github.com/ZengLiangYi/til/blob/main/typescript/token-refresh-queue.ts)

- [RFC 6750：OAuth 2.0 Bearer Token Usage](https://www.rfc-editor.org/rfc/rfc6750)

- [BroadcastChannel API](https://developer.mozilla.org/docs/Web/API/BroadcastChannel)

分享：

##  相关文章

[##  五种 AI 对话解析器：字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵，检查工具标记、思考文本、顺序与快照覆盖范围。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/ai-conversation-parser-boundaries)    [##  AI SDK Provider 抽象：能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出，用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#TypeScript](/blog/ai-sdk-provider-abstraction)    [##  SourceAdapter 内部链路：身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷，验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/source-adapter-internals)

##  从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录，欢迎交流实现细节。

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "并发 401 下的 Token 刷新竞态：一个被低估的 Bug",
  "description": "多个请求同时收到 401 时，如何用单次刷新与等待队列避免 refresh token 轮换竞态，并确保刷新失败时所有请求都能正确结束。",
  "url": "https://zengliangyi.com/blog/token-refresh-race-condition/",
  "image": "https://zengliangyi.com/images/blog/token-refresh-race-condition.svg",
  "datePublished": "2026-03-16T00:00:00.000Z",
  "dateModified": "2026-10-08T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/token-refresh-race-condition/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "并发 401 下的 Token 刷新竞态：一个被低估的 Bug",
      "item": "https://zengliangyi.com/blog/token-refresh-race-condition/"
    }
  ]
}
```
