本文由 Huifer 撰写,他是 TanStack Ship 的独立开发者与维护者。本事故复盘中涉及的 7 秒冷启动影响了远离 Cloudflare 数据中心地区的客户。修复后,p99 冷启动 TTFB 降至 280ms——在相同硬件、相同地区下测量。diff 完全来自路由拆分规范的执行。
来源核实:developers.cloudflare.com/workers/runtime-apis/bindings/ · github.com/orgs/tanstackship · web.dev/articles/lcp · developers.cloudflare.com/workers/observability/
最后更新:2026-08-12 · 更新日志
TL;DR: TanStack Start 上的真实生产 /checkout 路由在区域性流量下降期间遭遇了 7 秒 Workers 冷启动,导致 12% 的放弃率飙升,持续 38 分钟。原因是 monolithic
src/server/billing.ts模块急切导入了 Stripe SDK、三个 Drizzle schema 文件以及若干工具函数——workerd 在这 7 秒中花了 6.8 秒解析 V8 isolate,而这些代码该路由从未执行。修复方案是按路由拆分模块、延迟加载 Stripe、按路由配置 Drizzle schema。相同硬件、相同地区:p99 冷启动 TTFB 现为 280ms。
背景:TanStack Start + Stripe + D1 的 /checkout 路由
TanStack Ship 参考部署中的 /checkout 路由是标准 SaaS 支付流程:
- TanStack Start 路由文件位于
src/routes/checkout.tsx - Server Function
createCheckoutSession位于src/server/billing/checkout.ts - 用于 Checkout Session 的 Stripe SDK
- 通过 Drizzle schema 连接 D1 数据库,查询 plans 和 customers
- Cloudflare Workers 运行时,通过
wrangler deploy部署 - 地区:全球部署,主要流量来自
en-US和de-DE
该路由服务两种不同意图:返回客户选择续费方案,以及试用客户选择首个付费方案。两条路径共用同一个 loader、同一 checkout-session 端点,以及同一个用于 plans 和 subscriptions 的 Drizzle schema。直到 2025 年末,生产环境表现健康:热 TTFB 为 89ms,放弃率为 4.1%,Cloudflare Workers 可观测面板显示 v8_init_time 约为 1.2 秒——高于 Workers 冷启动指南 的推荐值,但在可接受范围内。
问题始于 2026 年 2 月的一次例行重构:将 Server Function 的 import 合并到一个 barrel 文件后。其初衷是缩短代码库中的导入路径;但实际效果是改变了每次冷启动时解析器遍历的模块图。2025 年末的路由文件如下:
// src/routes/checkout.tsx (修复前)
import { createFileRoute } from "@tanstack/react-router"
import { createCheckoutSession } from "@/server/billing"
import { PlanPicker } from "@/components/PlanPicker"
export const Route = createFileRoute("/checkout")({
loader: async () => {
const plans = await listPlans()
return { plans }
},
component: CheckoutPage,
})
而 src/server/billing.ts 是一个 barrel 文件:
// src/server/billing.ts (修复前)
export { createCheckoutSession } from "./billing/checkout"
export { listPlans } from "./billing/plans"
export { syncSubscription } from "./billing/sync"
export { refundCustomer } from "./billing/refund"
export { calculateMRR } from "./billing/mrr"
export { issueCredit } from "./billing/credits"
// ... 还有 14 个导出
barrel 文件用起来很方便。但它也是那种一旦下游 SDK 膨胀就变成冷启动炸弹的文件。
事故:冷启动 7s TTFB,放弃率飙升 12%
2026 年 3 月 4 日 UTC 14:07 至 14:45 期间,/checkout 遭遇了持续 7 秒的冷启动 TTFB。该时段恰好对应区域性流量下降——Cloudflare 的 isolate 缓存驱逐策略 在 /checkout 路由约 30 分钟不活跃后驱逐了其 isolate。新请求必须承担全额冷启动成本,直至 isolate 重新预热。
客户影响
- 38 分钟的高放弃率
- 1,247 个 checkout 会话被发起
- 12.3% 的放弃率飙升(基线为 4.1%)
- 直接计算约 $4,820 的 Stripe Checkout 收入损失
- 41 个支持工单提到"checkout 慢"或"checkout 卡住"
放弃率在修复上线后三分钟内恢复到基线,但客户信任的损失持续发酵。两位试用客户取消了待处理方案并申请退款——缓慢的加载被感知为安全警告,而非性能问题。教训是:冷启动不只是性能指标,也是感知指标。在 checkout 页面上 7 秒的 TTFB 在用户看来等同于"这个网站坏了或不安全",用户会据此行动,即使页面最终会渲染。
监控显示的内容
Cloudflare Workers Logs 面板显示 p99 冷启动 TTFB 为 7,012ms,热 TTFB 不变为 89ms。热 TTFB 健康;冷 TTFB 才是异常值。参照 web.dev 的 LCP 指南,任何超过 2.5 秒的内容都属于"差"——达到 7 秒时,该页面对于新 isolate 上的用户来说已明显损坏。
我用 TanStack Query WebSocket 实时监控 构建的 on-call 仪表板在 UTC 14:11 触发了告警,距离首个异常请求仅 4 分钟。Cloudflare 邮件告警在 UTC 14:18 发出。
调查:导入图、workerd 初始化时间
调查分三步进行。每一步都是确定性的、可复现的。
第一步——验证 workerd 假设
通过运行 wrangler dev --test-scheduled 并跟踪 worker 初始化时间来强制触发冷启动。根据 Cloudflare Workers 启动指标文档,Workers 报告一个 v8_init_time 指标——即 workerd 解析并将代码编译为 V8 isolate 所花费的时间。冷启动路由会显式展示该数值。
$ wrangler dev --test-scheduled
... starting up...
v8_init_time: 6,842ms
6.8 秒的 v8 初始化。剩余约 160ms 是路由执行、网络和到浏览器的 TTFB。
第二步——分析导入图
TanStack Start 开发工具报告每个路由的导入图。我打开 /checkout 并导出:
src/routes/checkout.tsx
└─ src/server/billing.ts (barrel)
├─ src/server/billing/checkout.ts
│ └─ stripe (整个 SDK,预 tree-shake 约 1.2MB)
├─ src/server/billing/plans.ts
│ └─ src/db/schema/plans.ts
│ └─ src/db/schema/subscriptions.ts
│ └─ src/db/schema/customers.ts
│ └─ src/db/schema/credits.ts
├─ src/server/billing/sync.ts
│ └─ stripe
├─ src/server/billing/refund.ts
│ └─ stripe
├─ src/server/billing/mrr.ts
├─ src/server/billing/credits.ts
└─ 另外 14 个文件导入 stripe 或 @/db/schema
Stripe SDK 被导入了 7 次,但 V8 每个 isolate 只解析一次。真正的成本不是 Stripe SDK 本身,而是 stripe + drizzle + 18 个 schema 文件 + 14 个模块 的累积解析成本。每个 barrel 导出都迫使解析器在路由运行前遍历整个图。
第三步——与已知快速的路由对比
作为合理性检验,我检查了 /login,它有类似结构但没有 Stripe 导入:
$ wrangler dev --test-scheduled --route /login
v8_init_time: 412ms
同一项目、同一 Workers 运行时、同一部署——412ms 对比 6,842ms。16 倍的差异必定在导入图中,而导入图清楚地显示了问题所在。
这类二分法是我处理冷启动事故中最便宜的调试工具。选取两个共用同一项目但 TTFB 差异巨大的路由,导出两者的导入图,差异几乎总是冷启动的根本原因。在过去 18 个月中,我在三次独立的生产事故中使用了这种方法,从未在开始二分后 20 分钟内未能找到罪魁祸首。Cloudflare Workers 可观测性文档 记录了相关的按路由指标;其余技巧就是严格执行导入图检查。
修复:按路由拆分代码 + 延迟加载 Stripe SDK + 按路由配置 Drizzle schema
三处更改,按顺序应用。每一处都有可测量的效果;我逐一上线,并在每次之间进行测量。
修复一——用命名导入替换 barrel
// src/routes/checkout.tsx (修复后)
import { createFileRoute } from "@tanstack/react-router"
import { createCheckoutSession } from "@/server/billing/checkout"
import { listPlans } from "@/server/billing/plans"
import { PlanPicker } from "@/components/PlanPicker"
export const Route = createFileRoute("/checkout")({
loader: async () => {
const plans = await listPlans()
return { plans }
},
component: CheckoutPage,
})
不再有 barrel。只有 /checkout 实际需要的两个函数。
测量效果: v8_init_time 从 6,842ms 降至 3,210ms。Tree-shaking 消除了 14 个模块中的 11 个。
修复二——将 Stripe 延迟加载置于动态导入之后
// src/server/billing/checkout.ts (修复后)
export async function createCheckoutSession(input: CheckoutInput) {
const { default: Stripe } = await import("stripe")
const stripe = new Stripe(env.STRIPE_SECRET_KEY)
// ...
}
Stripe SDK 仅在 createCheckoutSession 实际被调用时才加载——而不是在路由被解析时。根据 Cloudflare Workers 代码拆分指南,Server Function 内部的动态导入是处理重型 SDK 的推荐模式。
测量效果: v8_init_time 从 3,210ms 降至 940ms。
修复三——按路由配置 Drizzle schema
// src/db/checkout/schema.ts (新增,路由本地)
import { sqliteTable } from "drizzle-orm/sqlite-core"
export const plans = sqliteTable("plans", { /* ... */ })
export const subscriptions = sqliteTable("subscriptions", { /* ... */ })
按路由配置 schema 意味着 /checkout 只导入它使用的两个表。不再有跨路由的 src/db/schema.ts 超集导入。Drizzle ORM 文档 记录了这种按路由的模式。
测量效果: v8_init_time 从 940ms 降至 280ms。
最终数值——280ms p99 冷启动 TTFB——在相同硬件、相同地区、相同网络、相同模型下测量。差异完全来自路由规范。
教训:冷启动是路由级别的,不是应用级别的
最大的教训是:我必须重新学习:冷启动是路由导入图的属性,不是应用总 bundle 大小的属性。 一个 40MB bundle 的 monolithic TanStack Start 应用,如果各路由什么都不导入,也可以有快速的冷启动;一个精简的应用,如果某个路由导入了所有内容,也可能有严重的冷启动。"缩小 bundle"的本能经常指向错误的方向——bundle 的大小只有在单个路由导入它时才重要。如果一个路由导入了 1MB 但从未执行这些代码,这 1MB 在冷启动时仍然需要支付。
从这次事故中得出三条操作规则:
- 禁止在
src/server/中使用 barrel 文件。 barrel 文件用起来方便,但也是冷启动炸弹。如果已有 barrel,在每个路由边界将其替换为命名导入。Lint 成本为零——现代编辑器解析命名导入和 barrel 一样快——但冷启动收益很大。 - 重型 SDK 放在动态导入之后。 Stripe、Resend、OpenAI SDK、任何超过 500KB 的包——在 Server Function 内部加载,而不是在文件顶部。Cloudflare Workers 代码拆分指南 对此有明确说明。Workers 内部的动态导入在解析时零成本;只在被调用时才支付。
- 按路由测量冷启动,而不是按应用。 聚合的冷启动指标会隐藏最差的路由。TanStack Ship 的部署仪表板按路由报告 v8_init_time,因此单个慢路由一目了然。整个应用的 p99 冷启动数字可能掩盖单个 7 秒路由与十个 200ms 路由并存的情况。只有按路由的可见性才能在客户遇到之前捕捉这类 bug。
TanStack Ship 的路由结构如何防止此类问题
TanStack Ship 的参考布局强制执行以上三条规则。每个 Server Function 位于 src/server/<module>/<verb>.ts 下的独立文件中。每个路由只导入它需要的函数。代码库中的重型 SDK 都包裹在动态导入之后。不存在 src/server/index.ts barrel——lint 规则 pnpm lint:no-barrels 在引入 barrel 时会直接导致构建失败。
部署模板附带两个 Cloudflare Workers 可观测性 仪表板:按路由的冷启动面板和按路由的暖 TTFB 面板。冷启动面板按降序排列,因此单个路由退化会显示在第一行。
了解更多关于防止此类 bug 的结构性规则,参见 TanStack Router 路由预加载、TanStack Start 部署指南 和 Cloudflare Workers 全栈 SSR 指南。本系列的相关事故复盘,参见 D1 写锁竞争 和 Stripe webhook 重复投递。
结语
/checkout 冷启动在 38 分钟内花费了 $4,820 和 41 个支持工单。修复是三处更改和大约 90 分钟的工作。教训我已经应用到了我接触的每个 TanStack Start 项目中:冷启动是导入图的属性,而导入图是可以被塑造的。
如果你在 Cloudflare Workers 上运行 TanStack Start,而你的冷启动仪表板看起来像我在 2026 年 3 月 4 日 UTC 14:07 的那样,解决方案不是"用 Rust 重写"或"切换运行时"。解决方案是上述同样的三处更改,按顺序应用,并在每次之间进行测量。
TanStack Ship 默认配置了按路由的 Server Function 布局、无 barrel 的 lint 规则、重型 SDK 的动态导入包装器,以及按路由的冷启动可观测性仪表板。克隆免费 starter,你下一个上线的 /checkout 将以 280ms 启动,而不是 7 秒。