两位有权编辑群公告的人都打开了第 7 版。甲把“会议周五举行”改成周六并保存,公告已经变成第 8 版;乙仍在旧页面里改成周日,稍后点了保存。若系统只按请求到达顺序替换全文,甲刚保存的修改就会被覆盖。
问题不在于谁点得更快,而在于保存操作没有说明:这份修改是基于哪一版内容做出的。 “允许编辑”与“允许覆盖当前版本”需要分别判断。
米米商聊的产品方手机端资料确认,群主和管理员可以发布及编辑群公告。本文以这一真实功能为背景,把两位有权编辑者修改同一内容作为独立教学情境。资料没有说明其版本字段、并发控制或桌面端完整操作,本文不判断当前客户端是否采用下面的方案。代码、数据与图均为通用设计示例,不是产品源码或实际架构;文章与配图使用 AI 辅助,示例另做独立验证。
1. 编辑权限与修改基线回答不同的问题
可以把一次保存拆成三个输入:
| 输入 | 回答的问题 | 教学数据 |
|---|---|---|
| 目标公告标识 | 正在修改哪份内容 | notice-1 |
| 修改基线版本 | 编辑者当时看到哪一版 | expectedRevision: 7 |
| 本地新正文 | 编辑者希望提交什么 | “会议周日举行” |
权限回答“此刻还能不能编辑”,基线回答“是否仍在修改原来读到的那一版”。即使两位编辑者都有权限,也不能由此推导后来提交的全文可以无条件覆盖别人刚完成的修改。
客户端应在打开内容时保留基线版本,保存时把它与新正文一起提交。版本比较发生在负责保存的服务或存储侧,不能只在编辑器里比较一次。
2. 检查版本与写入必须作为一个整体
如果流程是“读取版本一致 → 等待其他操作 → 无条件写入”,两个保存仍可能都通过检查,然后先后覆盖。因此要表达的是:仅当当前版本仍等于修改基线,才执行这次写入。
成功后返回新的版本;版本不一致则返回冲突,并保留当前内容。不要把版本冲突混成“网络异常”,也不要直接把新版本号配上旧全文再次提交。后者会让保护条件失去作用。

