Cloudflare D1 + Pages Functions 全栈实战:在边缘跑一个带数据库的应用

Cloudflare D1 是 Cloudflare 推出的 Serverless SQLite 边缘数据库,与 Pages Functions 原生绑定,无需服务器即可构建带数据库的全栈应用。本文从 wrangler d1 建库、迁移管理、SQL 实战到完整留言板 CRUD 示例,详解 D1 的定位、性能特征与生产实践。

Cloudflare D1 是 Cloudflare 推出的 Serverless SQLite 数据库服务:它运行在 Cloudflare 全球网络上,与 Workers 和 Pages Functions 原生集成,开发者通过简单的绑定即可在边缘函数中执行 SQL,写入发往主库、读取就近分发,且无需管理任何数据库服务器。

本文面向想在 Cloudflare 上构建全栈应用的开发者,覆盖 D1 的定位选型、快速上手、SQL 实战、Pages Functions 集成、性能优化与生产运维。对 Pages 平台本身不熟悉的读者,建议先阅读 Cloudflare Pages 完全指南

D1 的定位:适合什么,不适合什么

D1 本质上是托管的 SQLite,这决定了它的能力边界。

适合的场景

  • SaaS 应用的配置与元数据:租户信息、订阅状态、功能开关
  • 用户系统:账号、会话、个人资料(配合 Better Auth 等认证方案)
  • 轻量业务库:博客文章、留言、短链接映射、表单提交
  • 读多写少的场景:内容型产品、仪表盘、内部工具
  • 按租户分库的多租户架构:D1 支持创建上万个数据库,每个租户一个库的成本极低

不适合的场景

  • 高频写入:大量并发写会成为瓶颈(SQLite 单主库写入模型)
  • 海量数据:单库建议控制在 GB 级,不适合 TB 级数据仓库
  • 复杂分析查询:OLAP 请用 ClickHouse 或 D1 + 数据导出到分析系统

快速上手:从建库到本地开发

创建数据库

# 创建数据库
npx wrangler d1 create my-guestbook

# 输出示例:
# ✅ Successfully created DB 'my-guestbook' in region APAC
# [[d1_databases]]
# binding = "DB"
# database_name = "my-guestbook"
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

绑定到 wrangler.toml

把输出中的绑定配置写入项目根目录的 wrangler.toml

name = "my-guestbook-app"
compatibility_date = "2025-10-01"
pages_build_output_dir = "./public"

[[d1_databases]]
binding = "DB"
database_name = "my-guestbook"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

binding = "DB" 决定了你在代码中通过 context.env.DB 访问数据库,这个名字可以自定义(比如 DATABASE)。

本地开发:–local vs –remote

# 本地模式:读写本地 .wrangler/state 中的 SQLite 文件,速度快、离线可用
npx wrangler pages dev public --local

# 远程模式:直接操作云端真实数据库(小心污染线上数据!)
npx wrangler pages dev public --remote

日常开发用 --local;调试点对点的线上问题时才用 --remote

Migration 管理

D1 使用文件化的迁移工作流。在项目下创建 migrations/ 目录:

migrations/
├── 0001_create_messages.sql
└── 0002_add_email_column.sql

第一个迁移文件 0001_create_messages.sql

