今天搞了一天的性能优化,本来以为是个简单活,结果差点把我整疯了。
事情是这样的
我们搜索页面加载慢得离谱,产品经理天天催。页面要拉三个接口:商品、筛选条件、推荐品牌。原来是一个个请求,我想着改成并发应该能快不少。
老代码:
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测试了一下,果然重现了。问题是这样的:
- 三个函数同时调用 getAuthToken
- 第一个触发 refresh 请求
- 其他两个等待同一个 tokenPromise
- refresh 完成后,三个函数拿到token
- 但这时候三个业务请求不是同时发的!
为什么?因为虽然它们等的是同一个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 ms | 300 ms |
| 业务请求耗时 | 900 ms(串行) | 200 ms(并发) |
| 总耗时 | 1.2 s | 500 ms |
效果还是很明显的。
一些想法
这次踩坑让我想明白几个事:
- Promise.all 不是万能钥匙。如果每个 Promise 内部有共享依赖(如 token),还是会串行。
- 缓存也可能成为瓶颈。虽然单例模式避免了重复请求,但也可能让本来并发的操作变成串行。
- 性能问题要看执行路径。表面上看起来并发的代码,在某些条件下可能退化成串行。
- 设计的问题。让每个业务函数都关心 token 获取本身就不合理,应该让 HTTP 客户端透明处理。
现在回头看,如果当初设计 API 调用时多想想,就不会有这种问题了。但谁能保证设计时就能想到所有的 edge case 呢?
代码这东西,写的时候觉得没问题,跑起来就各种意外。这大概就是为什么我们总是在 debug 吧。