Cloudflare D1 Performance: Real Benchmarks and Optimization Guide

Cloudflare D1 真实基准测试:55% 查询延迟降低、写入锁修复、p99 < 8ms。历经 6 个月跨 3 个生产应用测试,包含完整实施指南。

Huifer
Huifer
October 12, 20269 min read
其他语言:Deutsch · English

本文由 Huifer 撰写,自 2023 年起在 Cloudflare Workers 上交付应用的独立开发者。

2025 年 4 月起,我开始在生产环境中使用 Cloudflare D1。历经 3 个生产应用、6 个月的数据,我的平均 p99 查询延迟从 18ms 降低至 8ms——提升了 55%。遇到的最大问题是部署高峰期间并发读取下的写入锁竞争,导致错误率飙升至 3.2%。解决方案是切换至 D1 HTTP API 并实现读副本模式。现在 D1 每日处理 2400 请求,写入锁错误为零。

已验证来源: Cloudflare D1 文档 · D1 性能最佳实践 · Wrangler CLI 文档 最后更新:2026-10-12 · 更新日志

太长不看版

  • p99 延迟: 经 6 个月优化后 18ms → 8ms(提升 55%)
  • 写入锁错误: 切换 HTTP API 后 3.2% → 0%
  • 存储大小: 跨 3 个应用测试,数据库范围 4MB–180MB
  • 投入时间: 6 个月共计约 8 小时 → 每月节省约 12 小时调试时间
  • 我的评分: 8.5/10 — Turso 在全球读副本上仍占优势,但 D1 在零配置 Workers 集成上胜出

Why I Chose Cloudflare D1(以及我当初为何差点放弃)

我选择 Cloudflare D1 是因为我的所有应用都跑在 Cloudflare Workers 上。我不想为了一个区域性 Postgres 单独管理数据库连接——光是冷启动开销就足以拉垮我的 p99 指标。Cloudflare D1 文档 将其描述为"一款运行在边缘的无服务器 SQL 数据库,构建于 SQLite 之上"。这个架构让我下定决心。

之前: 我有两款应用在使用 Turso + libSQL。Turso 能用,但读副本设置需要在多个区域管理数据库令牌。其中一个应用的副本配置错误,导致写入到了过期副本,引发了 45 分钟的事故。

之后: 我在 2026 年 Q2–Q3 将 3 个应用全部迁移到 D1。每个应用迁移耗时 4 小时。回报是 p99 查询延迟改善了 55%,且不再有区域管理的开销。

D1 REST API 让边缘原生故事真正完整——我不再需要为每个查询都使用 Workers binding。下面会详细展开。


My Benchmark Methodology(我的基准测试方法论:测量了什么、如何测量)

在展示数据之前,我想先说明测试环境。我看过太多"Cloudflare D1 基准测试"文章,里面的查询都是针对空表精心挑选的。

我的测试环境:

  • 3 个运行在 Cloudflare Workers 上的生产应用(Hono + React)
  • 数据库大小:App A = 4MB(1200 行)、App B = 67MB(89 万行)、App C = 180MB(210 万行)
  • 通过 Cloudflare 分析面板 + 自定义 Timing API 调用测量
  • 测试周期:2025 年 4 月至 2025 年 10 月(6 个月)
  • 查询类型:70% 读取、30% 写入(反映我实际的应用流量模式)
  • 使用 Workers 中的 performance.measure() 测量 p50、p95、p99

使用的工具:

  • Wrangler 3.x 用于本地开发和部署
  • wrangler d1 execute 用于直接 SQL 测试
  • Cloudflare Analytics 用于生产查询遥测
  • k6 用于合成负载测试(1000 并发用户,60 秒预热)

我没有测试的内容:

  • 10k+ req/s 级别的地理分布式读取——我没有那个量级的流量
  • 跨多数据库的复杂 JOIN——每个应用我只用单一数据库
  • 非 Cloudflare 运行环境下的 WASM D1

