[{"data":1,"prerenderedAt":523},["ShallowReactive",2],{"post:\u002Fblog\u002Fedge-stateful-apps":3,"surroundings:\u002Fblog\u002Fedge-stateful-apps":517},{"id":4,"title":5,"accent":6,"body":7,"description":497,"draft":498,"experience":499,"extension":500,"featured":342,"image":501,"meta":507,"navigation":342,"path":508,"publishedAt":509,"readingTime":186,"seo":510,"stem":511,"tags":512,"updatedAt":515,"__hash__":516},"posts\u002Fblog\u002Fedge-stateful-apps.md","在边缘构建有状态应用","cyan",{"type":8,"value":9,"toc":489},"minimark",[10,18,21,25,28,84,87,93,96,99,102,196,199,293,296,299,302,382,397,400,403,406,409,422,425,428,431,434,449,452,456,459,462,479,485],[11,12,13,14],"p",{},"边缘计算最吸引人的承诺，是让代码离用户更近。但当应用开始保存账号、会话和留言，问题就不再只是“把函数部署到更多节点”，而是：",[15,16,17],"strong",{},"状态究竟在哪里，谁对它负责，失败时系统会怎样退化？",[11,19,20],{},"这篇文章用一个带登录和留言的博客作为最小案例，梳理在 Cloudflare Workers 与 D1 上构建有状态应用时真正重要的设计边界。",[22,23,24],"h2",{"id":24},"先画清楚状态地图",[11,26,27],{},"一个看起来很小的博客，至少有三类完全不同的状态：",[29,30,31,47],"table",{},[32,33,34],"thead",{},[35,36,37,41,44],"tr",{},[38,39,40],"th",{},"状态",[38,42,43],{},"来源",[38,45,46],{},"生命周期",[48,49,50,62,73],"tbody",{},[35,51,52,56,59],{},[53,54,55],"td",{},"文章正文",[53,57,58],{},"Git 中的 Markdown",[53,60,61],{},"随部署更新",[35,63,64,67,70],{},[53,65,66],{},"用户与留言",[53,68,69],{},"D1",[53,71,72],{},"长期持久化",[35,74,75,78,81],{},[53,76,77],{},"登录会话",[53,79,80],{},"D1 + Cookie",[53,82,83],{},"有明确过期时间",[11,85,86],{},"文章内容适合跟着代码版本走。它需要评审、回滚和构建时校验，却不需要读者在运行时修改。用户数据则刚好相反：它必须脱离部署生命周期存在。",[88,89,90],"blockquote",{},[11,91,92],{},"选择数据库之前，先判断一份数据应该跟随代码版本，还是跟随用户行为。",[11,94,95],{},"这个区分会自然导向一个简单架构：Nuxt Content 负责 Markdown，D1 负责运行时业务数据，Worker 负责把两者组合成一次请求。",[22,97,98],{"id":98},"绑定比网络请求更重要",[11,100,101],{},"Worker 访问 D1 时不需要自己保存 API Token，也不应该在运行时调用 Cloudflare REST API。D1 通过 binding 直接进入运行时：",[103,104,110],"pre",{"className":105,"code":106,"filename":107,"language":108,"meta":109,"style":109},"language-json shiki shiki-themes github-dark github-dark","{\n  \"d1_databases\": [\n    {\n      \"binding\": \"DB\",\n      \"database_name\": \"signal-log-db\",\n      \"database_id\": \"\u003CYOUR_DATABASE_ID>\"\n    }\n  ]\n}\n","wrangler.jsonc","json","",[111,112,113,122,132,138,154,167,178,184,190],"code",{"__ignoreMap":109},[114,115,118],"span",{"class":116,"line":117},"line",1,[114,119,121],{"class":120},"suv1-","{\n",[114,123,125,129],{"class":116,"line":124},2,[114,126,128],{"class":127},"s8ozJ","  \"d1_databases\"",[114,130,131],{"class":120},": [\n",[114,133,135],{"class":116,"line":134},3,[114,136,137],{"class":120},"    {\n",[114,139,141,144,147,151],{"class":116,"line":140},4,[114,142,143],{"class":127},"      \"binding\"",[114,145,146],{"class":120},": ",[114,148,150],{"class":149},"s4wv1","\"DB\"",[114,152,153],{"class":120},",\n",[114,155,157,160,162,165],{"class":116,"line":156},5,[114,158,159],{"class":127},"      \"database_name\"",[114,161,146],{"class":120},[114,163,164],{"class":149},"\"signal-log-db\"",[114,166,153],{"class":120},[114,168,170,173,175],{"class":116,"line":169},6,[114,171,172],{"class":127},"      \"database_id\"",[114,174,146],{"class":120},[114,176,177],{"class":149},"\"\u003CYOUR_DATABASE_ID>\"\n",[114,179,181],{"class":116,"line":180},7,[114,182,183],{"class":120},"    }\n",[114,185,187],{"class":116,"line":186},8,[114,188,189],{"class":120},"  ]\n",[114,191,193],{"class":116,"line":192},9,[114,194,195],{"class":120},"}\n",[11,197,198],{},"在服务端路由里，数据库对象来自当前请求上下文。每一条 SQL 都使用 prepared statement：",[103,200,204],{"className":201,"code":202,"language":203,"meta":109,"style":109},"language-ts shiki shiki-themes github-dark github-dark","const comment = await db\n  .prepare(`\n    SELECT id, body, created_at\n    FROM comments\n    WHERE post_slug = ?1 AND status = 'published'\n    ORDER BY created_at DESC\n    LIMIT 100\n  `)\n  .bind(postSlug)\n  .all()\n","ts",[111,205,206,224,239,244,249,254,259,264,272,282],{"__ignoreMap":109},[114,207,208,212,215,218,221],{"class":116,"line":117},[114,209,211],{"class":210},"sOPea","const",[114,213,214],{"class":127}," comment",[114,216,217],{"class":210}," =",[114,219,220],{"class":210}," await",[114,222,223],{"class":120}," db\n",[114,225,226,229,233,236],{"class":116,"line":124},[114,227,228],{"class":120},"  .",[114,230,232],{"class":231},"sFR8T","prepare",[114,234,235],{"class":120},"(",[114,237,238],{"class":149},"`\n",[114,240,241],{"class":116,"line":134},[114,242,243],{"class":149},"    SELECT id, body, created_at\n",[114,245,246],{"class":116,"line":140},[114,247,248],{"class":149},"    FROM comments\n",[114,250,251],{"class":116,"line":156},[114,252,253],{"class":149},"    WHERE post_slug = ?1 AND status = 'published'\n",[114,255,256],{"class":116,"line":169},[114,257,258],{"class":149},"    ORDER BY created_at DESC\n",[114,260,261],{"class":116,"line":180},[114,262,263],{"class":149},"    LIMIT 100\n",[114,265,266,269],{"class":116,"line":186},[114,267,268],{"class":149},"  `",[114,270,271],{"class":120},")\n",[114,273,274,276,279],{"class":116,"line":192},[114,275,228],{"class":120},[114,277,278],{"class":231},"bind",[114,280,281],{"class":120},"(postSlug)\n",[114,283,285,287,290],{"class":116,"line":284},10,[114,286,228],{"class":120},[114,288,289],{"class":231},"all",[114,291,292],{"class":120},"()\n",[11,294,295],{},"参数绑定不是代码风格问题，而是输入边界的一部分。文章路径、邮箱、昵称和留言正文都来自用户，任何一个值都不应该被拼接进 SQL。",[22,297,298],{"id":298},"会话只存不可逆摘要",[11,300,301],{},"登录成功后，浏览器拿到一个随机会话令牌；数据库只保存它的 SHA-256 摘要。这样即使会话表意外暴露，原始令牌也不能被直接拿去登录。",[103,303,305],{"className":201,"code":304,"language":203,"meta":109,"style":109},"const token = createRandomToken()\nconst tokenHash = await sha256(token)\n\nawait db\n  .prepare('INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)')\n  .bind(tokenHash, userId, expiresAt)\n  .run()\n",[111,306,307,321,338,344,351,364,373],{"__ignoreMap":109},[114,308,309,311,314,316,319],{"class":116,"line":117},[114,310,211],{"class":210},[114,312,313],{"class":127}," token",[114,315,217],{"class":210},[114,317,318],{"class":231}," createRandomToken",[114,320,292],{"class":120},[114,322,323,325,328,330,332,335],{"class":116,"line":124},[114,324,211],{"class":210},[114,326,327],{"class":127}," tokenHash",[114,329,217],{"class":210},[114,331,220],{"class":210},[114,333,334],{"class":231}," sha256",[114,336,337],{"class":120},"(token)\n",[114,339,340],{"class":116,"line":134},[114,341,343],{"emptyLinePlaceholder":342},true,"\n",[114,345,346,349],{"class":116,"line":140},[114,347,348],{"class":210},"await",[114,350,223],{"class":120},[114,352,353,355,357,359,362],{"class":116,"line":156},[114,354,228],{"class":120},[114,356,232],{"class":231},[114,358,235],{"class":120},[114,360,361],{"class":149},"'INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)'",[114,363,271],{"class":120},[114,365,366,368,370],{"class":116,"line":169},[114,367,228],{"class":120},[114,369,278],{"class":231},[114,371,372],{"class":120},"(tokenHash, userId, expiresAt)\n",[114,374,375,377,380],{"class":116,"line":180},[114,376,228],{"class":120},[114,378,379],{"class":231},"run",[114,381,292],{"class":120},[11,383,384,385,388,389,392,393,396],{},"Cookie 使用 ",[111,386,387],{},"HttpOnly","，让前端 JavaScript 无法读取；使用 ",[111,390,391],{},"SameSite=Lax"," 降低跨站请求风险；生产环境再加上 ",[111,394,395],{},"Secure","。",[11,398,399],{},"这并不意味着系统从此“绝对安全”。更准确的说法是：我们让每一层只持有它完成工作所需的最少能力。",[22,401,402],{"id":402},"一致性要围绕用户动作设计",[11,404,405],{},"D1 是关系数据库，但边缘应用仍然需要明确哪些流程要求强一致。",[11,407,408],{},"对博客来说，关键动作很少：",[410,411,412,416,419],"ol",{},[413,414,415],"li",{},"注册后应当立刻能够登录；",[413,417,418],{},"留言成功后应当立刻在当前页面出现；",[413,420,421],{},"退出后旧会话应当立刻失效。",[11,423,424],{},"这些流程都应该从同一个主数据库完成写入和紧随其后的读取。文章列表、公开留言列表等纯读取场景，才有更大的缓存空间。",[11,426,427],{},"不要从“哪里可以缓存”出发，而要从“用户刚刚完成了什么承诺”出发。",[22,429,430],{"id":430},"失败也需要产品设计",[11,432,433],{},"边缘运行时会失败，数据库也可能暂时不可用。区别只在于，我们有没有把失败变成用户能理解的状态。",[435,436,437,440,443,446],"ul",{},[413,438,439],{},"登录失败时，不区分“邮箱不存在”和“密码错误”，避免暴露账号枚举信息；",[413,441,442],{},"留言写入失败时，保留输入内容，让用户可以重试；",[413,444,445],{},"内容查询失败时，返回真正的错误状态，而不是空白页面；",[413,447,448],{},"所有服务端错误使用结构化日志，但不把内部 SQL 或堆栈发给浏览器。",[11,450,451],{},"系统可靠性不是“永远不出错”，而是错误发生时边界仍然完整。",[22,453,455],{"id":454},"最后让架构保持无聊","最后：让架构保持无聊",[11,457,458],{},"这个方案没有消息队列、没有分布式锁，也没有额外的身份服务。不是因为那些工具没有价值，而是当前问题还没有提出相应要求。",[11,460,461],{},"一个长期可维护的个人站，最好的基础通常是：",[435,463,464,467,470,473,476],{},[413,465,466],{},"内容跟着 Git；",[413,468,469],{},"数据进入 D1；",[413,471,472],{},"密钥留在 Worker secret；",[413,474,475],{},"输入在边界验证；",[413,477,478],{},"失败清楚、可观察、可恢复。",[11,480,481,482],{},"当流量或产品需求真正变化时，再让架构跟着证据演进。",[15,483,484],{},"边缘已经足够快，工程的任务是让它继续简单。",[486,487,488],"style",{},"html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}",{"title":109,"searchDepth":134,"depth":134,"links":490},[491,492,493,494,495,496],{"id":24,"depth":124,"text":24},{"id":98,"depth":124,"text":98},{"id":298,"depth":124,"text":298},{"id":402,"depth":124,"text":402},{"id":430,"depth":124,"text":430},{"id":454,"depth":124,"text":455},"Cloudflare Workers 很快，D1 很近，但真正困难的是把状态、一致性与失败边界设计清楚。",false,null,"md",{"src":502,"alt":503,"caption":504,"width":505,"height":506},"\u002Fmedia\u002Fposts\u002Fedge-stateful-apps\u002Fcover-v1.webp","边缘节点围绕中心数据库形成有状态应用拓扑","状态、边缘节点与一致性边界",1600,900,{},"\u002Fblog\u002Fedge-stateful-apps","2026-07-21",{"title":5,"description":497},"blog\u002Fedge-stateful-apps",[513,69,514],"Cloudflare","工程","2026-07-24","qVlQ94aZiwjIHRu5cLeMAhbFz-mhfV7p2j4KjRlWv_U",[499,518],{"title":519,"path":520,"stem":521,"description":522,"children":-1},"从一封 GPT‑4 邀请邮件，到智能体时代","\u002Fblog\u002Ffrom-gpt-4-invite-to-agents","blog\u002Ffrom-gpt-4-invite-to-agents","2023 年，我等来的是一次 8K context 的 API 访问资格；三年后，我们开始学习如何向智能体交代目标、权限、边界与验收标准。",1785034337141]