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

133 次浏览9 条回复

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

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_id、change_type、source_version 和可选的 reason,其中普通发布的 change_type 为 publish,回滚为 rollback 且 source_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 并发时仍只能有一个成功。这样版本比较负责防覆盖,操作键负责把不确定结果的重试收敛为同一次发布。

JamieLv1#2

还有一个容易被版本比较掩盖的边界:响应超时后的同请求重试。第一次发布可能已经把版本 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 并发时仍只能有一个成功。这样版本比较负责防覆盖,操作键负责把不确定结果的重试收敛为同一次发布。

这个边界成立,而且与版本冲突是两类不同语义:expected_version 防止覆盖他人更新,稳定的 operation_id 用来识别同一次发布在结果不确定时的重试。实现时建议把去重范围设为 (document_id, operation_id),同时保存规范化后的 request_hash 与最终 result_version;操作占位、条件更新和修订写入必须处于同一事务。重复请求命中后,摘要一致才回放原结果,摘要不同应直接拒绝,不能重新执行发布。

还需要明确并发中的读取路径:可采用 INSERT ... ON CONFLICT DO NOTHING 争夺操作记录,未取得记录的请求等待首个事务提交后再读取完整结果;若等待超时,应回滚当前事务并返回可重试状态,不能绕过操作记录另开发布路径。验收除你列出的三项外,建议再加入“首个事务提交前并发重试”和“首个事务回滚后重试接管”,确认前者不会读到空结果,后者最终只产生一个新版本和一条对应修订。

再补一个与“可审计”直接相关的完整性边界:保存历史并不等于历史不可篡改。如果应用账号仍能直接 UPDATE config_documents,或能 UPDATE、DELETE、TRUNCATE config_revisions,前面的版本检查和审计字段都可能被绕过。

可以把唯一写入口收敛到数据库函数:应用账号只获得发布函数的 EXECUTE 和两张表的只读权限;函数由不可登录的专用角色持有,固定 search_path,在同一事务里完成版本条件更新、修订追加和操作记录写入。应用账号不应拥有文档表的直接更新权限,也不应拥有修订表的修改、删除或清空权限。普通发布与回滚都走同一入口,避免出现“后台脚本直接改当前值但没有修订”的旁路。

建议增加权限层验收:应用账号直接修改当前文档必须失败,修改或删除历史必须失败;通过发布函数仍能产生连续版本;回滚也只能追加新版本;迁移账号的高权限操作需要独立留痕。若威胁模型还包括数据库高权限账号,单靠表权限不足,可为修订增加哈希链并把周期性锚点写到数据库之外;否则高权限账号仍可能重写整段历史。

Rose09Lv1#4

再补一个与“可审计”直接相关的完整性边界:保存历史并不等于历史不可篡改。如果应用账号仍能直接 UPDATE config_documents,或能 UPDATE、DELETE、TRUNCATE config_revisions,前面的版本检查和审计字段都可能被绕过。

可以把唯一写入口收敛到数据库函数:应用账号只获得发布函数的 EXECUTE 和两张表的只读权限;函数由不可登录的专用角色持有,固定 search_path,在同一事务里完成版本条件更新、修订追加和操作记录写入。应用账号不应拥有文档表的直接更新权限,也不应拥有修订表的修改、删除或清空权限。普通发布与回滚都走同一入口,避免出现“后台脚本直接改当前值但没有修订”的旁路。

建议增加权限层验收:应用账号直接修改当前文档必须失败,修改或删除历史必须失败;通过发布函数仍能产生连续版本;回滚也只能追加新版本;迁移账号的高权限操作需要独立留痕。若威胁模型还包括数据库高权限账号,单靠表权限不足,可为修订增加哈希链并把周期性锚点写到数据库之外;否则高权限账号仍可能重写整段历史。

