Cloudflare D1 适合怎样的 AI 内容社区 MVP

shancha.org 跑在 Cloudflare Pages + Functions + D1 上,没有框架也没有构建步骤。记录真实的 10 张表结构、时区与 N+1 查询的坑,以及 D1 在什么规模下开始不够用。

Cloudflare D1 适合怎样的 AI 内容社区 MVP

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_url
  • categories / 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 的 datePublishednew 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 会不够用

按目前的使用方式,瓶颈会依次出现在:

  1. 全文搜索:现在用 LIKE '%关键词%',几百篇文章还行,上千篇就会慢。下一步要么上 SQLite FTS5,要么外挂搜索服务
  2. 写入并发:多人同时在后台发布时会排队。单人或小团队感知不到
  3. 单库容量:D1 有单库大小上限,纯文本内容站很难摸到,但如果把图片 base64 塞进正文就会很快撞墙——所以图片走 R2,正文只存 URL

小结

D1 适合的画像很清楚:内容结构化、写入集中在少数作者、读取要全球低延迟、团队不想维护数据库。shancha.org 现在整站零运维成本,部署就是 git push。

如果你在做英文内容站、工具目录站或 Affiliate 站,这套组合的启动成本几乎为零。真正的难点从来不是数据库选型,而是持续产出值得被读的内容。