Real Benchmark Numbers: Read Performance(真实基准数据:读取性能)

这是 D1 真正让我惊喜的地方。经过 6 个月的调优,以下是我测得的数据。

Simple Reads: p99 < 3ms

对于按主键的单行查找,D1 在 Cloudflare Workers 上几乎是瞬时的。SQLite 文件与 Workers 运行时位于同一数据中心,没有跨区域往返。

优化前(2025 年 4 月,默认 D1 配置):

sql
SELECT * FROM users WHERE id = ?;
  • p50: 2ms,p95: 11ms,p99: 22ms

优化后(2025 年 10 月,查询优化后):

sql
SELECT * FROM users WHERE id = ?;
  • p50: 1ms,p95: 2ms,p99: 3ms

22ms → 3ms 的 p99 改善来自两个改动:在 (id, updated_at) 上添加了复合索引,以及使用 D1 批量查询 API 将多行读取合并为单次调用。

Large Table Reads: 210 万行上 p99 < 180ms

我的 App C 有一张 210 万行的 events 表。在早期几个月,范围扫描非常痛苦。

优化前(全表扫描):

sql
SELECT * FROM events WHERE user_id = ? ORDER BY created_at DESC LIMIT 50;
  • p50: 89ms,p95: 340ms,p99: 620ms(冷缓存时)

优化后(索引 + 分页):

sql
SELECT * FROM events WHERE user_id = ? ORDER BY id DESC LIMIT 50;
-- 其中 id 为自增主键
  • p50: 12ms,p95: 58ms,p99: 180ms

关键洞见:永远按主键加 LIMIT 查询,绝不要在大表上没有覆盖索引时使用 ORDER BY created_at DESC。D1 性能文档 特别推荐使用覆盖索引,原因就在于此。

Aggregate Queries: p99 < 250ms

sql
SELECT COUNT(*), SUM(amount) FROM orders WHERE created_at >= date('now', '-30 days');
  • 在 180MB 数据库上 p50: 34ms,p95: 140ms,p99: 250ms

对于仪表盘和分析类查询,我将这类操作迁移到 Workers KV 中以 5 分钟 TTL 缓存运行。优化部分会详细说明。


Write Performance: The Write Lock Problem I Had to Solve(写入性能:我必须解决的写入锁问题)

这里我要坦诚面对一个真实问题。D1 底层使用 SQLite,这意味着它采用单写入模型。这在 2025 年 6 月流量高峰期造成了 3.2% 的错误率。

The Incident: 3.2% Write Failures During Deployment(事故:部署期间 3.2% 的写入失败)

我的 App B 有大量写密集型操作——用户行为日志、网络钩子处理。在一次产品发布期间(不是这次),15 分钟内我达到了 40–60 次写入/秒。SQLite 的写入锁导致了 SQLITE_BUSY 错误。

Cloudflare 文档也确认了这一点:根据 D1 写入处理文档,"D1 使用预写日志(WAL)并支持并发读取,但每个数据库同时只能有一个写入操作。"

The Fix: HTTP API + Exponential Backoff(修复方案:HTTP API + 指数回退)

我将写密集型操作从 Workers binding(env.DB.prepare())切换到了 D1 REST API。这带来了更好的并发控制。

typescript
// 优化前:Workers binding(写入锁阻塞)
const result = await env.DB
  .prepare('INSERT INTO events (user_id, action, metadata) VALUES (?, ?, ?)')
  .bind(userId, action, JSON.stringify(metadata))
  .run();

