渡劫博客的评论系统跑在 Cloudflare D1 上,从上线到现在这段时间运行下来攒了一肚子经验:事务怎么开、批量怎么写不炸、索引怎么建、冷启动到底有多冷。这篇全部讲清楚——D1 是 SQLite,但不是你能本地随便造的 SQLite,边缘环境的约束会改变你写 SQL 的习惯。
D1 是什么,不是什么
D1 是托管在 Cloudflare 边缘的 SQLite,支持标准 SQL 子集与 SQLite 方言,通过 wrangler d1 管理迁移。它不是:
- 不是本地 SQLite——没有
PRAGMA自由,没有文件系统访问 - 不是高并发写库——单库单写点,写吞吐有硬上限,热点行更新会排队
- 不是关系齐全的 PG——窗口函数支持有限,外键默认不强制(可开)
所以正确的姿势是:读走索引、写走 batch、Schema 一次想清楚。
Schema:先把约束建对
评论系统的四张表,注意每处 UNIQUE 都在替代一段应用层逻辑:
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
parent_id INTEGER DEFAULT NULL,
nickname TEXT NOT NULL,
email TEXT DEFAULT NULL,
content TEXT NOT NULL,
visitor TEXT NOT NULL,
status INTEGER NOT NULL DEFAULT 1, -- 0 待审核 / 1 通过
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_comments_slug ON comments(slug, status, created_at);
CREATE TABLE IF NOT EXISTS likes (
visitor TEXT NOT NULL,
comment_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (visitor, comment_id) -- 一人一赞,重复提交由 DB 拦截
);
CREATE TABLE IF NOT EXISTS views (
slug TEXT NOT NULL,
visitor TEXT NOT NULL,
n INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (slug, visitor) -- UV 级去重
);
所有迁移语句一律带 IF NOT EXISTS,保证 CI 里幂等重放不炸——我们的部署流程会重复执行 wrangler d1 migrations apply,见三端同步架构记。
事务:batch() 就是你的 transaction
D1 没有交互式事务(BEGIN…COMMIT 跨往返)。多语句原子性靠 db.batch([...]):要么全部成功,要么全部回滚。发一条评论同时要把文章评论数 +1,这两步必须同生共死:
const [comment] = await db.batch([
db.prepare(
'INSERT INTO comments (slug, nickname, email, content, visitor) VALUES (?, ?, ?, ?, ?)'
).bind(slug, nickname, email, content, visitor),
db.prepare(
`INSERT INTO stats (slug, comments) VALUES (?, 1)
ON CONFLICT(slug) DO UPDATE SET comments = comments + 1`
).bind(slug),
]);
return comment.results[0].last_row_id;
batch 的代价:所有语句共享一次往返,但总大小与语句数有上限(超限返回 400)。经验值:单批控制在几十条、几百 KB 以内,超长内容先截断再入库。
批量读写:消灭 N+1 往返
最常见的性能事故是循环里 await 单条语句——每条都是一次边缘往返,十条评论就是十次。正确做法是预编译后一次 batch:
// 错误:N 次往返
for (const s of slugs) { await db.prepare('SELECT * FROM views WHERE slug = ?').bind(s).first(); }
// 正确:1 次往返
const rows = await db.batch(slugs.map((s) =>
db.prepare('SELECT * FROM views WHERE slug = ?').bind(s)
)).then((r) => r.map((x) => x.results).flat());
索引与查询:从深分页到 keyset
评论列表用 ORDER BY created_at DESC LIMIT 20 OFFSET 4000 会把页面拖死——OFFSET 本质是「扫过并丢弃前 4000 行」。改成 keyset 分页,索引直接定位:
-- 深分页:越翻越慢
SELECT * FROM comments WHERE slug = ? AND status = 1
ORDER BY created_at DESC LIMIT 20 OFFSET 4000;
-- keyset:恒速,配合 (slug, status, created_at) 索引
SELECT * FROM comments WHERE slug = ? AND status = 1 AND created_at < ?
ORDER BY created_at DESC LIMIT 20;
时间统一存 ISO-8601 文本(datetime('now') 输出 UTC),字符串比较即时间比较,跨语言零转换。楼中楼查询同理:先取顶层 20 条,再按 parent_id IN (...) 一次取回,避免递归往返。
冷启动到底有多冷
实测:一个新库在新 PoP 的首次查询约 20–80ms(SQLite 文件打开 + 索引预热),同 PoP 后续查询降到个位数 ms;跨国场景受就近 PoP 与库副本同步影响。优化手段按收益排序:
- 减少往返次数——batch 一次搞定,比任何微优化都管用
- 保持 Schema 精简——列越少、行越小,打开越快
- 读路径全走索引——
EXPLAIN QUERY PLAN验证没有全表扫描 - 合并轮询——前端用
/stats?slugs=a,b,c批量拉,别一页发十个请求
迁移与本地开发
# 本地:wrangler 会起一个本地 SQLite,数据不落远端
npx wrangler d1 migrations apply blog --local
npm run dev
# 生产:幂等,可重复执行
npx wrangler d1 migrations apply blog --remote
# 临时查询(不写文件)
npx wrangler d1 execute blog --remote --command "SELECT count(*) FROM comments"
迁移文件按时间戳命名、只追加不删除——这是 D1 世界的「schema 版本控制」:回滚代码时不回滚表结构,新旧代码必须能在同一张表上共存,可退可进。
计数与聚合:能下推就不上层
「每篇文章多少评论、多少赞」这类仪表盘数字,不要查出来在内存里加。用 SQL 聚合一次算完,索引覆盖下即使十万行也是毫秒级:
SELECT slug, COUNT(*) AS c FROM comments WHERE status = 1 GROUP BY slug;
更稳的做法是物化:写评论时顺手 UPSERT 更新 stats 表的计数字段,读路径零计算。空间换时间,在读多写少的边缘场景永远划算。我们的批量接口 /api/blog/stats?slugs=a,b,c 就是这么设计的——一次请求拿回首页全部文章的互动数据。如果某个 key 的写入成为全站热点(比如全局计数器),正确的下一步不是硬扛,而是把它挪进 Durable Objects:用 DO 的串行内存在累加,定时落库——D1 的写瓶颈就这样被绕开,选型逻辑见 Workers 全栈实践。
备份、导出与监控
D1 支持快照导出,定时把数据落进 R2,个人项目就拥有了「真的备份」:
npx wrangler d1 export blog --remote --output=backup-$(date +%F).sql
# 生产方案:Workers Cron Trigger 每天触发一次导出,写入 R2 桶
监控方面,Cloudflare 后台能看到 D1 的读写次数与延迟曲线;我们还在 Worker 里加了一层耗时打点,超过 200ms 的查询记警告日志,wrangler tail 实时可见。数据量级心里有数,流量来时第一个夜晚才睡得着。
小结
D1 的哲学是「用 SQLite 的简单换边缘的延迟」。约束本身给了你明确的写法:batch 代替交互事务、UNIQUE 代替应用层校验、keyset 代替深分页、聚合下推代替内存计算。把这些当成肌肉记忆,边缘数据库就不会成为瓶颈——渡劫博客的评论、点赞、浏览全部由它承载,至今没有一次因为数据库半夜报警。