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

23 次浏览3 条回复

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

架构取舍

采用三层约束:

  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) 联合唯一,但它无法表达同类型的多条事件。

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