Cloudflare D1 上手指南:把 SQLite 放到离用户最近的地方
D1 是 Cloudflare 托管的 SQLite。它怎么建库、怎么绑定、和自建 SQLite 有什么不一样、哪些 SQL 写法会踩坑,这篇一次说清。
D1 到底是什么
一句话:D1 是一个由 Cloudflare 托管的 SQLite 数据库,通过 Worker 的绑定(binding)访问,不需要连接串和连接池。
它和「在 Worker 里跑 SQLite」有本质区别。Worker 是无状态的、分布在全球几百个机房的,而你只有一份数据。D1 的做法是把存储放在主库,读取通过 Cloudflare 的缓存和只读副本就近提供。
所以你会在文档里反复看到一个词:最终一致性(eventual consistency)。写操作会先落到主库,读操作可能从副本读到稍旧的数据。对博客这种读多写少的场景完全无感,但如果你要做「转账」这类强一致业务,就要重新考虑了。
第一步:创建数据库
wrangler login
wrangler d1 create blog-db
第二条命令会返回一个 database_id,把它填进 wrangler.toml:
[[d1_databases]]
binding = "DB"
database_name = "blog-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
binding = "DB" 这个名字就是你在代码里访问的入口,叫什么随你,但一旦上线就别改了——改名会导致新版本读不到旧绑定。
第二步:在项目里访问
export default {
async fetch(request, env) {
const { results } = await env.DB
.prepare('SELECT * FROM posts WHERE status = ?1 ORDER BY published_at DESC LIMIT ?2')
.bind('published', 20)
.all();
return Response.json(results);
},
};
三个 API 需要记牢:
| 方法 | 用途 | 返回 |
|---|---|---|
.first() |
只要一行,如按 slug 查文章 | 对象或 null |
.all() |
多行 | { results: [...] } |
.run() |
写操作 | { success, meta },meta.changes 是影响行数 |
注意 .all() 返回的是 { results } 而不是数组本身,这个包装结构我一开始忘了解构,debug 了十分钟。
占位符的两种写法
SQLite 支持位置参数 ? 和编号参数 ?1,D1 两者都认:
// 位置参数:按 bind 的顺序填
await env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(id).first();
// 编号参数:同一个值可以复用
await env.DB.prepare('SELECT * FROM posts WHERE title LIKE ?1 OR summary LIKE ?1').bind('%' + q + '%').all();
永远不要用字符串拼接拼 SQL。 D1 没有「注入」这个说法只要你老老实实用 bind,但一旦拼接就全废了:
// 绝对不要这样写
await env.DB.prepare("SELECT * FROM posts WHERE slug = '" + slug + "'").first();
批量操作用 batch
如果你要一次插入很多行,别循环 await,用 batch 把它们塞进一次往返:
const stmts = posts.map((p) =>
env.DB.prepare('INSERT INTO posts (slug, title, content) VALUES (?, ?, ?)').bind(p.slug, p.title, p.content)
);
await env.DB.batch(stmts);
batch 是原子的:里面任何一条失败,整批回滚。这个特性很适合「更新文章 + 重建标签关联」这种需要一起成功的组合操作。
本地开发:--local 和 --remote 的区别
这是新手最容易困惑的地方。D1 的 CLI 有两个完全隔离的目标:
# 操作本地 .wrangler/state 下的 SQLite 文件,随便玩
wrangler d1 execute blog-db --local --file=./schema.sql
# 操作线上真实数据库,执行前会要你确认
wrangler d1 execute blog-db --remote --file=./schema.sql
默认是 --local。我第一次跑完 schema 后打开网站发现表不存在,就是因为忘了加 --remote。
查询数据也同理:
wrangler d1 execute blog-db --local --command="SELECT count(*) FROM posts"
wrangler d1 execute blog-db --remote --command="SELECT count(*) FROM posts"
几个会踩的坑
1. 不能用 PRAGMA 和 ATTACH。
D1 出于安全考虑禁了一部分 SQLite 能力,包括多数据库附加。想跨库查询只能拆成两次请求。
2. 没有真正的「事务 API」,但有 batch。
BEGIN / COMMIT 写在语句里是不生效的,要原子性就用 batch。
3. 一次查询返回行数有上限,结果集也有体积上限。
分页是必须的,别想着 SELECT * 拉全表然后在 Worker 里 slice。
4. datetime('now') 返回的是 UTC。
存的时候统一存 UTC,展示的时候再按用户时区转换。D1 不存时区信息,前端拿到 '2026-09-12 08:30:00' 这种字符串时,浏览器会按本地时区去解析它——这是个很容易写出「文章时间差了 8 小时」bug 的地方。
我最后选择的做法是:存 ISO 8601 带 Z 后缀的字符串(2026-09-12T08:30:00Z),这样 new Date(...) 在任何浏览器里都能正确解析。
-- 建表时用这个默认值
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
免费额度够用吗
D1 免费版大致是:每天 500 万行读、10 万行写,总存储 5GB。
对个人博客来说是天文数字。一篇文章列表页大概读 10 行,就算每天一万次访问也才 10 万行读,用掉 2%。真正需要注意的是写放大——如果你在每次请求里都写一行「访问日志」或者「浏览量 +1」,写额度会消耗得很快。
所以这个博客的浏览计数我做了一个取巧:不精确统计。同一 IP 在 24 小时内只 +1,用 D1 存一个轻量的去重表,而不是每次请求都写。
小结
D1 的定位非常清晰:你不需要一个「数据库服务」,你只需要一个能在边缘访问的 SQL 表。 如果你的场景是博客、文档站、小型 SaaS 的配置数据、爬虫结果落库,它几乎是最省心的选择。如果你需要复杂事务、跨表 JOIN 大表、或者强一致读写,那还是老老实实用传统数据库。
下一篇写 R2——为什么它能把图片存储的账单打到接近于零。