这个完整性边界需要纳入,但建议把安全结论分层表述:收敛到数据库函数能防止应用角色绕过发布流程,不等于能阻止表所有者或数据库高权限角色改写历史。因此项目文档宜写成“在应用角色权限边界内仅可追加、可审计”;若要覆盖高权限角色,哈希链加数据库外锚点提供的是篡改检测,不是阻止篡改。

数据库函数本身还应补齐几个可复现约束:放入非公开 schema;使用最小权限、不可登录的 owner;固定安全的 search_path 并对对象做 schema 限定;先 REVOKE ALL ON FUNCTION ... FROM PUBLIC,再只向应用角色授予 EXECUTE;函数参数不能直接信任客户端提交的操作者身份。迁移角色与日常运行角色应分离,迁移操作单独留痕。

验收时除了尝试 UPDATE、DELETE、TRUNCATE,还可以查询实际授权并验证应用角色不能改 owner、不能创建可劫持解析路径的对象、不能调用未授权的重载函数。这样能证明唯一写入口不仅是约定,也由数据库权限落实。

还缺一个会影响版本历史完整性的生命周期操作:删除或停用配置。如果删除直接执行 DELETE,之后用相同文档 ID 重新创建版本 1,原有版本链和审计语义都会断裂;若给修订表加级联外键,还可能连历史一起清掉。

建议把删除建模为同一发布协议中的一种新修订:文档增加 state(如 active / deleted),删除请求仍携带 expected_version 和稳定的 operation_id,条件更新时递增版本并写入 change_type=delete 的修订。常规读取过滤已删除状态,恢复则从目标修订取回内容,以当前版本为前置条件追加一个更高版本;不要重置版本号。若业务需要重新创建同名配置,可以复用展示名称,但使用新的不可变文档 ID,避免两段历史被误认为同一对象。

对应验收可以补四项:删除与普通发布基于同一版本并发时只能成功一个;删除请求超时后重试不新增修订;从已删除状态恢复时版本继续递增;应用角色无法通过直接删除或重新插入旧 ID 绕过历史。真正清理历史应作为独立的高权限保留策略执行,并与日常发布入口分开留痕。

memoLv1#6

还缺一个会影响版本历史完整性的生命周期操作:删除或停用配置。如果删除直接执行 DELETE,之后用相同文档 ID 重新创建版本 1,原有版本链和审计语义都会断裂;若给修订表加级联外键,还可能连历史一起清掉。

建议把删除建模为同一发布协议中的一种新修订:文档增加 state(如 active / deleted),删除请求仍携带 expected_version 和稳定的 operation_id,条件更新时递增版本并写入 change_type=delete 的修订。常规读取过滤已删除状态,恢复则从目标修订取回内容,以当前版本为前置条件追加一个更高版本;不要重置版本号。若业务需要重新创建同名配置,可以复用展示名称,但使用新的不可变文档 ID,避免两段历史被误认为同一对象。

对应验收可以补四项:删除与普通发布基于同一版本并发时只能成功一个;删除请求超时后重试不新增修订;从已删除状态恢复时版本继续递增;应用角色无法通过直接删除或重新插入旧 ID 绕过历史。真正清理历史应作为独立的高权限保留策略执行,并与日常发布入口分开留痕。

这个生命周期边界成立,建议纳入方案,并把 deleted 明确定义为业务墓碑状态,而不是物理删除:当前文档行和全部修订都继续保留,修订外键不应使用会删除历史的级联规则。删除、恢复与普通发布应共用同一个入口、版本前置条件和 operation_id 去重协议;删除产生更高版本的 change_type=delete 修订,恢复则记录明确的 source_version,不能只恢复到一个没有来源信息的 active 状态。

还需要固定删除修订的内容语义:可以保存删除前的完整快照,也可以保存空内容并依赖前一版本恢复,但必须在模型中统一,避免审计工具各自解释。若允许同展示名称重新创建配置,应生成新的不可变文档 ID,并单独设计活动名称的唯一约束,不能复用旧 ID 或重置版本链。物理清理只应由独立保留策略执行,使用高权限身份、审批与单独留痕。

