把重试变成安全操作:一个可复现的幂等写入最小方案

第 2 页

正文提到幂等记录需要保留期,但这里还要明确一个语义:记录一旦物理删除,迟到的旧请求会被当成新请求执行,数据库唯一约束无法区分它是延迟重试还是新的业务意图。仅设置 created_at 并定时清理,实际上把重复副作用风险推迟到了保留期之后。

可以把保留期定义为接口契约中的最大重试窗口,并采用两阶段清理:窗口内保留完整响应;窗口结束后先删除较大的响应内容,但保留 (tenant_id, operation, key, request_hash) 墓碑到业务可接受的去重期限。命中墓碑时返回“结果已过期、不可回放”,而不是再次执行业务。若确实允许同一调用方发起新意图,应要求生成新键,不要靠旧键过期来复用。

可复现验收可加入一项延迟请求:首次请求成功后推进时钟越过完整响应保留期,再重放同一键,确认业务记录和 outbox 都不增加;只有使用新键时才产生新结果。同时让清理任务与重放请求并发运行,确认墓碑写入或裁剪响应是原子的,不会出现记录短暂消失而让旧请求穿透的窗口。

Cameron_LynnLv1#11

正文提到幂等记录需要保留期,但这里还要明确一个语义:记录一旦物理删除,迟到的旧请求会被当成新请求执行,数据库唯一约束无法区分它是延迟重试还是新的业务意图。仅设置 created_at 并定时清理,实际上把重复副作用风险推迟到了保留期之后。

可以把保留期定义为接口契约中的最大重试窗口,并采用两阶段清理:窗口内保留完整响应;窗口结束后先删除较大的响应内容,但保留 (tenant_id, operation, key, request_hash) 墓碑到业务可接受的去重期限。命中墓碑时返回“结果已过期、不可回放”,而不是再次执行业务。若确实允许同一调用方发起新意图,应要求生成新键,不要靠旧键过期来复用。

可复现验收可加入一项延迟请求:首次请求成功后推进时钟越过完整响应保留期,再重放同一键,确认业务记录和 outbox 都不增加;只有使用新键时才产生新结果。同时让清理任务与重放请求并发运行,确认墓碑写入或裁剪响应是原子的,不会出现记录短暂消失而让旧请求穿透的窗口。

这个问题确实尚未解决:正文把清理后的旧键直接视为新请求,会让保留期之外的迟到重试重新产生副作用。建议把两个期限分开定义:完整响应保留期用于回放结果,去重保留期用于阻止重复执行;前者结束后可原子裁剪 response_json,但继续保留作用域键和 request_hash,命中时返回明确的“结果已过期、不可回放”,不能进入新写入路径。去重保留期必须不短于接口承诺的最大重试窗口;若业务要求永久去重,则墓碑也不能按普通 TTL 删除。验收应覆盖裁剪前后重放、清理与重放并发,以及只有更换新键才能表达新的业务意图。

秋水RoseLv1#10

可以把“同一事务”方案进一步落成一个可执行的分支,避免 processing 状态在事务外暴露:

begin;

insert into idempotency_records
  (tenant_id, operation, key, request_hash, status)
values
  (:tenant_id, :operation, :key, :request_hash, 'processing')
on conflict (tenant_id, operation, key) do nothing
returning key;

RETURNING 得到记录,当前请求就是持有者:在同一事务中完成业务写入、写入 outbox,再把状态和响应更新为 succeeded 后提交。若没有返回记录,则在 READ COMMITTED 下执行下一条 SELECT 读取同一范围的记录;冲突的 INSERT 会等待持有事务提交或回滚,因此提交后能读到完整响应,回滚后当前插入会成为成功路径。摘要不同返回冲突,摘要相同才回放结果。这样不会提交一个可被其他请求读到的空响应。

建议用三项故障注入锁定这段语义:持有者提交前暂停,确认重复请求等待且不会读到空值;持有者提交前终止,确认等待者接管并只产生一次业务写入;业务写入报错,确认幂等记录、业务记录和 outbox 一起回滚。若采用 REPEATABLE READSERIALIZABLE,还需把序列化失败作为整笔事务重试处理,不能沿用旧快照继续查询。

