快照同步与并发冲突:怎样避免旧窗口覆盖新内容
在两个窗口打开同一份笔记,窗口甲改了标题并保存,窗口乙仍拿着旧标题。几分钟后,乙只修改分类,却把整份旧快照提交上来,甲的新标题随之消失。
另一种更隐蔽的情况是:甲删除一篇笔记,乙的离线快照里仍有它。网络恢复后,这篇笔记又出现在列表中。
可靠同步需要同时回答三个问题:这次保存基于哪一版、哪些操作必须一起完成、删除状态能不能被普通保存改变。
本文的“当前实现”来自本地源码梳理,不代表已通过线上并发验证。示例从零重写,仅表达通用方法;验证步骤是落地建议。
先分清三种不同的保护
| 机制 | 回答的问题 | 单独使用的局限 |
|---|---|---|
| 数据库事务与锁 | 这组读写怎样作为一个整体执行 | 不能自动识别客户端拿着旧内容 |
| 版本检查 | 客户端的编辑基线是否过期 | 检查与写入分开执行仍可能有竞态 |
| 幂等或状态比较 | 重试同一操作应得到什么结果 | 不能代替不同操作之间的冲突处理 |
“用了事务,所以不会覆盖别人的修改”并不成立。事务可以把一次旧快照覆盖完整提交,数据库层面没有半成品,用户的新内容却丢了。
“带上版本号就够了”也不成立。如果版本检查后先释放保护,再开始修改,其他请求仍可以插入这个空隙。
当前实现:锁住同一账号的一致性边界
普通笔记的快照覆盖和单条修改,都先获取同一账号的数据库行锁,再执行业务操作。快照版本检查、内容变更和版本推进在一个事务里完成。
读取快照时也使用相同的保护边界,使返回的内容和版本属于同一次一致读取,而不是分别查出后拼接。
text
读取快照及其版本
↓
客户端编辑并携带基线版本
↓
开启事务 → 获取账号锁 → 比较当前版本
├─ 不一致 → 回滚并返回冲突
└─ 一致 → 写内容、推进版本 → 提交MySQL 的锁定读用于在事务中保护后续操作需要依据的行,相关锁在提交或回滚时释放。这里引用的是机制说明,并不意味着取材系统使用文档对应的数据库版本。MySQL 锁定读说明
下面是教学伪代码,省略了认证、输入校验和数据库适配器:
ts
async function replaceSnapshot(owner, request) {
return transaction(async (tx) => {
const current = await tx.lockWorkspace(owner)
if (current.epoch !== request.expectedEpoch) {
throw new StaleSnapshot(current.epoch)
}
await tx.replaceVisibleItems(owner, request.items)
await tx.advanceEpoch(owner)
})
}这里真正重要的是三个动作共享事务,而不是函数叫什么。快照内每个对象的归属、引用是否合法,仍需另行校验。
当前实现还对全局唯一标识的归属做了写入前后检查,避免“先查未占用,插入时已被其他账号占用”的窗口变成越权更新。
取舍:账号锁简单,但同一账号会排队
账号级串行化容易理解,也让整库覆盖与单条写入遵守统一顺序。代价是大快照会占用事务时间,让这个账号的其他写操作等待。
它不等于所有账号共用一把全局锁,但数据库容量、热点和索引仍会影响整体吞吐。
**建议:**先观察快照体积、事务耗时与锁等待,再决定是否引入增量同步。直接把锁拆得更细,可能让整库操作失去原本依赖的一致性。
条目版本不能只看“最近编辑时间”
知识库条目还有父级、目录位置和排序。用户移动目录时,希望位置立即更新,却通常不希望文档因此跑到“最近编辑”的最前面。
当前实现把两个概念分开:
- 内容时间只随标题、正文或内容类型变化推进;
- 状态标识同时反映内容时间与目录位置等字段;
- 保存携带读到的状态标识,服务端据此判断基线是否仍然有效。
状态标识由字段摘要派生,不是正文哈希,也不是单调递增修订号。正文变化通过内容时间的推进反映到摘要中,不能遗漏这条写入约束。
如果状态经历甲→乙→甲,摘要可能回到原值。它回答“当前状态是否与基线相同”,不负责记录中间发生过几次操作。
当前实现还保留旧客户端的兼容入口,版本参数并非所有请求都强制提供。因此不能把“支持冲突检测”描述为“任何客户端都已受到完整保护”。
相同重试和过期覆盖要分开处理
假设甲的保存实际成功,但响应在途中丢失。甲重试时,携带的旧状态标识已不匹配,不过请求希望得到的字段值与服务端完全一致。
条目更新的当前实现允许这种相同保存重试成功;如果字段确实还要变化,才基于版本判断冲突。
| 请求情况 | 当前条目更新的处理 |
|---|---|
| 版本一致,字段有变化 | 执行更新 |
| 版本过期,提交值已等于当前值 | 允许作为相同结果的重试 |
| 版本过期,提交值会改变当前状态 | 返回冲突和最新内容 |
这条规则不能直接套到整库快照。整库保存成功后已经推进全局版本,再用旧版本重试,当前实现仍会要求重新读取并处理冲突。
冲突界面应让用户保留修改、比较最新内容、另存副本或明确覆盖。把旧请求无限重发,既不能恢复丢失的基线,也不能替用户决定谁的修改优先。
编辑器怎样保留未保存输入,见编辑会话与本地草稿。
删除必须是独立业务动作
当前快照同步会保留服务端的删除状态:旧快照里仍出现的已删除笔记,不会被普通保存恢复。
服务端把本次跳过的对象告诉客户端,让客户端处理残留修改。这个返回值很重要,否则用户看到“保存成功”,实际上有一部分内容没有写入。
text
旧快照包含已删除内容
↓
保存时检查持久删除状态
↓
保留删除状态 + 返回跳过清单
↓
客户端提示、保留副本或引导专门恢复树形目录还有另一层问题:客户端的子节点列表可能已经过期。当前整支删除由服务端读取现有关系,展开全部后代,并用同一批次记录删除。
恢复批次时,如果原父级已经不存在,相关顶层条目会回到可见根级,避免出现“接口说恢复成功,页面却找不到”的状态。
彻底删除与恢复都需要自己的权限和范围检查。不能为了让同步方便,就让任何一次普通保存顺带恢复回收站。
自动保存、历史和界面偏好各有边界
当前实现会在真实的自动保存链路归档旧内容,而不只在手动保存入口做历史。归档按时间间隔节流并限制保留数量,防止每次输入停顿都产生一份完整正文。
“节流归档”不等于“每一次修改都可恢复”。知识库在间隔内抑制新增历史,明确恢复旧版本前则强制保留当前内容。
界面偏好采用另一套存储,不推进正文快照版本。偏好更新使用按键合并语义,避免仅上传局部状态时抹掉其他设备保存的键;相同键仍可能后写生效。
| 数据 | 合理的成功含义 |
|---|---|
| 正文快照 | 对应版本的数据已被确认 |
| 历史版本 | 某个恢复点已保留 |
| 回收站 | 删除状态已持久化,可按规则恢复 |
| 界面偏好 | 提交的偏好键已合并 |
这些成功含义不同,不宜统一显示成一句“全部已备份”。
失败案例与验证建议
下面是建议在隔离环境验证的场景,不是本次已执行的业务测试。
| 场景 | 应观察的结果 |
|---|---|
| 两个窗口以同一快照版本提交不同修改 | 后提交者收到冲突,已确认内容不被静默覆盖 |
| 内容提交成功但响应丢失 | 条目重试按实际字段判定;快照重试按自身版本规则处理 |
| 一端删除,另一端保存旧快照 | 删除不被撤销,跳过内容有明确反馈 |
| 客户端删树时遗漏刚新增的子项 | 服务端按当前树处理,不能留下隐藏孤儿 |
| 恢复前原父级已被彻底删除 | 恢复结果仍出现在可见位置 |
| 事务中途抛错 | 内容与版本一起回滚,不能只推进其中一项 |
| 只改变侧栏偏好 | 不触发正文版本冲突 |
验证冲突时要检查最终持久状态,并确认客户端仍保留未提交内容。只断言 HTTP 状态码,无法证明用户的工作没有丢失。
接下来读什么
- 笔记应用开发实践总览:看清客户端与服务端的责任边界。
- 富文本与附件生命周期:理解正文保存与资源登记为什么不能混为一谈。
- 懒加载与发布恢复:发布失败恢复时怎样保护仍未提交的内容。