消除 API 路由与 Server Actions 中的瀑布链:cal.diy(Cal.com)实践下的"先启动、后 await"并行化指南
【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy
在 Next.js 的 Route Handlers(app/api/.../route.ts)与 Server Actions 中,多个本可并行的 I/O 操作如果被逐个await,会形成"瀑布链"(waterfall):每个请求的端到端延迟约等于所有串行操作的耗时之和,而请求处理线程在此期间被白白阻塞。本文基于 Vercel 工程团队维护的性能规则 async-api-routes.md(该规则将此项优化评级为CRITICAL,标称 2–10× 的延迟改善),结合本仓库真实的 Next.js 路由实现,讲解在 API 路由与 Server Actions 中如何通过"尽早发起、延迟 await、Promise.all合并"彻底消除串行等待,并给出可复制、可运行的模式与判定边界。
瀑布链的本质:串行等待在事件循环中不等于"同时在做"
很多开发者误以为await只是"挂起一下",不会浪费多少时间。实际上,对 Node.js 事件循环而言,await一个尚未就绪的 Promise 会把当前 async 函数的继续执行推迟到该 Promise settle 之后——如果后续代码又await下一个 Promise,那么两次网络/IO 往返之间没有任何重叠。
考虑一个典型的认证型接口,三段逻辑存在天然的依赖关系:
auth():解析会话,得到session.user.id;fetchConfig():读取租户/站点配置,与用户无关;fetchData(userId):基于第 1 步拿到的 userId 拉取用户数据。
若每段耗时都是 300ms 的网络往返,串行总耗时约 900ms;而fetchConfig与"认证 + 取数"这两条分支互不依赖,若让配置请求与认证请求同时发出,数据请求再紧随认证完成发起,总耗时可降到约 600ms,甚至更优。
反例:把每次 I/O 都写成"等上一个完成"
async-api-routes.md 给出了最典型的错误写法——每个 await 都等上一个 settle 后才发起下一个请求,config白白等待与它毫无关系的auth(),而data又必须等config与auth都结束:
// Incorrect:config 等待 auth,data 等待两者 export async function GET(request: Request) { const session = await auth() const config = await fetchConfig() const data = await fetchData(session.user.id) return Response.json({ data, config }) }这段代码的依赖图呈一条直线:auth → config → data。即便fetchConfig不依赖session,也会被排在auth之后;即便fetchData只依赖session.user.id,也必须等config先返回。三次网络往返被压缩成了不可重叠的一段串行时间。
正例:先启动独立操作,把 await 推迟到数据真正被需要的那一刻
规则的核心只有一句话:在 API 路由和 Server Actions 中,立即启动彼此独立的异步操作,即使此刻还不必await它们。
// Correct:auth 与 config 立即同时启动 export async function GET(request: Request) { const sessionPromise = auth() const configPromise = fetchConfig() const session = await sessionPromise const [config, data] = await Promise.all([ configPromise, fetchData(session.user.id) ]) return Response.json({ data, config }) }逐行拆解这个正确的编排:
| 步骤 | 行为 | 时间线 |
|---|---|---|
const sessionPromise = auth() | 立即调用并持有 Promise,不阻塞 | t=0 发出会话请求 |
const configPromise = fetchConfig() | 紧随其后立即发出,不等 auth | t≈0 发出配置请求(与认证并行) |
const session = await sessionPromise | 在此真正需要 session | 认证完成后立即恢复 |
fetchData(session.user.id) | 在得到 userId 的同一时刻发起 | 认证完成时发出数据请求 |
Promise.all([...]) | 等待 config 与 data 两者都完成 | config 与 data 并行收尾 |
对比之下,配置请求原本被排在认证之后(多等一个完整往返),现在与认证同时飞行;数据请求从"等 config 返回之后"提前到"认证一返回就发"。当数据请求耗时较长时,其与尚未完成的config请求在时间上完全重叠,这正是"2–10× 改善"的主要来源——改善幅度取决于各分支耗时与重叠程度,最理想情况可把"三段串行耗时"压缩到"最长依赖链耗时"。
依赖链更复杂时:用 better-all 让每条任务"最早可启动即启动"
Promise.all只能处理"要么全部独立、要么手写中间依赖"的场景。当任务图呈菱形或多层时,手写编排极易退化成新的瀑布,例如下面的写法让profile不必要地等待与它无关的config:
// Incorrect:profile 明明只依赖 user,却被迫等 config 完成 const [user, config] = await Promise.all([ fetchUser(), fetchConfig() ]) const profile = await fetchProfile(user.id)规则文档 async-api-routes.md 的结尾与同目录规则 async-dependencies.md 都指向同一个解法:使用better-all的all(),它会在"所有任务互不依赖"之外,自动识别"部分依赖",让每个任务在依赖就绪的最早时刻被启动:
import { all } from 'better-all' const { user, config, profile } = await all({ async user() { return fetchUser() }, async config() { return fetchConfig() }, async profile() { return fetchProfile((await this.$.user).id) } })profile只声明对user的依赖,better-all因此让config与profile并行执行,而不再像手写Promise.all那样被迫把fetchProfile挪到第二阶段。这与规则集合中 async-parallel.md(完全独立操作用Promise.all合并为一次往返)、async-defer-await.md(把await下推到真正使用它的分支,避免阻塞用不到该数据的路径)共同构成 Vercel 技能包 SKILL.md 中优先级最高(CRITICAL)的"消除瀑布"(Eliminating Waterfalls)四件套。
仓库实证:同一规则在真实代码中的两种形态
本仓库(cal.diy / Cal.com 调度基础设施)自身就是 Next.js App Router 应用,其 apps/web/app/api 目录下有 39+ 个route.ts路由处理器,恰好能同时看到"规则未被遵守"与"规则被遵守"两种现实写法。
反例形态:me 路由中的逐段串行
me/route.ts 的getHandler是教科书式的串行链——prisma动态导入、headers()/cookies()读取、getServerSession会话解析、prisma.user.findUnique用户查询依次等待,代码还专门用performance.now()记录了prismaDuration、sessionDuration、userDuration三段耗时用于观测。需要注意的是:其中的await顺序由数据依赖(用户查询必须等session.user.id)与框架约束(headers()/cookies()在 Next.js 中需要先 await 才能使用)决定,属于"被依赖关系强约束"的部分串行;而真正与后续逻辑无依赖的步骤(例如模块的动态导入、被提前发起的独立读取)完全可以前移到更早位置。把它作为对照案例的价值在于:当你在自己的路由里写类似层层await的代码时,应逐行自问"这一行是否真的依赖上一行的返回值"。
正例形态:recorded-daily-video 路由的 Promise.all 合并
daily 视频 webhook 路由 是仓库中符合规则的正向示例:先按依赖顺序取得bookingReference与booking(这两步有强依赖,无法并行),随后把四个彼此独立、且都只依赖booking的后处理任务一次性合并:
const [evt, updateRecordStatus, downloadLink, teamId] = await Promise.all([ getCalendarEvent(booking), // 由 booking 构造日历事件 bookingRepository.updateRecordedStatus({ // 更新录制状态 bookingUid: booking.uid, isRecorded: true, }), getProxyDownloadLinkOfCalVideo(recording_id), // 生成代理下载链接 getTeamIdFromEventType({ ... }), // 推导团队 ID ]);同一文件 L201-L205 的batch-processor.job-finished分支也采用了相同写法,把getCalendarEvent、下载链接与 batch processor 访问链接三个互不依赖的调用放进Promise.all。这正是规则要求的实战形态:先完成必要的依赖链(booking),随后立刻并行扇出所有分支。
补充观察:并行的另一面——Promise.allSettled 处理"副作用扇出"
值得一提的是,该文件 L119-L149 中,webhook 触发、转写任务提交、录制邮件发送这三个互不依赖的副作用任务不仅被并行执行,还使用了Promise.allSettled而非Promise.all,逐项记录失败原因而不让单个任务的 rejection 中断其余任务。这提示了并行编排中值得配套的两个细节:
- 对结果必需的并行请求用
Promise.all(任一失败则整体快速失败); - 对尽力而为的副作用扇出用
Promise.allSettled(单个失败不影响其他任务,也避免 unhandled rejection 拖垮进程)。
何时该停手:并行化的三个边界
"先启动、后 await"不是无脑把一切并行。规则集合在 API 场景之外强调"不阻塞用不到的数据"(async-defer-await.md),结合该思路,以下情况应谨慎甚至避免并行:
- 存在真实数据依赖:后一个请求的入参来自前一个请求的返回值,只能串行。此时优化空间在于"压缩必须串行的依赖链长度",而非消灭全部串行。
- 下游承受不了并发:同一用户/租户的多个请求并发打到脆弱的第三方、数据库连接池或限流 API,可能引发 429、超时甚至雪崩。并行提升的是吞吐与延迟,代价是瞬间并发峰值,需要结合
Promise.allSettled、节流与重试策略权衡。 - 请求体本身是稀缺资源:在 CPU 密集、内存受限的 Serverless 环境,无上限地并发触发大量请求会抬高单实例资源占用;并行收益应通过真实场景压测验证,而非仅凭"看起来能并行"就照搬。
此外,路由内尽早失败(如认证失败直接返回 401)依然优先:应先做便宜的守卫检查再决定是否值得并行发起重活。这与 async-defer-await.md 中"把 await 移入实际使用它的分支、让走不到的路径立即返回"是同一思想在错误处理上的延伸。
落地清单:重构你的 Route Handler
将上述规则沉淀为可执行的重构步骤:
- 画依赖图:为路由处理器里每个 async 调用标注"它需要谁的返回值"。没有依赖关系的调用,理论上应当在同一事件循环 tick 内被发起。
- 把
await拆成"发起 + 消费"两步:const p = fetchX()负责发起;只有真正要用到结果的代码处才写await p。中间插入其他独立请求的发起语句,即可获得并行。 - 用
Promise.all收口:把"发起后已就绪的 Promise + 依赖刚满足的新请求"合并到一次Promise.all中等待。 - 识别更复杂依赖图:出现"profile 只依赖 user 却被迫等 config"这类菱形依赖时,改用
better-all的all(),把任务声明式表达为async (this) => ...,让库自动决定启动时机(详见 async-dependencies.md)。 - 副作用与核心结果分流:必需的并行结果用
Promise.all整体返回;日志、通知、webhook 等副作用扇出用Promise.allSettled,避免单个失败拖垮主链路。 - 回归验证:重构前后用真实流量或负载脚本对比 p50/p95 延迟,确认收益并观察下游并发压力。规则文件标注的 2–10× 是理想重叠下的上限量级,实际收益由依赖链长度与各分支耗时分布决定。
该规则与其余 44 条规则按"瀑布消除 → 包体积 → 服务端性能 → 客户端取数 → 重渲染 → 渲染性能 → JS 性能 → 高级模式"的优先级分级编排,完整清单见 SKILL.md,编译后的全量说明见 AGENTS.md。在写、审或重构任何 Next.js API 路由与 Server Actions 时,把它作为第一道检查项,往往能花最小的改动换回最大的延迟收益。
【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考