CREATE TABLE IF NOT EXISTS messages (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  content TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

应用迁移:

# 应用到本地开发库
npx wrangler d1 migrations apply my-guestbook --local

# 应用到远程生产库
npx wrangler d1 migrations apply my-guestbook --remote

# 查看迁移状态
npx wrangler d1 migrations list my-guestbook

SQL 实战:Prepared Statement 与批量操作

Pages Functions 中的 D1 客户端 API 围绕 prepared statement 设计。

参数绑定:永远不要拼接 SQL

// ❌ 错误:SQL 注入风险
const sql = `SELECT * FROM messages WHERE name = '${name}'`;

// ✅ 正确:参数绑定
const stmt = context.env.DB.prepare(
  'SELECT * FROM messages WHERE name = ?'
).bind(name);
const { results } = await stmt.all();

常用方法一览:

  • .all():返回所有行({ results, meta }
  • .first():返回第一行或 null
  • .run():执行写入语句,返回 { meta: { changes, last_row_id } }
  • .raw():返回原始数组格式的行

批量操作 batch()

插入多条数据时,用 batch() 一次往返完成:

const stmts = items.map((item) =>
  context.env.DB.prepare(
    'INSERT INTO messages (name, content) VALUES (?, ?)'
  ).bind(item.name, item.content)
);
await context.env.DB.batch(stmts);

batch() 保证这些语句在原子批内执行——这正是 D1 对「事务」的替代方案:D1 不支持跨多次往返的交互式事务,但 batch() 内的语句具备事务性。如果你的流程必须先读后写(例如扣库存),把读校验和写入放在同一批中,或在应用层做幂等设计。

Pages Functions 集成:目录结构与绑定访问

Pages Functions 本质上是 Workers 的轻量封装(Workers 完整能力见 Cloudflare Workers 入门实战)。

Pages Functions 通过约定式目录结构工作:

my-app/
├── public/            # 静态资源
│   └── index.html
├── functions/         # 后端函数
│   └── api/
│       └── messages.js
└── wrangler.toml

URL 路由直接映射文件路径:functions/api/messages.js 对应 /api/messages。函数文件使用现代格式导出按 HTTP 方法命名的处理器:

export async function onRequestGet(context) { /* GET 逻辑 */ }
export async function onRequestPost(context) { /* POST 逻辑 */ }

通过 context.env.DB 访问绑定的 D1 数据库。

完整示例:留言板 CRUD

后端 API:functions/api/messages.js

// GET /api/messages — 拉取最新 50 条留言
export async function onRequestGet(context) {
  try {
    const { results } = await context.env.DB.prepare(
      'SELECT id, name, content, created_at FROM messages ORDER BY id DESC LIMIT 50'
    ).all();

    return Response.json({ ok: true, data: results });
  } catch (err) {
    return Response.json({ ok: false, error: err.message }, { status: 500 });
  }
}

// POST /api/messages — 提交新留言
export async function onRequestPost(context) {
  let body;
  try {
    body = await context.request.json();
  } catch {
    return Response.json({ ok: false, error: '无效的 JSON' }, { status: 400 });
  }

  const name = String(body.name ?? '').trim().slice(0, 50);
  const content = String(body.content ?? '').trim().slice(0, 1000);

  if (!name || !content) {
    return Response.json({ ok: false, error: '昵称和留言不能为空' }, { status: 400 });
  }

  const { meta } = await context.env.DB.prepare(
    'INSERT INTO messages (name, content) VALUES (?, ?)'
  ).bind(name, content).run();

  return Response.json({ ok: true, id: meta.last_row_id }, { status: 201 });
}

前端页面:public/index.html 核心片段

<form id="guestbook">
  <input name="name" placeholder="昵称" required maxlength="50" />
  <textarea name="content" placeholder="说点什么..." required></textarea>
  <button type="submit">提交</button>
</form>
<ul id="messages"></ul>

<script>
async function loadMessages() {
  const res = await fetch('/api/messages');
  const { data } = await res.json();
  document.getElementById('messages').innerHTML = data
    .map((m) => `<li><b>${m.name}</b>:${m.content} <small>${m.created_at}</small></li>`)
    .join('');
}

document.getElementById('guestbook').addEventListener('submit', async (e) => {
  e.preventDefault();
  const form = e.target;
  await fetch('/api/messages', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: form.name.value,
      content: form.content.value,
    }),
  });
  form.reset();
  loadMessages();
});

loadMessages();
</script>

端到端流程就此打通:浏览器 fetch → Pages Functions 路由 → D1 prepared statement → SQLite 执行 → JSON 响应。部署只需 npx wrangler pages deploy public

查询性能与最佳实践

索引设计

SQLite 索引语法与常规数据库一致,D1 场景下尤其要为「每页必查」的条件建索引:

CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages (created_at DESC);
-- 多租户场景:复合索引
CREATE INDEX IF NOT EXISTS idx_tenant_status ON orders (tenant_id, status);

避免 N+1

循环中逐条查询是边缘函数的大忌——每次查询都是一次往返。用 INJOIN 合并:

