N14第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

多窗口编辑与冲突

多窗口编辑与冲突 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

同一个浏览器开两个标签页改同一篇笔记,是单人应用最真实的并发场景。本课用「版本号乐观锁」检测冲突:后保存者收到明确提示,先留副本再人工合并——绝不静默覆盖已经落库的改动。

问题场景

你开了两个窗口:左边改开头,右边改结尾。右边先保存;几分钟后左边也点了保存——如果服务端直接落库,左边那份「基于旧版本」的整页内容就把右边的结尾改动无声覆盖了。这比崩溃更糟:它不报错,你甚至不知道丢了东西。

核心解释

解法是乐观锁:每条笔记带一个 revision 数字,读时拿到,保存时必须原样交回。服务端比对:对得上才写入,对不上说明期间有人改过,拒绝写入并返回当前版本。

// app/notes/actions.ts(节选)
"use server";

import { getDb, getNote, nowIso } from "@/lib/db/client";

export async function saveNote(input: {
  id: string;
  title: string;
  body: string;
  revision: number;
}) {
  const result = getDb()
    .prepare(
      "UPDATE note SET title = ?, body = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?"
    )
    .run(input.title, input.body, nowIso(), input.id, input.revision);

  if (result.changes === 0) {
    const current = getNote(input.id);
    return {
      ok: false as const,
      conflict: true as const,
      currentRevision: current?.revision ?? 0,
      currentBody: current?.body ?? "",
    };
  }
  revalidatePath("/notes");
  return { ok: true as const };
}

注意冲突检测靠的是 UPDATE 的 WHERE 条件,而不是「先 SELECT 再比较」——changes === 0 就是原子判定,检查与写入之间不存在时间缝隙。

客户端收到冲突后不能自动覆盖,正确姿势是把两个版本都摆出来:

"use client";

if (result.conflict) {
  setConflict({ mine: localBody, theirs: result.currentBody });
  // 界面双栏展示「我的改动 / 服务端当前版本」,
  // 用户合并后带着返回的新 revision 重新保存
}

策略定三条:

  1. 拒绝而不代合并:服务端没有义务猜你的意图,冲突必须由人裁决。
  2. 副本先落地:把本地编辑内容留在编辑器或草稿区,用户合并失败也不能丢字。
  3. 自动保存要克制:定时静默保存遇冲突必须停下来等确认,否则就是冲突的帮凶。

示例拆解

双窗口冲突复现(本机工程):

  1. 打开 /notes/[id] 两个标签页,记下初始 revision(比如 3)。
  2. 窗口 A 改标题保存成功——库里 revision 变成 4。
  3. 窗口 B 不刷新,直接改正文保存——服务端比对发现 3 ≠ 4,返回冲突。
  4. 确认窗口 B 的本地改动没有丢:编辑器里内容还在,界面上能看到服务端当前版本。
  5. 窗口 B 把两边改动合并进正文,带着新 revision 再保存——成功。

关键点:冲突不是错误,是协作信号。UI 的责任是把「你这边基于旧版本」翻译成人话,而不是把 409 状态码甩给用户。

动手任务

在本机工程里完成:

  1. 给笔记更新逻辑加 revision 条件更新,验证 changes=0 时返回冲突结构。
  2. 双标签页复现完整冲突流,确认后保存者能带着新版本合并重存。
  3. 若工程里有自动保存,加「遇冲突暂停」逻辑,手动制造冲突验证不会静默覆盖。

常见错误

  • 先查后写当乐观锁:SELECT 与 UPDATE 之间存在时间窗,两个请求可能同时通过检查;条件更新一条 SQL 才是原子判定。
  • 冲突时直接拿服务端版本覆盖本地:后保存者的字丢了——这恰是要防的事故本身。
  • revision 放在用户可编辑的字段里:被随手改掉,锁形同虚设;它应是随表单携带的隐藏字段。
  • 只锁正文不锁标题:两个字段共用一个版本号,要么一起更新、要么都不更新,部分更新会绕过锁。

检查与复盘

自查清单:

  • 双窗口同时编辑不再发生静默覆盖,冲突能复现也能恢复
  • 冲突发生时本地副本始终有处可寻
  • 说得出条件更新与「先查后写」在并发上的区别

参考资料

检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码中等

阅读笔记更新接口的乐观锁逻辑。窗口 A 和 B 同时打开 revision=3 的同一条笔记,A 先保存成功。B 随后带着 revision=3 保存,结果是?

const row = db
  .prepare('SELECT revision FROM note WHERE id = ?')
  .get(id);
if (!row || row.revision !== body.revision) {
  return NextResponse.json(
    { error: '内容已被其他窗口修改,请刷新后重试' },
    { status: 409 }
  );
}
db.prepare(
  'UPDATE note SET title = ?, body = ?, revision = revision + 1 WHERE id = ?'
).run(body.title, body.body, id);
Q2故障定位较难

bug 现场:两个浏览器窗口编辑同一条笔记,窗口 B 后保存,把窗口 A 刚写入的修改直接覆盖了,双方都不知情。现有更新代码如下。根因和修法是?

db.prepare(
  'UPDATE note SET title = ?, body = ? WHERE id = ?'
).run(body.title, body.body, id);
return NextResponse.json({ ok: true });

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...