哈喽大家好,最近在朋友那里看到了一个工程题面试题,我是没太接触过,一时间无从下手,欢迎有兴趣的朋友可以看看讨论讨论。
题目的大概内容是: 设计请求层缓存与并发控制
要求:
- 以url+method+body指纹作为key,
- 对相同key的并发请求复用同一promise
- 超时使用promise.race
- 提供手动失效与LRU上限
- 封装
面对这种面试题,不要慌一步步的去思考,逐步解决,想一次性就把整体都想好,我感觉还是需要一定的能力。上面题目的内容已经是我拆解完的需求,逐步去实现它

准备(简单介绍内部的一些知识点)
LRU缓存机制 :设置最大缓存大小,重新添加
或者访问了其中的某条缓存
,放最后一位。当超出缓存的size,删除第一个(当我放入了 1,2,访问1,就变成了 2,1。此时再放入3,变成1,3,删除了2。)
可以把这个过程想象成'书架',从左到右 = 最久没使用 → 最近刚使用"(左边是 "老古董",右边是 "新宠"),每次 "用了某条缓存"(不管是访问还是重新添加),都把它挪到最右边(变成新宠);缓存满了就删掉最左边的 "老古董"。
LRU | 最大缓存2 |
---|---|
先放 1 → 缓存 | 只有 1,既是老古董也是新宠 |
放 2 → 缓存 | 1 在左:最久没动;2 在右:最近刚放 |
访问 1 → 1 变成 "新宠" | 挪到最右边 → 缓存变成:[2, 1](2 变老古董,1 变新宠) |
放 3 → 缓存满了(已占 2 个) | 删最左边的老古董 "2",再把 3 放最右边 → 最终缓存:[1, 3](1 现在是 "次新",3 是 "最新") |

