多人同时编辑同一文档,后保存的人把前人的内容悄悄覆盖了
某后台管理系统上线了一个富文本文档模块。两个运营同学同时打开同一篇文档,各自改了不同段落,先后点击保存。结果——后点击的那位把前一位的所有修改静默覆盖,没有任何提示。活动日志里能看到两次保存记录,但只有最后一次的内容留下来了。
这个坑隐蔽在于:页面没有报错,接口返回 200,保存"成功"了——只是数据已经丢了。
现象
- 用户 A 14:00 保存,用户 B 14:01 保存;最终内容是 B 的版本,A 的修改消失。
- 换成用户自己:在两个浏览器 tab 里分别打开同一份文档,修改后分两次保存,同样复现。
- 接口层日志显示两次
update()均返回受影响行数 = 1,完全正常。
根因
UPDATE doc SET content=?, update_time=? WHERE id=? 是无条件"最后写赢"。服务端只比对 id,完全不知道客户端是从哪个版本出发做的修改。
客户端加载文档时拿到了某一刻的 update_time,但之后它只带着 id 和新内容去 PUT。服务端无法区分"你是从最新版本出发改的"还是"你加载的是半小时前的旧版本,期间别人已经改过"。于是数据安静地丢失,没有任何异常信号。
正解
用行的 update_time 充当乐观锁 token,三步到位:
1. GET 时把 update_time 透传给前端,前端保存为 lastUpdateTime。
2. PUT 时带上 lastUpdateTime,服务端比对:
$lastUpdateTime = $this->request->post('lastUpdateTime', '');
$force = filter_var($this->request->post('force', ''), FILTER_VALIDATE_BOOLEAN);
if (!$force && !empty($lastUpdateTime)) {
if ($doc['update_time'] !== $lastUpdateTime) {
// 409 — 把冲突元数据返回给前端
$this->error('文档已被他人修改', [
'conflict' => true,
'lastEditor' => $doc['update_user_name'],
'lastEditTime' => $doc['update_time'],
], 409);
}
} elseif (!$force && empty($lastUpdateTime)) {
// 向后兼容旧客户端:记日志但放行(可后续收紧为强制拒绝)
Log::warning('缺少 lastUpdateTime,跳过乐观锁检查', ['doc_id' => $id]);
}3. 前端处理 409: 展示冲突对话框,告知"文档已被 X 在 14:01 修改",提供「重新加载」或「强制覆盖」两个选项;用户选后者则带 force=true 重发请求。
409 响应体:
{
"code": 409,
"msg": "文档已被他人修改",
"data": {
"conflict": true,
"lastEditor": "张三",
"lastEditTime": "2026-04-15 14:01:22"
}
}
GET 时透传 update_time → PUT 时带回做比对 → 不一致返回 409 并附带冲突元数据 → 前端展示冲突对话框 → 用户选「强制覆盖」则带 force=true 重发。整条链路无静默数据丢失。
几个实现细节
- 向后兼容:旧客户端可能不传
lastUpdateTime。短期内可以记 warning 日志放行,等客户端全量升级后再把"缺少 token = 拒绝"收紧。 - 精度:
update_time用datetime秒级精度在极端情况下(同一秒内两次保存)仍会误判。如需更高保证,改用自增整数version_number列,每次UPDATE时version_number = version_number + 1,比对整数更可靠。 - 事务包裹:写操作(更新文档 + 记录版本快照)要放在同一个事务里,防止写文档成功但快照写失败的半成功状态。
- 适用边界:追加类数据(评论、日志流)不需要乐观锁;多行事务型编辑(同时改多张表)用 saga 或行级
version_number+ 顶层守卫更合适。
一句话外卖
任何允许多人编辑的单行资源,PUT 接口都必须携带并校验版本 token(
update_time或version_number),否则"保存成功"和"数据丢失"会同时发生。