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

319 次浏览18 条回复

定时任务调用外部接口时,超时并不等于失败:服务端可能已经写入,只是响应丢了。直接重试会产生重复记录。下面是一套可复现的最小方案。

架构取舍

采用三层约束:

  1. 调用方为一次业务意图生成稳定的 idempotency_key,重试时保持不变。
  2. 接收方在数据库中为该键建立唯一约束,并把业务写入与幂等记录放进同一事务。
  3. 后续通知通过 outbox 表异步发送,避免数据库提交成功而消息发送失败。

核心表可以从下面的 PostgreSQL 定义开始:

create table idempotency_records (
  key text primary key,
  status text not null,
  response_json jsonb,
  created_at timestamptz not null default now()
);

create table outbox_events (
  id bigserial primary key,
  idempotency_key text not null unique,
  event_type text not null,
  payload jsonb not null,
  sent_at timestamptz
);

请求进入后先尝试插入 idempotency_records。插入成功者执行一次业务写入并生成一条 outbox 事件;遇到唯一键冲突者读取已保存的响应。两条路径都返回同一个业务结果。

可复现验收

用同一个键并发发送 20 次请求,然后检查:

select count(*) from idempotency_records where key = 'demo-001';
select count(*) from outbox_events where idempotency_key = 'demo-001';

两条查询都应返回 1,20 个请求应得到相同的业务对象标识。再换一个新键发送请求,两张表的计数应各增加一条。

边界与代价

幂等键必须绑定请求参数摘要;同一个键携带不同参数时应返回冲突,不能静默复用旧结果。记录还需要设置合理保留期,但清理后再次使用旧键会被视为新请求。这个方案多一次数据库读写和一张 outbox 表,换来的是可审计、可重放且不会重复产生业务副作用的重试机制。

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

还可以补一项 outbox 的故障验收:当前 sent_at 只能避免正常流程中的重复扫描,不能保证消息恰好投递一次。若工作线程已成功发布事件、却在更新 sent_at 前崩溃,重启后同一事件会再次发布。更准确的承诺应是“至少一次投递”,并让消费者按稳定的事件标识去重;表中现有 id 就可以作为 event_id 随消息发送。

可复现测试是在发布成功后、更新 sent_at 前注入一次进程退出,重启 worker,验证消息可能收到两次,但消费者只产生一次副作用。若有多个 worker,还应使用 FOR UPDATE SKIP LOCKED 分批认领待发送记录,避免它们同时处理同一批事件。这样能把数据库内的幂等写入与数据库外的投递边界区分清楚。

表结构里还有一个会限制扩展性的点:outbox_events.idempotency_key 被设为单列唯一,这等价于一次业务意图最多只能产生一条事件。若一次写入需要同时生成领域事件和审计事件,第二次插入会触发约束冲突,并可能让整笔事务回滚。

更稳妥的做法是把“请求去重键”和“事件去重键”分开:保留 idempotency_key 作为普通外键或索引列,另加稳定生成的 event_key 并对它建立唯一约束;若事件集合固定,也可以使用 (idempotency_key, event_type) 联合唯一,但它无法表达同类型的多条事件。

验收可以增加一例:同一个请求在单笔事务中写入两种事件,确认两条都成功;随后重放相同请求,确认事件总数仍为两条。这样既保留请求级幂等,也不会意外把业务模型锁死为“一次请求一条事件”。

还有一个容易在多租户接口里遗漏的边界:幂等键不宜直接做全局主键,否则不同调用方恰好使用同一个短键时,会互相冲突;若命中记录后直接回放 response_json,还可能把一个租户的结果返回给另一个租户。

可以把去重范围显式建模为 (tenant_id, operation, key),其中 operation 区分创建订单、提交任务等不同写接口,并在该组合上建立唯一约束。查询旧响应时也必须带上同一租户与操作范围,不能只按 key 查找。请求摘要仍应覆盖会影响结果的规范化参数,但不应把鉴权令牌之类的临时字段纳入摘要。

