Cloudflare D1 适合怎样的 AI 内容社区 MVP
shancha.org 从第一行代码开始就跑在 Cloudflare Pages + Functions + D1 上,没有用任何框架,也没有构建步骤。这篇记录真实的表结构、踩过的坑,以及 D1 在什么规模下开始不够用。
为什么选 D1 而不是 Postgres
内容站的读写比例极度不对称:一篇文章写一次,可能被读几万次。D1 是跑在 Cloudflare 边缘网络上的 SQLite,读取延迟低、免费额度足够早期内容站,而且不需要维护连接池——Workers 里直接 env.DB.prepare() 就能查。
代价是:D1 单库有写入吞吐上限,跨区域写入会回源到主副本。对内容站这不是问题,对高频写入的应用(实时协作、计数器、队列)就是问题。判断标准很简单:如果你的写入来自少数几个作者,而读取来自全世界,D1 合适。
真实表结构
整站 10 张表,全部在一个 migration 文件里:
posts:文章主体。除了 title / slug / content,还有post_type(daily、news、tool_review、deep、skill、build_log 等 11 种)、status(draft / published / archived)、is_member_only,以及独立的 SEO 字段seo_title/seo_description/canonical_url/og_image_urlcategories/tags/post_tags:分类是一对多,标签是多对多users/sessions/user_tags:Google OAuth 登录,会员权限用标签实现tools/skills:结构化条目,不走文章表site_settings:key-value 表,前台导航、页脚、主视觉图全部从这里读audit_logs:后台操作留痕
关键设计:SEO 字段独立成列,而不是塞进 Markdown 正文。这样列表页、sitemap、RSS、JSON-LD 可以直接查字段,不需要解析正文。后来做结构化数据时,这个决定省了大量工作。
踩过的三个坑
1. CURRENT_TIMESTAMP 没有时区标记
SQLite 的 CURRENT_TIMESTAMP 存的是 YYYY-MM-DD HH:MM:SS(UTC,但字符串里没有 Z)。直接把它塞进 sitemap 的 <lastmod> 或 JSON-LD 的 datePublished,new Date() 在不同运行时会按本地时区解析,产生几小时的偏移。
解决办法是统一转换:
export function toISO(value) {
const raw = String(value || "").trim();
if (!raw) return "";
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)
? `${raw.replace(" ", "T")}Z`
: raw;
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
}所有对外输出的时间都过这个函数。
2. 标签用 GROUP_CONCAT 一次查完
文章列表要显示标签,最直觉的写法是先查文章、再循环查标签——N+1 查询,在边缘环境每次往返都要钱也要时间。D1 支持 GROUP_CONCAT,可以一次查完:
SELECT p.*, c.name AS category_name,
GROUP_CONCAT(t.name || ':' || t.slug, '|') AS tag_pairs
FROM posts p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN post_tags pt ON pt.post_id = p.id
LEFT JOIN tags t ON t.id = pt.tag_id
WHERE p.status = 'published'
GROUP BY p.id再在 JS 里把 tag_pairs 拆开。一次查询解决列表页全部数据。
3. 纯客户端渲染让爬虫看到空页面
最早前台是纯 CSR:_redirects 把所有路由重写到 index.html,JS 再去 fetch API 渲染。结果是每个 URL 返回的原始 HTML 完全一样,canonical 还硬编码指向首页——等于告诉 Google「所有页面都是首页的副本」。两个月只收录了 1 个 URL。
D1 本身没问题,问题是渲染时机。改造方式是把渲染移进 Pages Functions:同样的 D1 查询,在服务端拼好完整 HTML 直出。前端 JS 降级成只处理登录态和移动端导航。
这一步对 GEO(AI 搜索引擎优化)尤其关键——GPTBot、ClaudeBot、PerplexityBot 都不执行 JavaScript,CSR 站在它们眼里就是一张白纸。
什么时候 D1 会不够用
按目前的使用方式,瓶颈会依次出现在:
- 全文搜索:现在用
LIKE '%关键词%',几百篇文章还行,上千篇就会慢。下一步要么上 SQLite FTS5,要么外挂搜索服务 - 写入并发:多人同时在后台发布时会排队。单人或小团队感知不到
- 单库容量:D1 有单库大小上限,纯文本内容站很难摸到,但如果把图片 base64 塞进正文就会很快撞墙——所以图片走 R2,正文只存 URL
小结
D1 适合的画像很清楚:内容结构化、写入集中在少数作者、读取要全球低延迟、团队不想维护数据库。shancha.org 现在整站零运维成本,部署就是 git push。
如果你在做英文内容站、工具目录站或 Affiliate 站,这套组合的启动成本几乎为零。真正的难点从来不是数据库选型,而是持续产出值得被读的内容。
