这篇是一次”画布存不上”问题的复盘,前后修了一周。
画布是用户的作品,保存失败就等于丢作品。这一周里用户遇到的现象各不相同:有的是”刷新后生成好的视频全没了”,有的是”一直显示已暂存在本地”,有的是”删不掉节点、提示请先读取服务器画布”。追下去,它们都是同一个保存队列在不同条件下”永久停摆”。
开发全程在 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 || terminalts两个问题:
- 临时服务故障被当成”离线”。502/503 这类错误会被归为 offline,于是队列暂停,等待”恢复联网”的信号;但浏览器其实一直在线,这个信号永远不会来。
- 强制保存期间的任何失败都会让队列暂停。页面失焦、切到后台时会强制保存一次(
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 && !recoveryExhaustedts然后:
- 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。这是终止性错误,队列暂停,重试和刷新都会原样重发同一个请求。
修复分两部分:
- 删除操作不再携带整节点前置条件。后端按 ID 找到节点,把它自己手里的那一份移进回收站。前端的快照对删除来说本来就没有必要。
- 遇到
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,新的队列能把它们全部处理完,而且最终结果和用户本地的文档一致。确认之后才上线。
五、第四轮:边边角角#
还有几个小问题,也是”状态没有成对”造成的:
- 保存处于暂停状态时,删除/恢复/撤销节点会把状态栏改回”正在保存”,但实际上什么都没有发送。
- 空批次(没有任何操作的保存任务)会让恢复队列错误地接管,并导致核对失败。
- 崩溃页面增加了”有未保存修改,可以导出恢复文件”的提示;导入画布时,等第一次保存完成后才提示成功。
六、收获#
- 列出所有让队列停下的原因,并确认每一个都有对应的恢复路径。这一周修的 bug,本质上都是”进入了暂停状态,却没有任何东西会把它解除”。
- 错误分类决定了系统的韧性。408/429/5xx/网络超时属于”等一等就好”,鉴权失败、内容冲突、参数非法属于”永远不会好”,把它们混在一起,要么无限重试,要么轻易放弃。
- “保守地拒绝”不等于安全。拒绝覆盖云端看起来稳妥,但如果用户侧没有任何出路,它就是死锁。冲突策略要从用户意图出发:用户改了什么、想做什么。
- 页面状态、已确认版本、队列这三者必须一致。只换已确认版本而不更新页面,会让下一次 diff 把云端的改写当作用户的修改发回去。
- 前置条件只带必要的部分。删除操作带整节点快照,相当于”任何人改过任何字段我都不删”,这个条件太强了。
- 用真实数据回放验证,比自己构造的测试用例可靠得多。