Promise.all 并发失效?一次 token 缓存引发的串行陷阱

171 阅读3分钟

今天搞了一天的性能优化,本来以为是个简单活,结果差点把我整疯了。

事情是这样的

我们搜索页面加载慢得离谱,产品经理天天催。页面要拉三个接口:商品、筛选条件、推荐品牌。原来是一个个请求,我想着改成并发应该能快不少。

老代码:

async function loadSearchData(keyword) {
  const products = await fetchProducts(keyword);
  const filters = await fetchFilters(keyword);  
  const brands = await fetchBrands(keyword);
  
  return { products, filters, brands };
}

改成这样:

async function loadSearchData(keyword) {
  const [products, filters, brands] = await Promise.all([
    fetchProducts(keyword),
    fetchFilters(keyword),
    fetchBrands(keyword)
  ]);
  
  return { products, filters, brands };
}

结果测试的时候我人都傻了,Network 里面三个请求还是一个接一个发的,根本没并发。我还以为自己代码写错了,检查了半天,Promise.all 没问题啊。

开始排查

单独测试这几个函数,发现能正常并发:

console.time('test');
const p1 = fetchProducts('iPhone');
const p2 = fetchFilters('iPhone');  
const p3 = fetchBrands('iPhone');

Promise.all([p1, p2, p3]).then(() => {
  console.timeEnd('test'); // 这时候是并发的
});

但在实际页面里就不行。我开始怀疑是不是我们封装的问题,翻了一下代码:

async function fetchProducts(keyword) {
  const token = await getAuthToken();
  const response = await axios.get('/api/products', {
    headers: { Authorization: `Bearer ${token}` },
    params: { keyword }
  });
  return response.data;
}

三个函数都要先拿token。看看 getAuthToken:

let tokenPromise = null;

async function getAuthToken() {
  if (tokenPromise) {
    return tokenPromise;
  }
  
  tokenPromise = new Promise(async (resolve) => {
    const stored = localStorage.getItem('auth_token');
    if (stored && !isTokenExpired(stored)) {
      resolve(stored);
      return;
    }
    
    const response = await axios.post('/api/auth/refresh');
    const newToken = response.data.token;
    localStorage.setItem('auth_token', newToken);
    resolve(newToken);
  });
  
  return tokenPromise;
}

看起来也没毛病,用单例避免重复请求。但我突然想到,如果本地没token会怎么样?

找到问题了

清空localStorage测试了一下,果然重现了。问题是这样的:

  1. 三个函数同时调用 getAuthToken
  2. 第一个触发 refresh 请求
  3. 其他两个等待同一个 tokenPromise
  4. refresh 完成后,三个函数拿到token
  5. 但这时候三个业务请求不是同时发的!

为什么?因为虽然它们等的是同一个Promise,但JavaScript 是单线程的,Promise 的回调是微任务队列顺序执行,不是真的“同时”
换句话说:Promise.all 只是并发“启动”,不是并发“执行”

更坑的是,我们axios还有个拦截器:

axios.interceptors.request.use(async (config) => {
  const token = await getAuthToken();
  config.headers.Authorization = `Bearer ${token}`;
  return config;
});

这就雪上加霜了,即便函数里已经拿过token,axios又要拿一次。

怎么解决

最简单的办法,先统一拿token:

async function loadSearchData(keyword) {
  const token = await getAuthToken();
  
  const [products, filters, brands] = await Promise.all([
    fetchProductsWithToken(keyword, token),
    fetchFiltersWithToken(keyword, token),
    fetchBrandsWithToken(keyword, token)
  ]);
  
  return { products, filters, brands };
}

但这样改动太大,我重新写了token管理,并加了超时保护请求级缓存

class TokenManager {
  constructor() {
    this.token = null;
    this.refreshing = false;
    this.waitList = [];
  }
  
  async getToken() {
    if (this.token && !this.isExpired(this.token)) {
      return this.token;
    }
    
    if (this.refreshing) {
      return new Promise((resolve, reject) => {
        this.waitList.push(resolve);
        // 防止 refresh 挂死
        setTimeout(() => reject(new Error('Token refresh timeout')), 5000);
      });
    }
    
    this.refreshing = true;
    
    try {
      const response = await axios.post('/api/auth/refresh');
      this.token = response.data.token;
      
      // 同时通知所有等待的请求
      this.waitList.forEach(resolve => resolve(this.token));
      this.waitList = [];
      
      return this.token;
    } finally {
      this.refreshing = false;
    }
  }
  
  isExpired(token) {
    try {
      const payload = JSON.parse(atob(token.split('.')[1]));
      return Date.now() >= payload.exp * 1000;
    } catch {
      return true;
    }
  }
}

// axios 拦截器也加缓存穿透保护
let tokenCache = null;
axios.interceptors.request.use(async (config) => {
  if (!tokenCache || new TokenManager().isExpired(tokenCache)) {
    tokenCache = await new TokenManager().getToken();
  }
  config.headers.Authorization = `Bearer ${tokenCache}`;
  return config;
});

测试结果

改完之后测试:

阶段优化前优化后
token 获取300 ms300 ms
业务请求耗时900 ms(串行)200 ms(并发)
总耗时1.2 s500 ms

效果还是很明显的。

一些想法

这次踩坑让我想明白几个事:

  • Promise.all 不是万能钥匙。如果每个 Promise 内部有共享依赖(如 token),还是会串行。
  • 缓存也可能成为瓶颈。虽然单例模式避免了重复请求,但也可能让本来并发的操作变成串行。
  • 性能问题要看执行路径。表面上看起来并发的代码,在某些条件下可能退化成串行。
  • 设计的问题。让每个业务函数都关心 token 获取本身就不合理,应该让 HTTP 客户端透明处理。

现在回头看,如果当初设计 API 调用时多想想,就不会有这种问题了。但谁能保证设计时就能想到所有的 edge case 呢?

代码这东西,写的时候觉得没问题,跑起来就各种意外。这大概就是为什么我们总是在 debug 吧。