KernelRanch
返回文章列表

D1 数据库入门:建表、参数化查询与批量写入

2026-11-10 #Cloudflare

读完这一篇,你能在 Workers 里接一个 D1 数据库:写 schema、用 prepare().bind() 做参数化查询、用 batch() 合并多次写入、读懂每次执行返回的 meta,并且知道怎么处理约束冲突。下面的返回体来自 2026-11-10 本机 wrangler d1 execute --local 与 wrangler dev --local 的真实输出。

账号没有 D1 权限,所以没有在线上创建数据库,也没有部署。第 2 节的建库命令按文档写,其余代码与输出都是本地 workerd 环境跑出来的,meta.served_by 显示为 miniflare.db 就是本地实例的标志。

1. D1 是什么

D1 是建在 SQLite 上的托管数据库,一个库就是一个 SQLite 文件,通过 Worker 的绑定访问。它的 SQL 方言就是 SQLite 的方言:AUTOINCREMENT、datetime('now')、WITH RECURSIVE、PRAGMA 都能用。

适合放业务表:用户、订单、文章、短链映射。不适合放图片(那是 60-workers-r2-image-hosting.md)也不适合放高频写的计数器(那是 62-workers-durable-objects.md)。配置类的小数据用 59-workers-kv-tutorial.md 更省。

2. 建库与绑定

有权限时:

wrangler d1 create cf61-db

输出里会给 database_id,填进 wrangler.toml:

name = "cf61-d1-demo"
main = "src/index.js"
compatibility_date = "2026-10-08"

[[d1_databases]]
binding = "DB"
database_name = "cf61-db"
database_id = "00000000-0000-0000-0000-000000000000"

本地开发时 database_id 可以是占位 UUID,wrangler 不去校验它;要跑远端命令才需要真 id。这一点让本地起步很轻。

绑定名 DB 是代码里 env.DB 的来源,改名要同步改代码。一个 Worker 可以绑多个库,按读写分离或按业务拆开,env.DB、env.ANALYTICS 各指向一个。

3. 执行 schema

DROP TABLE IF EXISTS notes;
CREATE TABLE notes (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  slug TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_notes_created ON notes (created_at DESC);
wrangler d1 execute cf61-db --local --file=schema.sql

实测输出:

🌀 Executing on local database cf61-db (00000000-...-000000000000) from .wrangler\state\v3\d1:
🚣 3 commands executed successfully.

本地库文件落在 .wrangler\state\v3\d1 下。再查一次表名,能看到 notes、sqlite_sequence(自增用的内部表)、_cf_METADATA(D1 自己加的元数据表)。三条语句各自返回一条 {"results":[],"success":true,"meta":{"duration":0}}。

--local 与 --remote 是两套库。不加标志默认本地,要动线上得显式 --remote,误操作的机会小一些,但忘了加标志时会以为"线上没数据"。

4. 查询:prepare + bind

const db = env.DB;

// 列表
const { results } = await db
  .prepare("SELECT id, slug, title, created_at FROM notes ORDER BY id DESC")
  .all();

// 单行
const row = await db.prepare("SELECT COUNT(*) AS n FROM notes").first();

// 写入,参数化
const res = await db
  .prepare("INSERT INTO notes (slug, title) VALUES (?, ?)")
  .bind(body.slug, body.title)
  .run();

三个终结方法的区别:all() 返回全部行,first() 返回第一行(没有则 null),run() 用于写操作并返回执行结果。拼接字符串构造 SQL 会把注入风险带进来,? 占位符交给驱动处理,成本为零。

返回值的形状也要留意:all() 给的是 { results, success, meta },first() 直接给行对象,run() 只有 meta 和 results(写入时 results 是空数组)。把 first() 的结果当成 { results } 去解构会拿到 undefined,这是从别的数据库客户端切过来时最常见的笔误。

写入返回的 meta 信息量比想象中大:

{"served_by":"miniflare.db","duration":0,"changes":1,"last_row_id":1,
 "changed_db":true,"size_after":24576,"rows_read":1,"rows_written":4}

last_row_id 是刚插入的自增 id,changes 是影响行数,rows_read / rows_written 是这次查询实际扫过和写入的行数——D1 按行计费,这两个数字直接对应额度消耗。size_after 是执行后库大小(字节)。线上环境 served_by 会显示具体的数据中心标识。

rows_read 还有个用处是验证索引。给 notes 建了 idx_notes_created 之后,按 created_at 排序的查询不该扫全表;如果 rows_read 和表总行数一样,说明索引没被用上,可能是排序方向或表达式对不上。加索引之前先看一眼查询计划:

wrangler d1 execute cf61-db --local --command "EXPLAIN QUERY PLAN SELECT * FROM notes ORDER BY created_at DESC"

输出里出现 SCAN notes 是全表扫,出现 SEARCH notes USING INDEX ... 才是走了索引。这个命令在本地和远端都能跑,改索引前后各跑一次对比最直观。

5. 批量写与事务

多次写入用 batch(),它在一个事务里顺序执行,任何一条失败整体回滚:

const out = await db.batch([
  db.prepare("INSERT INTO notes (slug, title) VALUES (?, ?)").bind("b1", "batch one"),
  db.prepare("INSERT INTO notes (slug, title) VALUES (?, ?)").bind("b2", "batch two"),
  db.prepare("SELECT COUNT(*) AS n FROM notes")
]);

返回的是一个数组,和传入的语句一一对应。实测前两条是 changes:1 的写入结果,第三条返回 {"n":4}。这比发三次 run() 少两次网络往返,额度上也更省。

需要读写的原子性就用 batch,它在语义上等同 BEGIN 到 COMMIT,不需要手动开事务。

6. 约束冲突的原始报错

用同一个 slug 插两次,返回 400 和这段文本:

D1_ERROR: UNIQUE constraint failed: notes.slug: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_UNIQUE)

