LZWcc's Blog

Back

这篇是一次”画布存不上”问题的复盘,前后修了一周。

画布是用户的作品,保存失败就等于丢作品。这一周里用户遇到的现象各不相同:有的是”刷新后生成好的视频全没了”,有的是”一直显示已暂存在本地”,有的是”删不掉节点、提示请先读取服务器画布”。追下去,它们都是同一个保存队列在不同条件下”永久停摆”。

开发全程在 Claude Code 里协作完成:我负责定位现场、定方案和取舍、验证;Claude Code 负责读源码、写代码和测试。文中的”我们”指这个组合。

一、保存体系长什么样#

先简单介绍一下背景,后面的问题都和这几个概念有关:

  • 操作式保存:前端不是每次上传整份画布,而是把用户的修改算成一组操作(patch),比如”节点 A 的 content 字段改成 X”,放进一个串行的保存队列按顺序发给后端。
  • 版本号(revision):每次保存成功,云端版本号 +1。请求里带着”我是基于哪个版本改的”,后端发现版本对不上就返回 409。
  • 条件操作(precondition):部分操作带”期望值”,比如删除节点时附带”我以为这个节点现在长这样”,云端实际值不一致就拒绝。
  • 本地草稿:队列里还没被确认的修改,会备份到浏览器的 IndexedDB,刷新后可以恢复。
  • 已确认版本(confirmed):前端记着”云端最后确认的那一份画布”,新修改就是相对它算出来的。

这套设计本身是对的,问题出在各种”异常路径”上。

二、第一轮:临时 502 让自动保存停摆#

现象#

有用户刷新页面后,最近一段时间生成的几个视频都不见了。

定位#

先是同一天的一次发布:前后端切换的那一两分钟里,保存请求收到 502,之后一个多小时都没有新的保存成功。再是这位用户:从 nginx 日志还原出来,这位用户的保存请求在某一秒碰上了全局限流 429,之后很长一段时间没有发出过任何保存请求,而视频轮询、生成请求一直正常——页面还活着,只有保存队列停了。这段时间里视频陆续生成完成、回写到页面上,但都没有保存到云端。刷新之后页面从云端加载,这些视频就”丢了”。

看保存队列失败后的处理:

// 修复前
const terminal = status >= 400 && status < 500 && status !== 429
const shouldPause = flushRequested || offline || authError || terminal
ts

两个问题:

  1. 临时服务故障被当成”离线”。502/503 这类错误会被归为 offline,于是队列暂停,等待”恢复联网”的信号;但浏览器其实一直在线,这个信号永远不会来。
  2. 强制保存期间的任何失败都会让队列暂停。页面失焦、切到后台时会强制保存一次(flushRequested = true)。这时只要碰上一个错误,哪怕是 429 这种等一等就会好的错误,shouldPause 也是 true,而且之后没有任何地方会把它恢复。这位用户就是在切换窗口的那一刻撞上了限流。

修复#

核心思路:把错误分成”等一等就会好”和”永远不会好”两类,前一类无论如何都不能让队列停下。

const retryable = (s?: number) => s === 408 || s === 429 || (s !== undefined && s >= 500)
const terminal = status >= 400 && status < 500 && !retryable(status)
const disconnected = isOffline()
const shouldPause = flushRequested || disconnected || authError || terminal || recoveryExhausted

// 关键:记录"这次暂停是不是可以自动恢复的"
controller.resumeWhenOnline = shouldPause && !authError && !terminal && !recoveryExhausted
ts

然后:

  • 408/429/5xx 和网络超时按退避继续重试,只有真正离线才等待恢复联网。
  • 强制保存失败时,向调用方报告失败,但后台队列继续重试。
  • 在所有”环境可能变好”的时机(健康检查通过、重新联网)检查 resumeWhenOnline,满足条件就恢复队列。
  • 请求发出的同时写本地草稿,保留原请求 ID 和不可变的请求载荷,重试时原样重发,由后端按 ID 去重。
  • 状态栏区分”已暂存在本地”和”已保存到云端”,处于 offline 状态时也提供”导出恢复文件”和”重试”。

验证时我们在本地注入故障:保存请求连续 27 次返回 502,页面上的修改一直保留;撤掉故障后不点”重试”,队列自动补发成功,刷新后内容完整。

