前端面试题:请求层缓存与并发控制的完整设计(含原理拆解)

哈喽大家好,最近在朋友那里看到了一个工程题面试题,我是没太接触过,一时间无从下手,欢迎有兴趣的朋友可以看看讨论讨论。

题目的大概内容是: 设计请求层缓存与并发控制

要求:

  • 以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会把键值对移到末尾(标记为 "最近使用");

  • 流程:

    1. 存缓存(set):如果缓存满了,删除Map的第一个键(最久未用),再插入新缓存;
    2. 取缓存(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

    1. 如果有对应的 Promise → 直接返回这个 Promise(复用结果);
    2. 如果没有 → 发起新请求,把 Promise 存入pendingRequests
    3. 请求完成(成功 / 失败)后,从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 数组,谁先完成(成功 / 失败),就返回谁的结果

  • 逻辑:

    1. 第一个 Promise:执行实际请求(比如fetcher(...));
    2. 第二个 Promise:定时器(setTimeout),延迟timeout毫秒后触发reject(抛出超时错误);
    3. 如果请求在timeout内完成 → 返回请求结果;
    4. 如果请求超时 → 定时器先触发,抛出 "超时错误"。
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. 尝试执行请求,失败则重试次数 + 1;
    2. 如果重试次数达到maxRetries → 抛出最终错误;
    3. 否则计算延迟时间,等待后重新尝试。
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));

可能会被面试官问到的问题

  1. 为什么用 Map 实现 LRU,而不用数组?

数组删除第一个元素的时间复杂度是 O (n)(需要移动所有元素),而 Map 的deleteset是 O (1),性能更优;且 Map 天然维护插入顺序,无需额外维护索引

  1. 如果某个请求失败了,复用这个 Promise 的请求也会失败,怎么处理

失败后不会存入缓存,下次请求会重新发起

  1. 为什么要序列化 body?直接用 body 作为 key 不行吗?

对象引用不同即使内容相同,也会被判定为不同(比如{a:1}{a:1}是两个不同对象),序列化后才能基于内容判定一致性。

总结

这种题目的核心考点我感觉是对思路的考虑,如何设计,而不只是代码是否会写,毕竟ai的加持,如何写代码的能力的差距是可以缩小的,但是相关的思路,告诉ai怎么做,还是可以学习一下的。

相关推荐
卧指世阁3 小时前
深入 Comlink 源码细节——如何实现 Worker 的优雅通信
前端·前端框架·源码
恋猫de小郭3 小时前
深入理解 Flutter 的 PlatformView 如何在鸿蒙平台实现混合开发
android·前端·flutter
小白64023 小时前
前端梳理体系从常问问题去完善-网络篇
前端·网络
Mintopia3 小时前
🧙‍♂️ Next.js 权限区分之术:凡人 vs 管理员
前端·后端·全栈
过往入尘土3 小时前
深入探索现代前端开发:从基础到架构的完整指南
前端·人工智能·算法·前端框架
比老马还六3 小时前
Blockly串口积木开发
前端
小奋斗4 小时前
浏览器原理之详解渲染进程
前端·面试
伶俜monster4 小时前
搞定 Monorepo,工程能力升级,升职加薪快人一步
前端·架构
猪哥帅过吴彦祖4 小时前
Flutter 系列教程:常用基础组件 (下) - `TextField` 和 `Form`
前端·flutter·ios