判断冲突不要靠匹配这段字符串,用 SQL 层面解决更稳:INSERT ... ON CONFLICT(slug) DO UPDATE SET title = excluded.title(upsert),或者 INSERT OR IGNORE。把异常留给真正的意外情况。

7. 常见问题

问:all() 一次能返回多少行?会不会截断? 答:单次查询有行数上限,结果集很大时要自己加 LIMIT 和游标分页。rows_read 这个 meta 字段能告诉你实际扫了多少行——它远大于返回行数时,说明索引没走对。

问:本地 wrangler dev 里数据好好的,部署后表不存在? 答:本地库和线上库是两套。线上要在 --remote 下重新执行 schema,或者用 wrangler d1 migrations apply 管理变更。schema 变更建议走 migrations 目录,别手工改线上表。

问:日期为什么是 2026-11-10 06:51:03 这种格式,没有时区? 答:datetime('now') 返回 UTC 的 YYYY-MM-DD HH:MM:SS 字符串,不带时区标记。要在前端正确显示,存的时候用 strftime('%Y-%m-%dT%H:%M:%SZ','now') 存成 ISO 格式更省事。

小结

D1 的用法是 SQLite 的老一套加三个终结方法:all、first、run。写 SQL 时用 ? 占位符,多次写用 batch,约束冲突交给 upsert 处理而不是解析错误文本。

值得记住的是 meta 里的 rows_read 和 rows_written:D1 按行计费,这两个数字就是成本表。一次没走索引的全表扫描,可能就是几百行的额度。库的容量与额度上限见 56-free-tier-limits-cheatsheet.md,本地开发流程见 65-wrangler-dev-debug.md。

数据来源与检索时间

检索时间 2026-11-10。schema 执行输出、meta 字段、UNIQUE 报错、batch 返回均为本机 local 模式实测。

来源URL对应数据
D1 入门https://developers.cloudflare.com/d1/get-started/建库命令、绑定配置、schema 执行方式(未抓取)
D1 Workers Binding APIhttps://developers.cloudflare.com/d1/worker-api/prepare / bind / all / first / run / batch 语义(未抓取)
D1 计费与行数https://developers.cloudflare.com/d1/platform/pricing/按行读写的计费口径(未抓取)

核验说明

  • 未部署,本地验证:账号无 D1 权限,没有执行 wrangler d1 create,没有真实数据库。全部验证在本机 _cf61src 项目的 local 模式下完成——wrangler d1 execute cf61-db --local --file=schema.sql 返回 3 commands executed successfully,wrangler dev --local 起服务后实测 GET /notes、两次 POST /notes、重复 slug 的 400、GET /count、GET /batch、递归 CTE 20000 行、再次 GET /count。
  • 第 4 节的 meta 原样来自 local 实例,其中 served_by: "miniflare.db" 是本地标志,线上会换成本地数据中心的标识,因此这个字段值不代表线上行为。
  • 第 2 节的 wrangler d1 create 与线上 migrations 流程仅依据文档描述,本次未执行。
  • 官方 D1 文档三页均未抓取(抓取额度已用完),相关 API 说法以既有文档记忆为准,读者以 wrangler d1 --help 输出核对。
  • 未做:没有测超过行数上限的结果集,没有测 migrations 的版本管理,没有测并发写入时的锁行为。