一波三折:每步我都学过,但没在对的位置用对——从一道差旅系统题看 Promise 缓存模式

2 阅读7分钟

最近刷到一道题,业务场景很真实,写完之后发现自己走了不少弯路。

复盘下来有个感受:不是知识不够,是知识的"激活条件"没建立起来。 每一步用到的概念我都学过,但在具体场景里,没能在正确的位置正确地使用。

这篇文章把这个过程完整记录下来,包括弯路。


题目

业务场景:差旅系统需要实现一个权限检查函数,判断用户是否有权限执行某个操作。权限规则从服务端获取,需要缓存结果避免重复请求。

type Permission = 'view' | 'edit' | 'approve' | 'cancel'

// 已提供——每次调用都会发网络请求
declare function fetchUserPermissions(userId: string): Promise<Permission[]>

// 要求实现
function createPermissionChecker(userId: string) {
  // 返回一个函数,调用时检查用户是否有某个权限
  // 权限列表只请求一次,后续复用缓存
}

// 使用方式
const checker = createPermissionChecker('U001')
await checker('view')   // true or false
await checker('edit')   // true or false(不再发请求,用缓存)

第一折:闭包存缓存——我以为写对了

看到"缓存结果、复用"这几个字,我立刻想到了闭包。

之前练过闭包的题:闭包变量能跨越多次函数调用保持状态,用来存缓存再合适不过。

function createPermissionChecker(userId: string) {
  let cachedPermissions: Permission[] | null = null  // 闭包变量

  return async function(permission: Permission): Promise<boolean> {
    if (!cachedPermissions) {
      cachedPermissions = await fetchUserPermissions(userId)  // 第一次发请求
    }
    return cachedPermissions.includes(permission)  // 后续用缓存
  }
}

逻辑清晰:第一次调用发请求存结果,后续直接读缓存。

我觉得写完了。

然后被追问:如果两个请求同时发出呢?

await Promise.all([checker('view'), checker('edit')])

盯着代码看了几秒,发现问题——

两个调用同时执行,同时检查 cachedPermissions === null,同时判断为 true,然后各自发了一次请求:

checker('view'):cachedPermissions = null → 发请求 ✗
checker('edit'):cachedPermissions = null → 又发请求 ✗

这就是竞态条件(Race Condition)

缓存的逻辑本身没错,但它保护不了异步并发的时间窗口——两个调用都在请求还没回来之前就完成了"检查",各自都以为自己是第一个。

这一折的收获:闭包能存缓存,但存什么决定了能不能防住竞态。


第二折:withLimit(1) 控制并发——方向对,但太重

我想到了并发控制。

思路是:用 withLimit 限制同时只有一个请求在飞,第二个调用等第一个完成再执行,自然就能读到缓存了。

// 伪代码思路
const limitedFetch = withLimit(1, fetchUserPermissions)

function createPermissionChecker(userId: string) {
  let cachedPermissions: Permission[] | null = null

  return async function(permission: Permission) {
    if (!cachedPermissions) {
      cachedPermissions = await limitedFetch(userId)
    }
    return cachedPermissions.includes(permission)
  }
}

方向对——让第二个调用"等"第一个。

但这个解法太重了

withLimit 是为了管理多个不同请求的并发数量设计的,背后维护着一个任务队列。而这里的场景极其简单:只有一个请求,只需要防止它被重复发出。

用并发控制队列来解决这个问题,像是为了锁一扇门造了整套门禁系统。

而且仔细想,即使加了 withLimit(1),竞态问题依然没有被根本解决——两次调用还是可能在"检查缓存"和"写入缓存"之间发生时序交叉。

这一折的收获:识别"解法的重量"。能用更轻的方式解决,就不需要引入复杂机制。更重要的是要找到问题的本质:这里的根本问题不是"并发数量",而是"同一个请求被重复创建"。


第三折:缓存 Promise 本身——顿悟

提示来了:缓存的不是结果,而是 Promise 本身。

当时愣了一下。

Promise 也可以被存起来?——当然可以,Promise 就是一个普通的 JavaScript 对象,是一个值

// 缓存结果——必须等 await 完成才有值可存
let result: Permission[] | null = null
result = await fetchUserPermissions(userId)  // 加了 await,等完成才赋值

// 缓存 Promise——请求发出的瞬间就有值了
let promise: Promise<Permission[]> | null = null
promise = fetchUserPermissions(userId)  // 不加 await,存 Promise 本身

这两行的区别,就是整道题的核心:

  • 缓存结果:必须等 await 完成才有值可存,在这之前的并发调用都看到 null
  • 缓存 Promise:请求发出的瞬间就有值了——一个 pending 状态的 Promise