开始拆解设计
1. 生成指纹
要求是url+method+body作为指纹key,那么应该如何确定两个组合的key不同呢?我采用的是「method+url+body 序列化字符串」组合成唯一key。
javascript
------------------------- 实例代码 -------------------------------
function generateRequestKey(url, method = 'GET', body = null) {
// 序列化body:忽略函数/undefined,确保相同内容生成相同字符串
const bodyStr = body
? JSON.stringify(body, (key, val) =>
typeof val === 'function' || val === undefined ? null : val
)
: '';
// 组合key(method统一大写,避免大小写差异)
return `${method.toUpperCase()}:${url}:${bodyStr}`;
}
// 测试:相同请求生成相同key,不同请求生成不同key
console.log(generateRequestKey('/api/user', 'get', { id: 1 }));
// 输出:GET:/api/user:{"id":1}
console.log(generateRequestKey('/api/user', 'POST', { id: 1 }));
// 输出:POST:/api/user:{"id":1}(与上不同)
2. LRU缓存
LRU策略(Least Recently Used):当缓存达到上限时,删除 "最久没被使用" 的缓存项。(上面的预备知识有说到)
原理
-
核心逻辑:"最近使用的留着,最久不用的删了";
-
实现工具:利用 ES6
Map
的特性 ------Map
会按 "插入顺序" 保存键值对,且delete
后重新set
会把键值对移到末尾(标记为 "最近使用"); -
流程:
- 存缓存(
set
):如果缓存满了,删除Map
的第一个键(最久未用),再插入新缓存; - 取缓存(
get
):如果存在,先删除再重新插入(更新为 "最近使用"),再返回值。
- 存缓存(
kotlin
----------------- 实例代码(内容比较多) ------------------------
class LRUCache {
constructor(maxSize = 100) {
this.maxSize = maxSize;
this.cache = new Map(); // 用Map维护缓存,利用其插入顺序特性
}
/**
* 获取缓存(并更新为最近使用)
* @param {string} key - 请求指纹
* @returns {any|null} 缓存值(无则返回null)
*/
get(key) {
if (!this.cache.has(key)) return null;
// 关键:获取后删除再重新插入,把key移到Map末尾(标记为最近使用,变成新宠)
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
/**
* 存入缓存(满了则删除最久未用)
* @param {string} key - 请求指纹
* @param {any} value - 缓存值(请求结果)
*/
set(key, value) {
// 如果key已存在,先删除(避免重复,后续重新插入)
if (this.cache.has(key)) {
this.cache.delete(key);
}
// 如果缓存满了,删除Map第一个键(最久未用)
else if (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value; // Map.keys()是迭代器,next().value取第一个键
this.cache.delete(oldestKey);
}
// 插入新缓存(移到末尾,标记为最近使用)
this.cache.set(key, value);
}
/**
* 手动删除指定缓存(支持缓存失效)
*/
delete(key) {
this.cache.delete(key);
}
/**
* 清空所有缓存
*/
clear() {
this.cache.clear();
}
}
// 测试:LRU淘汰逻辑
const cache = new LRUCache(2); // 最大容量2
cache.set('key1', 'val1'); // [val1]
cache.set('key2', 'val2'); // [val1,val2]
cache.get('key1'); // 访问key1,使其变为最近使用 [val2,val1]
cache.set('key3', 'val3'); // 缓存满了,删除最久未用的key2 [val1,val3]
console.log(cache.get('key2')); // 输出:null(已被淘汰)
3. 并发控制-相同key的并发请求复用同一promise
根据要求,采用的方案:用 "pending 请求池"(一个 Map)存储 "正在进行的请求 Promise"------ 相同 key 的请求过来时,直接复用已有的 Promise,不重新发请求
原理
-
维护一个
pendingRequests
Map,key 是请求指纹,value 是 "正在进行的请求 Promise"; -
发起请求前,先查
pendingRequests
:- 如果有对应的 Promise → 直接返回这个 Promise(复用结果);
- 如果没有 → 发起新请求,把 Promise 存入
pendingRequests
; - 请求完成(成功 / 失败)后,从
pendingRequests
中删除这个 Promise(避免内存泄漏)
javascript
--------------- 实例代码(结合了前面的代码内容) -----------------------
/**
* 带并发控制的请求函数(基础版)
* @param {Function} fetcher - 底层请求工具(比如fetch/axios)
* @param {number} maxCacheSize - LRU缓存最大容量
*/
function createRequestWithConcurrency(fetcher, maxCacheSize = 100) {
const cache = new LRUCache(maxCacheSize);
const pendingRequests = new Map(); // 存储正在进行的请求Promise(并发控制核心)
/**
* 核心请求方法
* @param {string} url - 请求地址
* @param {string} method - 请求方法
* @param {object|null} body - 请求体
* @returns {Promise<any>} 请求结果
*/
async function request(url, method = 'GET', body = null) {
const key = generateRequestKey(url, method, body); // 生成key
// 1. 先查缓存:有缓存直接返回
const cachedValue = cache.get(key);
if (cachedValue) {
console.log(`[缓存命中] ${key}`);
return cachedValue;
}
// 2. 再查pending请求:有则复用Promise
if (pendingRequests.has(key)) {
console.log(`[并发复用] ${key}`);
return pendingRequests.get(key);
}
// 3. 发起新请求:把Promise存入pendingRequests
console.log(`[发起新请求] ${key}`);
const requestPromise = fetcher(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : null
})
.then(res => {
if (!res.ok) throw new Error(`HTTP错误:${res.status}`);
return res.json();
})
.then(result => {
// 请求成功:存入缓存
cache.set(key, result);
return result;
})
.finally(() => {
// 无论成功/失败,都从pending中删除(避免内存泄漏)
pendingRequests.delete(key);
});
// 把新请求的Promise存入pending池
pendingRequests.set(key, requestPromise);
return requestPromise;
}
// 手动失效缓存的方法
function invalidateCache(url, method = 'GET', body = null) {
const key = generateRequestKey(url, method, body);
cache.delete(key);
}
return { request, invalidateCache };
}
// 测试:并发请求复用
const fetcher = window.fetch; // 用浏览器fetch作为底层工具
const { request } = createRequestWithConcurrency(fetcher);
// 同时发起3个相同请求
Promise.all([
request('/api/user', 'GET', { id: 1 }),
request('/api/user', 'GET', { id: 1 }),
request('/api/user', 'GET', { id: 1 })
]).then(results => {
console.log('3个请求的结果:', results); // 结果相同,且只发1次真实请求
});
4. 超时控制
用Promise.race
实现超时控制 ------ 让 "请求" 和 "定时器" 赛跑,定时器先触发则判定为 "超时失败
原理
-
Promise.race(iterable)
:接收一个 Promise 数组,谁先完成(成功 / 失败),就返回谁的结果; -
逻辑:
- 第一个 Promise:执行实际请求(比如
fetcher(...)
); - 第二个 Promise:定时器(
setTimeout
),延迟timeout
毫秒后触发reject
(抛出超时错误); - 如果请求在
timeout
内完成 → 返回请求结果; - 如果请求超时 → 定时器先触发,抛出 "超时错误"。
- 第一个 Promise:执行实际请求(比如
javascript
--------------- 实例代码 --------------------------
/**
* 超时控制函数
* @param {Function} fn - 异步函数(比如请求函数)
* @param {number} timeout - 超时时间(默认5000ms)
* @returns {Promise<any>} 带超时的Promise
*/
// 如果fn() 没有在 timeout时间内成功,就会触发第二个promise,报出请求超时错误
function withTimeout(fn, timeout = 5000) {
return Promise.race([
fn(), // 实际的异步操作(比如请求)
// 超时定时器
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`请求超时(已超过${timeout}ms)`));
}, timeout);
})
]);
}
// 测试:超时控制
const slowRequest = () => new Promise(resolve => {
setTimeout(() => resolve('请求成功'), 6000); // 6秒后才完成
});
// 给slowRequest设置5秒超时
// fn()六秒,第二个promise 5秒。 结果报错超时
withTimeout(slowRequest, 5000)
.then(res => console.log(res))
.catch(err => console.log(err.message)); // 输出:请求超时(已超过5000ms)
5. 指数退避重试 - 失败后自动重试
失败后自动重试,且重试间隔 "指数增长"(避免短时间内密集重试导致服务器压力过大),同时加 "随机抖动"(避免多个请求同时重试导致 "请求风暴")。
原理
-
指数退避:重试间隔 =
initialDelay * 2^retries
(比如初始延迟 1000ms,第一次重试等 2000ms,第二次等 4000ms...); -
随机抖动:在指数延迟基础上,加 0~100ms 的随机值(比如 2000ms→2050ms,避免多个请求同时重试);
-
流程:
- 尝试执行请求,失败则重试次数 + 1;
- 如果重试次数达到
maxRetries
→ 抛出最终错误; - 否则计算延迟时间,等待后重新尝试。
javascript
-------------- 实例代码 ------------------------------
/**
* 指数退避重试函数
* @param {Function} fn - 异步函数(比如带超时的请求函数)
* @param {number} maxRetries - 最大重试次数(默认3次)
* @param {number} initialDelay - 初始重试延迟(默认1000ms)
* @returns {Promise<any>} 带重试的Promise
*/
async function withRetry(fn, maxRetries = 3, initialDelay = 1000) {
let retries = 0; // 已重试次数
while (true) { // 无限循环,直到成功或达到最大重试次数
try {
return await fn(); // 尝试执行异步操作
} catch (error) {
retries++;
// 达到最大重试次数,抛出最终错误
if (retries >= maxRetries) {
console.log(`[重试结束] 已重试${retries}次,仍失败`);
throw error;
}
// 计算重试延迟:指数增长 + 随机抖动(避免请求风暴)
const delay = initialDelay * Math.pow(2, retries) + Math.random() * 100;
console.log(`[重试中] 第${retries}次重试,延迟${Math.round(delay)}ms`);
// 等待延迟时间(await会暂停循环,不阻塞主线程)
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// 测试:重试逻辑
const unstableRequest = () => {
// 模拟50%概率失败的请求
return new Promise((resolve, reject) => {
Math.random() > 0.5
? resolve('请求成功')
: reject(new Error('临时失败'));
});
};
// 给unstableRequest加重试(最多2次重试,初始延迟1000ms)
withRetry(unstableRequest, 2, 1000)
.then(res => console.log(res))
.catch(err => console.log(err.message));

可能会被面试官问到的问题
- 为什么用 Map 实现 LRU,而不用数组?
数组删除第一个元素的时间复杂度是 O (n)(需要移动所有元素),而 Map 的delete
和set
是 O (1),性能更优;且 Map 天然维护插入顺序,无需额外维护索引
- 如果某个请求失败了,复用这个 Promise 的请求也会失败,怎么处理
失败后不会存入缓存,下次请求会重新发起
- 为什么要序列化 body?直接用 body 作为 key 不行吗?
对象引用不同即使内容相同,也会被判定为不同(比如{a:1}
和{a:1}
是两个不同对象),序列化后才能基于内容判定一致性。
总结
这种题目的核心考点我感觉是对思路的考虑,如何设计,而不只是代码是否会写,毕竟ai的加持,如何写代码的能力的差距是可以缩小的,但是相关的思路,告诉ai怎么做,还是可以学习一下的。