返回文章索引
TRANSMISSION / CYAN8 MIN READ

在边缘构建有状态应用

Cloudflare Workers 很快,D1 很近,但真正困难的是把状态、一致性与失败边界设计清楚。

#Cloudflare#D1#工程
边缘节点围绕中心数据库形成有状态应用拓扑
状态、边缘节点与一致性边界

边缘计算最吸引人的承诺,是让代码离用户更近。但当应用开始保存账号、会话和留言,问题就不再只是“把函数部署到更多节点”,而是:状态究竟在哪里,谁对它负责,失败时系统会怎样退化?

这篇文章用一个带登录和留言的博客作为最小案例,梳理在 Cloudflare Workers 与 D1 上构建有状态应用时真正重要的设计边界。

先画清楚状态地图

一个看起来很小的博客,至少有三类完全不同的状态:

状态来源生命周期
文章正文Git 中的 Markdown随部署更新
用户与留言D1长期持久化
登录会话D1 + Cookie有明确过期时间

文章内容适合跟着代码版本走。它需要评审、回滚和构建时校验,却不需要读者在运行时修改。用户数据则刚好相反:它必须脱离部署生命周期存在。

选择数据库之前,先判断一份数据应该跟随代码版本,还是跟随用户行为。

这个区分会自然导向一个简单架构:Nuxt Content 负责 Markdown,D1 负责运行时业务数据,Worker 负责把两者组合成一次请求。

绑定比网络请求更重要

Worker 访问 D1 时不需要自己保存 API Token,也不应该在运行时调用 Cloudflare REST API。D1 通过 binding 直接进入运行时:

{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "signal-log-db",
      "database_id": "<YOUR_DATABASE_ID>"
    }
  ]
}

在服务端路由里,数据库对象来自当前请求上下文。每一条 SQL 都使用 prepared statement:

const comment = await db
  .prepare(`
    SELECT id, body, created_at
    FROM comments
    WHERE post_slug = ?1 AND status = 'published'
    ORDER BY created_at DESC
    LIMIT 100
  `)
  .bind(postSlug)
  .all()

参数绑定不是代码风格问题,而是输入边界的一部分。文章路径、邮箱、昵称和留言正文都来自用户,任何一个值都不应该被拼接进 SQL。

会话只存不可逆摘要

登录成功后,浏览器拿到一个随机会话令牌;数据库只保存它的 SHA-256 摘要。这样即使会话表意外暴露,原始令牌也不能被直接拿去登录。

const token = createRandomToken()
const tokenHash = await sha256(token)

await db
  .prepare('INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)')
  .bind(tokenHash, userId, expiresAt)
  .run()

Cookie 使用 HttpOnly,让前端 JavaScript 无法读取;使用 SameSite=Lax 降低跨站请求风险;生产环境再加上 Secure

这并不意味着系统从此“绝对安全”。更准确的说法是:我们让每一层只持有它完成工作所需的最少能力。

一致性要围绕用户动作设计

D1 是关系数据库,但边缘应用仍然需要明确哪些流程要求强一致。

对博客来说,关键动作很少:

  1. 注册后应当立刻能够登录;
  2. 留言成功后应当立刻在当前页面出现;
  3. 退出后旧会话应当立刻失效。

这些流程都应该从同一个主数据库完成写入和紧随其后的读取。文章列表、公开留言列表等纯读取场景,才有更大的缓存空间。

不要从“哪里可以缓存”出发,而要从“用户刚刚完成了什么承诺”出发。

失败也需要产品设计

边缘运行时会失败,数据库也可能暂时不可用。区别只在于,我们有没有把失败变成用户能理解的状态。

  • 登录失败时,不区分“邮箱不存在”和“密码错误”,避免暴露账号枚举信息;
  • 留言写入失败时,保留输入内容,让用户可以重试;
  • 内容查询失败时,返回真正的错误状态,而不是空白页面;
  • 所有服务端错误使用结构化日志,但不把内部 SQL 或堆栈发给浏览器。

系统可靠性不是“永远不出错”,而是错误发生时边界仍然完整。

最后:让架构保持无聊

这个方案没有消息队列、没有分布式锁,也没有额外的身份服务。不是因为那些工具没有价值,而是当前问题还没有提出相应要求。

一个长期可维护的个人站,最好的基础通常是:

  • 内容跟着 Git;
  • 数据进入 D1;
  • 密钥留在 Worker secret;
  • 输入在边界验证;
  • 失败清楚、可观察、可恢复。

当流量或产品需求真正变化时,再让架构跟着证据演进。边缘已经足够快,工程的任务是让它继续简单。

END OF SIGNAL
READER CHANNEL

留言

00
还没有留言。成为第一个发出回应的人。