LIVING WEB MAGAZINE
komugi.小麦

小麦的照片,还有一些生活与工程手记

目录 (7 节)↓
全栈ISSUE 2026-11

个人博客的文章发布可靠性:保存超时、编辑冲突与内容撤回

网络超时后,保存可能已经完成。以内容发布系统为例,讨论如何用稳定请求标识、版本条件更新、原子写入和可恢复交接处理重试,并说明撤回内容与缓存之间的边界。

一篇长文写完,点击保存,页面转了几秒后提示网络失败。作者再次点击,后台出现两条记录;或者文章只留下一条,另一个页面刚改好的标签却被覆盖了。

请求超时以后,客户端只知道自己没有拿到结果。服务端可能尚未收到请求,也可能已经提交数据库,只在返回响应时断开了连接。把这两种情况都当成保存失败,再提交一份新请求,会让一次操作产生额外的副作用。

下面用一个内容管理系统说明这条链路。字段和请求均为教学示例,设计来自一次实际的发布可靠性改造。隔离环境中验证过响应丢失后的重试、版本冲突与发布撤回;真实移动设备上的完整操作仍需单独验收。

一次保存需要保留自己的身份

浏览器产生一次保存意图时,为它分配 writeId,并保存当时的正文和版本:

{
  "writeId": "request-a",
  "revision": 7,
  "title": "一篇文章",
  "contentRaw": "正文快照",
  "tags": ["engineering"]
}

如果这次请求超时,重试继续使用同一份请求体。重新生成 writeId 会让服务端把它视为另一次写入;从编辑器重新读取正文则可能让相同标识对应不同内容,两种做法都会破坏重试语义。

编辑器可以继续允许作者输入,但需要分别保留正在发送的快照和后续编辑。后续内容形成下一次保存意图,等待前一次结果确定后再处理,不能悄悄替换待重试的请求体。

禁用保存按钮能减少双击,却无法覆盖刷新、网络断开或进程退出。请求身份必须进入服务端判断,设备侧也要保存尚未确认的请求。

去重和版本检查承担不同职责

假设两个页面都读到了版本 7。页面 A 保存成功,文章变成版本 8;页面 B 继续提交基于版本 7 的正文。如果只检查 writeId,页面 B 的请求是全新的,仍可能覆盖页面 A。

更新需要把版本条件放进数据库语句:

-- 教学示意:参数通过绑定传入。
UPDATE articles
SET content = ?, revision = revision + 1, last_write_id = ?
WHERE id = ? AND revision = ?;

只有数据库当前版本与请求基线相同,更新才生效。先读版本、在 JavaScript 中比较、再执行无条件更新,会在比较与写入之间留下竞争窗口。

服务端还要区分相同请求的重放。如果刚才的 request-a 已经成功,文章版本从 7 变成 8,原样重试应返回此前的成功结果,而不能仅因请求仍携带版本 7 就报冲突。因此,可以先在授权范围内查找已完成请求,再处理新写入的版本条件。

这次改造使用文章上的 last_write_id 识别最近一次成功写入。它适合当前编辑流程,不能当成永久的幂等请求账本:文章又被其他请求修改后,旧标识已经不在该字段中。需要跨多次后续写入识别历史请求时,应增加有明确保留期限的请求记录,并绑定作者、目标资源、请求摘要和结果。相同标识携带不同正文时应拒绝,不能直接返回一个看似成功的结果。

正文和标签必须属于同一次提交

保存文章通常包含正文更新、删除旧标签关联、写入新标签关联。如果它们分别提交,正文成功后标签失败,作者会看到半次保存;再次重试时,又难以知道哪些步骤已经完成。

这次实现将文章和标签关联放进 D1 的 batch()。D1 文档规定,批次内语句依次执行,并具有事务语义,语句失败会中止并回滚批次。D1 Batch API

这里还有一个容易漏掉的条件:带版本的 UPDATE 没匹配到记录,通常仍是一条合法执行的 SQL。不能期待它自动让后面的标签操作停止。后续关联写入也要限定目标文章持有本次 writeId,否则旧版本请求虽然没有改正文,却可能改掉别人的标签。

批次结束后,服务端读回文章标识、版本和最近写入标识,确认本次请求是否拥有最终结果。这个实现仍有自己的并发边界:如果在提交与读回之间又发生新写入,读回可能无法确认较早的成功请求。对结果确认有更严格要求的系统,需要在事务内保存可查询的请求结果,不能只依赖文章上的最后一个标识。

冲突之后保留内容,交给作者合并

版本冲突说明远端在基线读取后发生了变化。把请求中的 revision 自动改成最新值再发一次,会绕过原本用来保护内容的条件。

编辑器应保留当前正文,展示远端变化,并允许下载副本或重新打开文章进行合并。作者确认后的内容才形成新的写入意图,使用新 writeId 和新基线。设备草稿则负责刷新、断网和误关页面后的恢复,不能替代服务端版本检查。

设备草稿需要按作者和文章隔离。共用浏览器上切换账户时,不能把上一位作者的内容自动灌入当前编辑器;本地存储中的草稿也不应被描述为加密备份。

保存结果与公开可见性需要分别确认

数据库已经提交,随后清理缓存失败,如果接口因此返回保存失败,客户端就会被迫重复一个已经完成的写入。提交状态和缓存维护结果应分别记录。

对内容撤回,还需要考虑旧快照。此次实现给公开内容设置数据库版本标记:读取时先取得当前版本,再查对应的 KV 快照;缓存未命中时读数据库,并在写入缓存前复查版本。旧版本快照可以继续存在,但后续读取不应把它当成当前内容。

版本标记采用随机值,目的是降低数据库恢复后新分支复用旧整数版本、误命中旧缓存的风险。它需要相关内容表的变更都触发更新,也会增加每次读取的数据库成本。这里选择数据库不可读时失败,避免继续提供无法确认是否已经撤回的快照。

这套机制不能回收已经打开的页面、用户保存的副本或独立公开 CDN 上的图片,也不应被描述成任意并发时序下都能瞬间撤回。HTTP 缓存策略、读取时序和媒体权限仍需分别设计与验证。

Agent 需要在发请求之前留下记录

当保存动作由本地 Agent 发起,浏览器会话和 Agent 进程都可能中断。仅在收到成功响应以后写日志,会遗漏最需要恢复的状态:请求已经发出,结果未知。

交接文件需要记录目标站点、文章身份、源内容摘要、远端基线和确切请求体。发出写入前先落盘本次 attempt;恢复后沿用原请求身份与正文进行核对或重试。发现远端变化、目标不符或交接文件损坏时停止,不自行升级版本。

更新一篇已发布文章还要明确:保存可能立即影响公开正文。预览应使用不落库的渲染路径,避免为了预览先撤回文章,也避免把一次预览当成无副作用的保存。

验证最危险的时刻

正常保存后看到成功提示,只覆盖了顺利路径。更有区分度的测试是让数据库提交成功,再丢弃响应;随后重复发送同一请求,检查文章数量、版本和标签是否符合预期。

并发测试需要让两个请求读取同一基线,再分别保存,检查失败请求没有修改标签。缓存测试则应先预热公开快照,再撤回文章,验证详情、列表、标签、RSS 和 sitemap,不只检查管理页面里的状态。

此次隔离验收覆盖了重复写、旧版本冲突、发布与撤回,以及 Agent 更新交接。生产部署记录与这些测试分开保存,没有拿既有公开文章做写入实验。后续仍需补齐真实移动设备上的草稿恢复、登录与发布操作,才能判断作者实际使用的整条链路是否可靠。