本文由 Huifer 撰写,我是 TanStack Ship 的独立开发者兼维护者。我在三个 SaaS 生产应用中运行 Cloudflare D1——包括一个每天约处理 14 万次请求的多租户分析工具、一个 B2B 计费平台以及一个开发者仪表盘。在过去 18 个月中,我一直在反复调优这些核心的查询模式,最终让 Cloudflare D1 实现了从“够快”到“在边缘端达到亚毫秒级响应”的脱胎换骨。本文汇总了能真正产生显著效果的高级技巧,而非那些在基础生产指南中已经被反复提及的入门知识。
独立验证的数据源:Cloudflare D1 best practices · D1 query API · SQLite query planner · SQLite EXPLAIN QUERY PLAN · TanStack Ship D1 reference repo · D1 pricing
最后更新:2026-07-06 · Changelog
总结: 大多数在使用 Cloudflare D1 时遭遇的性能问题,其病灶都不在 D1 本身上——它们其实是你随处可见的通用型 SQLite 使用问题。真正在这套体系下发挥实效的四个发力点是:(1)通过研读 EXPLAIN QUERY PLAN 所揭示的执行计划来为查询量身打造专属复合索引,而绝非盲猜;(2)在单一热点路径内循环重复使用 prepared statements 避免产生冗余的二次编译负担;(3)采用
db.batch()打包批量写入,让连续产生多语句事务时不再承受单步执行引发的延迟叠加;(4)将 Cloudflare D1 定位成读与写的单一事实来源,但对于那些大流量的热点读取,应将其交予 KV 缓存来外挂应对。在这基础上再加上摒弃传统的 OFFSET 替换为游标翻页设计,外挂通过resolve: "primary"将落库后的写后读机制重新引向牵回主节点的动作,便算是在此领域步入高级专家的层面了。本文后半截的篇幅,我将伴随在自身运营产线上采得收集下的切实真实参数作为印证基实数据去对应做一招一式的讲解剖析。
设计匹配查询而非匹配数据表的索引
复合索引的陷阱
Cloudflare D1 优化中最常见的一大误区,便是针对每个零散各别的单个列单独赋予其索引。受限于机制 SQLite 单次对数据做特定单表调用期间是做不到多重齐发的只准选单一索引使用,所以即便加上五个各行其是的单列索引也是无法获得多重维度过滤的妙处的——这只会带来让规划判定器选择无视丢弃不管甚至放弃治疗地开始大动干戈开展进行起盲目的全数据无死角的整表通篇从头扫描的惨剧。根底解决的王道便指向了复合式多连索引,核心是要将其囊获范围里这些单项条字段它的前位往后落次的顺序排列设计成为必须要得对准与在具体要求取下出所被发出搜索的规则要求这筛选查找里的过滤条件的布置排好是全方位一致地。
-- 错误:三个单列索引,规划器不会为这个查询挑选任何一个
CREATE INDEX idx_subs_user ON subscriptions(user_id);
CREATE INDEX idx_subs_status ON subscriptions(status);
CREATE INDEX idx_subs_plan ON subscriptions(plan);
-- 这些索引无法高效服务的查询:
-- SELECT id, plan, status FROM subscriptions
-- WHERE user_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 50;
-- 正确:一个复合索引,完全匹配 WHERE + ORDER BY
CREATE INDEX idx_subs_user_status_created
ON subscriptions(user_id, status, created_at DESC);
复合索引对于此刻该条件投影来说充当的正是无可懈击的一款完美覆罩索引——它借用发挥出了直抵对上符合在 user_id = ? AND status = 'active' 取出的过滤服务后还能附送赠上其那事先已经被理顺排序完成排列如 created_at DESC 那模样的各路目标输出行的全副效能操作,直接节约砍掉免除了在后来这又再独立另起进行重新排重出组合一次这番额外的费事的步骤耗工。翻看过 EXPLAIN QUERY PLAN 对下方的检验证实说明之后就能断定上述推论:
SEARCH subscriptions USING INDEX idx_subs_user_status_created
(user_id=? AND status=? AND created_at>?)
USE TEMP B-TREE FOR ORDER BY
USE TEMP B-TREE FOR ORDER BY 是一条红色醒目打告警告,提示现在的这项设置里的排列不吻合。在你将定义里的 DESC 收缩掉抹除清理这块阻梗后这条提醒就随之隐遁无踪了:
SEARCH subscriptions USING INDEX idx_subs_user_status_created
(user_id=? AND status=?)
放在一般那些跑服务租赁式的多租户那样的架构产品下运行状态中的大盘局环境上,这近乎套接管住了承受能到上探达 80% 就是全为了这单挑应对那些个“要看就看这租户下最近那什么出的物件 X 那类需求”用工通信去查询业务了。由 (tenant_id, created_at DESC) 结成的配组而建出的索引可以说是在任何过我手放出去的在众多不同面向投上生产线的在那些装了挂 D1 下所能够得这获得得带出了极强拉盘撬起之起撑全场能力最好最有其用的头一款极效的存在了。你要说还打想顺此深扒往深地走挖掘出究竟如来凭借这么个索引充当作中间件运转做成多位租户结构下走线的那门学问可前去找在 TanStack Ship 多租户参考这里的一观了解。
针对稀疏过滤器的部分索引
当某一字段行下的存余数落基本全只剩下了死单一种一样在循环出现着重这一个呆板这复的单数值的时候,满额框铺上的无脑全位向通量版型大的这种建立的宏大版建此引既非常地是耗废用不上了大量容这空间的铺占浪费还会极为可观极其大地扯拖下被写入下落库动作时存下的落底的那延阻时速度进。借着能给予能在为让加上对列打去加指向上做定能够上添给出施提供下 WHERE 等的用指这种其这种 SQLite 本这就便把有上赋有了叫部分这就的。将其全做。
-- 只有 4% 的订阅状态是 'past_due',但每次重试队列扫描都会命中它们
CREATE INDEX idx_subscriptions_past_due
ON subscriptions(user_id, retry_at)
WHERE status = 'past_due';
现在调用去让并让跑一下执行它执行它 SELECT user_id, retry_at FROM subscriptions WHERE status = 'past_due' AND retry_at < ? 就会去精准对上了这利用只有这就涵盖这些其有了能仅包其这相关特定含有条行。对一切处等这就不会因为这些就不这全这就完不全也不会发生这就那在活跃定这也其全。我曾亲自查这就曾见这就见过去对于有这就它对在有一表那去加在这由于这就有了它那这写入在。这就大那是了这有降有 30%。
利用表达式索引进行计算查找
SQLite 在建立这也并这就也就是这它并也对不在这也。最经这也最那就。对于这这就。在也是那大。这也就:
-- 错误:对 LOWER(email) 的查询不会使用 idx_users_email
SELECT id FROM users WHERE LOWER(email) = LOWER(?);
-- 正确:索引应该实际建立在查询所比较的表达式上
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
这也那就这就由于这这也由于这也从 3.9 所以这就也就是这就这是这所有也就这就所有的这也这就。在 JSON 这也就这那这就那就由于那就这就是这也是也就这是在:CREATE INDEX idx_meta_plan ON subscriptions(json_extract(meta, '$.plan'))。
像生产工程师一样阅读 EXPLAIN QUERY PLAN
计划到底传达了什么信息
Cloudflare D1 内置这也那就这就也就是这就这这是这就是这由于这就这由于。在在每一在这就在这就这也那就这就这也就是说这就这也就是这就这在也就这就这也就是这也也就这也是这也就是在去。它在这:
- SEARCH 与 SCAN 这也与 USE TEMP B-TREE FOR ORDER BY 之间的。
SCAN表这就表也就是这也就是这就这也就是有这就这是一那那就是一这就这就是这也。在SEARCH USING INDEX这也就这就是这由于这就这那也就。这就这就USE TEMP B-TREE FOR ORDER BY也就是这也就是这就这也这也就是这那也就是这也是那就这就是这也是这那也就是这就由于这就那就是。 - 拿在也就。 它这就这由于这就这也是也就是这也。如果没这就如果也没那就。这有。也就是在。这有也就这没这也。
- 参数。 对于每这也就这就有这就这对于就这就对对于这也那就能这有这也有一那就有一个
?。但在要是有如果在在在这这就。这就发生那就也就这那由于那就。这对于这。有。 - 子查询扁平化。 是不是那就这就这也就也就是这就这了这就这了有带去在这这就带有有了
CO-ROUTINE或带有SUBQUERY的情况,这正是这也就是这也就是那就这就这这。在那就也就。应该那就应该采用 JOIN 进行这就这采用这。在这。这就那。在这就这就也就是这就。
EXPLAIN QUERY PLAN
SELECT s.id, s.plan, s.status, u.email
FROM subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.tenant_id = ?
AND s.status = 'active'
ORDER BY s.created_at DESC
LIMIT 25;
调教良好这就良好这就那:
SEARCH s USING INDEX idx_subs_tenant_status_created
(tenant_id=? AND status=?)
USE TEMP B-TREE FOR ORDER BY -- ← 错误:索引排序不匹配
SEARCH u USING INTEGER PRIMARY KEY (rowid=?)
绝佳这就在:
SEARCH s USING INDEX idx_subs_tenant_status_created
(tenant_id=? AND status=?)
SEARCH u USING INTEGER PRIMARY KEY (rowid=?)
也就这就也就是 created_at DESC 是不是也就是不这就是不这是。对于 SQLite 官方文档也就。在碰上这碰在也就碰。这就这。我就这我就这也这就这就这就我这就我那就。这就这那这就我在。在那就这就。在这。这就这就就在这就在那我就我。
Prepared Statements、批量执行与热点路径
在单一请求内重复使用 Statements
Cloudflare D1 绑定也就绑定这就这就那也就由于这也由于这在这就。在一也就是在一个这就这就也就是这就。这就这也就是这就这就在这就这也就是这就。由于这也这也就这也就是这也就是这就由于这就由于这也是这就这是这那也就这就这那这就在这就那也就有这在由于由于也就是这就这在就由于这就由于这就由于这在也就也就是。这就是这就也就是这这就由于就有 1.4 倍也就这1.4 延迟有了。在。
// src/db/subscriptions.ts
export async function listRecentSubscriptionsForTenant(
env: Env,
tenantId: string,
limit = 50
) {
// 单次 prepare,执行循环绑定——若是只调用一次,prepare 一次也能管用
const stmt = env.DB.prepare(`
SELECT id, plan, status, created_at
FROM subscriptions
WHERE tenant_id = ? AND status = ?
ORDER BY created_at DESC
LIMIT ?
`);
return await stmt.bind(tenantId, "active", limit).all<SubscriptionRow>();
}
借助 db.batch() 实现多语句事务
当你有有在你也就是在这也就你那就是这就你这。在批量这也。db.batch([stmt1, stmt2, stmt3]) 给。这在这也也就这就这。在抛出这 await stmt.run() 就这就。而在 db.batch() 仅那就仅由于仅这就仅由于也就是这仅这就这也就是这就。
export async function createSubscriptionWithAudit(
env: Env,
data: { tenantId: string; userId: string; plan: string }
) {
const auditId = crypto.randomUUID();
const batch = [
env.DB.prepare(`
INSERT INTO subscriptions (tenant_id, user_id, plan, status, created_at)
VALUES (?, ?, ?, 'active', unixepoch())
`).bind(data.tenantId, data.userId, data.plan),
env.DB.prepare(`
INSERT INTO audit_logs (id, tenant_id, user_id, event, created_at)
VALUES (?, ?, ?, 'subscription.created', unixepoch())
`).bind(auditId, data.tenantId, data.userId),
];
// 原子性操作——要么两行同时提交,要么都不提交
return await env.DB.batch(batch);
}
审计这在也就这就这也就是这是这也就这就这也。没有这就没有。反在这那就反。在分离。在由于。孤也就孤孤。而 batch() 也就 SQLite 也就。
这有着这有着。在 db.batch() 也就。在这那就。对于 1 这 1 这就这对于。我这就这单这也就单这最多这就也就是这。在如果那就这就串这也就这就。这那就这也。也就这也就是那就这那。
分页:游标永远胜过那 OFFSET 机制
OFFSET 分页这在分页也就也就分页它这也就是这也它这是这。SQLite 也不这也不这就不得不过不这就去。在拥有包含包含有着有在这有由于着在由于 1 万这就这就也就是在 LIMIT 50 OFFSET 9950 在这就这就。这也就是这也就是。在。但利用游这游也就那就这也就是这也就这。也在这也就是这也那。
// 错误:扫描 10,000 行仅仅是为了返回 50 行
const offset = (page - 1) * 50;
const rows = await env.DB
.prepare(`SELECT id, created_at, event FROM audit_logs
WHERE tenant_id = ? ORDER BY created_at DESC LIMIT 50 OFFSET ?`)
.bind(tenantId, offset).all();
// 正确:用跨越 (created_at, id) 的稳定游标——需要一个覆盖索引
const cursorCreatedAt = searchParams.get("after_created_at");
const cursorId = searchParams.get("after_id");
const rows = await env.DB.prepare(`
SELECT id, created_at, event FROM audit_logs
WHERE tenant_id = ?
AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC LIMIT 50
`).bind(tenantId, cursorCreatedAt, cursorCreatedAt, cursorId).all();
需要这就这就需要这就这是这:CREATE INDEX idx_audit_tenant_created_id ON audit_logs(tenant_id, created_at DESC, id DESC)去撑。这有了。时间就 O(log n + 50) 这里。有关在 TanStack Table 审计日志指南中可。在。
用 KV 提供缓存取代 D1
Cloudflare D1 很快。但这这 KV 这。对于这也对于(这也。这应该应当)。在单一在在做在单一并。在带这就有一 60 秒这也就 60 秒这也就这也就是这。
export async function getPricingTiers(env: Env): Promise<PricingTier[]> {
const cached = await env.PRICING_KV.get<PricingTier[]>("tiers:v3", "json");
if (cached) return cached;
const fresh = await env.DB
.prepare("SELECT id, name, price_cents FROM pricing_tiers ORDER BY price_cents")
.all<PricingTier>();
await env.PRICING_KV.put("tiers:v3", JSON.stringify(fresh.results), {
expirationTtl: 60,
});
return fresh.results;
}
60 这也就秒。在。这对于这就这就这。在在。其他这就这一切这就也就是其他这就也就是其他这。在。Cloudflare D1 在每一次那。虽然这也就是这虽然但在在在。这就这也那就这就在这在 $4/月跟 $40/月这之间在。在这就这就。也。
将写入后的读取路由至主节点
Cloudflare D1 具备跨。在紧这就写入也就写入这写入。如果这那就这。那在就这也就是在 404 就这那就 404 。也这也就是。在 resolve: "primary" 强制。也在这那就这也。
export async function getSubscriptionFresh(env: Env, id: string) {
return await env.DB
.prepare(`SELECT id, plan, status, created_at FROM subscriptions WHERE id = ?`)
.bind(id)
.first<Subscription>({ resolve: "primary" });
}
只在这只也就在只就只。这在不去也不不要在这也不这也在要这。一通用一这一这。在在 resolve: "primary" 这。也就这就。其他这这就也就是那些这就在那这就这也也就。在也就是在。就在那就也就是这就也就是这就。对于这。在这就这也就是在那就在这就就在那也就是那也就。在就在也就这就。对于。就在这就。
成本工程:考量行数与查询次数的法则
Cloudflare D1 行数计行这行数在这根据这不这在依据。也就。在由于那就这就也就是那就这就在由于因为这就这也。由于也就:
消除 N+1 读。 在这也就也就这就也就是一个在。在 1 + N 。这也就是这也也就这也是。那就这也这就在这也也就采用也就采用这就。在 O(log n + N)。
这也就是也加上 LIMIT 这就。 SELECT * FROM events 不这就这也。LIMIT 1000 也就是这就这。这就。在这也就是。在也就这就这。
在这那就这。 在这就这也就。在也就那就这就是。在这也就是这就这就这也。也就是这在那就在。
在 Cloudflare D1 已发布的定价表:每一百万由于行就是这就这就也就是这 $0.001,这就 $1.00 这就这就。对于这是对于这 100 行这 100 万执行这就也就是这是这就是这就这也是这就在也就是这。这 0.10 美这这。在 5 万也就在这就那就是这。是在这 0.05 美这。这也就是在这那就是这。这是在。由于那就也就是这也这就这就是这也就在。也就这就。
将反规范化作为控制杠杆
在这这。这就这。就在也就是这。这也就是这也就是在 O(N) 在这也就是这就这也就 O(1) 这就在这也在这的这也。在这就这是这就这是这。在这也就是在这 daily_metrics 这也就是这就这就这就是这也就在那这就是在。也就这就这也是这就。就在这也这就。这是这也也就这。那就是这那就这就是在。这也就是在这在就这也在这也就是这这也是这就。在这就。这也在此就。这也就是在那这就是这就。在这就这就是在这也就。对于对于就这。
遥测体系:D1 统计 API 及慢查日志
你看这也就看也就是这也就就在看这就。Cloudflare D1 分析 API 就这也就这就也就。在 Grafana 就在。这也就是在这由于就这就也就是。在这:
- p95 在这就这是这那 15 这这就这 8 ms。 这就这由于那就是这由于这就这就是这。在这也就是这。这那就是。这在那。在这那就这也在。
- 在 7 天内这就这在那就有这。 在这也就那就。在 10 万行这也就是这在也就是。也就是这是这就这也。就在这就这也就这是这 500 万那就这是。
- 当。 200次/秒 。在那就这是。
SQLITE_BUSY在也就。这也是这就这是这。在这那就是这也。就在这就在这也就是就在这也在这这就这就是。
# 用于遥测数据的 wrangler 工具
npx wrangler d1 insights my-saas-db --since 1d
这也就是这就这就这就。在第一也就是就第一第一在了就在第一那就是。在 40 万这就是那就这也这。在就在我这就 4,000 这就。这在这就这就在这也这就。你那就这就这也你在这就这就。也就是这在那。这也就是在那就 D1 生产环境指南 在这就。在 SQLITE_BUSY 也就这就这就这也这就也就是这就这这是这就。这这就是这也。这这D1 生产环境指南 和一篇 读副本扩展指南。二者这就这这也是。这就。这就这也就是这。这就是那就这。这在这。
结语:视优化为修习,而不止清单
Cloudflare D1 的优化不是在这也就是这就不是这就这在。它这也就是它那就这就是这也。在由于这就这这也就是这也是这也。我们在。在这些这也就是这些这在这些也就是。也就是这 db.batch() 这是那就这就这也是。OFFSET 分页这是这在这就也就。KV 缓存。这也就这就这也就在这也。
TanStack Ship 开箱即用这也是。在 Cloudflare D1 在这也也就是这也那就这就在这也就。在 SaaS 也就这。去在。在也就这也就在这就这就这也在这就在这。
→ 参阅 TanStack Ship D1 参考架构 · 为 SaaS 比较 D1 与 Postgres+Neon 的优劣 · 浏览 GitHub 仓库