// ❌ N+1:逐条查
for (const id of ids) {
  await DB.prepare('SELECT * FROM items WHERE id = ?').bind(id).first();
}

// ✅ 一次查询
const placeholders = ids.map(() => '?').join(',');
const { results } = await DB.prepare(
  `SELECT * FROM items WHERE id IN (${placeholders})`
).bind(...ids).all();

读写延迟特性

D1 的架构是「单主写入 + 边缘读副本」:

  • 写入:无论函数在哪个边缘节点执行,写请求都要路由到主库区域,延迟取决于用户到主库的距离
  • 读取:开启读副本(Read Replication)后,只读查询可以命中就近副本,全球读取延迟显著下降

因此 D1 应用的设计准则是:把写路径做短(单次写入、批量写入),把读路径交给副本放大

备份与导出

wrangler d1 export

# 导出整库为 SQL 文件
npx wrangler d1 export my-guestbook --remote --output=backup.sql

# 只导出 schema 或数据
npx wrangler d1 export my-guestbook --remote --no-data --output=schema.sql

建议将导出加入 CI 定时任务,保存到 R2 或外部存储。

Time Travel 时间点恢复

D1 内置 Time Travel,可以将数据库恢复到过去任意时间点(免费版保留 7 天,付费版更长):

# 恢复到指定时间点(Unix 时间戳或 ISO 格式)
npx wrangler d1 time-travel restore my-guestbook --timestamp=2026-07-29T02:00:00Z

# 查询可恢复的时间范围
npx wrangler d1 time-travel info my-guestbook

误删数据、错误迁移都可以用 Time Travel 一键回滚,这是 D1 相对自建 SQLite 的核心运维优势。

生态工具:ORM 接入

手写 SQL 适合小项目,业务复杂后建议引入 ORM。

Drizzle ORM(推荐)

Drizzle 对 D1 有一等支持,类型安全、零运行时开销:

npm install drizzle-orm
import { drizzle } from 'drizzle-orm/d1';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { desc } from 'drizzle-orm';

const messages = sqliteTable('messages', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  content: text('content').notNull(),
});

export async function onRequestGet(context) {
  const db = drizzle(context.env.DB);
  const rows = await db.select().from(messages).orderBy(desc(messages.id)).limit(50);
  return Response.json({ ok: true, data: rows });
}

配合 drizzle-kit 可以从 schema 自动生成迁移文件。生产级的 React + Hono + Drizzle + D1 完整架构可参考 React + Hono + Better Auth 全栈技术方案

Prisma

Prisma 通过 driver adapter(@prisma/adapter-d1)支持 D1,适合已有 Prisma 技术栈的团队,但冷启动和包体积代价高于 Drizzle,新项目更推荐 Drizzle。

常见问题(FAQ)

D1 的免费额度是多少?

免费套餐每日包含 500 万次读、10 万次写,存储上限 5 GB。Workers Paid 套餐读 250 亿次/月起步,写入另有配额,超出按量计费。绝大多数中小项目在免费额度内即可运行。

D1 支持事务吗?

不支持跨多次往返的交互式事务(如 BEGIN ... COMMIT 分段执行),但 batch() 批内的多条语句是原子执行的。涉及先读后写的资金类逻辑,需要把校验与写入放进同一批,或在应用层实现幂等与对账。

D1 和 KV 该怎么选?

KV 是全球最终一致的键值存储,读极快但不支持 SQL、无事务、无关系查询;D1 是带强一致读的 SQLite,支持复杂查询。简单配置、Session、缓存选 KV;有结构化数据、需要 WHERE/JOIN/聚合选 D1。很多生产架构两者并用。

D1 能当生产数据库吗?

可以,但要看负载画像。读多写少的 SaaS、内容站、内部工具完全没问题,官方也提供 Time Travel、读副本等生产特性。高频写、跨库复杂事务、海量分析查询则不适合,这类场景应选传统托管数据库或专用 OLAP 系统。

D1 的数据存在哪里?

D1 数据库有一个主区域(创建时可选择就近落点),写入发生在主库;开启读副本后,数据会异步复制到边缘节点供就近读取。创建时通过 --location 参数指定主库位置,满足数据驻留合规需求。

相关阅读

下一篇 →

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「saas」更多文章