图:独立教学模型。甲先保存形成新版本,乙的旧基线产生冲突;保留基线、草稿与有权读取的最新内容,不是 App 界面或产品现有机制。
下面用一个单进程内存对象演示。每次成功保存都增加版本,包括正文恰好相同的情况;这是本例的更新序号规则,不是产品版本号或字数限制。
function createAnnouncementStore(initial) {
const {
id, revision, text } = initial ?? {
};
if (typeof id !== "string" || !id || id !== id.trim() ||
!Number.isSafeInteger(revision) || revision < 1 ||
typeof text !== "string") {
throw new TypeError("invalid-initial-state");
}
let state = Object.freeze({
id, revision, text });
const snapshot = () => ({
...state });
return Object.freeze({
read: snapshot,
save(command, canEdit) {
if (canEdit !== true) return {
kind: "forbidden" };
if (!command || typeof command !== "object" ||
Array.isArray(command)) return {
kind: "invalid" };
const {
id: targetId, expectedRevision, text: nextText } = command;
if (targetId !== state.id) return {
kind: "wrong-resource" };
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1 ||
typeof nextText !== "string" || !nextText.trim()) {
return {
kind: "invalid" };
}
if (expectedRevision !== state.revision) {
return {
kind: "conflict", current: snapshot() };
}
if (!Number.isSafeInteger(state.revision + 1)) {
return {
kind: "version-exhausted" };
}
state = Object.freeze({
id: state.id, revision: state.revision + 1, text: nextText
});
return {
kind: "saved", current: snapshot() };
}
});
}
函数复制输入字段,在比较版本之后直接更新自身状态,中间没有 await 或外部调用。它只演示同一个实例中的条件写入。Node.js 官方资料说明了事件循环中回调的执行方式;这不等于数据库或多个服务进程也能共享这份内存状态。Node.js:事件循环与回调
若实际内容保存在数据库,需要由条件更新、事务或相应存储机制保证比较与写入的原子性。不能把示例里的两行判断复制到数据库读取和写入之间,就声称已经避免多进程竞争。
canEdit 是调用方传入的可信权限判断,函数不认证账号,也不计算群角色。实际保存时应根据当前权限判断,不能直接接受客户端提交的 canEdit: true。本例的 read 仅用于教学快照,也没有实现真实读取鉴权;返回当前正文前仍需遵守实际读取规则。
3. 用 HTTP 条件请求表达相同的约束
HTTP 接口可以让读取响应携带强 ETag,保存时发送对应的 If-Match,例如 If-Match: "opaque-ann-v7"。这个值应使用服务端返回的标签,客户端不应自行猜测或把本地时间当成它。
RFC 9110 规定 If-Match 使用强比较;条件不成立时,服务端不能执行该修改,通常以 412 Precondition Failed 表示失败。标准也为能够确定该请求已经成功执行的情况保留了成功响应的可能,所以客户端不应把任何版本不匹配都解释成“别人修改了”。RFC 9110:If-Match
几个容易混淆的细节:
W/开头的弱标签不能在强比较中匹配成功。If-Match: *检查的是当前表示是否存在,并不等于“还是我打开时的那一版”。编辑器需要使用读到的具体标签来约束基线。- 标签要能区分目标表示的变化;同一资源删除后重建时,也不能复用会让旧草稿误匹配的标识或版本。它不是某个编辑者的权限凭证。
MDN 对 If-Match 的说明也列出了强比较及避免丢失更新的用途。MDN:If-Match
前面的 JavaScript 函数使用整数 expectedRevision 返回 conflict,没有解析 HTTP 头,也没有实现完整 HTTP 服务。它与 ETag、If-Match 是表达相同设计意图的不同层次,不能把函数测试当作 HTTP 条件请求集成测试。
4. 冲突后保留基线、草稿与最新内容
只弹出“保存失败”还不够。编辑器最好保留三份信息:
| 内容 | 用途 |
|---|---|
| 当时读到的基线 | 判断本地改动从哪里开始 |
| 尚未保存的本地草稿 | 保留编辑者已经输入的修改 |
| 有权读取的最新内容 | 解释为何旧基线已经失效 |
对于一整段公告文本,这三份内容并不自动导出一种正确合并。可以让编辑者对照差异、复制需要的部分,再明确确认下一次提交;没有可靠规则时不要静默合并,更不要用最新正文直接覆盖本地输入框。
网络响应丢失也需要保留草稿。原保存可能已经成功,只是客户端没有拿到结果。回读状态、识别已处理操作与保留草稿是不同职责;本文模型没有实现请求去重日志,不能据一次 conflict 断言是谁修改,或原保存一定没有成功。
权限变化则是另一条路径:当编辑资格已被移除,应停止写入,不能因为旧页面仍开着就继续保存。草稿是否可以留在本地、多久清理,需要另行定义,不能把“保留输入”承诺成永久保存。
5. 验证旧版本不能覆盖新内容
把下面代码接在第一段函数后,用 Node.js 运行。会议文案、公告标识和版本都是教学数据。
const assert = require("node:assert/strict");
const store = createAnnouncementStore({
id: "notice-1", revision: 7, text: "会议周五举行"
});
const baseA = store.read();
const baseB = store.read();
const localDraftB = {
id: baseB.id, expectedRevision: baseB.revision, text: "会议周日举行"
};
const draftBefore = JSON.stringify(localDraftB);
assert.deepEqual(store.save({
id: baseA.id, expectedRevision: baseA.revision, text: "会议周六举行"
}, true), {
kind: "saved",
current: {
id: "notice-1", revision: 8, text: "会议周六举行" }
});
assert.deepEqual(store.save(localDraftB, true), {
kind: "conflict",
current: {
id: "notice-1", revision: 8, text: "会议周六举行" }
});
assert.equal(store.read().text, "会议周六举行");
assert.equal(JSON.stringify(localDraftB), draftBefore);
// 冲突后不自动更换草稿基线并重试;合并或覆盖需先明确确认。
console.log("旧版本未覆盖新内容,本地草稿未被改写");
本轮在 Node.js v24.19.0 中实际运行了文章两个代码块,并补充独立边界检查,共 18 类通过,覆盖旧版本、未来版本、重复旧提交、目标不匹配、权限拒绝与撤销、输入类型、快照不反向改写状态,以及两个保存任务基于同一版本调度时只有一个成功等。
调度检查作用于同一个内存实例,不是网络并发、数据库隔离级别或吞吐量测试。没有验证产品客户端、真实群公告接口、账号权限系统或 HTTP 头处理。
6. 让“保存成功”具有可解释的条件
开头的乙仍提交第 7 版时,系统应该明确告知基线已经变化,保留乙的草稿和当前第 8 版,让下一次提交建立在新的明确选择上。只按到达顺序覆盖,会把旧页面里的全文当成对当前内容的确认。
因此,公告编辑可以把目标、权限、基线与正文一起考虑:权限决定能否写,基线决定能否按原修改上下文写,条件写入决定如何保存,冲突处理决定怎样继续编辑。这样既保护别人已经完成的修改,也保留当前编辑者的工作,并如实解释本次保存为什么成功或失败。
参考资料
- 功能背景:产品方提供的手机端群公告说明;仅采用已确认的发布和编辑功能,不推测桌面流程或内部并发机制。
- RFC 9110 §13.1.1:If-Match:条件请求、强比较与失败处理。
- MDN:If-Match:标签、弱标签的边界及丢失更新问题。
- Node.js:Don't Block the Event Loop:事件循环与回调执行模型;不构成跨进程存储保证。