首页
/ cal.diy 跨请求 LRU 缓存实战:从 React.cache() 的边界到 lru-cache 服务端实现

cal.diy 跨请求 LRU 缓存实战:从 React.cache() 的边界到 lru-cache 服务端实现

2026-09-07 17:54:49作者:曹令琨Iris

本篇基于 cal.diy(Cal.com 系调度基础设施项目)仓库中的性能规则文档 server-cache-lru.md 展开,系统讲解服务端“跨请求”场景下的 LRU 缓存设计:为什么 React.cache() 无法覆盖连续请求间的数据共享、如何用 lru-cache 库实现带容量与 TTL 限制的内存缓存,以及 cal.diy 仓库中 getServerSession 里一份真实存在的 LRU 会话缓存实现。读完后可掌握“请求内去重”与“请求间共享”两类服务端缓存的选型依据、参数配置方法,并能对照仓库源码验证缓存命中路径。

一、问题的起点:React.cache() 只能覆盖单请求

cal.diy 的性能规则集(vercel-react-best-practices)将服务端缓存拆成两条互补的规则:

  • server-cache-react.md(Per-Request Deduplication,影响等级 MEDIUM):用 React.cache()单个请求内的重复调用去重,认证检查和数据库查询是最大受益者。其标准用法是:
import { cache } from 'react'

export const getCurrentUser = cache(async () => {
  const session = await auth()
  if (!session?.user?.id) return null
  return await db.user.findUnique({
    where: { id: session.user.id }
  })
})

在同一个请求内多次调用 getCurrentUser() 只会真正执行一次查询。

  • server-cache-lru.md(Cross-Request LRU Caching,影响等级 HIGH):React.cache() 的作用域仅限一次请求。而真实用户操作往往是跨请求的——先点按钮 A 触发一次接口调用,紧接着点按钮 B 再触发一次,两次请求在秒级时间窗口内需要同一份数据。这种场景下请求内去重毫无帮助,需要把缓存的生命周期延长到跨请求,这正是 LRU 缓存的定位。

一句话概括两者的分工:React.cache() 解决“一次请求内查了 N 遍同样的东西”,LRU 解决“连续 N 次请求都在查同样的东西”。

二、跨请求 LRU 缓存的标准实现

规则文档给出的参考实现如下,直接取自 server-cache-lru.md

import { LRUCache } from 'lru-cache'

const cache = new LRUCache<string, any>({
  max: 1000,
  ttl: 5 * 60 * 1000  // 5 minutes
})

export async function getUser(id: string) {
  const cached = cache.get(id)
  if (cached) return cached

  const user = await db.user.findUnique({ where: { id } })
  cache.set(id, user)
  return user
}

// Request 1: DB query, result cached
// Request 2: cache hit, no DB query

关键参数与行为说明:

参数 示例取值 含义
max 1000 缓存最多持有的条目数;超过后按 LRU(最近最少使用)策略淘汰最久未访问的条目,保证内存占用有上界
ttl 5 * 60 * 1000(5 分钟,毫秒) 条目生存时间;到期后即使仍在容量内也会失效,防止读到陈旧数据

实现上遵循经典的“先查缓存、未命中再回源、回源后回填”三步:

  1. cache.get(id) 命中则直接返回,完全绕开数据库
  2. 未命中时执行数据库查询(示例中为 Prisma 的 db.user.findUnique);
  3. 查询结果通过 cache.set(id, user) 写入缓存,供后续请求复用。

文档中的注释精确描述了这个机制的效果:第一个请求执行数据库查询并写入缓存;第二个请求直接命中缓存,不再产生数据库查询。

何时启用这种缓存

规则文档给出了明确的使用判据:当用户在数秒之内连续操作、连续请求命中多个端点、而这些端点需要同一份数据时,跨请求 LRU 缓存收益最大。典型如“打开页面→拉取用户资料→点击按钮→提交操作需要再次读取同一用户”这类链路。反之,如果是彼此独立、间隔较长的请求,或者数据变更频繁(TTL 内就可能被写入),内存缓存的收益和正确性都需要重新评估——ttl 参数正是为控制这类陈旧窗口而存在的。

三、运行环境决定缓存是否真的“跨请求”

规则文档特别强调了部署形态对 LRU 缓存有效性的影响,这是很多文章忽略的关键前提:

  • Vercel 的 Fluid Compute 模型:LRU 缓存在此场景下特别有效,因为多个并发请求可以共享同一个函数实例,模块级变量(也就是模块作用域创建的 cache 实例)得以跨请求存续。这意味着无需 Redis 等外部存储即可实现跨请求缓存。
  • 传统 Serverless 模型:每次调用彼此隔离(每个 invocation 可能是独立冷启动的运行时),模块级缓存可能只服务一次调用就随实例销毁。此时若要跨进程共享数据,应考虑引入 Redis 等外部缓存。