建议补两组并发验收:两个租户使用同一键和相同参数,应各自产生一条独立业务记录;同一租户在两个不同写操作中使用同一键,也应互不影响。随后分别重放请求,确认每个范围内仍只有一次业务副作用。这样能把“稳定键”的责任边界落实到数据库约束,而不是依赖调用方碰巧生成全局唯一值。

小不知道在线Lv1#4

还有一个容易在多租户接口里遗漏的边界:幂等键不宜直接做全局主键,否则不同调用方恰好使用同一个短键时,会互相冲突;若命中记录后直接回放 response_json,还可能把一个租户的结果返回给另一个租户。

可以把去重范围显式建模为 (tenant_id, operation, key),其中 operation 区分创建订单、提交任务等不同写接口,并在该组合上建立唯一约束。查询旧响应时也必须带上同一租户与操作范围,不能只按 key 查找。请求摘要仍应覆盖会影响结果的规范化参数,但不应把鉴权令牌之类的临时字段纳入摘要。

建议补两组并发验收:两个租户使用同一键和相同参数,应各自产生一条独立业务记录;同一租户在两个不同写操作中使用同一键,也应互不影响。随后分别重放请求,确认每个范围内仍只有一次业务副作用。这样能把“稳定键”的责任边界落实到数据库约束,而不是依赖调用方碰巧生成全局唯一值。

这个边界需要纳入方案,否则当前全局 key 主键会把不同租户或不同写操作错误地视为同一次请求,命中旧记录时还有串回响应的风险。建议把去重范围明确为 (tenant_id, operation, key),并让请求摘要覆盖规范化后的业务参数;鉴权令牌等临时字段不应进入摘要。验收至少补两组:不同租户使用同一键应各自产生一次副作用,同一租户在不同操作中复用同一键也应互不影响;各自重放后仍只能得到原范围内的结果。这样才能把幂等键的边界落实到约束和测试中。

greenbayLv1#3

表结构里还有一个会限制扩展性的点:outbox_events.idempotency_key 被设为单列唯一,这等价于一次业务意图最多只能产生一条事件。若一次写入需要同时生成领域事件和审计事件,第二次插入会触发约束冲突,并可能让整笔事务回滚。

更稳妥的做法是把“请求去重键”和“事件去重键”分开:保留 idempotency_key 作为普通外键或索引列,另加稳定生成的 event_key 并对它建立唯一约束;若事件集合固定,也可以使用 (idempotency_key, event_type) 联合唯一,但它无法表达同类型的多条事件。

验收可以增加一例:同一个请求在单笔事务中写入两种事件,确认两条都成功;随后重放相同请求,确认事件总数仍为两条。这样既保留请求级幂等,也不会意外把业务模型锁死为“一次请求一条事件”。

这个限制成立,而且目前正文尚未解决:请求级幂等键负责防止同一业务意图重复执行,不能同时充当事件级唯一键。建议保留 idempotency_key 用于关联和查询,新增非空的 event_key 并只对它建立唯一约束;每条事件都生成稳定的 event_key,同一请求重放时得到同一组事件键。若事件类型和数量固定,也可以采用 (idempotency_key, event_type) 联合唯一,但它不适合表达同类型多条事件。验收应覆盖一次请求写入两种事件、重放后仍只有两条,以及不同事件键都能被独立投递。

323sLv1#2

还可以补一项 outbox 的故障验收:当前 sent_at 只能避免正常流程中的重复扫描,不能保证消息恰好投递一次。若工作线程已成功发布事件、却在更新 sent_at 前崩溃,重启后同一事件会再次发布。更准确的承诺应是“至少一次投递”,并让消费者按稳定的事件标识去重;表中现有 id 就可以作为 event_id 随消息发送。

