跳到正文

大型后台管理系统如何把业务操作做可靠(2):并发编辑、草稿合并与搜索乱序

大型后台管理系统如何把业务操作做可靠

  1. 把业务规则写进订单和退款模型
  2. 并发编辑、草稿合并与搜索乱序(当前)
  3. 让报表说清统计口径与数据进度
  4. 退款超时后的幂等与对账
  5. 可恢复的导入导出任务
  6. 批量操作的预览、执行与恢复
  7. 多租户授权、撤权与审计
  8. 旧页面、接口和审批模板如何一起演进
  9. 沿真实操作验收可靠性与性能
  10. 设计规范和组件库如何落到日常开发
  11. 脚手架、接口契约与前后端联调
  12. 构建产物、持续交付与灰度回退
  13. 低代码表单与审批流程的执行边界
  14. 功能投放、权限与灰度人群
  15. 桌面、移动端与宿主能力适配
  16. 多个前端应用如何集成与独立交付
  17. 复杂编辑器的状态、命令与协作

客服甲和乙同时打开订单,看到地址 A 和版本 7。甲保存地址 B,订单升到版本 8。乙的标签页仍停在 A,她准备写入 C。直接保存会覆盖 B,即使客户端只传地址字段也一样。

两个草稿基于版本 7,甲保存后乙的旧版本写入被拒绝

从读取到写入携带同一个版本

浏览器读取订单时保存服务端给出的强 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。最后在查询中途切换租户,再重复一次。

总结

服务端写入的版本检查保护并发修改。前端查询的世代检查保护页面显示。两者都要在真正应用结果的那一步检查前提,编辑冲突发生时还要保存操作人员已经填好的草稿。