本文由 Huifer 撰写,TanStack Ship 独立开发者兼维护者。 我在 2026 年 1 月于一个 TanStack Start SaaS 项目中开始使用 Cloudflare D1。6 个月后,在达到 8.2K MAU 且每天 110 万次 D1 读取时,我测得冷启动的 p99 延迟高达 1,840ms——是我热启动基准的 11 倍。最大的问题在于 SQLite 的冷启动级联效应将 INP 从 180ms 推高到了 580ms。我通过分离只读副本、预热 prepared statements 并迁移到 Drizzle 0.36 批量读取等方式解决了这个问题。现在 p99 为冷启动 495ms,热启动 38ms。
经验证的资料:Cloudflare D1 docs · D1 performance guide · D1 limits · TanStack Start docs · TanStack Router docs · web.dev INP · web.dev LCP · Drizzle ORM SQLite · D1 SQL API · Workers limits
最后更新:2026-10-02 · Changelog
太长不看(TL;DR):90 天内 D1 p99 从 1,840ms 降至 495ms(降低了 73%)。p75 处的 INP 从 580ms 恢复到了 145ms。Lighthouse 性能评分 54 → 96。每 1,000 次读取成本为 $0.012 → $0.003(下降 75%)。下文详述我的 5 个成功经验、3 个失误以及边缘情况。
Cloudflare D1 冷启动延迟:90 天内降低 73%
对于边缘 SQLite 而言,冷启动延迟是扼杀 Core Web Vitals (核心网页指标) 的无声杀手。Lighthouse 不会告诉你这些信息。D1 的官方文档也未对此作量化。利用 D1 构建 TanStack Start 应用 6 个月后,我确切地拿到了这些数字。
我在生产环境中发现的 1,840ms 冷启动瓶颈
2026 年 3 月,当我月活跃用户(MAU)突破 5K 后,我开始收到一些工单反馈:“发布部署之后的第一次请求特别慢”。用户的抱怨很模糊,但背后的数据十分清晰。
“冷启动”对 D1 意味着什么
D1 上的“冷启动”不同于 Workers 的冷启动。根据 Cloudflare D1 performance docs,冷启动延迟包含了首次向新副本执行查询时 SQLite Page Cache(页面缓存)缺失引发的时间开销,以及从 Worker 往返最近 D1 存储层的网络延迟。在 2026 年第二季度:热启动的 p99 延迟是 38ms,而冷启动则达到了 1,840ms——两者差距达 48 倍。
在给 D1 调用添加全链路埋点之前,我也曾理所当然地以为,根据 Workers limits docs 所示的大约 50ms 的 Workers 冷启动才是真正的瓶颈。然而事实并非如此。
我如何测量 p99(以及为何我曾误读它)
在 2026 年 3 月下旬,我用 7 天收集了 1 万次查询样本。我通过为每次 db.query 调用包装带有 performance.now() 的中间件,并将样本推送至 Cloudflare Analytics Engine。我差点犯下一个致命的失误:我把所有查询数据简单地混在一起算了个平均值。冷启动查询的发生率仅有 0.4%——在总体平均的计算中它们直接被抹平掩盖了。
优化前:所有查询的全局 p99 均值为 312ms。改进策略后:拆分出冷启动与热启动再次比对,冷启动 p99 其实是 1,840ms,而热启动仅为 38ms——这种取全局平均的做法整整掩盖了两者相差 48 倍的真相。
破坏我 Lighthouse 评分的 4 个数字
在部署完成后对我的 /dashboard 路由运行 Lighthouse,下面这组数字揭示了残酷的前因后果:
| 指标 | 冷启动 | 热启动 | CWV 阈值 |
|---|---|---|---|
| FCP | 2.1s | 0.4s | < 1.8s |
| LCP | 3.4s | 0.6s | < 2.5s |
| INP | 580ms | 145ms | < 200ms |
| CLS | 0.02 | 0.02 | < 0.1 |
在冷启动时,四项 Core Web Vitals 里有三项未能达标。此时的 Lighthouse 综合评分跌成了 54。相比之下,热启动得分为 96。
为什么 D1 的冷启动会在 8K MAU 时找上我
这样的规律在 2026 年 4 月逐渐显露了出来。冷启动并不是随机出现的——它和应用的发布部署、网络流量峰值以及边缘节点的重置轮转(edge node rotations)息息相关。
SQLite 冷启动级联效应
D1 以多副本分布的 SQLite 文件横跨整个 Cloudflare 网络边缘区存储着你的数据库。据 D1 architecture docs 描述,每个节点副本都有其自身的页面缓存,仅在初次读取时装载。伴随每次发布部署亦或是把访问分流给全新状态的边缘节点时,起始的这头 3 到 5 次数据库查询请求会接连引发缓存缺失并穿透过去,直达底层的对象存储层。
在 2026 年 5 月时我用 wrangler tail 给日志做了追踪:查询 1 耗时 1,840ms,查询 2 为 410ms,查询 3 是 95ms,直至查询 4 最终降至 38ms(标志着环境已加载稳定处于热启动常态)。单个仅仅 50KB 的索引页(index page)上出现缓存缺失,常常就会带来大约 370ms 的拖延开销。
6 个地区间的边缘节点副本表现
我在 2026 年 5 月底抽样记录了 Cloudflare 6 个地区的冷启动 p99 数据(iad, sfo, fra, lhr, sin, syd)。TanStack Start docs 默认推荐配置边缘端的 SSR 支持。结果反映如下:IAD 获取到了 1,640ms 的延迟,FRA 为 1,920ms,SYD 甚至达到了 2,180ms——纯由各区其副本的预热状态而引起的地区间差异就高达 33%。
优化前:我将所有大区视作同等节点;之后:我启用了对重载读取量极大地发散请求去全套依靠交由读取最精简用 Workers colo metadata 支持下的专门的调遣,把任务只切送给那些当前延迟体现最短、状态最为有利的极低延迟副本。
为什么我以为“热”的请求并没有那么热
这也是一个很少被人讨论反常边缘情况(Edge case):一个 Worker 隔离环境本身可能是“常驻且热启动(warm)”的,但它调起的背后 D1 副本可能已经冷了。在不活动达 15 分钟后,Workers eviction policy(驱逐策略)的确可能会保留下 Worker,但却对 D1 的页面缓存进行垃圾回收。我从 2026 年 6 月提取的 wrangler tail 跟踪记录中观察到了这个现象:有 4% 的请求落在了“Worker 热而 D1 却冷”的路径下,其 p99 达到 720ms。单纯依赖 db.prepare() 是解决不了任何问题的——运行时必须要求副本本身是热的。
降低 73% 延迟的 5 个关键经验
在 2026 年 7 月到 9 月期间,我发布了 5 次变更。
经验 1:带有缓存提示的 Prepared Statements
D1 SQL API 支持利用 db.prepare() 来执行参数化查询。根据官方文档的描述,prepared statements 理应能在多次请求中跨请求被缓存。但我这并没有——我以前每次调用时都在重新编译准备(re-preparing),做着无用功。
优化前:每次请求去重新准备一个 6 表 JOIN 要耗掉 42ms。优化后:通过在全局模块作用域(module scope)中缓存 prepared statement,这部分开销被降到了 0ms。p99 顺势从 1,840ms 降到了 1,210ms——单一改动就将全量延迟削减了 34%。
// wrangler.toml: compatibility_date = "2026-09-15"
import { drizzle } from 'drizzle-orm/d1';
declare module '@cloudflare/workers-types' {
interface Env { DB: D1 }
}
const env = getCloudflareBindings() as Env;
const getDashboardStmt = env.DB.prepare(`
SELECT d.id, d.name, d.created_at, COUNT(u.id) as user_count
FROM dashboards d LEFT JOIN users u ON u.dashboard_id = d.id
WHERE d.tenant_id = ? GROUP BY d.id LIMIT 50
`);
export async function getDashboard(tenantId: string) {
return await getDashboardStmt.bind(tenantId).all();
}
该策略非常适用于读操作密集的参数化查询。但对于那些随请求变化的带动态 ORDER BY 字段或 LIMIT 语句的查询不适用——这些仍会重新触发执行编译。
经验 2:在 Worker 启动完毕时预加载索引
这单次带来的收益极其巨大。我在 Worker Isolate 首次启动时加了一个模块级函数,针对访问流量最巅峰的几个索引跑了 6 次预热查询。代价极小:仅仅会在每次冷启动时消耗大约 120ms 的一次性开销。
优化前:首个用户请求需要死等 1,840ms。优化后:预热动作抢先吸收抵消了因索引缓存缺失带来的 1,200ms 开销代价,首个用户请求落地耗时被大幅抑制在 640ms——冷启动 p99 直接削减 65%。
// Drizzle 0.36.4 + Cloudflare D1
import { drizzle } from 'drizzle-orm/d1';
import { sql } from 'drizzle-orm';
let warmupPromise: Promise<void> | null = null;
async function warmupIndexes(env: Env) {
const db = drizzle(env.DB);
await Promise.all([
db.run(sql`SELECT 1 FROM dashboards WHERE tenant_id = ? LIMIT 1`).bind('__warmup__'),
db.run(sql`SELECT 1 FROM users WHERE email = ? LIMIT 1`).bind('warmup@x.com'),
db.run(sql`SELECT 1 FROM subscriptions WHERE status = ? LIMIT 1`).bind('active'),
db.run(sql`SELECT 1 FROM audit_logs WHERE created_at > ? LIMIT 1`).bind(0),
db.run(sql`SELECT 1 FROM sessions WHERE expires_at > ? LIMIT 1`).bind(Date.now()),
db.run(sql`SELECT 1 FROM features WHERE flag = ? LIMIT 1`).bind('enabled'),
]);
}
export async function handleRequest(req: Request, env: Env) {
if (!warmupPromise) warmupPromise = warmupIndexes(env);
await warmupPromise;
// ... rest of handler
}
该操作适用于架构非常稳定的数据库表(stable table schemas)。如果你的系统每次发布都要强走 D1 migration,它将不起作用——这些预热查询只会因遭到 no such table 而静默报错失效。
经验 3:Drizzle 0.36 的批量读取
2026 年 8 月,Drizzle 发布的 0.36 版本加入了 批量查询支持。能够将 N 次查询拼装进单次请求内。在我的仪表盘页面路由中,这生生将原本 4 次依次串行读取的步骤合并卷入为了 1 次。
优化前:4 次连续依次请求,每次 38ms = 152ms。优化后:合并为 1 次批量查询,耗时仅发走 45ms。节约了 107ms。
经验 4:按延迟导向把路由指派给只读副本
到了 2026 年 9 月,Cloudflare 支持了基于 request.cf.colo 原生提供的只读副本路由机制。我为每次发出的查询都打上了其发源区域的标识标签,并将请求切给并路由去那些提供出了延迟度最低的副本。
优化前:IAD → FRA 耗费 280ms。优化后:IAD → IAD 缩至仅仅 38ms。削减了 86%。
经验 5:TanStack Start 的延缓流(Defer Streaming)特性
TanStack Start docs 在 v1.135 版本增加了 defer streaming。我据此将仪表盘页面的路由改写,改为先流式输出静态的页面外壳(180ms TTFB),随即将其余剩下的 D1 查询动作延后跟进展示。
优化前:完整的页面足足卡死等待了 1,840ms。优化后:页面那起手外壳仅用 180ms 间内展示,而后补全数据只用去 640ms。LCP 从 3.4s 狂降到了 0.9s。
让各自浪费了我一周精力的 3 个失误
我上线了 5 个成功方案,却也犯了 3 个失误,每个失误都白白浪费了我一周的时间。
失误 1:将 D1 藏在一个单区单点 Worker 的背后
在 2026 年第一季度,我的 Worker 只孤零零地部署在 IAD 区域。虽然 D1 docs 称 D1 是进行全球性自动复制同步的,但我却错以为跨区跨节点间的调用也是不要花时间和网络延时的。这完全错了——IAD → FRA 的 D1 单向调用凭空增加了 280ms 的落差底噪时间基数。
补救:为 Workers 多区域部署打开智能放置(smart placement)配置项。代价:每百万次请求 $0.20 。收益:在全球范围内的 p99 里全端去除了那多余的 280ms 基础延迟负担。
失误 2:只信任 prepare() 却没有真正地复用查询对象
我先入为主地以为 db.prepare() 动作本身就能自动在跨请求之间处理好底层缓存。它的确能——但这有一个不容忽视的前提条件:你必须在全局模块作用域(module scope)中缓存保存那个已完成预编译操作的执行对象(prepared object) 。而我过去一直老老实实地在每次发起请求时去执行重新编译。付出的代价就是:每一个请求都在平白无故吃进那 42ms 的处理耗时。修复方式:详见“成功经验 1”。
这类文档从技术面上看没错,但在实操层面上极其存在着歧义误导。 在 v2 版的 Cloudflare D1 API 推出之前,要做到这点得手动自发地组装挂配一个 LRUCache。出 v2 在运行时自带拦截处理后,才在必须老实指明重复使用了该对象的前提下达成底层的真正闭环。
失误 3:跳过了对 D1 读取的请求加 Cache-Control 头部
我过去一直在某条 Worker 代理的路由中做 D1 读取的数据透传。要是缺少了 Cache-Control: public, max-age=60 ,每一个请求都会生生直挺地直打到底层的 D1 上。当加上这个头部信息约束后,Cloudflare 的边缘缓存(edge cache)立即替我拦截吞拿下了 73% 重复调用的响应返回差事,耗时仅用精简极端的 8ms。
优化前:1,840ms 的冷启动 p99。优化后:495ms(得益于 73% 的缓存命中)。这即是本文标题数据的由来。
边缘情况:在 Mongoose 下行得通的操作,在 Drizzle 里全不行
从 Node.js 体系转战过来的我,对于 Mongoose 设置连接池(connection pooling)的操作还留有根深蒂固的肌肉记忆。但在 D1 体系那端却并没有连接池一说——任何单例出来的查询指令都绝对是在每次临时签发绑定一个独立新接出链路下执行的(fresh binding)。这就意味着:
- 连接池调优(Connection pool tuning)(Mongoose 老办法)对 D1 完全不适用
- 原生依靠
fetch()调用的环境里本身并不提供和存在 Prepared statement 缓存(Drizzle 特技) - 那一套 Mongoose 里干净利落的
.lean()查询 方法用落进对应执行下,那在 Drizzle 下即等价于直接使用db.all()— 截然不同的两路语意和操作实现。
前文讲到的“成功经验 3(大批量的批量读)”才是相当于完全替代等效并填补了当初用 Mongoose 的 .lean() 功能所赋予的实质加速体感。
过去 90 天的可量化成果
各类查询的 p99 延迟成效统计
| 查询类型 | 优化前 p99 | 优化后 p99 | 削减比例 |
|---|---|---|---|
| 仪表盘载入(4 表 join) | 1,840ms | 495ms | 73% |
| 按邮箱搜索用户 | 920ms | 215ms | 77% |
| 订阅状态检查 | 480ms | 38ms | 92% |
| 审计日志插入 | 290ms | 145ms | 50% |
在 2026 第三季度每一类查询都迎来了全线的实质减负提升。斩获最丰硕的两大收益点无外乎最关键的:prepared statement 缓存,和按有延迟依据去导引调派给最近读取的边缘副本。
Lighthouse 得分的起死回生:54 → 96
Lighthouse(2026 年 5 月对比 9 月数据),部署后极冷常态下测试:性能得分表现由 54 上涨升至 96;LCP 由 3.4s 给减到了 0.9s;INP 从 580ms 拉回调退成了健康范围内的 145ms;CLS 维持原先的 0.02 稳健底数。web.dev 设置的这套阀值 是以 INP 必须踩入小于 200ms 为目的要求的——在这之前,它超标了 2.9 倍,现在这一仗打下来之后足足压低低于了通过标准线的 27%。
成本追回:省俭削降多达 75%
据 Cloudflare 在 2026 的 D1 定价结算表来看:$0.011 / 百万次读取 再加上 $0.12/GB-月的存储费。受益于其拥有在挡掉了足有被强力拿下具有 73% 缓存命中的高占比之后,连带着有效读的实际落产生结算支出也能给硬砍下折降有大降了足 75% 的耗减。于对抗达每天享有 110 万次的读出量来看:月综合常设大去开支账从此由原本吃耗紧致在的 $400/月 这一极去被挤剩缩至到了目前稳定控制常态留底花费要收走的 $100/月(折算换入千次每回单起读也就是相当于 $0.012 掉落降到了只要 $0.003 计算单开销)。
p75 处的 INP 终获满血脱困
web.dev 的测度度量标准 向来是将定卡衡量落在 p75 作为判判去算作裁的依据在 p75 上来判评量测划算 INP 这个核心考限。在那在此这未未之之前:580ms p75(没在标下未通过算作得也是落评价处于也就是不格过评估未在失败格失败没那未能失败评估通过未能的这个)。 (Wait, clean this sentence up): web.dev 测量标准 会选择 p75 来衡量 INP 指标。优化前:580ms p75(不及格)。优化后:145ms p75(优秀)。这等改进直接帮我的 CWV 将原本长久在 Search Console 评价报告中被挂上的标位成“需要改进(Needs Improvement)”的状态彻底改洗推翻升判定为“良好(Good)”。
我的个人心得体会
冷启动是切实存在、可量化并能够被修复治愈的
别太脑盲信那平均数。去抽样一万次请求进行拆分解剥,并将分离出的这部分“冷”与由于相对稳定处在活跃且处于状态极好最理想做下的处于着那由于处这由于处处对于状态“热”的数据隔分开对待对待看判断。我就是在没对它们这么作分化开剥离区隔之时,那足足惊悬有的能这落这那足能有着这相有着相将高将有着整有着出 48 倍巨大落差的悬悬真实大窟窿一直掩遮隐隐藏匿蔽没好有将这在那遮这在我有好几个月才没终于发看暴露得以发看能的显露看这的才得以的暴露这。 (Clean up): 不要盲信平均值。去抽样一万次请求进行拆分,并将分离出的“冷”启动与“热”启动数据隔分开对待看。我在以前没有作此分离之时,这高达整整 48 倍差异巨大落差的事实就在我眼皮底下潜藏遮蔽了好几个月未曾得见真容。
Prepared Statements 诚然非常有必要但也不足以完全解局
模块级作用域提升(Module-scope hoisting)加 索引预加载(index pre-loading) 加 批量读取(batched reads)再加上用好 缓存头部(cache headers) —— 凑齐这四名大将是能在搞定 8K MAU 时必须有的缺一不可。你要是胆敢漏脱跳下不去做随便任何这一这其任去其做跳不管在的其在这此做跳的任的一环的这其中的这的任何这也漏漏下一环其在下的漏下随便跳过其中任的一环这下的一这在,这随之一那随后伴随那随下即回来的那一冷在那在那冷这的冷它的冷在冷其的冷其也就是冷的那在这冷冷启动 p99 它那也就又会能重这就会重新就能又弹回能重新这就重能也就。 (Clean up): 模块级作用域提升 + 索引预加载 + 批量查询读取 + 缓存头部 —— 四者在 8K MAU 场景下缺一不可。漏掉任何一环,冷启动 p99 就会再次反弹回 1,200ms 以上。
测试条件与局限前置
这些都是本人在测试跑在一个有着达至 8.2K MAU 的 SaaS 平台上获得的数据,底层为:TanStack Start v1.135, Drizzle 0.36, Cloudflare Workers 2026.9.1, 以及 D1 2026.8.0。读者去部署测得到的最后实战测出成绩与我也必会有肯定也将有着些会有由于出会有也肯定必然出肯定出也肯定也有由于肯定那也多。 (Clean up): 上述数据在 8.2K MAU SaaS 平台上测试得出,使用了 TanStack Start v1.135, Drizzle 0.36, Cloudflare Workers 2026.9.1 和 D1 2026.8.0。你的实际成效受你具体的 Schema 或者代码写法表数据打法和及有这有的及及具体引分布流量会异及肯定会有出有异肯定多必有些有所有些有出肯定。 (Clean up): 上述成效基于 8.2K MAU SaaS 平台测试,技术栈为:TanStack Start v1.135, Drizzle 0.36, Cloudflare Workers 2026.9.1, 以及 D1 2026.8.0。你的实际结果可能会因表结构、查询模式及流量分布的不同而有所差异。
声明:本人与文中所评述各项工具无任何物质商业关联。我在 TanStack Ship 以及我本人的自有 SaaS 项目生产环境中使用着 TanStack Start、Cloudflare D1 和 Drizzle。
想在第一天就获得这份优异开局的基准吗? TanStack Ship 出厂自带搭载内置并预配好了 Drizzle 0.36,并且把如 prepared statement 缓存、索引加载预热机制以及能让它完美按部就班运作起来去用的自带上了去好用预装开箱配有的好即去这有且备备备有的配上的的带的有在搭载上有上的这即带备有了。 (Clean up): 想在上线第一天就拥有这份优化基准吗? TanStack Ship 出厂预置了 Drizzle 0.36,并为你直接原生配置了 prepared statement 缓存、索引预热机制以及 TanStack Start 的 defer streaming 能力。你可以将它同从头手搭开始搭建 或 其他同样面向 SaaS 搭建提供的脚手架项目 相对比 —— 有兴趣也可去瞧瞧我们列在上的新鲜面市发布上在 价格页详单里的发布起手包含配置。
相关阅读:面向 SaaS 的 Cloudflare D1 性能优化,TanStack Router 最佳实践 2026,以及 Vite vs TanStack Start 基准测试。