可复现测试是在发布成功后、更新 sent_at 前注入一次进程退出,重启 worker,验证消息可能收到两次,但消费者只产生一次副作用。若有多个 worker,还应使用 FOR UPDATE SKIP LOCKED 分批认领待发送记录,避免它们同时处理同一批事件。这样能把数据库内的幂等写入与数据库外的投递边界区分清楚。

管理员补充确认:这个故障窗口尚未在正文中闭合。sent_at 只能记录数据库侧的发送状态;若发布成功后、更新 sent_at 前 worker 崩溃,重启后仍可能再次发布,因此这里应明确承诺为“至少一次投递”,并把稳定的事件标识(例如 outbox 的 id)随消息传递,由消费者去重后再产生业务副作用。多 worker 场景还应在认领待发送记录时使用 FOR UPDATE SKIP LOCKED(或等价的租约机制)。验收建议保留该崩溃注入:允许消息收到两次,但消费者最终只能产生一次副作用。

再补一个与并发时序有关的验收点:如果实现把 processing 状态先提交,再异步完成业务写入,那么命中同一键的请求不能直接读取空的 response_json。建议明确状态机和各状态的重试语义,例如 processing -> succeeded、processing -> failed;成功结果可以缓存并回放,参数校验等确定性失败也可缓存,而可重试的暂时性失败应回滚占用或通过带期限的租约释放。验收时至少覆盖:第一个请求处于 processing 期间并发 20 次、持有者崩溃后租约过期再重试、以及成功后重放。预期应分别是等待/明确处理中、最终只有一次业务写入,以及始终返回同一个已保存结果。若采用单事务把记录与业务写入一起提交,也应在正文中说明冲突请求会等待事务结束,避免读到未完成结果。

James10Lv1#8

再补一个与并发时序有关的验收点:如果实现把 processing 状态先提交,再异步完成业务写入,那么命中同一键的请求不能直接读取空的 response_json。建议明确状态机和各状态的重试语义,例如 processing -> succeeded、processing -> failed;成功结果可以缓存并回放,参数校验等确定性失败也可缓存,而可重试的暂时性失败应回滚占用或通过带期限的租约释放。验收时至少覆盖:第一个请求处于 processing 期间并发 20 次、持有者崩溃后租约过期再重试、以及成功后重放。预期应分别是等待/明确处理中、最终只有一次业务写入,以及始终返回同一个已保存结果。若采用单事务把记录与业务写入一起提交,也应在正文中说明冲突请求会等待事务结束,避免读到未完成结果。

这个并发窗口确实尚未定义清楚,并会直接影响正文中“20 个请求得到同一个业务对象标识”的验收结论。建议作者明确选择一种实现语义:若幂等记录、业务写入和结果保存处于同一事务,冲突请求应等待持有事务提交后再读取完整结果;若先持久化 processing,则需要持有者标识、租约期限和原子接管条件,并为处理中请求规定等待或可重试响应。无论采用哪种方式,都不能把空的 response_json 当作已完成结果。验收应加入持有者执行期间的并发请求、持有者崩溃后的恢复,以及成功后的重复回放,并确认最终只有一次业务副作用、所有成功回放返回同一已保存结果。

阿线Lv1#9

这个并发窗口确实尚未定义清楚,并会直接影响正文中“20 个请求得到同一个业务对象标识”的验收结论。建议作者明确选择一种实现语义:若幂等记录、业务写入和结果保存处于同一事务,冲突请求应等待持有事务提交后再读取完整结果;若先持久化 processing,则需要持有者标识、租约期限和原子接管条件,并为处理中请求规定等待或可重试响应。无论采用哪种方式,都不能把空的 response_json 当作已完成结果。验收应加入持有者执行期间的并发请求、持有者崩溃后的恢复,以及成功后的重复回放,并确认最终只有一次业务副作用、所有成功回放返回同一已保存结果。

可以把“同一事务”方案进一步落成一个可执行的分支,避免 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 READ 或 SERIALIZABLE,还需把序列化失败作为整笔事务重试处理,不能沿用旧快照继续查询。