返回博客

多人同时编辑同一文档,后保存的人把前人的内容悄悄覆盖了

某后台管理系统上线了一个富文本文档模块。两个运营同学同时打开同一篇文档,各自改了不同段落,先后点击保存。结果——后点击的那位把前一位的所有修改静默覆盖,没有任何提示。活动日志里能看到两次保存记录,但只有最后一次的内容留下来了。

这个坑隐蔽在于:页面没有报错,接口返回 200,保存"成功"了——只是数据已经丢了。

现象

  • 用户 A 14:00 保存,用户 B 14:01 保存;最终内容是 B 的版本,A 的修改消失。
  • 换成用户自己:在两个浏览器 tab 里分别打开同一份文档,修改后分两次保存,同样复现。
  • 接口层日志显示两次 update() 均返回受影响行数 = 1,完全正常。

根因

UPDATE doc SET content=?, update_time=? WHERE id=? 是无条件"最后写赢"。服务端只比对 id,完全不知道客户端是从哪个版本出发做的修改。

坑:无 token 的写操作天然是 last-write-wins

客户端加载文档时拿到了某一刻的 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"
  }
}
正解:update_time 做 token + 409 + force=true 三件套

GET 时透传 update_time → PUT 时带回做比对 → 不一致返回 409 并附带冲突元数据 → 前端展示冲突对话框 → 用户选「强制覆盖」则带 force=true 重发。整条链路无静默数据丢失。

几个实现细节

  • 向后兼容:旧客户端可能不传 lastUpdateTime。短期内可以记 warning 日志放行,等客户端全量升级后再把"缺少 token = 拒绝"收紧。
  • 精度update_timedatetime 秒级精度在极端情况下(同一秒内两次保存)仍会误判。如需更高保证,改用自增整数 version_number 列,每次 UPDATEversion_number = version_number + 1,比对整数更可靠。
  • 事务包裹:写操作(更新文档 + 记录版本快照)要放在同一个事务里,防止写文档成功但快照写失败的半成功状态。
  • 适用边界:追加类数据(评论、日志流)不需要乐观锁;多行事务型编辑(同时改多张表)用 saga 或行级 version_number + 顶层守卫更合适。

一句话外卖

任何允许多人编辑的单行资源,PUT 接口都必须携带并校验版本 token(update_timeversion_number),否则"保存成功"和"数据丢失"会同时发生。