大型后台管理系统如何把业务操作做可靠(2):并发编辑、草稿合并与搜索乱序
客服甲和乙同时打开订单,看到地址 A 和版本 7。甲保存地址 B,订单升到版本 8。乙的标签页仍停在 A,她准备写入 C。直接保存会覆盖 B,即使客户端只传地址字段也一样。
从读取到写入携带同一个版本
浏览器读取订单时保存服务端给出的强 ETag。编辑草稿保留一份打开时的基线,提交地址时用 If-Match 表达它所依赖的表示。示例用同一订单 URI 读取和修改,并假设版本标签覆盖所有影响此次写入的变化。
GET /orders/O42
HTTP/1.1 200 OK
ETag: "order-v7"
Content-Type: application/json
{"id":"O42","address":"A","shippingState":"pending"}
PATCH /orders/O42
If-Match: "order-v7"
Content-Type: application/json
{"address":"C"}
服务端用强比较判断 If-Match。版本已变时通常返回 412 Precondition Failed。如果版本检查通过,但业务规则仍禁止修改,例如该订单已处于发货流程,可以按接口约定返回业务冲突。对于多种内容表示,生成 ETag 时还要保证标签真正对应所选表示。应用也可以使用独立的业务版本字段表达并发控制。RFC 9110 的 If-Match 语义
一次原子更新可以同时验证租户、订单 ID、版本和允许修改的状态。下方是 SQL 示意,命名参数由数据库驱动绑定。操作者授权需要在进入写入路径时校验。发货等改变写入资格的动作也应推进版本。
UPDATE orders
SET address = :address, version = version + 1
WHERE tenant_id = :tenant
AND id = :order_id
AND version = :expected_version
AND shipping_state = 'pending';
更新行数为零时,先按权限允许的范围确定资源是否仍可读,再区分版本冲突、订单状态不允许和资源不可见。不要将 SQL 的零行结果一律解释为「地址被别人改过」。若检查跨多个表,事务中要约束所有涉及的并发写入路径。
冲突页面保留三份值
收到 412 后,保持用户输入 C,再读取服务端最新值 B。页面可根据打开时的 A、本地 C、服务端 B 做三方比较。
| 本地相对基线 | 远端相对基线 | 处理 |
|---|---|---|
| 未改 | 已改 | 展示远端值 |
| 已改 | 未改 | 保留草稿,等待新版本提交 |
| 双方改成同一个值 | 双方已改 | 该字段无需再次提交 |
| 双方各有不同修改 | 双方已改 | 显示两边值,由用户确认 |
这个比较以业务字段为单位。省、市、区与邮编可以作为一组判断,防止各合并一半形成无效地址。选完以后提交基于版本 8 的新请求,仍可能再冲突。若操作状态已经变化,例如订单已经发货,保留输入供查看,同时说明可用的业务处理路径。对编辑持续数小时且冲突昂贵的场景,可以考虑有期限的编辑租约,执行端仍要核对租约是否有效。
用请求世代隔离搜索响应
另一个常见场景是先搜索北京,再搜索上海。两个查询无需修改订单,却可能乱序完成。下面的伪代码用世代号保护成功、失败和 loading 三个分支。切换租户时调用同样的失效操作,并清空当前租户的列表状态。
search(query):
generation += 1
current = generation
previousController?.abort()
controller = new AbortController()
previousController = controller
loading = true
error = null
try:
rows = await queryOrders(query, controller.signal)
if current == generation:
visibleRows = rows
catch errorFromRequest:
if current == generation and not isAbort(errorFromRequest):
error = errorFromRequest
finally:
if current == generation:
loading = false
取消查询节省等待或传输成本。归属检查仍然需要保留,因为旧回调可能已经进入执行,或者使用的异步来源不支持取消。缓存键也包含租户、身份及查询参数,否则即使顺序正确,缓存仍可能返回另一个上下文的结果。
从冲突到重新提交走一遍
假设订单版本 7 的收件地址是 A,备注为空。甲只改了备注,远端到了版本 8。乙只改地址为 C,此时可以生成基于版本 8 的新草稿。若甲改了地址的区,乙改了同一地址的街道,虽然 JSON 路径不同,也可能拼成无效地址。可以把地址定义成一个原子编辑组,整组展示冲突。
下面用伪代码表示组级比较。比较之前先归一化允许忽略的格式差异,例如输入首尾空白。缺省、空值和显式清空仍按契约分别处理。
mergeGroup(base, draft, latest):
if equal(draft, base):
return takeLatest(latest)
if equal(draft, latest):
return takeLatest(latest)
if equal(latest, base):
return keepDraft(draft)
return requireChoice({ base, draft, latest })
合并结果应携带最新读取的版本。弹窗打开期间如果另一个客服再改一次,重新提交仍会冲突,此时需要重新比较并保留尚未提交的选择。已提交请求若超时,先查询服务端版本和可识别的操作结果,不能假定冲突页打开前的写入一定失败。
并发控制范围可以小于整笔订单。例如客服备注和收件地址互不影响,更新备注无需让修改地址的人反复解决冲突。代价是必须定义哪些规则依赖多个字段。发货状态决定地址可否修改,独立的地址版本也需要在写入时检查发货资格。列表排序和搜索归属则属于页面状态,不能靠数据库版本来处理。
查询条件要有自己的身份
输入框草稿、已提交筛选和已显示结果可能同时存在。用户键入「上海」但尚未确认时,表格仍可显示北京,同时把正在展示的筛选条件标清楚。正式切到上海后,要使旧分页游标失效,否则上海的第二页可能沿用北京的游标。
写测试时可用两个由测试控制完成顺序的 Promise。先发 A 再发 B,让 B 成功后才让 A 报错。断言表格仍是 B,错误区没有被 A 占据。再将顺序交换,确认 B 未完成时 A 的 finally 不会提前结束加载。若页面支持返回上一页,断言恢复的是已提交筛选和同一租户下的结果,而非输入框最后一次暂存的值。
验收两个竞争窗口
用两个标签页从版本 7 出发,确认甲的保存不会被乙覆盖,乙的草稿能在冲突后继续处理。然后让北京请求晚于上海请求返回,分别模拟旧请求成功和失败。两种情况下都只能显示上海的结果,旧请求结束也不能提前清掉上海的 loading。最后在查询中途切换租户,再重复一次。
总结
服务端写入的版本检查保护并发修改。前端查询的世代检查保护页面显示。两者都要在真正应用结果的那一步检查前提,编辑冲突发生时还要保存操作人员已经填好的草稿。