[Postmortem] Workers Cold Start Timeout on the /checkout Route — 7s TTFB

真实 TanStack Start 事故复盘:/checkout 路由遭遇 7 秒 Workers 冷启动,导致 12% 的放弃率飙升。修复方案是按路由拆分代码、延迟加载 Stripe SDK 及按路由配置 Drizzle schema——最终 diff 将 p99 冷启动 TTFB 从 7,000ms 降至 280ms。

Huifer
Huifer
August 12, 202610 min read
其他语言:Deutsch · English

本文由 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 年末的路由文件如下:

tsx
// 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 文件:

ts
// 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

tsx
// 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 延迟加载置于动态导入之后

ts
// 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

ts
// 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 在冷启动时仍然需要支付。

从这次事故中得出三条操作规则:

  1. 禁止在 src/server/ 中使用 barrel 文件。 barrel 文件用起来方便,但也是冷启动炸弹。如果已有 barrel,在每个路由边界将其替换为命名导入。Lint 成本为零——现代编辑器解析命名导入和 barrel 一样快——但冷启动收益很大。
  2. 重型 SDK 放在动态导入之后。 Stripe、Resend、OpenAI SDK、任何超过 500KB 的包——在 Server Function 内部加载,而不是在文件顶部。Cloudflare Workers 代码拆分指南 对此有明确说明。Workers 内部的动态导入在解析时零成本;只在被调用时才支付。
  3. 按路由测量冷启动,而不是按应用。 聚合的冷启动指标会隐藏最差的路由。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 秒。