本文由 Huifer 撰写,我是 TanStack Ship 的独立开发者兼维护者。我在三个 SaaS 应用(一款多租户分析产品、一个 B2B 计费平台以及一个开发者工具仪表盘)的生产环境中运行 D1。它们每天共处理约 14 万次请求,其中最大的数据库包含约 420 万行数据。本指南浓缩了我当初决定使用它之前,希望能有人直接给我的全部核心经验。
来源验证:Cloudflare D1 官方文档 · D1 最佳实践 · Cloudflare Workers SQL API · TanStack Ship D1 参考实现 · Wrangler D1 命令
最后更新:2026-06-24 · 更新日志
TL;DR: Cloudflare D1 是一款无服务器的、基于 SQLite 的关系型数据库,内嵌于 Cloudflare Workers 运行时中。对于以读取流量为主、写入量适中(持续写入低于约 200 次/秒)且数据集小于 10 GB 的 SaaS 工作负载,D1 能够在与 Worker 相同的边缘节点上运行查询,从而实现亚毫秒级的查询延迟。本指南汇总了你需要的一切:架构设计、多租户隔离模式、查询优化、
SQLITE_BUSY争用陷阱、读取自身写入(read-your-writes)模式、10 GB 容量上限、与 Postgres+Neon 和 Turso 的成本经济学对比,以及不会导致生产环境崩溃的迁移工作流。如果你在 Cloudflare Workers 上进行构建并需要真正的关系型数据库,D1 就是标准答案——前提是你要尊重它的各项限制约束。
为什么 D1 改变了生产环境数据库的格局
边缘计算的困境
边缘计算革命将计算推向了离用户更近的地方。如今,运行在法兰克福的 Worker 能够在不到 5 ms 的时间内响应柏林用户的请求——无需再绕回集中的源站数据库。然而,数据库本身依然是中心化的。你写得极好的分布式 Worker 仍然必须远程调用位于 us-east-1 的 Postgres 实例,这使得每次查询都会增加 100–300 ms 的延迟。这种达到 5 到 50 倍的性能倒退,悄无声息地抹平了边缘计算的速度优势。
最初的变通方案是使用 Hyperdrive——Cloudflare 的连接池工具,它可以在边缘缓存查询结果。这招确实管用,但这终究只是缓存。它并没有让数据库本身变成分布式的。
D1 到底是什么
D1 是编译为 WebAssembly 的 SQLite,在 Workers 运行时内部执行,其存储层由 Cloudflare 的分布式对象存储作为支持。读取操作命中本地副本。写入操作从单一主节点传播到最多 10 个全球只读副本,通常在一秒内即可完成。该数据库本质上就是一个 SQLite 文件。你可以使用 wrangler d1 export 将其导出,并在本地使用 sqlite3 进行操作。尽管存在这样那样的限制,但正是由于这个特点,我总是重新选择 D1:我可以利用积累了 25 年的 SQLite 知识来理解并驾驭它,而不用再去学一门新的专有查询语言。
我已经成功交付了 3 个基于 D1 构建的生产级应用。说句大实话:D1 并不是 Postgres 的平替。它是一款拥有不同取舍的数据库,而这些取舍更偏袒我们在本指南中马上就要精确描述的一种特定形态的 SaaS 工作负载。
生产环境架构:D1 如何融入你的技术栈
与 Worker 绑定的数据库模型
D1 不使用持久连接。每次 Worker 调用都会获取一个全新的数据库绑定(binding)。该绑定在读取时被解析为最近的副本,而在写入时解析为主节点。这对于习惯了 Postgres 的开发者来说颇不寻常,因为在过去,你下午入职后的第一件事就是调优连接池。
// wrangler.toml
[[d1_databases]]
binding = "DB"
database_name = "my-saas-prod"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
migrations_dir = "migrations"
// src/db/index.ts
export interface Env {
DB: D1Database;
KV_CACHE: KVNamespace;
}
export async function getUserById(env: Env, userId: string) {
const stmt = env.DB.prepare(
"SELECT id, email, name, created_at FROM users WHERE id = ?"
);
return await stmt.bind(userId).first<User>();
}
注意这里没有任何 await pool.connect() 的身影。因为压根就没有连接池。绑定的生命周期和 Worker 的一次调用完全一致。这就是关于连接管理的全部内容。
复制与一致性
D1 采用主从(primary-replica)架构,并保证最终一致性。当你执行写入时,数据会写在主节点上。在大约 60 秒(通常会更快)内,该写入将在全球各地的副本中可见。对于大多数 SaaS 读取路径而言,这完全可以接受——用户的个人主页从一个落后 200 ms 的副本中读取数据毫无问题,因为用户刚刚不过是提交了一个表单,而不是发起了一次数据库查询。
但有一类操作必须马上读到刚刚写进去的数据:即紧跟在写入操作之后的请求。如果用户创建了一条记录并被重定向到其详情页,该详情页必须能看到这条记录。如果你此时从陈旧的副本读取,就会得到一个 404 错误。这就是所谓的读取自身写入(read-your-writes)问题,我在下文专门介绍了一种针对这种情况的 D1 实施模式。
10 GB 存储上限
每个 D1 数据库最大被限制在 10 GB。这差不多等同于 2,000 万到 4,000 万行典型的 SaaS 数据(具体取决于行宽)。我第一次撞到这堵墙是在一次客户迁移期间:单张表就塞进了 8.7 GB 的审计日志。当时的解决办法是把旧日志归档到 R2 中,并按月对活跃数据集进行分区。10 GB 的限制是硬性规定,但它同时也倒逼你做好数据卫生规范——大多数体积小于 10 GB 的 SaaS 数据库也恰恰因为瘦身而保持了更健康的状态。
真正奏效的多租户 SaaS 模式
三种隔离策略对比
在使用 D1 构建应用时,多租户隔离是你将要做出的最具深远影响的架构决策。共有三种可行的模式,而正确答案取决于你的租户规模分布情况和合规性要求。
带 tenant_id 列的行级隔离。 每张表都设有 tenant_id。所有查询都基于此进行过滤。简单、廉价,且能够扩展到几千个租户。风险:查询语句里的一个小 bug 就会导致跨租户的数据泄漏。
通过多个 D1 数据库实现的单租户单 Schema。 为每个租户分配一个专门的数据库。优点是强隔离,但现在你需要对 N 个数据库进行迁移、监控和备份。Cloudflare 的计费也与数据库数量挂钩。除非针对 50 到 100 家企业级租户,否则我不建议这样做。
混合模式:中小企业租户共享 Schema,企业级租户使用独立数据库。 这是我在生产环境中上线的方案。未超过指定阈值的租户共享同一个多租户数据库;超过该阈值的老板们则拥有他们自己专属的 D1 绑定。这种切换本质上就是 Worker 里的路由决策。
// 租户路由模式
export async function getTenantDB(env: Env, tenantId: string): Promise<D1Database> {
const enterpriseTier = await env.TIER_CACHE.get(`tier:${tenantId}`);
if (enterpriseTier === "enterprise") {
const dbBinding = await getEnterpriseDBBinding(tenantId, env);
return dbBinding;
}
return env.DB; // 共享
}
查询规约就是你的安全层
如果在用行级隔离,每条查询都绝对必须带上 tenant_id。我发现的最安全的办法是把 tenant_id 变成显式传递的必需参数,并无情拒绝那些忘写这个参数的查询:
export async function listProjectsForTenant(
env: Env,
tenantId: string,
limit = 50
) {
// 必须始终绑定 tenant_id —— 绝对不允许拼接字符串
const stmt = env.DB
.prepare("SELECT id, name, created_at FROM projects WHERE tenant_id = ? ORDER BY created_at DESC LIMIT ?")
.bind(tenantId, limit);
return await stmt.all();
}
说起来像废话,直到你凌晨 2 点在一头雾水地通宵排查为什么其中一个租户能看到另一个租户的记录。教训就是:把租户隔离当作安全原语来看待,而非单纯的编码惯例。用 linter 去拦截,写测试去覆盖。还可以配个 CI 检查,如果在拼接进来的任何查询字符串中没看见 tenant_id 直接失败报错。若想深入了解 TanStack Ship 是如何在框架层面强制实现租户隔离的,请参阅多租户路由模式参考。
超越基础用法的性能优化
制胜查询模式
D1 继承了 SQLite 的优化器。这意味着那些通用法则依然适用:预编译语句是强制动作,索引的作用不可或缺,而 SELECT * 则是对每一次读操作收取的盲目税。把这些基础操作抛开,以下三种查询模式能专门且显著地改善 D1 的性能指标。
对于事务性写入,使用 db.batch()。 不管是有两处还是三处写入,只要必须保持原子性的,就全部批处理提交。整个批次在单一的 SQLite 事务中执行。中间任何地方发生故障都会回滚整个批次。
export async function createUserWithProfile(
env: Env,
data: { email: string; name: string; avatarUrl?: string }
) {
const batch = [
env.DB
.prepare("INSERT INTO users (email, name) VALUES (?, ?)")
.bind(data.email, data.name),
env.DB
.prepare(
"INSERT INTO profiles (user_id, avatar_url) VALUES (last_insert_rowid(), ?)"
)
.bind(data.avatarUrl ?? ""),
];
const results = await env.DB.batch(batch);
return results[0].meta.last_row_id;
}
仅对 DDL 语句使用 db.exec(),绝不能在请求热路径中使用它。 exec() 是专门留给迁移工作和一次性脚本的。如果在请求处理函数里来了一个 exec("VACUUM"),你预期的超低延时将瞬间灰飞烟灭。
在整个请求热路径的生命周期内重用预编译语句。 假如你的请求需要针对同一张表发出 5 次读查询,明智的做法是预编译这条查询,并在随后的操作中重用它。绑定 API 由于其优雅的设计在这里非常友好。
索引:早建早享受,常审常健康
在 D1 里排查过的各种生产事故,罪魁祸首无一例外不是漏掉了索引就是索引过期失效。SQLite 的查询规划器非常出色,但它也不能无中生有,在一个连索引都没建的列上做优化。我的死规矩是:只要是写进生产代码中的 WHERE、JOIN 或 ORDER BY 语句里的每一列,在代码上线前都必须老老实实加上索引。
-- migrations/0002_add_query_indexes.sql
CREATE INDEX IF NOT EXISTS idx_projects_tenant_created
ON projects(tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_event_time
ON audit_logs(tenant_id, event_type, created_at DESC);
这种形如 (tenant_id, created_at DESC) 的复合索引就是任意多租户 SaaS 中杠杆率最高的单一组件。它为每一个无排序需求的“为当前租户列出最新出现的某记录”查询提供服务。有关更深度的索引策略及它是如何与 TanStack Table 服务器端过滤配合使用的,具体请参见我们的 D1 查询优化深度剖析。
用 KV 做缓存,别用 D1
D1 的确很快,但位于边缘的 KV 更是快得多。对于诸如定价梯度、功能特性开关、全局公共配置这样一般一小时改不到一次的参考性数据而言,果断从 KV 读取,仅仅把 D1 当作数据真相源。我通常会在针对 KV 的读取操作上保留 60 秒的 TTL,这样我完全能接受这种轻微的数据陈旧带来的妥协。
生产环境避坑指南:倒逼我写出本文的复盘经历
SQLITE_BUSY 和写入冲突
D1 通过一个唯一的主节点来序列化写入操作。当持续写入速率到达约 200 次/秒以上时,你将不可避免地撞见 SQLITE_BUSY 报错。在一次大批量账单导入功能上线后,我所在的计费平台就中招了:由于并行导入了 4,000 张发票进而引发写入风暴,有 12% 的批处理死于 SQLITE_BUSY 异常。
破局办法并不是无脑堆硬件。正确的解法是引入退避重试(backoff)机制并对并发进行限制:
async function withBusyRetry<T>(
fn: () => Promise<T>,
maxRetries = 5
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (e: any) {
const isBusy = e?.message?.includes("SQLITE_BUSY");
if (!isBusy || attempt === maxRetries - 1) throw e;
const backoffMs = 25 * Math.pow(2, attempt) + Math.random() * 25;
await new Promise((r) => setTimeout(r, backoffMs));
}
}
throw new Error("unreachable");
}
// 在调用端限制并发
import pLimit from "p-limit";
const limit = pLimit(8); // 根据写入预算进行调整
await Promise.all(
invoices.map((inv) => limit(() => withBusyRetry(() => insertInvoice(env, inv))))
);
这个 pLimit(8) 就是下半场教训:光靠重试是无法真正解决并发争用冲突的。你必须在请求上游给并发加上限制。我将每个 Worker 调用内的并行写入上限设定为 8 作为安全阀值;你需要根据实际写入率对它微调。
在变更后读取自身的写入
如果你刚写完数据马上就要发起读请求,务必将该读操作路由回主节点,而不是分发到离你最近的副本。D1 通过特殊的 PRIMARY 位置提示项为此暴露了操作方式:
export async function getUserFreshAfterWrite(env: Env, userId: string) {
// 写入后强制从主节点读取,以避免副本延迟
return await env.DB
.prepare("SELECT id, email, name FROM users WHERE id = ?")
.bind(userId)
.first({ resolve: "primary" });
}
只在刚刚写完数据的热路径中使用它。主节点很少是离你最近的副本,这么做要付出高昂的延迟代价,切莫将其运用在每个平凡的读取上。
数据备份:D1 导出 + R2 是必选项
D1 的内置备份是时间点快照。这不能代替脱机数据库备份。我每天深夜都会为每一个生产环境数据库执行向 R2 的定期导出:
export async function nightlyD1Backup(env: Env) {
const tables = ["users", "projects", "subscriptions", "audit_logs"];
for (const table of tables) {
const result = await env.DB
.prepare(`SELECT * FROM ${table}`)
.all();
const date = new Date().toISOString().split("T")[0];
await env.BACKUP_BUCKET.put(
`backups/${date}/${table}.json`,
JSON.stringify(result.results)
);
}
}
没错,这种做法会全量读取遍历每一行。但不用慌,以我目前的数据量还不成问题。如果你的表超过 5 GB,请切换到基于时间戳的增量备份机制。
成本经济学:D1 什么时候最划算
每次请求的算账逻辑
D1 的收费按读取和写入的行数计算,且有极其慷慨的免费配额。一个极具代表性的 SaaS 请求——读取某用户、读取他的项目以及拉取他的账单——通常只会占掉 20–50 行的读取,这实际上就是绝对的 $0 成本开销。哪怕是极度依赖写入的创建项目请求,也就产出 5–10 行写入,仍然近乎免费。
对比一下 Neon 或是 Supabase 的情况,后者的成本驱动因素是计算时间。在 Postgres 上执行一次耗时较长的分析查询大概会花掉 $0.05 的计算费用。而在相同数据量下相同的查询,如果转由 D1 行读取计价只需 $0.00001。这两者的交叉分水岭点出现在涉及到伴随高密度写入以及长期打开持久连带的重度事务 OLTP 工作负载——在此局中 D1 注定溃败,因为那意味着受限于其底层唯一的队列化主节点执行。
D1 何时处于劣势
如果满足以下任意条件,D1 会是一个错误的选项:
- 持续写入吞吐量超过约 200 次/秒
- 单个逻辑数据库中的数据集超过 10 GB
- 需要强跨区域一致性(例如财务流水账、库存系统)
- 需要使用窗口函数或递归 CTE 的复杂分析查询(老版本的 D1 不支持这些特性)
针对这些情况,我会转由在 Neon 或是 Turso(同样是 SQLite 但是存在截然不同的数据副本克隆模式模型)上部署 Postgres 来应对。然而对于剩下来的所有情况——几乎包圆了我构建的绝大部分应用——D1 始终是摩擦阻力最低的终极选项,且它依旧大方提供给你带有完善的原生 SQL、快速索引机制加上坚实可靠的事务完整特征体系的关系型数据库。关于左右详实列举的全线定价对垒拆解,请浏览 TanStack Ship 价格页面 与 D1 只读副本扩展指南。
迁移工作流:从本地 SQLite 到生产环境
# 1. 在本地创建一个新迁移
npx wrangler d1 migrations create my-saas-db add_tenant_id_to_projects
# 2. 编写 SQL
# migrations/0003_add_tenant_id_to_projects.sql
# ALTER TABLE projects ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'legacy';
# CREATE INDEX idx_projects_tenant ON projects(tenant_id);
# 3. 应用于本地(使用本地基于 SQLite 的 D1)
npx wrangler d1 migrations apply my-saas-db --local
# 4. 根据本地 fixtures 进行测试
npx vitest run db/
# 5. 应用到生产环境(不可逆 —— 请务必先进行备份)
npx wrangler d1 export my-saas-db --output=./pre-migration-backup.sql
npx wrangler d1 migrations apply my-saas-db --remote
如果没有在 R2 中准备好经过验证的稳妥备份,我绝不会运行 --remote。在没有于生产数据拷贝上进行完整演练之前,我也决不执行哪怕一点点破坏性迁移(删除列、修改类型)。把你宝贵的数据库视为生产环境代码来看待,绝不要以为那是一个随时可以推倒重来的本地开发玩物。若需了解在整个战区崩盘下仍能幸免保命备援救灾的底线操作图景全貌,请仔细查阅这篇 D1 备份与恢复指南。
结语:这篇文章是写给谁的
如果您正在为某个 SaaS 工作负载评估 D1,真正的问题不是“D1 是否比 Postgres 更强”——而是“我的工作负载模式到底更契合 D1 的强项,还是更能发挥 Postgres 的优势”。大多数涉及多租户高频率读取、适度写入量、并且部署在边缘节点的 SaaS 工作负载,天生就与 D1 完美契合。我们在本指南中总结的模式——租户路由分发、限并发的忙碌重试机制、在主节点读自己的写入、使用 KV 缓存参考数据、在夜间全量备份到 R2——正是确保这些工作负载在生产环境中长长久久、稳如磐石运行的核心壁垒。
TanStack Ship 开箱即用地交付了这里的每一种模式。D1 的标杆级参考实现、多租户路由、审计日志分区、备份工作流以及并发安全的防冲突辅助函数,全部都已为你配置在启动模板中。如果你希望免去 18 个月的生产环境积累折磨,并且迫切希望能在今天立马上线由 D1 加持底层数据库支撑的健壮 SaaS 应用,请使用 TanStack Ship 作为你的全新起点。
→ 了解 TanStack Ship D1 参考架构 · 对比用于 D1 应用的 TanStack Ship 与 ShipFast · 浏览 GitHub 仓库