pending 状态的 Promise 也是可以被 await 的。它会在请求完成后返回结果。

两个调用同时 await 同一个 Promise,最终都拿到同一份数据,网络请求只发了一次


完整实现

function createPermissionChecker(userId: string) {
  let permissionsPromise: Promise<Permission[]> | null = null

  return async function(permission: Permission): Promise<boolean> {
    if (!permissionsPromise) {
      // 第一次:创建 Promise 并存起来(不等结果,直接存)
      permissionsPromise = fetchUserPermissions(userId)
    }
    // 每次都 await 同一个 Promise
    const permissions = await permissionsPromise
    return permissions.includes(permission)
  }
}

这是 Promise 缓存模式的核心。

关键:Promise 是"可共享的等待凭证"

// 假设 fetchUserPermissions 需要 1 秒完成
let permissionsPromise: Promise<Permission[]> | null = null

// 第一次调用
checker('view')  // 创建 Promise,开始网络请求,进入 pending

// 50ms 后,第二次调用(请求还没完成)
checker('edit')  // permissionsPromise 已存在,直接 await 同一个

两个调用如何"汇合"到同一个 await

时间线:
─────────────────────────────────────────►

checker('view'):
  发现 null → 创建 Promise → 发请求 ────────────────► 完成
                          ↓
                          await 这个 Promise ─────────► 拿到结果

checker('edit'):
  发现已有 Promise ───────► await 同一个 Promise ─────► 拿到相同结果
                          (不需要再发请求)

核心机制await 不是"重新执行请求",而是"订阅这个 Promise 的完成事件"。

为什么多个 await 不会导致多次请求

const p = fetchUserPermissions(userId)  // 只发一次请求

// 下面两个都 await 同一个 p,不会触发新请求
const r1 = await p
const r2 = await p  // 复用结果,不重新执行

fetchUserPermissions 只被调用了一次。后续的 await 只是等待那个已经飞出去的请求回来,而不是再发一个新请求

一句话总结

缓存结果 = 请求完成后才有东西可缓存,并发调用在"空窗期"各自发请求
缓存 Promise = 请求发出的瞬间就有东西可缓存,后续调用都"挂号"等同一个结果

Promise 在这里充当了一个共享的占位符:谁先创建谁发请求,后来的都排队等这一个结果。

执行流程对比:

❌ 缓存结果的问题(竞态):
  checker('view'):null → 发请求,await 中...
  checker('edit'):null → 又发请求,await 中...   ← 竞态!
  请求发了两次

✅ 缓存 Promisechecker('view'):null → 创建 Promise 存起来(pending)→ await 等结果
  checker('edit'):Promise 已存在 → 直接 await 同一个 Promise
  请求只发了一次 ✅

延伸:请求失败怎么办?

上面的实现有一个隐患:如果请求失败,permissionsPromise 里存的是一个 rejected Promise,后续调用会一直拿到错误,没有机会重试。

处理方式:失败时把缓存清掉。

function createPermissionChecker(userId: string) {
  let permissionsPromise: Promise<Permission[]> | null = null

  return async function(permission: Permission): Promise<boolean> {
    if (!permissionsPromise) {
      permissionsPromise = fetchUserPermissions(userId).catch(err => {
        permissionsPromise = null  // 失败时清除缓存,下次调用重新发请求
        return Promise.reject(err)
      })
    }
    const permissions = await permissionsPromise
    return permissions.includes(permission)
  }
}

这个模式叫什么

Promise 缓存(Promise Memoization)

在实际项目里很常见,往往藏在不起眼的地方:

  • 应用初始化时加载配置,只请求一次,全局复用
  • 获取当前用户信息,首次加载后缓存
  • 权限系统、Feature Flag 的初始化

核心不变:把 Promise 存在闭包里,而不是把结果存在闭包里。


面试快速参考

Q:如何实现一个只发一次网络请求的权限检查器?

核心:缓存 Promise 而非缓存结果,防止并发竞态。

function createPermissionChecker(userId: string) {
  let permissionsPromise: Promise<Permission[]> | null = null

  return async (permission: Permission): Promise<boolean> => {
    if (!permissionsPromise) {
      permissionsPromise = fetchUserPermissions(userId)
    }
    return (await permissionsPromise).includes(permission)
  }
}

Q:追问——请求失败时如何处理?

.catch 里重置 permissionsPromise = null,允许下次调用重新发请求。

Q:这道题考察了哪些知识点?

  • 闭包:跨调用保存状态
  • Promise 是值:可以被赋值、传递、在 pending 状态被 await
  • 竞态条件:异步并发的时间窗口问题
  • 解法选型:识别"解法的重量",用最轻的方式解决问题