从源码结构看,这一区别的本质是:new LRUCache(...) 是模块级单例,它的生命周期绑定在“运行时实例”上,而不是绑定在“一次请求”上。因此缓存能跨多少个请求,取决于底层平台复用运行实例的程度。这也是文档把该规则标注为 HIGH impact 的原因——它同时决定了缓存是否生效(平台)和生效时的收益大小(请求模式)。

四、仓库实证:cal.diy 认证模块中的跨请求 LRU 会话缓存

cal.diy 仓库中已经存在与上述规则完全对应的真实实现:getServerSession.ts。该文件是 next-authgetServerSession 的简化版,服务端从请求 token 中直接构造会话,而 LRU 缓存正是用来削减“每次请求都查一次数据库”的成本。

缓存实例与容量配置

getServerSession.ts#L26 中定义了模块级缓存:

const CACHE = new LRUCache<string, Session>({ max: 1000 });

可以看到它与规则文档的示例高度一致:容量上限同样是 1000 条,泛型为 LRUCache<string, Session>(键是字符串、值是 Session 会话对象)。该文件所在的 package.json 中声明了对应依赖版本为 lru-cache@9.0.3,与文档推荐使用的 lru-cache 库(isaacs/node-lru-cache 的官方实现)一致。

键的设计:以序列化 token 为键

与规则文档示例中用 id 作键不同,真实实现选择了把整个 token 序列化后的字符串作为缓存键(getServerSession.ts#L57):

const cachedSession = CACHE.get(JSON.stringify(token));

if (cachedSession) {
  log.debug("Returning cached session", safeStringify(cachedSession));
  return cachedSession;
}

这样设计可以推断出其意图:会话内容不仅取决于用户 ID,还取决于 token 中的 exp(过期时间)、impersonatedBy(模拟身份)等字段。用整个 token 作键,能确保 token 中任何影响会话语义的字段变化都会落到新的键上、触发重新构造会话,避免“同一用户不同 token 状态”误命中旧会话。

未命中时的回源路径:一次查询 + 两次组装

缓存未命中后的处理链(getServerSession.ts#L64-L147)展示了跨请求缓存所保护的真实成本:

  1. 校验 token.sub 解析出的 userId 合法(否则直接返回 null);
  2. prisma.user.findUnique 按 ID 查询用户(getServerSession.ts#L71-L73);
  3. 通过 UserRepository.enrichUserWithTheProfile 进一步补齐用户 profile 信息,组装出完整的 Session 对象(包含 userupIdexpires 等字段);
  4. 若 token 带有 impersonatedBy,还要额外查一次被模拟者的用户记录;
  5. 最终在 getServerSession.ts#L147 以同一个 JSON.stringify(token) 为键回填缓存:
CACHE.set(JSON.stringify(token), session);

对于持有相同 token 的同一用户,其后续请求在秒级窗口内反复进入认证中间件时,第 2~4 步的数据库查询都会被缓存命中跳过——这正是规则文档中“Request 1: DB query, result cached / Request 2: cache hit, no DB query”模式在认证热路径上的落地。文件头部注释也说明了配套的会话保活策略:该缓存不主动刷新过期(30 天)的 token,因为客户端会频繁调用 /auth/session 保持会话活跃,因此“缓存不刷新过期态”在此业务下是可接受的权衡。

与规则示例的差异对照

维度 规则文档示例 cal.diy 认证实现
缓存键 用户 id JSON.stringify(token)(更细粒度)
max 1000 1000
ttl 5 分钟 未显式设置(由 max 淘汰控制)
回源成本 1 次用户查询 用户查询 + profile 组装 + 可选的模拟者查询
典型收益 连续操作命中同一端点 同一用户请求高频经过认证路径

这份对照说明:规则文档给出的是可迁移的通用骨架,而真实业务中缓存键的粒度、回源链路的复杂度都需要按具体数据流调整;核心不变的是“模块级 LRU 实例 + 先查后取再回填”的结构,以及“缓存生命周期取决于运行实例复用程度”这一平台前提。

五、落地要点小结

结合 server-cache-lru.md 的规则与 getServerSession.ts 的仓库实现,跨请求 LRU 缓存的落地要点可以归纳为:

  1. 先分清缓存层级:请求内去重用 React.cache()(见 server-cache-react.md),跨请求共享才上 LRU;
  2. 参数必须有界max 限定条目数防止内存膨胀,ttl 限定陈旧窗口防止脏读,二者共同构成内存缓存的安全边界;
  3. 缓存键要覆盖影响数据的全部状态:cal.diy 认证缓存以序列化 token 为键,就是为了让 token 任一相关字段变化都能正确失效旧缓存;
  4. 确认部署形态:在会复用运行实例的平台上,模块级 LRU 天然跨请求生效;在传统隔离式 serverless 上,跨进程共享需要 Redis 等外部缓存;
  5. 用日志验证命中:仓库实现中 Returning cached session 的 debug 日志(getServerSession.ts#L60)是排查缓存是否真正生效的实用手段,建议在自建 LRU 缓存时保留同等的命中/未命中可观测性。
登录后查看全文
热门项目推荐
相关项目推荐