Essay 015 · 软件工程

防重的两个时间窗口

从原操作的执行状态出发,理解在途去重、完成后的结果复用,以及两者如何组成完整的防重设计。

首版:用在途与完成后两个时间窗口统一防重设计

Version 1.0

面对需要主动防重的重复调用,先看原操作仍在执行还是已经完成:前者要避免重复启动,后者要避免再次生效。完整的防重设计需要把这两个窗口连接成同一条状态链。

为什么写这篇

重复下单、接口超时重试、消息重复消费和缓存击穿,通常对应不同的技术方案。查资料时很容易得到一张工具清单:Redis 锁、singleflight、幂等键、去重表、结果缓存。但清单回答不了几个关键问题:并发重复到达时应该等待还是返回冲突?为什么只加锁仍然可能重复下单?原操作成功后,第二次调用应该返回什么?

这些问题有一个共同的入口:重复调用到达时,原操作处于生命周期的哪个阶段。本文讨论的是同一逻辑操作的重复调用;不同业务操作竞争同一资源,属于另一类并发控制问题。

先划分两个时间窗口

“并发重复”和“时序重复”的叫法容易让人盯着请求到达的先后。更有用的判断依据是原操作是否已经完成。

序号 原操作状态 防重目标 重复调用的常见处理
1 执行中 不再启动一次执行 等待并共享结果,或返回处理中、冲突
2 已完成 不再产生一次副作用 返回首次结果,或忽略已处理消息

执行中的重复可以叫在途重复、并发重复;完成后的重复常表现为超时重试、消息重投或人工重放。名称不是关键,状态才是。

这个分类也不是说每个场景都要选两套独立工具。它的作用是先把问题说清楚,再决定锁、请求合并、幂等记录和结果缓存各自负责哪一段。

在途窗口:先决定谁能执行

假设两个下单请求使用同一个幂等键。若服务端先查询“有没有处理过”,再执行下单,两个请求可能同时查不到记录,然后都进入业务逻辑。问题不在于没有查,而在于查询和占位之间存在竞态窗口。

常见解法是原子占位:把“检查是否已有执行”和“登记自己开始执行”合成一个不可分割的动作,例如依赖数据库唯一约束或原子写入。没有占到执行权的请求不再进入业务逻辑,而是等待结果、查询当前状态,或收到 409 Conflict。

Go 的 singleflight 是这个窗口里的典型机制:同一 key 同时只有一个执行实例,重复调用等待并共享结果。缓存失效时,它可以合并同时发生的回源查询,避免大量相同请求打到数据库。

但 singleflight 只处理在途调用。执行结束后,它不会替你持久化首次结果;进程外的其他实例也不共享同一个调用组。锁也有类似边界:它能决定此刻谁能执行,却不能单独回答“上次已经成功后,这次该返回什么”。

完成后窗口:让第二次得到第一次的答案

原操作完成后,如果服务端只释放了锁,却没有留下处理事实和结果,后续重试仍可能再次执行业务。因此,有副作用的操作还需要一份完成记录,至少能表达:

  • 哪个逻辑操作已经完成;
  • 对应的请求内容或指纹;
  • 操作结果是什么;
  • 这份记录何时失效。

同一逻辑操作再次到达时,服务端返回首次结果,而不是重新执行。若同一个幂等键却对应不同的请求内容,则应该拒绝或报告冲突,避免把另一笔操作误认为重试。Stripe 的幂等请求文档也分别说明了已保存响应的复用和并发执行冲突。

消息消费里的去重表体现的是同一个道理。Idempotent Consumer 模式建议记录已处理消息 ID,并用唯一键阻止重复插入;消息处理记录和业务写入可以放在同一个数据库事务中。这样,重复消息不会再次改变业务状态。

这里的结果复用不是普通的“读缓存”。它保存的是一个事实:这个逻辑操作已经发生过,再次调用应该得到同一次操作的答案。

两个窗口要连接成一条状态链

把在途去重交给 singleflight,把完成后去重交给缓存,并不意味着系统已经完整。对于有副作用的操作,更有用的是让同一份防重记录贯穿两个窗口:

未记录 → IN_PROGRESS → COMPLETED
             │              │
             │              └─ 重复调用:返回首次结果
             └─ 重复调用:等待、共享结果或返回冲突

首次执行前先原子写入 IN_PROGRESS;完成后把结果写入同一记录,再转为 COMPLETED。重复调用看到的是同一个逻辑操作的状态,而不是两个互不知情的去重设施。

这种结构已经出现在不同资料中。IETF 的 Idempotency-Key 草案把重复请求分为原请求完成后的 Retry 和完成前的 Concurrent Request;后者建议返回冲突。它是工作草案,不是正式 RFC,但分类本身很能说明问题。AWS Powertools 的幂等实现也使用 INPROGRESS、COMPLETE、响应数据和过期时间共同协调重复调用。

真正困难的是状态转换之间不能留下未交代的空隙。业务已经提交、完成记录却未写入时,重试会看到什么?业务写入和防重记录能否进入同一个本地事务?如果副作用发生在外部支付平台,就需要平台幂等、状态查询或对账补偿,不能只靠服务内部的一张表声称实现了端到端“恰好一次”。

两个窗口之外,还要补上失败与时间

两个窗口是主干,不是完整状态机。落地时至少要补齐以下边界:

  1. 失败不等于成功完成。要明确哪些结果允许复用,哪些失败应当释放执行权。尤其是超时只代表调用方没有等到答案,不代表服务方没有产生副作用。
  2. 执行中可能永远卡住。进程在占位后崩溃,会留下陈旧的 IN_PROGRESS。需要执行截止时间、租约或恢复流程;占位过期也不等于原执行已经停止,晚到的完成动作仍需要被识别。
  3. 防重记录通常有保留期。记录过期后,同一个键可能被当作新操作。保留期要覆盖业务真正需要防御的重试和重投范围。Amazon SQS FIFO 的消息去重 ID有五分钟去重窗口,AWS Powertools 也把幂等记录过期作为独立配置,说明“防重多久”本身就是设计的一部分。
  4. 先定义什么叫同一个操作。请求 ID、幂等键、消息 ID 或业务单号都可能成为标识,但必须明确作用域。不同用户、不同接口或不同请求内容不能仅凭一个相同的字符串就被合并。

缓存场景还要额外区分“重复调用”和“结果刷新”:缓存过期后的重新回源通常是新一轮读取,不是必须消灭的重复副作用。singleflight 防的是同一时刻的回源风暴,不是让数据永远不再更新。

结论与行动

设计防重机制时,可以先不写工具名,依次回答四个问题:

  1. 用什么标识同一个逻辑操作?
  2. 它在执行中收到重复调用时,后来者等待、共享结果还是收到冲突?
  3. 它完成后收到重复调用时,从哪里取得首次结果或已处理事实?
  4. 失败、超时、崩溃和记录过期时,状态如何恢复,保留期是否覆盖业务需要?

这四个答案明确之后,再选择锁、唯一约束、singleflight、幂等表或缓存。工具仍可能组合使用,但每一种都有清楚的责任边界,也不会因为只处理了其中一个窗口,就被误认为已经解决全部防重问题。

参考资料

查看本文修订记录(1)
  1. v1.0 · 2026.10.30 首次发布。