三、第二轮:打开画布时队列永久暂停#

现象#

直接打开或刷新画布后,所有修改都显示”已暂存在本地”,一直不发送。切换一下浏览器标签页又好了。

根因:两个异步流程的先后顺序#

打开画布时有两件事同时进行:

  • A:编辑会话做一次”核对”(reconcile),比较本地草稿和云端。
  • B:画布列表加载完成后,开启账号级的同步开关。

核对函数是这样写的:

async function reconcile() {
  pauseQueue()                       // ① 先暂停队列
  if (!token || !syncEnabled) {
    throw new Error('同步未就绪')     // ② 再检查条件,不满足就抛错
  }
  ...
  resumeQueue()
}
ts

如果 A 比 B 先执行,就会①暂停、②抛错,之后再也没有人去恢复队列。切换标签页之所以能恢复,是因为页面可见性变化会触发一次重新核对。

修复#

两行改动:先检查条件再暂停;同步开关打开的那一刻,把之前失败的核对重新跑一遍。

async function reconcile() {
  if (!token || !syncEnabled) throw new Error('同步未就绪') // 先检查,不留下暂停状态
  pauseQueue()
  ...
}

function setSyncEnabled(enabled: boolean) {
  const was = syncEnabled
  syncEnabled = enabled
  if (enabled && !was) resumeAllEditors() // 补跑失败的核对
}
ts

这类 bug 的共同特征是:“暂停”和”恢复”不成对。任何可能提前退出的路径,都要保证不会留下一个”没人会来解除”的状态。

四、第三轮:云端改写画布后,409 冲突永远恢复不了#

这是最难的一轮,前后改了三次。

现象#

用户开着画布,同时在”云空间”里永久删除了一些文件。之后画布上的任何修改都存不上,删除节点时提示”请先读取服务器画布后再编辑”,刷新也没用。

根因#

云空间删除文件时,后端会改写所有引用了这个文件的画布:把节点里的媒体地址清空,然后版本号 +1。

此时前端的状态是:

  • 已确认版本是 v10,云端已经变成 v11(媒体字段被清空)。
  • 用户在 v10 的基础上继续编辑,发送时收到 409。
  • 409 的恢复逻辑是:读取云端最新版本,把本地的修改 rebase 到它上面。如果本地改的字段云端也改过(同一个字段冲突),或者找不到当初的基准版本,就放弃并暂停,保留恢复材料,不覆盖云端。

“不覆盖别人的修改”听起来很安全。但在这里,“别人”其实是后端的自动清理,而用户这一侧的修改永远发不出去,刷新后从草稿恢复又会遇到同样的冲突。原本出于安全考虑的保守策略,变成了一个死锁。

另外还有一个后端问题:画布引用了本人已经删除的文件时,后端会整批拒绝(canvas_patch_invalid),前端重试时原样重发,结果永远失败。导入的画布、撤销恢复出来的节点都会触发这个问题。

第一次修改:本地优先#

先解决”永久卡住”:

  • 后端:只拒绝引用他人文件的情况,本人的文件就算已删除或不存在也放行(引用一个已删除的本人文件,最坏的结果就是显示一张破图)。
  • 前端:409 时把本地修改套到云端最新版本上重发,同字段冲突时以本地为准;找不到基准版本时,退回使用草稿里保存的基准。
  • 覆盖云端之前,把当时的云端版本存到浏览器本地(canvas-overwritten:v1:*),万一需要可以手工恢复。
  • 从旧页面恢复出来的保存任务重置重试预算,避免一上来就”恢复次数已用完”。

第二次修改:本地优先带来的新问题——已删除的图片”复活”了#

上线几天后出现了新问题:有用户的画布上出现了一批破图节点,而这些图片明明已经在云空间删掉了。

原因是:409 恢复时,我们只把”已确认版本”换成了云端最新版本,页面上显示的内容仍然是旧的(还带着那些媒体地址)。下一次用户随便改点什么,前端用”页面当前内容”减去”新的已确认版本”计算 diff,就会发现”媒体地址从空变成了 X”,于是把这个”修改”发了出去。后端清空的字段,就这样被前端写了回去。

修复的方法是:恢复时页面显示的内容 = 云端最新版本 + 本页仍在排队的修改。

