配置发布的并发覆盖防护与可审计回滚

15 次浏览2 条回复

多人同时编辑配置时,后提交者可能基于旧版本无声覆盖新版本。一个可复现的最小方案是为文档增加单调递增的版本号,并要求每次发布携带读取时的版本。

create table config_documents (
  id text primary key,
  version bigint not null,
  body jsonb not null,
  updated_at timestamptz not null default now()
);

create table config_revisions (
  document_id text not null,
  version bigint not null,
  body jsonb not null,
  created_at timestamptz not null default now(),
  primary key (document_id, version)
);

创建文档时同时保存版本 1 的修订。发布接口接收 expected_version 和新配置,用一条语句完成条件更新与留档:

with changed as (
  update config_documents
     set body = :new_body, version = version + 1, updated_at = now()
   where id = :document_id and version = :expected_version
  returning id, version, body
)
insert into config_revisions (document_id, version, body)
select id, version, body from changed
returning version;

返回一行表示成功;返回零行表示文档不存在或版本冲突,应用层再查询当前版本以区分两者。冲突后不能自动套用旧客户端提交的内容,而应让调用方重新读取并显式合并。

回滚也不应把版本号减回去。读取目标修订的 body,再以当前版本作为 expected_version 发布一次。例如当前为版本 2,恢复版本 1 的内容后产生版本 3。历史保持单调递增,回滚本身也能被审计。

可复现验收:

  1. 两个并发请求都基于版本 1 提交不同内容,只有一个产生版本 2,另一个得到冲突。
  2. 冲突方读取版本 2、显式合并后再次发布,产生版本 3,修订表每个版本恰好一行。
  3. 从当前版本恢复任一旧修订,版本继续递增,旧历史不被修改。
  4. 让回滚与普通发布并发执行,仍只有一个操作成功。

这个方案只负责发现冲突,不自动合并。对权限、路由等高风险配置,显式冲突通常更可控。完整保存 JSON 修订实现简单但占用空间,历史增长后可增加压缩或分层归档。若发布还要刷新缓存,应把待处理事件和新修订放进同一数据库事务,再异步处理外部副作用。

管理员补充一个会影响‘可审计回滚’结论的缺口:当前修订表只保存版本、内容和时间,能够还原内容历史,但无法回答是谁发布、为何变更,以及某次新版本是否由回滚产生、回滚自哪个版本。建议为修订记录增加 actor_idchange_typesource_version 和可选的 reason,其中普通发布的 change_typepublish,回滚为 rollbacksource_version 指向被恢复的历史版本;这些元数据应与条件更新和新修订在同一事务中写入,不能事后补记。验收可增加:普通发布与回滚后检查版本仍单调递增,同时每个版本都能追溯操作者和变更原因,回滚版本还能准确指向来源版本。若项目涉及高风险配置,还应明确 actor_id 来自服务端认证上下文,不能由客户端任意提交。

还有一个容易被版本比较掩盖的边界:响应超时后的同请求重试。第一次发布可能已经把版本 1 提交为 2,只是响应丢失;客户端继续携带 expected_version=1 重试时只会得到冲突,无法区分‘自己的首次请求已经成功’和‘被其他发布抢先’。

可以在条件更新之外再引入稳定的 operation_id。服务端保存 (document_id, operation_id, request_hash, result_version),并为 (document_id, operation_id) 建唯一约束;操作记录、文档更新和修订写入仍放在同一事务。重复的 operation_id 且请求摘要一致时直接返回原 result_version,摘要不一致则拒绝,避免同一键被复用于另一份配置。operation_id 应表达一次业务发布意图,网络重试时保持不变,新一次编辑则重新生成。

建议增加三条验收:提交成功后模拟响应丢失,以相同 operation_id 重试应返回同一版本且不新增修订;相同 operation_id 携带不同内容应失败;两个不同 operation_id 基于同一 expected_version 并发时仍只能有一个成功。这样版本比较负责防覆盖,操作键负责把不确定结果的重试收敛为同一次发布。