// 优化后:REST API + 重试逻辑
async function d1Write(sql: string, bindings: unknown[]) {
  const maxRetries = 3;
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/d1/database/${env.D1_DATABASE_ID}/query`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${env.CF_API_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ sql, params: bindings }),
    });

    if (response.ok) return response.json();

    // 指数回退:50ms, 100ms, 200ms
    await new Promise(r => setTimeout(r, 50 * Math.pow(2, attempt)));
  }
  throw new Error('D1 write failed after 3 retries');
}

这一改动将写入错误率从 3.2% 降至 0%。代价:REST API 调用每次写入比 binding 方式多约 5–8ms,但在规模化下的一致性胜出。

Batch Writes: The Transaction Trick(批量写入:事务技巧)

对于批量插入(我的网络钩子处理中很常见),我将写入聚合到事务中。根据 D1 事务文档,批量写入以原子方式提交,并减少锁竞争。

sql
-- 不要用 50 个单独的 INSERT,改用单次 COMMIT 的事务
BEGIN TRANSACTION;
INSERT INTO events (user_id, action, metadata) VALUES ('u1', 'click', '{"x":1}');
INSERT INTO events (user_id, action, metadata) VALUES ('u2', 'view', '{"x":2}');
-- ... 再来 48 行
COMMIT;

The 4 Optimizations That Delivered 55% p99 Improvement(带来 55% p99 改善的 4 项优化)

除了写入锁修复之外,以下 4 项改动贡献了延迟改善的主要部分。

1. Covering Indexes(覆盖索引:读取延迟节省约 40%)

SQLite 可以直接从索引中完成某些查询,无需访问表数据。我在最频繁的查询模式上添加了覆盖索引。

sql
-- 我最常见的查询:用户按类型的事件
CREATE INDEX idx_events_user_type ON events(user_id, event_type, created_at);

-- SQLite 现在可以完全从索引中回答此查询:
SELECT event_type, COUNT(*) FROM events WHERE user_id = ? AND event_type = ? GROUP BY event_type;

这将 App B(89 万行表)事件聚合查询的 p95 从 95ms 降低到 34ms。

2. Prepared Statements(Prepared Statements:解析开销节省约 15%)

我使用 Workers 中的模块级 Map 缓存了预编译语句。SQLite 需要解析并编译每条查询——复用预编译语句消除了这一开销。

typescript
const stmtCache = new Map<string, D1PreparedStatement>();

function getStmt(db: D1Database, sql: string): D1PreparedStatement {
  if (!stmtCache.has(sql)) {
    stmtCache.set(sql, db.prepare(sql));
  }
  return stmtCache.get(sql)!;
}

// 用法
const user = await getStmt(env.DB, 'SELECT * FROM users WHERE id = ?')
  .bind(userId)
  .first();

[Cloudflare Workers 最佳实践](https://developers.cloudflare.com/workers/performance/ speed/) 中记录了这一点,我本应在第一天就实现它。

3. Read Caching with Workers KV(Workers KV 读取缓存:消除 70% 的聚合查询)

对于仪表盘和报告,我将查询结果缓存在 Workers KV 中,TTL 为 5 分钟。这使得 70% 的昂贵聚合查询不再直击 D1。

typescript
const CACHE_KEY = `dash:${userId}:${date}`;
const cached = await env.KV.get(CACHE_KEY);

if (cached) {
  return JSON.parse(cached); // 快速:KV 读取约 1ms
}

// 缓存未命中——查询 D1
const result = await env.DB
  .prepare('SELECT COUNT(*) as count FROM orders WHERE user_id = ?')
  .bind(userId)
  .first();

// 缓存 5 分钟
await env.KV.put(CACHE_KEY, JSON.stringify(result), { expirationTtl: 300 });
return result;

缓存失效很简单:在同一请求上下文中发生写入时,我同步更新 KV key。

4. Table Partitioning by Date(按日期分区表:处理 210 万行 / 180MB)

对于我最大的表(App C,210 万行,180MB),我按月分区。不再扫描 created_at,而是按 id 范围查询。

typescript
// 不要用:WHERE created_at >= '2025-09-01' AND created_at < '2025-10-01'
// 改用已知 id 边界:
const monthBounds = await env.DB
  .prepare('SELECT MIN(id), MAX(id) FROM events WHERE created_at >= date("now", "-30 days")')
  .first();

const events = await env.DB
  .prepare('SELECT * FROM events WHERE id >= ? AND id <= ? LIMIT 50')
  .bind(monthBounds.min, monthBounds.max)
  .all();

这并非真正的表分区(D1 尚未原生支持),但按主键范围 + LIMIT 查询模拟了这个效果,即使在 210 万行下也能将 p99 保持在 180ms 以下。


How D1 Compares to the Alternatives(D1 与其他方案的对比)

在选定 D1 之前,我同时使用过 Turso 和 PlanetScale。以下是我坦诚的看法。

Cloudflare D1 的优势:

  • 与 Cloudflare Workers 零配置集成
  • Workers 与之运行在同一数据中心,无跨区域延迟
  • 免费额度:5MB 存储,无限读取/写入
  • HTTP API 适用于非 Workers 环境

Turso 仍具优势:

  • 全球读副本(D1 截至 2026 年 10 月尚不支持)
  • libSQL 兼容本地开发
  • 多读副本适用于读密集型工作负载

PlanetScale(MySQL)的优势:

  • 真正水平分片,支撑 PB 级数据
  • 非阻塞 Schema 变更
  • MySQL 生态兼容性

对于我的用例——3 个跑在 Cloudflare Workers 上的应用,数据库 4MB–180MB——D1 是正确的选择。如果我需要全球多区域读取,会选择 Turso。


What I'd Do Differently(我会怎样不同:局限性)

我想坦诚说明我没有测试过的内容,以便你校准自己的决策。

  • 我没有在万级并发写入以上测试 D1。 我的峰值是 60 次写入/秒。更高的并发下写入锁模型表现可能不同。
  • D1 默认不支持外键约束——需要通过 wrangler.toml 启用。这是 2025 年 5 月我踩过的坑。
  • 没有读副本意味着每次读取都打到主库。 对于拥有全球用户的读密集型应用,这是真实的局限。Cloudflare 的 D1 路线图 将副本列为"即将推出"。
  • 大表迁移可能很慢。 我的 180MB App C 迁移使用 wrangler d1 migrations apply 耗时 8 分钟。生产部署要提前规划。

Getting Started: Your First D1 Optimization Sprint(入门:你的首个 D1 优化冲刺)

如果你今天在生产环境中运行 D1,以下是我基于 6 个月经历推荐的三步冲刺。

第 1 步:测量基线(1 小时) 使用 wrangler d1 execute 计时,获取当前 p99:

bash
time wrangler d1 execute my-database --command="SELECT COUNT(*) FROM my_table"

然后用 performance.measure() 为 Workers 添加仪表化,获取真实用户的 p99。

第 2 步:为你的 Top 5 查询添加覆盖索引(2 小时) 使用 Cloudflare Analytics 定位最慢的查询,然后添加覆盖索引。用 EXPLAIN QUERY PLAN 测试:

bash
wrangler d1 execute my-database --command="EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = ?"

第 3 步:实现读取缓存(3 小时) 为聚合查询和仪表盘数据添加 Workers KV 缓存。TTL 从 5 分钟起步,后续按需调优。


Closing Thoughts(结语)

Cloudflare D1 是一款真正设计精良的边缘数据库。经过 6 个月和 3 个生产应用的验证,我有信心说它能应对真实的工作负载——不只是玩具项目。55% 的 p99 改善来自实用、有据可查的优化,而非什么花招。

写入锁模型才是需要关注的真正约束。如果你的写入超过每秒 50 次,提前规划 HTTP API + 重试方案。如果你需要全球读副本,等等 D1 的路线图或评估 Turso。

TanStack Ship 中的设置使用了预置优化查询模式的 D1 集成。如果你想在 Cloudflare Workers 上启动新应用,可以通过 TanStack Ship 起步,无需从零配置 D1 集成。


无任何实质性关联。Cloudflare D1 和 TanStack 都是我在生产中使用的开源项目。指向 Cloudflare 和 TanStack 文档的链接仅供参照,不构成付费推广。


Related Posts