cal.diy(cal.com)中的服务端性能实践:用 React.cache() 做请求级去重
在 Next.js / React Server Components 应用中,同一个页面请求内往往会有多个组件分别发起相同的认证查询或数据库读取,造成重复的 DB 访问。本文基于 cal.diy 仓库内置的 server-cache-react 规则文档(Vercel 出品、MIT 许可的 React 性能规则库中的一条 MEDIUM 级规则),讲解 React.cache() 的请求级去重机制、适用边界,并结合 cal.com 源码中真实的 unstable_cache 封装实现,说明“请求内去重”与“跨请求缓存”在工程上如何各取所需。
一、规则出处:Vercel React Best Practices 规则库中的位置
cal.diy 仓库在 agents/skills/vercel-react-best-practices 中内置了一份由 Vercel Engineering 维护的 React / Next.js 性能优化规则集,共 45 条规则、8 个分类,按影响面排序(见 SKILL.md 中的优先级表):
| 优先级 | 分类 | 影响 | 前缀 |
|---|---|---|---|
| 1 | 消除瀑布流 | CRITICAL | async- |
| 2 | 打包体积优化 | CRITICAL | bundle- |
| 3 | 服务端性能 | HIGH | server- |
| 4 | 客户端数据获取 | MEDIUM-HIGH | client- |
| 5~8 | 重渲染 / 渲染 / JS / 高级模式 | MEDIUM ~ LOW | rerender- 等 |
server-cache-react 属于第 3 类“Server-Side Performance(服务端性能)”,规则文件自身元信息(frontmatter)标注为:
- 标题:Per-Request Deduplication with React.cache()
- 影响等级:MEDIUM(
impactDescription: deduplicates within request) - 标签:
server, cache, react-cache, deduplication
完整的规则汇编版见 AGENTS.md 的 3.4 节(约 L715-L735),与本规则文件内容一致;它的姊妹规则 server-cache-lru.md 则解决“跨请求缓存”问题。两条规则合起来覆盖了服务端数据获取的两种去重/缓存场景。
二、规则要解决的问题:同一请求内的重复查询
规则文档给出的核心结论是一句话:用 React.cache() 做服务端请求级去重,收益最大的是认证(authentication)和数据库查询(database queries)这类场景。
典型痛点是:一个 RSC 页面中,页面组件、侧边栏、导航栏各自独立地调用“获取当前用户”逻辑,若该逻辑内部要 await auth() 拿会话再查一次数据库,那么同一个 HTTP 请求内就会打出多次完全相同的查询。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() 只会真正执行一次查询。 第二个及之后的调用直接复用第一次的结果(对异步函数而言,React 缓存的是返回的 Promise,同一请求内并发调用共享同一个 Promise),从而把 N 次会话解析 + N 次 findUnique 收敛为 1 次。
几个值得注意的机制细节:
- 它是“记忆化”而不是“缓存”:
React.cache()不引入任何过期时间、容量上限或持久化,记忆化作用域就是一次渲染/一次请求,请求结束即失效。因此它不承担“加速跨请求访问”的职责——这正是需要 LRU 的原因(见第四节)。 - 适合无参或纯参函数:规则示例中的
getCurrentUser不接收参数。记忆化以参数身份为键,参数来自不可变来源(如当前请求内的稳定值)时效果最确定;带复杂可变参数的函数需谨慎评估。 - 对认证链路的意义:
auth()这类函数通常内部包含 token 校验、会话存储读取甚至数据库查询,是页面中扇出最广的公共依赖,天然适合包一层cache()。
三、边界:请求内去重 vs 跨请求缓存
同属 server- 分类的 server-cache-lru.md 明确划出了 React.cache() 的边界:
React.cache()only works within one request. For data shared across sequential requests(用户先点了按钮 A、又点了按钮 B,两个连续端点需要同一份数据),use an LRU cache.
该规则给出的 LRU 实现范式(影响等级 HIGH,impactDescription: caches across requests):
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
适用条件是“用户在数秒内的连续操作会命中多个端点、且这些端点需要同一份数据”。规则同时指出了部署形态对 LRU 有效性的影响:在函数实例可被并发请求共享的托管运行时中,进程内 LRU 天然跨请求生效;而在传统 serverless 中每次调用相互隔离,跨进程缓存则需要 Redis 一类的共享存储。
由此可以整理出清晰的选型表:
| 维度 | React.cache() |
LRU(进程内/共享存储) |
|---|---|---|
| 作用域 | 单次请求 | 跨请求(受 TTL / 容量约束) |
| 过期机制 | 无(请求结束即失效) | TTL + 容量上限 |
| 典型场景 | 页面内多组件重复取数、认证解析 | 连续操作命中多端点的同一数据 |
| 部署约束 | 无 | 实例隔离时需外置存储 |
四、cal.com 源码中的对照实践:unstable_cache 封装
从源码结构看,cal.diy 的 Web 应用(Next.js)并没有直接依赖 React.cache(),而是把“请求去重/缓存”建立在 Next.js 的 unstable_cache 之上,并做了仓库级的统一封装。这条实践链可以作为规则落地方式的真实参照。
4.1 仓库级封装:packages/lib/unstable_cache
packages/lib/unstable_cache/unstable_cache.ts 对 next/cache 的 unstable_cache 做了一层包装,核心是解决序列化问题(unstable_cache 要求函数返回值可 JSON 序列化):
/**
* This implementation is adapted from https://github.com/vercel/next.js/issues/51613#issuecomment-1892644565.
* It is a wrapper around `unstable_cache` that adds serialization and deserialization
*/
import { unstable_cache } from "next/cache";
import { parse, stringify } from "superjson";
export const cache = <T, P extends unknown[]>(
fn: (...params: P) => Promise<T>,
keys: Parameters<typeof unstable_cache>[1],
opts: Parameters<typeof unstable_cache>[2]
) => {
const wrap = async (params: unknown[]): Promise<string> => {
const result = await fn(...(params as P));
return stringify(result);
};
const cachedFn = unstable_cache(wrap, keys, opts);
return async (...params: P): Promise<T> => {
const result = await cachedFn(params);
return parse(result);
};
};
可以看到封装的要点:入参以数组形式收集(unstable_cache 的键机制要求参数可序列化,且多个参数需打包成单一数组参数传入),出参用 superjson 的 stringify/parse 往返,从而支持 Date 等原生类型。仓库内还有 getInstallCountPerApp.ts 等使用该封装的业务缓存函数。
4.2 业务示例:团队计划校验的缓存与失效
apps/web/app/cache/membership.ts 展示了完整的“缓存 + 标签失效”闭环:
"use server";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { revalidateTag, unstable_cache } from "next/cache";
const CACHE_TAGS = {
HAS_TEAM_PLAN: "MembershipRepository.hasAnyAcceptedMembershipByUserId",
} as const;
export const getCachedHasTeamPlan = unstable_cache(
async (userId: number) => {
const hasTeamPlan = await MembershipRepository.hasAnyAcceptedMembershipByUserId(userId);
return { hasTeamPlan: !!hasTeamPlan };
},
["getCachedHasTeamPlan"],
{
revalidate: NEXTJS_CACHE_TTL,
tags: [CACHE_TAGS.HAS_TEAM_PLAN],
}
);
export const revalidateHasTeamPlan = async () => {
revalidateTag(CACHE_TAGS.HAS_TEAM_PLAN, "max");
};
配套的参数与取值(均有仓库依据):
revalidate:取自常量NEXTJS_CACHE_TTL,在 packages/lib/constants.ts 中定义为3600(秒,即 1 小时)。tags:缓存标签使用“仓库名 + 方法名”命名(如MembershipRepository.hasAnyAcceptedMembershipByUserId),便于定位缓存归属。- 失效方式:
revalidateTag(tag, "max")将缓存 TTL 直接推进到最大,即立刻失效——“读多写少 + 变更时定向失效”的组合,而不是无差别清缓存。
4.3 与规则的关系
从源码结构看,cal.com 的选择与规则文档并不矛盾,而是分层互补:
React.cache()(规则server-cache-react)解决的是同一请求内的重复调用,是 React 运行时的记忆化;unstable_cache+ 封装 +revalidateTag(cal.com 现状)解决的是跨请求的重复数据获取,缓存键含函数键与参数(userId),带 TTL 与定向失效。
也就是说,若一个页面在同一请求内多处需要 hasAnyAcceptedMembershipByUserId 的结果,unstable_cache 在请求内同样只查一次库(第二次命中缓存);而跨请求的重复读取则完全由 revalidate TTL 覆盖。这正是“请求级去重”与“跨请求缓存”两种手段在真实大型 Next.js 应用中如何协同的例证。
五、落地建议小结
结合规则文档与仓库实践,可以给出可操作清单:
- 优先包装高频公共依赖:会话/认证解析、当前用户查询、组织/团队读取等扇出最广的函数,是
React.cache()的第一梯队候选(规则原文:认证与数据库查询受益最大)。 - 认清作用域:它只对单次请求内的多次调用去重,请求之间零共享;不要把业务正确性建立在“它缓存了结果”上。
- 异步函数放心用:
cache()包裹async函数时,同一请求内的并发调用共享同一个 Promise,不会各自开查询。 - 跨请求需求另选方案:连续操作命中多端点、且数据在数秒内可复用,用带 TTL 与容量上限的 LRU(见 server-cache-lru.md);Next.js 应用也可直接使用
unstable_cache+revalidateTag的仓库式封装(参照 unstable_cache 封装 与 membership 缓存)。 - 缓存键与失效策略要显式:无论哪种缓存,都建议像 cal.com 那样为每个缓存定义命名标签,并在数据变更点(如成员关系变化)调用定向失效,避免依赖 TTL 自然过期造成的数据陈旧窗口。
参考路径
- 规则原文:.opencode/skill/vercel-react-best-practices/rules/server-cache-react.md
- 规则库总览:.opencode/skill/vercel-react-best-practices/SKILL.md、汇编版 .opencode/skill/vercel-react-best-practices/AGENTS.md
- 姊妹规则:.opencode/skill/vercel-react-best-practices/rules/server-cache-lru.md
- 仓库实践:packages/lib/unstable_cache/unstable_cache.ts、apps/web/app/cache/membership.ts、packages/lib/constants.ts
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00