本文由 Huifer 撰写,我是独立开发者,也是 TanStack Ship 的维护者。在过去 18 个月中,我已将 D1 应用于 8 款生产级 SaaS 应用程序中:一个请求量约 14 万次/天的多租户分析工具、一个拥有约 3,200 个付费客户的 B2B 计费平台、一个开发者仪表盘、两个 B2B 市场平台,以及三个较小的应用。本文汇总了我实际测量出的性能数据、总结出的有效模式,以及在规模扩展时可能导致 SaaS 应用崩溃的性能瓶颈。
验证来源:Cloudflare D1 documentation · D1 best practices · D1 query API · D1 read replicas · D1 limits · SQLite query planner · Workers Analytics Engine · TanStack Ship D1 reference
最后更新:2026-08-22 · Changelog
TL;DR: 对于在边缘运行的读密集型 SaaS 而言,D1 是一项极佳的基础组件——从最近的副本读取即可实现亚毫秒级延迟,零连接池成本,且 SQLite 的查询计划也可预测。但对于突发流量下的写密集型单行路径来说,它是错误的选择:数据库级写锁会将每次变更串行化,当写入速率达到约 200 次/秒时,p99 延迟会急剧攀升。在这 8 个生产级应用中,我测得的特定区域内 p50 读取延迟在 0.8–1.4 ms,p95 读取延迟在 2–6 ms,而数据库写锁上限约为 250 req/s,超过后便会出现超时。能让 D1 达到 SaaS 级标准性能表现的模式是:优先使用以
tenant_id开头的组合索引、采用db.batch()执行事务性的多行写入,以及借助 Cloudflare Queues 实现写入扇出(write fan-out)。如果您正在进行技术选型,请查阅 TanStack Ship features page;若想了解更广泛的边缘数据库选择,请阅读 D1 vs alternatives comparison。
面向 SaaS 的 Cloudflare D1 性能:在生产环境历练 18 个月教会我的事
对于一款 SaaS 数据库来说,性能绝非单一数字,而是一个向量:你需要关注 p99 读延迟、突发条件下的写入吞吐量、各区域间的复制延迟,以及当发布日流量突增 10 倍时系统是否依然响应迅速。D1 在部分维度表现卓越,但在其他维度却显得毫无容错度。本文呈现了翔实的测评结果、行之有效的模式,以及需提前规划避开的性能悬崖。
对于 SaaS 数据库而言,“性能”到底意味着什么
当创始人们问我“D1 到底快不快?”时,往往描述得不够具体。对于一款真实的 SaaS,至少有四个性能维度至关重要,而 D1 在这些维度的表现大相径庭。
区域边缘的读取延迟(Read latency) 是 D1 的最强项。在我的遥测数据中,通过最近副本来响应的主键查询能在不到 1.5 ms 内返回。对于 SaaS 仪表盘、身份验证,或是项目列表,这种延迟对用户近乎无感。
数据库写锁约束的写入吞吐量(Write throughput) 则是 D1 最陡峭的悬崖。在事务期间,每一次变更都会独占写锁被强制串行。在我最繁忙的数据库中——一款服务于约 3,200 个客户的计费模块——从 200 req/s 起就开始进入瓶颈期,在达到 250 req/s 时更是演变成 30% 的请求超时。该事故的复盘详见 D1 write-lock postmortem。
写后读的一致性(Read-after-write consistency) 是那种你亲身踩坑前绝不会有人警告你的隐形维度。根据 D1 read replicas documentation,主库 ENAM 节点的数据写入同步至 WEU 副本的典型窗口期在 5 秒以内,但“典型”绝非“确定”。
长尾行为(Tail behavior) 则是触发值班报警的根本原因。中位数延迟只是个营销数字,p99 才是真正起决定作用的关键指标。我曾测得一次 p99 写入延迟:从平常稳定的约 120 ms 飙升到了突发流量阶段的 8 秒以上——这是一种在冲击来临前无法察觉的 60 倍性能衰退。
文章剩下的部分将会围绕这四项维度铺开具体测量数值,以及将 D1 保持在合乎 SaaS 性能边界线内的关键策略模式。
我在 8 个生产应用中的实际测量数据
我这里并不是在跑虚拟的基准测试,而是在如实汇报过去 18 个月来实时记录到 Workers Analytics Engine 中的原生态 Workers 追踪数据(traces)。方法论极其简单:每个数据库的调用都被包装进一层薄薄的辅助函数中,记录下 SQL 哈希值、消耗时间以及绑定源。借助对 Analytics Engine 的分位查询,我们可以精准获取各查询分布的延迟情况。
区域边缘的读取延迟(Read latency)
横跨这 8 个独立应用,围绕热点实体(用户、项目、订阅)执行的单主键读取(SELECT ... WHERE id = ?),在由区域副本来响应的情况下,其延迟分布如下:
| Percentile | Latency | Notes |
|---|---|---|
| p50 | 0.8–1.4 ms | Nearest replica |
| p95 | 2.0–6.0 ms | Cross-region replicas |
| p99 | 8–18 ms | Cold-path binding init |
令我大感意外的模式特性在于:对于受众主要集聚于单一地域的应用来说,延迟分布异常平缓。我的一款分析工具大约 92% 的流量来自北美;该区域对应的 p99 为 8 ms。而对于用欧元结算的 B2B 市场平台,它的 p99 则处于 14 ms——其长尾几乎全由跨大洋的请求引发。完整的查询调整解析见 D1 optimization writeup。
数据库层级锁带来的写入吞吐量(Write throughput)
最忙碌的数据库在执行大量单行 INSERT 与 UPDATE 语句时,展现出的极限收缩口明显紧逼得多。事务期间的每一个变更操作都会获取写锁;后续写入只能在后方排队串行。
-- 适用该上限条件的查询示例
INSERT INTO usage_events (id, tenant_id, kind, payload, created_at)
VALUES (?, ?, ?, ?, unixepoch());
UPDATE tenants SET balance_cents = balance_cents - ?
WHERE id = ?;
以下是计费中心在部署队列写入(queue-backed writer)之前测得的平稳状态基准:
| Percentile | Latency | Notes |
|---|---|---|
| p50 | 35 ms | Single statement, no contention |
| p95 | 140 ms | Light contention from cron |
| p99 | 1.2 s | Bursty periods |
| Timeouts | ~2% | At ~180 mutations/s |
爬升逼近约 250 req/s 大关后,系统会直接崩溃:p99 写延迟刺穿 8 秒红线,超时率超 30%。这中间没有任何优雅降级的余地,它就是一个硬直的悬崖式滑落。这个问题的修复措施沉淀在 D1 write-lock postmortem 篇章中,如今此队列方案已作为 TanStack Ship 计费模块的默认配置。
突发流量下的 p99 尾端震荡
评估一款 SaaS 时,突发测试就是最核心的实战。一次 Product Hunt 上线、登上 Hacker News 头版或是营销邮件轮发,分分钟都能在 5 分钟内导致流量激增 10 倍。D1 数据库面对的爆发式访问并不会压垮读取端——众多副本自然分摊了压力——但如果这 10 倍的请求中包含任何的写操作需求,就立刻会打破僵硬的数据库直写锁机制。
计费事故的真实数据如下:
- 爆发前(Pre-burst): 约 50 次变更/s,p99 写入延迟 120 ms。
- 09:00 发送营销邮件: 流量在 8 分钟内攀升至约 250 次变更/s。
- 持续爆发(09:08 至 09:54): p99 写入延迟从 120 ms 飙升至 8.2 s,超时突破 30%,Stripe webhook 开始重试退避。
- 09:54 执行缓解措施: 将
/api/billing/usage/record移至 Cloudflare Queue 生产者处理;p99 延迟在 15 分钟内恢复到 180 ms。
由此得出的经验教训是:D1 的性能上限绝非只是营销话术里的卖点——它是实打实的运维瓶颈。对于预期写入路径会发生明显流量脉冲的 SaaS 而言,引入队列作为底座的写入架构绝非“可选项”。
D1 性能大放异彩之处
区域副本带来的亚毫秒级读取
用于展示 SaaS 仪表板、设置页面以及项目列表等只读情境——特别是绝大多数操作属于调取小范围核心实体的状态下——D1 的区域副本是绝佳的基础组件。同一大洲之内的副本响应请求可控制在 2 ms 以内的返回延迟,这种速度决定了数据库在整个架构请求路径中绝不会成为性能瓶颈。
零连接池成本
D1 并无连接池机制。任何单个请求直接获取一次运行绑定(binding);对于每个请求而言,该绑定是一种完全“无状态”的存在。若是应用于原生态搭载在 Workers 生态链中的 SaaS 上,它移除了一整个层级的操作复杂度——无需部署 pgbouncer、无需再烦恼微调最大连接数,更不用提“我们在系统发布时耗尽了 Postgres 连接”这般老生常谈的惨案了。针对正在单打独斗推进 SaaS 发版的开发者而言,这种系统架构复杂度的降低是非常有价值的。
稳定可预测的 SQLite 执行计划
SQLite 的查询规划器自带了严谨的文档,且运行机制相当保守。根据 SQLite query planner documentation 的论述,该规划机制在不同版本间具有极高的一致稳定性,这也意味着你在开发初期查看到的各项 EXPLAIN 输出状况将不打折扣地复刻到生产环境。对于极度渴求理清查询性能表现的 SaaS 应用来说,拥抱这种确定性要比面对某种总是“出其不意”的自作聪明的执行方案架构实在得多。
D1 性能崩溃的盲区
写入锁的极限天花板
D1 性能约束里最锐利的一环是在于其数据库层面的全域写锁。每一次 INSERT、UPDATE 或者 DELETE 操作,都会获取这把写锁,导致所有并发写入需求被强制排队并串行分发。具体的容量顶值因实际执行语句的形状和数据库大小而异,不过下坠趋势毫无二致:一旦越过每秒数百次的变更,就直坠深渊。对于写操作频繁的 SaaS(如计费模块、长尾遥测数据摄取、审计日志等),正确的架构是将面向用户的接口与数据库的实际写入分离——一般是引入 Cloudflare Queue 作为前置缓冲来进行接管。
区域间引发的写后读一致性(Read-after-write)滞后隐患
排在第二位的性能限制则是主库和各副本之间存在的数据同步滞后。假设一个身处 WEU 区域的用户刚刚成功创建了一个项目,随后页面跳转去抓取项目概览页面却收到 404,正是由于响应被分派到了未拉取最新数据的 WEU 副本,它还未消化来自中心 ENAM 主站的更新指令。解决这个隐患的修复方式是在写入后的关键读取时显式引入 resolve: "primary" 配置。但必须指出,这就等同于你自行放弃了原有的跨区域极速读取特权——因此仅建议在刚发生写操作后立刻发起读取的极短平快核心链条内适度使用。
在多租户 SaaS 中典型的热点分区(Hot partition)模式问题
第三项触发限制则是基于单一租户引起的热点效应。在服务多租户架构的 SaaS 中,如果某个主要租客发出极不对等的高频写入冲击——例如用量计费模型下涌入的某位重量级企业“巨鲸”,亦或是能在一分钟内触发成千上万次请求的大型 B2B 账号——单凭此一家的并发写入负荷就会吃尽整个数据库写锁的额度,迫使所有其它的正常并发常客一起陷入响应迟滞的长尾泥沼中去。应对策略是,在负责派发的首道关口点(Worker 处理函数前)置放针对性的单租户维度限流管制,并结合使用队列作为缓冲的写入架构,从而直接给前台的接口请求送出极速的应答反馈,而不被底层的写入阻塞。
能将 D1 性能保持在 SaaS 级标准的高级模式
优先将 tenant_id 作为打头的组合索引
在我的 D1 应用的所有表架构设计中,相关领域中每个组合索引的第一个字段排位,无一例外必定会指派给 tenant_id。原因十分明了:在服务多租户 SaaS 架构的底层数据库查询中,默认都必须划归并过滤出专属的那一个核心主账号;只有以 tenant_id 打头镇守组合索引的首位,索引扫描才能不去触碰其他租户的数据,径直指向专属数据块。对包含 (tenant_id, created_at) 的索引只需依靠单次范围扫描就可以提取数据;要仅凭着孤零的 (created_at) 索引去进行探查,就等于必须全库遍历抽取记录后再逐个比对筛选相应的租户,这将是非常耗时的操作。
-- Pattern:领域类表的索引必须先行把 tenant_id 放在首位
CREATE TABLE projects (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_projects_tenant_created
ON projects(tenant_id, created_at DESC);
CREATE INDEX idx_projects_tenant_active
ON projects(tenant_id, archived_at)
WHERE archived_at IS NULL;
这套使用了 archived_at IS NULL 的局部附加索引能在表格数据成倍膨胀期间依然将这份索引保持在一个微小的体积。并且依据 SQLite query planner documentation 明文记载:该查询系统天然就会将这类专注的分类目录用于大多数“活跃项目”查询,并直接在检索归档记录的扫描动作中忽略这部分专门的索引。
采用 db.batch() 执掌事务型的多行写入操作
任何涉及改写操作横跨多表的联动业务——诸如记录一次付款过账、或者处理从 Stripe 下发的 webhook 时——务必封装在一个单独的 db.batch() 中完成,而不是按顺序执行三次 await env.DB.prepare(...).run()。因为在一个 batch 包内,所有的语句将会同进共退一并提交,并且它将把原本的三次网络请求缩减为一次。关于同步依序逐条执行这一落后做法,在官方的 D1 best practices guide 内也被明确列为了反面模式。
// src/server/billing.ts
export async function recordInvoicePayment(invoiceId: string, amount: number) {
return await env.DB.batch([
env.DB.prepare(
'UPDATE invoices SET status = ?, paid_at = unixepoch() WHERE id = ?'
).bind('paid', invoiceId),
env.DB.prepare(
'INSERT INTO payments (id, invoice_id, amount, created_at)
VALUES (?, ?, ?, unixepoch())'
).bind(crypto.randomUUID(), invoiceId, amount),
env.DB.prepare(
'UPDATE tenants SET balance_cents = balance_cents - ?
WHERE id = (SELECT tenant_id FROM invoices WHERE id = ?)'
).bind(amount, invoiceId),
])
}
引入 Cloudflare Queues 云端队列进行写入扇出(Write fan-out)
要是遭遇并不迫切要求同步给出响应的异步重荷载纯写工作——比方说向各渠道分发的 webhook 投递差事、又或者汇总审计日志的入库记录——利用 Cloudflare Queues 无疑是上乘之策。由于面向前台发起的生产者仅是一次轻易的 env.WRITE_QUEUE.send(...) 调用,命令一出便可即刻退出返回,毫无阻塞;而在底层承接重载任务的消费者只需将消息在内部积累打包,最多直至包含 100 条消息为一组,最后采用那决定性的一击 db.batch() 统一集中批量入库。除了能将这些繁重的旁支写入完全从面向用户的直写路径中脱离开,避免了与主线程抢锁排队争空间之外,有关这种队列缓冲后写方案的详尽剖析还被专门收入在那份深入探索的 D1 deep-dive writeup 指南里。
哪些情况下 D1 的性能并不符合要求
确切地讲也该坦白承认,在几种构建特定 SaaS 形态的模型里,D1 并不是最理想的适配数据库引擎。如果你的这套业务严重倚赖高密度的写回并发——比如高频交易账本收录、海量遥测日志的吞入采集端,或者是需要应对实时事件流的大型分析运算架构——那么写锁天花板必定会在你的业务成型前率先扼杀系统的生命线。在这种情况下,应对的出路只能是全面转向那类专精应对大规模且抗写能力极强的数据库引擎方案:例如可分片的 Postgres 方案(如 Neon、Supabase),或者是纯流式的存储分析系统(ClickHouse、Tinybird),又或是将单一实体状态挂载到自带队列的独立 Durable Object 身上。
如果此刻你正在权衡是否将 D1 作为你 SaaS 系统的基础组件之一,那么强烈建议你先查看这份 TanStack Ship features page 详细目录介绍,然后查阅这篇全面的导论 D1 production guide,最后也请不要遗漏这篇细致的 D1 vs alternatives comparison 以作全面鉴别。如果你想要进一步了解曾经有哪些故障让我下定决心转向队列写入方案,务必看看我亲自复盘的 D1 write-lock postmortem。最后若想要继续深研如何极尽可能优化好每一个查询语句的方法课,也一定别错过这篇专门深入研究的 D1 optimization writeup。