cal.diy 跨请求 LRU 缓存实战:从 React.cache() 的边界到 lru-cache 服务端实现
本篇基于 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 分钟,毫秒) |
条目生存时间;到期后即使仍在容量内也会失效,防止读到陈旧数据 |
实现上遵循经典的“先查缓存、未命中再回源、回源后回填”三步:
cache.get(id)命中则直接返回,完全绕开数据库;- 未命中时执行数据库查询(示例中为 Prisma 的
db.user.findUnique); - 查询结果通过
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-auth 中 getServerSession 的简化版,服务端从请求 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)展示了跨请求缓存所保护的真实成本:
- 校验
token.sub解析出的userId合法(否则直接返回null); prisma.user.findUnique按 ID 查询用户(getServerSession.ts#L71-L73);- 通过
UserRepository.enrichUserWithTheProfile进一步补齐用户 profile 信息,组装出完整的Session对象(包含user、upId、expires等字段); - 若 token 带有
impersonatedBy,还要额外查一次被模拟者的用户记录; - 最终在 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 缓存的落地要点可以归纳为:
- 先分清缓存层级:请求内去重用
React.cache()(见 server-cache-react.md),跨请求共享才上 LRU; - 参数必须有界:
max限定条目数防止内存膨胀,ttl限定陈旧窗口防止脏读,二者共同构成内存缓存的安全边界; - 缓存键要覆盖影响数据的全部状态:cal.diy 认证缓存以序列化 token 为键,就是为了让 token 任一相关字段变化都能正确失效旧缓存;
- 确认部署形态:在会复用运行实例的平台上,模块级 LRU 天然跨请求生效;在传统隔离式 serverless 上,跨进程共享需要 Redis 等外部缓存;
- 用日志验证命中:仓库实现中
Returning cached session的 debug 日志(getServerSession.ts#L60)是排查缓存是否真正生效的实用手段,建议在自建 LRU 缓存时保留同等的命中/未命中可观测性。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00