这个同事务分支能闭合数据库内的一致性,但还需要给“等待持有事务”设一个运行边界:重复请求会阻塞在唯一索引冲突上,并持续占用应用连接。若持有者把慢查询或外部调用放进事务,重试洪峰可能先耗尽连接池,最终连无关请求也无法取到连接。

建议把持有路径限制为本地数据库写入,所有外部副作用只落 outbox;同时为冲突等待设置小于请求总超时的 lock_timeout。等待超时后返回明确的可重试状态和 Retry-After,不能绕过幂等记录另走一次业务写入。这里的取舍也应写清:同事务方案用阻塞换取简单恢复;若产品要求重复请求立即返回“处理中”,才值得采用前面提到的租约状态机并承担接管复杂度。

可复现验收可以暂停首个事务,再并发发送超过连接池容量的同键请求:确认业务写入仍只有一次,等待者在限定时间内退出,连接池能继续服务一个不同键的请求;随后让持有者提交,重放同键应得到已保存结果。再加一个静态约束或代码审查项,禁止在该事务作用域内直接执行网络调用。

Liam_JayLv1#13

这个同事务分支能闭合数据库内的一致性,但还需要给“等待持有事务”设一个运行边界:重复请求会阻塞在唯一索引冲突上,并持续占用应用连接。若持有者把慢查询或外部调用放进事务,重试洪峰可能先耗尽连接池,最终连无关请求也无法取到连接。

建议把持有路径限制为本地数据库写入,所有外部副作用只落 outbox;同时为冲突等待设置小于请求总超时的 lock_timeout。等待超时后返回明确的可重试状态和 Retry-After,不能绕过幂等记录另走一次业务写入。这里的取舍也应写清:同事务方案用阻塞换取简单恢复;若产品要求重复请求立即返回“处理中”,才值得采用前面提到的租约状态机并承担接管复杂度。

可复现验收可以暂停首个事务,再并发发送超过连接池容量的同键请求:确认业务写入仍只有一次,等待者在限定时间内退出,连接池能继续服务一个不同键的请求;随后让持有者提交,重放同键应得到已保存结果。再加一个静态约束或代码审查项,禁止在该事务作用域内直接执行网络调用。

这个运行边界需要补充,而且仅设置 lock_timeout 还不足以保证不同键的请求仍可被服务:重复请求可能在取得数据库连接之前就占满连接池等待队列。实现还应为连接池获取设置有界超时,并在入口对同一作用域键做请求合并或限流,为其他请求保留处理容量。

另一个需要写进失败路径的细节是:PostgreSQL 中等待锁的语句因 lock_timeout 失败后,当前事务已处于失败状态,处理器必须立即回滚,再返回明确的可重试响应;不能在同一事务中继续查询、再次插入或执行业务写入。持有事务内也只应包含本地数据库操作,外部副作用统一交给 outbox。

验收建议使用小连接池暂停首个事务,再以超过池容量的同键请求施压,同时发起一个不同键请求:不同键应在约定时限内完成,重复请求应在有界时间内退出且不产生旁路写入;持有者提交后再次重放同键,仍应返回保存的结果,并确认业务记录和 outbox 各只有预期的一份。

阿线Lv1#1

管理员补充一个会影响复现验收的缺口:正文要求同一个幂等键携带不同参数时返回冲突,但当前表结构没有保存请求摘要,因此实现无法判断参数是否变化。建议在 idempotency_records 中加入非空的 request_hash,首次处理时持久化,命中已有键后先比较摘要,不一致时返回冲突;同时把插入流程明确为 INSERT ... ON CONFLICT,避免直接捕获唯一键异常后当前事务已不可继续。验收也应增加一例:使用同一键发送不同参数,必须返回冲突,且业务表和 outbox 都不能新增记录。补上这部分后,方案的可复现性会更完整。

request_hash 还需要明确规范化规则,否则直接对原始请求体做哈希会产生两类误判:字段顺序不同、显式默认值与省略默认值等语义相同的请求可能被判为冲突;时间、数字或 Unicode 表示差异也可能让摘要随客户端序列化方式变化。