// 409 恢复:页面立刻切到"云端最新版 + 本页排队中的修改"
const server = latest.project
setProject({
  ...applyQueuedJobs(server, [controller.retry, ...controller.restored, controller.pending]),
  // 视口、侧边栏这类纯本地的 UI 状态保持不变
  viewport: current.viewport,
  sidePanel: current.sidePanel,
})
ts

效果就是按字段合并:用户自己改过的字段(还在队列里)以本地为准,其余字段以云端为准。后端清空的媒体字段不在用户的修改里,就不会再被写回。

我们先写了一个复现测试(修复前确实失败),再写修复代码。

第三次修改:删除命令被”整节点期望值”卡住#

又过了几天,再次出现”同步未完成 / 目标内容已被修改”,这次是删除节点删不掉。

删除节点的请求会附带一个”我以为这个节点现在长这样”的整节点快照作为前置条件。云空间清理改写了节点的媒体字段之后,这个快照永远对不上,后端返回 canvas_content_conflict。这是终止性错误,队列暂停,重试和刷新都会原样重发同一个请求。

修复分两部分:

  1. 删除操作不再携带整节点前置条件。后端按 ID 找到节点,把它自己手里的那一份移进回收站。前端的快照对删除来说本来就没有必要。
  2. 遇到 canvas_content_conflict 时,读一次云端,然后按用户的意图重放,而不是原样重发:
    • 删除/恢复命令:在云端版本上重新计算操作和期望值后再发送;如果要删的节点已经不存在,就把它降级为普通编辑。
    • 撤销/重做:如果目标字段已经被云端改过,这一步撤销就不再是”撤销用户自己的修改”了,所以放弃这一步,保留云端的值,并提示用户”云端内容已更新,本次撤销/重做未执行”。
if (!job.command) {
  // 普通条件编辑只有撤销/重做:目标已变,放弃这一步并提示
  retry = { ...job, patch: [], project: server }
  notice = '云端内容已更新,本次撤销/重做未执行'
} else {
  const intended = applyOperations(server, job.patch)              // 把意图套到云端版本上
  const patch = withPreconditions(server, diff(server, intended))  // 重新生成期望值
  retry = { ...job, patch, project: { ...intended, revision: server.revision } }
}
ts

怎么验证#

这一次我们没有只依赖单元测试:卡住的画布里积压了一批保存任务,我们把它们拿到真实的后端仓储代码上回放——旧请求能稳定复现 409,新的队列能把它们全部处理完,而且最终结果和用户本地的文档一致。确认之后才上线。

五、第四轮:边边角角#

还有几个小问题,也是”状态没有成对”造成的:

  • 保存处于暂停状态时,删除/恢复/撤销节点会把状态栏改回”正在保存”,但实际上什么都没有发送。
  • 空批次(没有任何操作的保存任务)会让恢复队列错误地接管,并导致核对失败。
  • 崩溃页面增加了”有未保存修改,可以导出恢复文件”的提示;导入画布时,等第一次保存完成后才提示成功。

六、收获#

  1. 列出所有让队列停下的原因,并确认每一个都有对应的恢复路径。这一周修的 bug,本质上都是”进入了暂停状态,却没有任何东西会把它解除”。
  2. 错误分类决定了系统的韧性。408/429/5xx/网络超时属于”等一等就好”,鉴权失败、内容冲突、参数非法属于”永远不会好”,把它们混在一起,要么无限重试,要么轻易放弃。
  3. “保守地拒绝”不等于安全。拒绝覆盖云端看起来稳妥,但如果用户侧没有任何出路,它就是死锁。冲突策略要从用户意图出发:用户改了什么、想做什么。
  4. 页面状态、已确认版本、队列这三者必须一致。只换已确认版本而不更新页面,会让下一次 diff 把云端的改写当作用户的修改发回去。
  5. 前置条件只带必要的部分。删除操作带整节点快照,相当于”任何人改过任何字段我都不删”,这个条件太强了。
  6. 用真实数据回放验证,比自己构造的测试用例可靠得多。
画布自动保存踩坑:从 409 卡死到按字段合并
https://lzwcc.xyz/blog/canvas-autosave-conflict/
Author LZWcc
Published at October 21, 2026