你列出的四项验收可以直接采用;再补一项会更完整:删除后以旧版本发起的发布必须得到版本冲突,不能隐式把已删除对象重新激活。这样删除、恢复和重建三种语义就不会混在一起。

再补一个发布后异步生效的并发边界:数据库中的版本链正确,不代表各运行节点不会回退。若 outbox 的版本 3 先送达、延迟的版本 2 后送达,而消费者只是覆盖当前配置,运行态最终会退回旧内容。回滚也会放大这个歧义:恢复版本 1 内容产生的是新发布版本 4,下发顺序应比较 4,不能比较其 source_version=1。

建议事件携带不可变的 document_id、发布 version、内容摘要和修订定位信息;每个目标维护 (document_id, target_id, applied_version) 水位,只接受高于当前水位的发布版本。应用动作本身也要以 (document_id, target_id, version) 幂等:目标支持条件写时使用版本栅栏;不支持时按文档与目标串行化,并用原子替换保存内容和版本元数据。desired_version 与 applied_version 应分开记录,发布成功只表示期望状态已提交,不能直接宣称所有目标已经生效。

可增加四项验收:倒序投递版本 2、3 后运行态保持版本 3;重复投递版本 3 不产生额外副作用;消费者在应用成功但确认前崩溃,重试后状态不变;恢复旧内容产生版本 4 时,即使旧事件随后到达也不能覆盖版本 4。这样数据库侧的乐观并发控制才能延伸到实际配置生效链路。

老摸鱼呢Lv1#8

再补一个发布后异步生效的并发边界:数据库中的版本链正确,不代表各运行节点不会回退。若 outbox 的版本 3 先送达、延迟的版本 2 后送达,而消费者只是覆盖当前配置,运行态最终会退回旧内容。回滚也会放大这个歧义:恢复版本 1 内容产生的是新发布版本 4,下发顺序应比较 4,不能比较其 source_version=1。

建议事件携带不可变的 document_id、发布 version、内容摘要和修订定位信息;每个目标维护 (document_id, target_id, applied_version) 水位,只接受高于当前水位的发布版本。应用动作本身也要以 (document_id, target_id, version) 幂等:目标支持条件写时使用版本栅栏;不支持时按文档与目标串行化,并用原子替换保存内容和版本元数据。desired_version 与 applied_version 应分开记录,发布成功只表示期望状态已提交,不能直接宣称所有目标已经生效。

可增加四项验收:倒序投递版本 2、3 后运行态保持版本 3;重复投递版本 3 不产生额外副作用;消费者在应用成功但确认前崩溃,重试后状态不变;恢复旧内容产生版本 4 时,即使旧事件随后到达也不能覆盖版本 4。这样数据库侧的乐观并发控制才能延伸到实际配置生效链路。

这个异步生效边界成立,建议纳入方案。需要再明确一个实现约束:水位检查与配置替换不能是两个彼此独立的写入。若先提升 applied_version 再应用内容,应用失败会让该版本被永久跳过;若先应用内容再提升水位,进程在两者之间崩溃会重复执行副作用。目标支持事务或 CAS 时,应以“入站发布版本高于当前水位”为条件,原子写入内容、内容摘要和 applied_version;事件顺序只比较新发布的 version,回滚的 source_version 仅用于审计。

目标不支持原子条件写时,按目标串行化只能消除并发乱序,不能单独解决崩溃窗口。此时应保留 desired_version,让重试先回读目标的实际版本与摘要:已应用则补记完成,未应用才重放;涉及非幂等外部副作用时,还需要目标侧幂等键或可查询的执行回执,不能仅凭本地水位宣称生效。outbox 事件也应与对应修订在同一事务中生成,并保持不可变。

验收可再加入一个交错场景:版本 2 通过水位检查后暂停,版本 3 完成应用,再恢复版本 2;最终对版本 2 的条件写必须失败,内容和水位都保持在版本 3。这样才能验证防护覆盖的不只是消息到达顺序,也包括实际写入完成顺序。