踩坑记录:画布回滚后一直提示“版本冲突,请刷新后再操作“,排查了半天才发现是版本号搞混了
2026/7/8 4:40:20 网站建设 项目流程

目录

前言

一、问题现象

二、排查过程

第一步:看请求

第二步:查数据库

第三步:翻代码

三、核心知识:两个版本号的区别

四、为什么会触发

五、怎么修

修复 1:回滚后把 canvas_version 传给子组件

修复 2:手动保存和自动保存串行化

修复 3:后端 409 返回当前版本号

六、总结


前言

最近在开发一个在线画布编辑器,支持快照回滚功能。测试时发现一个诡异的 bug:回滚到旧版本后,再点击保存,必现 409 版本冲突。查了半天才发现是两个长得像但含义不同的"版本号"被混用了。记录一下这个坑,希望能帮到有类似场景的同学。


一、问题现象

正常编辑、保存都没问题。但执行以下操作后必崩:

  1. 画布当前版本 8

  2. 进入快照历史,回滚到版本 4

  3. 点击保存按钮

  4. 有时自动保存也会触发

  5. 再点保存,报错:

{ "code": 409, "msg": "版本冲突,画布已被其他端更新,请刷新后再操作", "data": null }

到这一步用户就很懵:"我就一个人在用,哪来的其他端?"


二、排查过程

第一步:看请求

打开 DevTools 抓包,发现保存时发的请求长这样:

POST /api/v1/studio/canvases/xxx/operations { "items": [ { "operation_type": "update_node", "version": 15 } ] }

version: 15?哪来的 15?

第二步:查数据库

回滚前:

  • drama_canvas.version= 8

  • drama_canvas_snapshot最新version= 14

回滚到快照 v4 后:

  • drama_canvas.version→ 9(乐观锁自增了)

  • drama_canvas_snapshot新增一条 v=15 的快照

真相大白:前端把快照版本 15 当成画布版本提交了,但后端期望的是 9,15 ≠ 9 → 409。

第三步:翻代码

回滚成功后,后端返回:

{ snapshot_version: 15, // 新创建的快照编号 canvas_version: 9 // 更新后的画布乐观锁版本 }

前端做了什么?

// 只拿了 snapshot_version 去拉快照详情 const detail = await getStudioCanvasSnapshotDetailApi( canvasId, result.snapshot_version, // 15 ); // 导入了快照内容,但没有把 canvas_version 传下去 (canvasRef.value as any)?.importCanvasSnapshot?.(detail);

画布子组件内部保存时,用的是本地缓存的一个版本号,但这个值在回滚后没有被更新,导致提交了错误的值。


三、核心知识:两个版本号的区别

很多人在这个场景会踩坑,因为系统里有两个叫 version 的字段,但职责完全不同

Canvas.version

Snapshot.version

是什么

画布的乐观锁版本号

快照的序号

什么时候变

每次更新画布内容时自增

每创建一个快照时自增

谁在用

写操作记录、更新画布内容时校验

快照历史列表、回滚时定位

类比

Git 仓库的 HEAD commit

某条记录在日志里的行号

回滚后

+1(因为内容被替换)

+1(因为创建了新的快照记录)

两者各自独立,没有数学关系。Canvas.version = 9 时,Snapshot.version 可能是 15,也可能还是 3——取决于这个画布一共创建过多少次快照。


四、为什么会触发

画了一个简化的流程图:

回滚前: Canvas.version = 8 MAX(snapshot.version) = 14 用户点击"回滚到 v4" ↓ 后端: INSERT drama_canvas_snapshot (version=15, content=v4.content) UPDATE drama_canvas SET content=v4.content, version=9 返回 { canvas_version: 9, snapshot_version: 15 } ↓ 前端(BUG 在这): 收到了 canvas_version=9,但没有传给画布子组件 画布子组件本地缓存还是旧值(或被误写为 15) ↓ 用户点击保存: 前端发请求 version=15 后端当前 Canvas.version=9 15 ≠ 9 → 409 冲突

所以后端返回 409 是正确行为,它在保护数据一致性。问题出在前端版本同步的流程上。


五、怎么修

修复 1:回滚后把 canvas_version 传给子组件

回滚成功回调里加一行:

// 把回滚返回的 canvas_version 同步给画布子组件 (canvasRef.value as any)?.setBackendCanvasVersion?.(result.canvas_version); (canvasRef.value as any)?.importCanvasSnapshot?.(detail);

子组件新增方法:

function setBackendCanvasVersion(version: number) { if (version > 0) { backendCanvasVersion.value = version; } }

修复 2:手动保存和自动保存串行化

之前手动保存可能和自动保存并发,两个请求拿到同一个 version,一个成功后另一个必 409。现在都走同一个队列:

async function persistCanvasSnapshot(reason?: string) { await enqueuePersistTask(async () => { await persistCanvasOperationRecord(); await saveStudioCanvasSnapshotApi(canvasId, { reason }); }); }

修复 3:后端 409 返回当前版本号

即使前端没跟上,后端也帮它一把:

# 冲突时在 data 里返回当前 Canvas.version raise errors.ConflictError( msg='版本冲突,画布已被其他端更新,请刷新后再操作', data={'current_version': current_canvas_version}, )

前端收到 409 后自动用这个值更新缓存并重试,用户无感知。


六、总结

问题

原因

解法

回滚后保存 409

快照版本号污染了画布乐观锁版本号

回滚成功后显式同步canvas_version

自动保存 + 手动保存冲突

并发两个请求用同一个 version

同队列串行化

409 后用户不知道怎么办

错误响应 data 为空

后端返回current_version,前端自动重试

三条经验:

  1. 同名不代表同义——系统里有多个version字段时,一定要搞清楚每个的含义和生命周期。

  2. 回滚不是只读操作——它会同时推进快照序号和画布乐观锁,前端需要把两个返回值都消费掉。

  3. 冲突响应要有恢复路径——返回 409 的时候,告诉客户端"你应该用什么 version 重试",而不是只返回一个错误信息。

本文基于真实项目 的排查记录整理,涉及后端 FastAPI + SQLAlchemy 乐观锁、前端 Vue 3 + TypeScript 画布编辑器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询