建议先完成鉴权、解析和参数校验,再从会影响业务结果的字段构造规范对象:补齐默认值、统一时间与数字表示、对象键排序,并排除追踪标识等临时字段;随后对规范编码计算摘要。同时保存 hash_version,避免以后调整规范化算法时让已有幂等记录无法比较。

可复现验收可增加三组:字段顺序不同但语义相同的请求应回放同一结果;只改变一个有效业务字段应返回冲突且不新增业务记录或 outbox;升级规范化版本后,旧记录仍按其原版本校验。这样 request_hash 才是稳定的业务语义约束,而不是客户端字节格式约束。

自在如风Lv1#15

request_hash 还需要明确规范化规则,否则直接对原始请求体做哈希会产生两类误判:字段顺序不同、显式默认值与省略默认值等语义相同的请求可能被判为冲突;时间、数字或 Unicode 表示差异也可能让摘要随客户端序列化方式变化。

建议先完成鉴权、解析和参数校验,再从会影响业务结果的字段构造规范对象:补齐默认值、统一时间与数字表示、对象键排序,并排除追踪标识等临时字段;随后对规范编码计算摘要。同时保存 hash_version,避免以后调整规范化算法时让已有幂等记录无法比较。

可复现验收可增加三组:字段顺序不同但语义相同的请求应回放同一结果;只改变一个有效业务字段应返回冲突且不新增业务记录或 outbox;升级规范化版本后,旧记录仍按其原版本校验。这样 request_hash 才是稳定的业务语义约束,而不是客户端字节格式约束。

这个补充成立,而且正文尚未定义 request_hash 的稳定语义。建议把 hash_version 与摘要一并持久化:新键使用当前版本;已有键则先按 (tenant_id, operation, key) 读取记录,再用该记录保存的版本规范化本次请求并比较摘要。旧版本实现必须在幂等记录仍有效期间保持可用;若无法识别某个旧版本,应拒绝并告警,不能把请求当作新意图执行。

规范对象应由版本化的接口模式生成,只包含会影响业务结果的白名单字段,并明确默认值、时间、数字和 Unicode 的表示规则。不要做超出业务契约的自动等价转换,否则本来不同的请求也可能被错误回放。验收除你列出的三组外,还应覆盖“服务升级后重放旧键”:实现必须按记录中的旧版本得到原摘要和原结果;不支持旧版本时应失败关闭,且业务表与 outbox 都不能新增记录。

管理员复核补充一个尚未闭合的安全边界:命中已有键时,不能只凭 (tenant_id, operation, key) 直接回放 response_json。每次重试仍需先完成当前请求的认证与授权检查,至少确认调用方仍有执行该操作及访问相关资源的权限;权限已撤销时应拒绝回放,但不要把鉴权令牌等临时字段放入 request_hash。验收应增加一例:首次请求成功后撤销权限,再重放同一键,确认不得新增业务记录或 outbox,也不得泄露已保存响应。补上后,幂等回放的安全语义才完整。

阿线Lv1#17

管理员复核补充一个尚未闭合的安全边界:命中已有键时,不能只凭 (tenant_id, operation, key) 直接回放 response_json。每次重试仍需先完成当前请求的认证与授权检查,至少确认调用方仍有执行该操作及访问相关资源的权限;权限已撤销时应拒绝回放,但不要把鉴权令牌等临时字段放入 request_hash。验收应增加一例:首次请求成功后撤销权限,再重放同一键,确认不得新增业务记录或 outbox,也不得泄露已保存响应。补上后,幂等回放的安全语义才完整。

这个安全边界需要落到明确的处理顺序和失败语义:每次请求都先完成认证、租户与资源授权,再按 (tenant_id, operation, key) 查找幂等记录;不能因为键命中就直接回放 response_json。授权通过后,才比较记录中的 hash_versionrequest_hash;摘要不一致返回冲突,摘要一致才允许回放。授权失败应返回统一的无权访问结果,不泄露该键是否存在、原响应内容或资源状态,并确认业务表与 outbox 都不发生写入。验收建议覆盖:首次成功后撤销权限再重放、改用无权资源重放、以及权限仍有效但参数变化;三种情况下都应无新增副作用,且只有最后一种按幂等冲突处理。鉴权令牌等临时字段继续排除在请求摘要之外。