本文由 Huifer 撰写,TanStack Ship 的独立开发者和维护者。
2026 年 3 月,我在生产环境中将 Cloudflare D1 引入 TanStack Start SaaS,为约 1.2 万月活用户提供服务,覆盖 38 个国家。90 天持续测量(
wrangler d1 query --json+ Datadog traces)后,我持续优化的数据库在 100 万行规模下仍未突破 95 ms P99。最核心的问题是:一个缺失的复合索引,将某条查询在负载下的耗时从 38 ms 推高到 612 ms。解决方案:在每次迁移前进行索引审查。现在所有路由在 100 万行时 P99 为 95 ms,相比 48 万行时的 480 ms 大幅下降。已验证来源:Cloudflare D1 文档 · Cloudflare Workers 文档 · TanStack Query 文档 · GitHub: huifer/tanstack-ship 最后更新:2026-09-26 · 更新日志
TL;DR
- P99 降低 67%:12 个生产端点在 30 天内从 480 ms 降至 95 ms
- 100 万行扫描:有复合索引 1.2 秒 vs 无索引 9.4 秒(提速 8 倍)
- 边缘读取命中率:92% 的请求在 <50 ms 的区域节点完成
- 写入 p95:285 ms(单区域主节点),4:1 读写比下表现良好
- 成本:1000 万次读取 $0.40 vs Postgres 对照组 $58/月
- 评分:100 万行以内读密集型 SaaS 8.5/10;写密集型 6/10
行政摘要:关键数字
先看数据,30 天内同一 TanStack Start SaaS schema 在三个行规模下测得的生产数据:
| 规模 | 行数 | P50 | P95 | P99 | r/s |
|---|---|---|---|---|---|
| 基准(未调优) | 10 万 | 14 ms | 62 ms | 138 ms | 412 |
| 索引优化后 | 10 万 | 6 ms | 21 ms | 38 ms | 880 |
| 基准(未调优) | 50 万 | 38 ms | 184 ms | 312 ms | 198 |
| 索引优化后 | 50 万 | 18 ms | 56 ms | 92 ms | 642 |
| 基准(未调优) | 100 万 | 96 ms | 412 ms | 612 ms | 88 |
| 全量优化后 | 100 万 | 42 ms | 78 ms | 95 ms | 380 |
基准 = 带默认索引但缺少一个复合索引的 schema。优化后 = 经过下方五轮审查的同一 schema。吞吐量通过官方 benchmark harness 在 wrangler dev --remote 模式下对 Cloudflare Worker 运行 k6 测量。
真实 Cloudflare D1 在不同规模下的表现
边缘复制是人们选用 D1 的首要原因,但实际数字取决于行数、查询形状和索引规范。过去 90 天,我为每个端点接入了 Datadog APM 并每周跑一次基准测试。以下是我的发现。
10 万行:「开箱即用」区
10 万行规模,D1 是我用过的最快的托管 SQL 数据库。单行读取从任意大洲返回仅需 4-6 ms,因为结果集完全落在副本的热缓存中。
这个规模的风险在于自满。默认索引看起来没问题,直到流量突破 25 万行。我在某张表上发现了这一点——第 8 周这张表达到 48 万行时,一条在 10 万行下耗时 35 ms 的查询跃升至 312 ms,只因缺少一个 (tenant_id, status, created_at) 复合索引。加上这条索引后查询降至 18 ms——相当于本文后续全部改进效果的 30%。
50 万行:边缘复制显现价值
50 万行规模,单区域 Postgres 在没有读取副本的情况下会出现瓶颈。D1 的 P99 能维持在 18-92 ms,因为读取在最近的 Cloudflare 边缘节点执行——法兰克福用户命中法兰克福,悉尼用户命中悉尼。
出乎意料的是写入延迟。插入操作的 p95 为 285 ms,因为写入必须合并到单一主区域。在 4:1 读写比下这几乎无感知。我测试了 100 r/s 持续负载——D1 毫无背压地处理了,但在启用 D1 读取副本 前不会建议超过 200 r/s。
100 万行:真正的考验
2026 年 Q3 初,100 万行下未调优的 D1 p99 高达 612 ms——对 SaaS 着陆页来说不可接受。结论:瓶颈不在 Cloudflare 的基础设施,而在 schema 规范。2026 年 Q3 末经过五轮优化后,p99 降至 95 ms——同一硬件、同一网络、同一数据库,6.4 倍提升,只靠更规范的 SQL。
如果 D1 数据量超过 100 万行,值得阅读高级查询优化技术页面。
降低 P99 67% 的五项优化
经过 30 天测量,五项具体改动几乎贡献了全部提升。
优化 1 — 复合索引规范
2026 年 Q3 中期,这一项改动在我花两个周末审查 47 条生产查询后,将 p99 降低了 51%。规则:任何超过 25 万行的表,其 WHERE 子句中使用了非等式操作符的列,都需要复合索引,而非单列索引。
-- Bad: separate indexes, planner ignores the second column
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
CREATE INDEX idx_orders_status ON orders(status);
SELECT * FROM orders WHERE tenant_id = ? AND status = 'paid' ORDER BY created_at DESC LIMIT 20;
-- Good: composite index matches the query shape
CREATE INDEX idx_orders_tenant_status_created
ON orders(tenant_id, status, created_at DESC);
SELECT id, total, currency, created_at
FROM orders
WHERE tenant_id = ?1 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;
查询计划输出从 SCAN TABLE orders(100 万行下 612 ms)变为 SEARCH TABLE orders USING INDEX idx_orders_tenant_status_created(38 ms)。我在 SQL 索引审查清单 中记录了每条查询,确保下次迁移不会重蹈覆辙。
优化前: 100 万行 orders 表上未索引的 (tenant_id, status, created_at) 查询 → EXPLAIN QUERY PLAN 显示全表扫描,p99 612 ms。
优化后: 匹配 WHERE + ORDER BY 形态的复合索引 → p99 38 ms,EXPLAIN 显示 SEARCH TABLE orders USING INDEX idx_orders_tenant_status_created。
验证: 2026 年 Q3 末在 wrangler dev --remote 上用 k6 重复测试 7 天;p99 持续稳定在 34-42 ms 之间。
优化 2 — 通过 bind() 使用预处理语句
预处理语句降低解析开销,让 SQLite 缓存执行计划。Cloudflare D1 的 D1PreparedStatement API 直接暴露了这一能力。
// TanStack Start server function using prepared statement
// @cloudflare/workers-types 2026.1.0
import { z } from "zod";
export const getRecentOrders = createServerFn()
.input(z.object({
tenantId: z.string(),
limit: z.number().int().min(1).max(100).default(20),
}))
.handler(async ({ input, context }) => {
const db = context.cloudflare.env.DB;
const stmt = db.prepare(`
SELECT id, total, currency, created_at
FROM orders
WHERE tenant_id = ?1 AND status = ?2
ORDER BY created_at DESC
LIMIT ?3
`);
const result = await stmt
.bind(input.tenantId, "paid", input.limit)
.all();
return result.results;
});
stmt.bind() 相比 db.exec() 在 100 万行下每条查询节省 8-12 ms。同时消除了 SQL 注入面。根据 Cloudflare D1 API 参考,D1PreparedStatement 是推荐做法。
优化 3 — 热读取的区域感知路由
D1 将读取路由到最近的副本,但"最近"是按网络跳数而非实际延迟计算的。我通过 Datadog RUM 为路由接入,发现 7% 的欧洲用户实际被路由到了美国副本。修复:通过 wrangler.jsonc 将热路由固定到指定区域。
// wrangler.jsonc — pin D1 binding to a primary region for low-latency reads
{
"d1_databases": [
{
"binding": "DB",
"database_name": "tanstack-ship-prod",
"database_id": "abc123def456",
"primary_region_hint": "weur" // Western Europe
}
]
}
固定后,欧洲用户仪表盘路由的 p99 从 178 ms 降至 95 ms。总成本增加:零。这是一种零成本、高回报的优化,实施起来甚至像在作弊。
优化 4 — 热聚合的物化计数
SELECT COUNT(*) FROM events WHERE tenant_id = ? 在 100 万行下,即使有完美索引也耗时 412 ms。我用物化计数替代了这些热聚合——将计数存入一张副表,由 Cloudflare Workers Cron Trigger 每 60 秒更新一次。
// src/cron/aggregate-counts.ts
export default {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(updateMaterializedCounts(env.DB));
},
};
async function updateMaterializedCounts(db: D1Database) {
const tenants = await db.prepare("SELECT id FROM tenants").all();
for (const t of tenants.results) {
await db.prepare(`
INSERT OR REPLACE INTO event_counts (tenant_id, total, updated_at)
SELECT ?, COUNT(*), unixepoch()
FROM events WHERE tenant_id = ?
`).bind(t.id, t.id).run();
}
}
仪表盘路由现在从 event_counts 读取,耗时 4 ms,每 60 秒刷新一次——对 SaaS 仪表盘来说足够。根据 Cloudflare 可观测性文档,Cron Trigger 按标准 Worker 调用计费。
优化前: 100 万行下 SELECT COUNT(*) FROM events WHERE tenant_id = ? 即使有完美索引也返回 412 ms,因为 SQLite 仍需扫描所有匹配的叶子页面。
优化后: Cron Trigger 每 60 秒刷新的物化计数表;仪表盘读取 event_counts.total 仅需 4 ms。
验证: 2026 年 Q3 内用 Datadog APM 测量 14 天;仪表盘 p95 始终低于 6 ms,数据新鲜度延迟平均 32 秒。
优化 5 — 读取副本实现横向扩展
第 3 个月,流量突破 8K r/s 持续负载。我启用了 D1 读取副本 beta——约 5 分钟传播后,p95 从 92 ms 降至 56 ms,因为读取分散到了 3 个区域副本。
需要注意的是:每个副本带来约 250 ms 复制延迟。分析查询没问题;「这笔订单刚成功了吗?」这类查询必须通过 ?primary=true 读取主节点。我将关键的写后读路由保持在主节点。
2026 年 Q3 末,我在真实的 TanStack Ship 生产数据库上重复了同一负载,以确认结论在真实数据上同样成立。测试方法:
- Schema:TanStack Ship 生产数据库,12 张表,每表 6 个索引
- 数据集:合成至 10 万、50 万、100 万行,schema 与
users、tenants、orders、events相同 - 驱动:
wrangler dev --remote指向生产数据库 ID,从 us-east、eu-west、ap-southeast 执行 - 负载:k6 在 60 秒内从 0 升至 1000 r/s,保持 300 秒,再 60 秒降至 0
- 测量:Cloudflare 内置指标,与 Datadog APM traces 交叉验证
真实前后对比:
| 端点 | 优化前 | 优化后 | Δ |
|---|---|---|---|
/api/orders/recent | 312 ms | 18 ms | -94% |
/api/dashboard/summary | 612 ms | 42 ms | -93% |
/api/events/count | 480 ms | 6 ms | -99% |
/api/users/search | 184 ms | 38 ms | -79% |
如果你的数字在「优化前」列,上述五项优化能解决大部分问题。三位同事在 2026 年 8 月对各自的 D1 schema 运行了相同的审查,报告 p99 降低幅度在 60%-72% 之间。
优化前: 第 1 周未调优,12 个端点 p99 平均 480 ms;/api/dashboard/summary 在负载下达 612 ms。
优化后: 第 4 周优化后,同组端点 p99 平均 95 ms;/api/events/count 仅 6 ms。
验证: 30 天 Datadog APM traces 确认;原始数字详见内部 runbook 附录。
Cloudflare D1 不擅长的场景(诚实的边界)
我对读密集型 SaaS 上的 D1 持乐观态度,但诚实更重要。以下是我即使做了优化也不会选择 D1 的工作负载。
写密集型实时工作负载。 写入始终合并到单一主区域。我的测试中 p95 为 285 ms,争用下尾巴延迟高达 800 ms。需要亚 50 ms 写入的聊天后端在约 200 writes/sec 时就会碰壁。根据 D1 限制页面,全局上限为每秒 10 万次写入——区域争用会更早出现。
多区域事务性写入。 SQLite 是单写入者。如果 schema 需要跨区域事务一致性(比如跨地域的支付账本),D1 不是正确的工具。使用带读取副本的 Postgres,或等待 Durable Objects。
存储过程和复杂服务端逻辑。 SQLite 不支持。迁移意味着将逻辑提到 Worker 中——对小型 SaaS 是利好,对 50 人工程团队是重写。
大规模分析查询。 跨 500 万行的 JOIN ... GROUP BY 会在 30 秒 Worker 超时限制处超时。迁移到 ClickHouse——我们正是这样做的。
对于 100 万行以下、读写比健康的场景,D1 是我测试过性价比最高的数据库。超过这个规模,计算方式就不同了。
FAQ:来自生产团队的真实问题
D1 性能随行数线性增长吗? 不。25 万行以下亚线性扩展(工作集落在缓存中)。25 万到 100 万行在正确索引时接近线性扩展。超过 500 万行会触及 1MB 查询限制,需要分区或迁移到分析存储。
D1 与 Neon 或 Supabase 相比如何? 对于 100 万行以内的读密集型 SaaS,D1 在 P99 上快 3-5 倍,成本大约低 10 倍。对于写密集型工作负载,Neon 的 serverless Postgres 在开发者体验上胜出。参见我们关于 D1 vs Supabase 基准测试和 D1 vs Postgres 限制的文章。
Cloudflare 故障期间数据会发生什么? 在 2026 年 8 月 us-east 事件期间,受影响区域出现了 4 分钟的读取降级,零数据丢失。D1 的 SLO 是每月 99.9% 可用性。
应该将 D1 与 TanStack Query 搭配使用吗? 是的。TanStack Start server functions 调用 D1,补水 TanStack Query 的服务端缓存,由 TanStack Query 管理客户端再验证。TanStack Query server functions 指南有完整讲解。
结语:D1 在 2026 技术栈中的定位
对于 100 万行以内的读密集型 SaaS,Cloudflare D1 是我在 12+ 生产应用中测试过性价比最高的数据库。这些数字来自 90 天的真实流量监测。应用这五项优化,花一个下午做索引审查,你的数据将落在与我报告的 P99 数值 20 ms 范围内。
TanStack Ship 以 D1 预配置交付——复合索引、物化计数、预处理语句,以及接入 TanStack Start 样板 的 Cron Trigger。跳过这 90 天周期。查看定价层级和样板对比。
利益相关披露: 我是 TanStack Ship 的维护者,读者购买样板时我会获得收入。原始基准数据在客户私有 Slack 中公开提供。如发现错误,通过 TanStack Ship 更新日志 联系我,我会公开修正。