1000usdinchina.com 没有传统后端,也没有常驻服务器。它完全 跑在 Cloudflare 边缘:Next.js 15 经 Cloudflare Pages 渲染,动态数据用 D1(边缘 SQLite), 计算用 Workers。这篇讲架构 —— 以及那个让我真正搞懂 Next.js 和 Cloudflare 怎么协商缓存的 线上 bug。
这是系列第 2 篇。
目录
为什么选边缘
选 Next.js + Cloudflare 有三个理由:
- 成本。 没有要保活的服务器。一个 100 城内容的单人项目养不起一队虚拟机。边缘函数按请求计费, 空闲时 $0。
- 延迟。 内容在离用户最近处渲染,全球都快。圣保罗和首尔的游客都拿到 100ms 内的响应。
- 缩到零和扩上去都免费。 没流量就没成本;Reddit 流量尖峰也不用半夜被叫起来。
sequenceDiagram
participant U as 浏览器
participant E as Cloudflare 边缘(PoP)
participant W as Worker(Next.js / OpenNext)
participant D as D1(SQLite)
U->>E: GET /en/routes/beijing-shanghai
E-->>U: 命中缓存(静态/已缓存)—— 立即返回
Note over E,W: 未命中时 ↓
E->>W: 触发渲染
W->>D: SELECT 路线、城市、价格
D-->>W: 行数据
W-->>E: HTML
E-->>U: HTML(按规则缓存)静态 vs 动态边界
最重要的一个架构决策是:哪些静态渲染、哪些打数据库。规则:
| 静态渲染(SSG) | 动态渲染(D1) |
|---|---|
| 景点、酒店、高铁、城市参考数据 | /routes/*(行程路线) |
| 一切 ETL 产物 | /cases/*(保存/生成的行程) |
ETL 产出的参考数据在两次部署之间几乎不变,所以在构建期烤进静态页 —— 几百条预渲染路线从缓存出。 真正动态的内容(用户路线、生成的 case)才按需查 D1。
这条边界是刻意的,而且不止一次被质疑(「为什么 routes 不也做静态?」)。答案:路线是组合爆炸、 用户驱动的,预渲染每种排列既浪费、又没必要,而边缘上的 D1 足够快,按需查更优。
D1 在边缘 + OpenNext
Next.js 原生不针对 Cloudflare runtime,所以应用经 OpenNext 构建,把 Next.js 产物适配到 Workers。D1 绑定到 Worker,用纯 SQL 查询:
// 边缘路由处理 —— 只用 Web API,禁 Node 内置(fs/path/crypto.randomBytes)
export const runtime = "edge";
export async function getRoute(env: Env, slug: string) {
const { results } = await env.DB
.prepare("SELECT * FROM routes WHERE slug = ?1")
.bind(slug)
.all();
return results;
}
边缘运行时的硬约束:禁用 Node 专属 API。没有 fs、path、crypto.randomBytes。
全部走 Web Crypto、Workers KV 或 D1。这影响你写每一个工具函数 —— 而且是强制的,不是可选。
i18n:货币与语种耦合
旅行预算工具如果显示错货币就废了。所以语种和货币强耦合:选语言就是选货币。
const LOCALE_CURRENCY = {
en: "USD", ja: "JPY", ko: "KRW", ru: "RUB", // ...
} as const;
// 0-decimal 货币(JPY、KRW、VND、IDR)必须取整到整数单位。
function formatPrice(amountCny: number, locale: Locale): string {
const currency = LOCALE_CURRENCY[locale];
const value = convert(amountCny, currency);
const fractionDigits = ZERO_DECIMAL.has(currency) ? 0 : 2;
return new Intl.NumberFormat(locale, {
style: "currency", currency,
minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits,
}).format(value);
}
0-decimal 处理错了,日文用户会看到「¥1,234.56」—— 瞬间不可信。货币正确性是功能,不是格式化的事后补丁。
值得花时间看的缓存 bug
我给 /routes 页加了 middleware 设 Cache-Control: public, s-maxage=86400,以为 Cloudflare
会在边缘缓存。它没有。curl -I 显示 private, no-store, max-age=0,没有 cf-cache-status。
教训:Next.js 在渲染层自动把动态 SSR 页(查 D1 的)标成 no-store,这会覆盖你在 middleware
里设的任何 Cache-Control。 Cloudflare 尊重源站的 no-store,拒绝缓存 —— 完全是设计如此。
flowchart TD
A[Middleware 设 s-maxage=86400] --> B[Next.js 渲染层]
B -->|被 no-store 覆盖| C[源响应:no-store]
C --> D{Cloudflare}
D -->|尊重源站| E[不缓存]
F[Cloudflare Cache Rule:Override origin Edge TTL] -.->|唯一解| D
D -->|加规则后| G[边缘已缓存 ✓]缓存 Next.js 标了 no-store 的动态 SSR 页,唯一办法是 Cloudflare Cache Rule + "Override
origin" Edge TTL —— 在平台层设,不在代码里。这种场景下 middleware 缓存头是死代码。
搞清楚缓存决策实际在哪做出,省了好几个小时瞎猜。
要点
- 边缘 serverless(Next.js + Cloudflare Pages/D1/Workers)给单人项目近乎零固定成本 + 全球低延迟。
- 刻意画静/动态边界:参考数据烤进静态,只有真动态的部分查 D1。
- 边缘运行时禁 Node API —— 第一天就按 Web Crypto / KV / D1 设计。
- Next.js 的
no-store覆盖 middleware 缓存;只有 Cloudflare Cache Rule 能缓存动态 SSR 页。
常见问题
Next.js 15 能跑在 Cloudflare Pages 上吗? 能 —— 经 OpenNext 把 Next.js 构建适配到 Workers runtime,D1 和 KV 绑定到 Worker。
为什么用 D1 而不是托管 Postgres? D1 是边缘 SQLite,与 Worker 同地部署,无连接池要管、无空闲成本,适合 serverless、缩到零的应用。
为什么 middleware 的 Cache-Control 缓存不了动态页?
Next.js 在渲染期给动态 SSR 页设 no-store,覆盖 middleware。改用 Cloudflare Cache Rule +
Override origin Edge TTL。