重复回调不是偶发意外:四层叠加里每一层各兜住哪一格|洹拓科技

一、至少一次是前提,四层各兜一格
回调通道的投递语义是至少一次:对方发出后没收到回执就重发,回执丢在网络上也会重发。前提一旦成立,任何单一幂等写法都存在兜不住的那一格。判重只靠键,键相同而内容不同就进来了;判重只靠代码里的一次查询,两个进程同时首次处理同一回调就漏了;判重只靠状态,业务已经跑完而响应丢了,重来的那一次照样合法。
一份公开技术整理稿把这四层的职责与叠加顺序摆得很清楚,也就是本文的骨架:业务键识别、唯一约束、状态机、结果回放。要强调的是,四层不是四种可选方案,而是四种互不替换的职责,顺序本身属于设计的一部分。
| 层 | 判的是什么 | 兜住的那一格 | 兜不住的那一格 |
|---|---|---|---|
| 业务键识别 | 这是不是同一次业务动作 | 常规重发 | 两个进程同时首次处理 |
| 唯一约束 | 库里是否已存在同键记录 | 并发首次处理 | 键合法但内容已过时 |
| 状态机 | 这一次迁移是否被允许 | 合法但过时的消息 | 业务已完成而响应丢失 |
| 结果回放 | 直接返回首次处理的结果 | 调用方对重试无感 | 首次处理未留下结果对象 |
多数团队只做到前两层,事故出在第三、四层之间:业务跑完了,响应没回去,对方重发,这一次该给什么答案,前两层谁都答不了。
二、第一层业务键:三条判据和选错的两种代价
业务键就是“用什么字段判定这是同一次”。这一步要写成可评审的三条判据,而不是在评审会上凭手感定。
- 对方是否稳定携带:该字段在每次重发中取值不变,不因通道、版本或批量入口的不同而缺失。这一条属于上游行为,需要在对表时确认,不能从一次联调通过里推断。
- 同一业务动作在多实例下是否唯一:只在单机唯一不算唯一,比如由本地计数器或实例内序号参与生成的键。
- 跨重试与跨版本是否不变:字段生成规则改版后,同一动作的新旧两次上报仍要落到同一个键上,否则改版当天会凭空多出一批“新业务”。
键选错的代价有两种方向,症状完全不同。键过宽,把不同业务合并成一次:后到的那次被判为重复直接吞掉,表现为漏处理、没有报错、也没有告警,是最难发现的一种。键过窄,把同一次当成多次:重复执行全部落地,表现为重复出库、重复记账、重复发通知,通常在业务对账时才发现,而那时已经需要人工清理。两者都值得在建表前先把键的取值来源写成一段说明,让评审能质疑它。
三、第二层唯一约束:识别管常规,约束管并发,摘要管内容
识别是代码里的判断,约束是并发下的最后防线,两者的分工要写清:只有前者时,“两个进程同时首次处理同一回调”这一格会漏,因为两边查询都得到“不存在”,然后各自写入。把业务键落成数据库层的硬约束之后,第二条插入会被挡下来,这一格才真正封住。
配套的三条要求:
- 约束冲突要被业务代码显式处理成“已处理”,向上返回首次那一次的结论;让它以未捕获异常冒出去,就等于把幂等做成了告警风暴
- 冲突记录要能区分是同键同内容还是同键不同内容,所以唯一键里除了业务键还要带上报文摘要字段;摘要参与比对的字段范围要写进设计说明,哪些字段排除(时间戳、随机标识这类每次都变的)也要写明
- 摘要比对的字段顺序与序列化方式要固定下来,否则同一份内容两次算出的摘要不同,误报会源源不断地从这一格里出来
“同一个键但内容不同”这件事值得单独说一句。它通常是对方改了金额或明细之后重发了同一个键,摘要冲突的告警语义要写成数据问题,而不是网络问题——网络问题重试可以解决,数据问题必须人工判定哪一份才是应当生效的那一份。表结构层面只需要按职责把字段摆对:一条处理记录里要有业务键、报文摘要、首次处理结论、状态与处理次数,职责各归各位,具体语句不属于本文范围。
四、第三层状态机:兜住的是“合法但过时”
重复消息不一定格式错,它往往完全合法,只是落到的状态已经不允许那次迁移——业务已经推进到终态,这一条还在请求“从中间态往前一步”。状态机这一层就是拿来拒绝这类迁移的,三种处置要事先写清楚,不能运行时临时决定。
| 处置 | 适用条件 |
|---|---|
| 忽略并回执 | 当前状态已等于或晚于消息要求的目标状态,且重复不会带来副作用 |
| 拒绝并告警 | 消息要求的方向与当前状态矛盾,说明上游数据或键本身有问题,需要人工判定 |
| 进延迟队列重判 | 状态仍可能在前进,此刻拒绝为时过早,等一个短窗口后再按当时状态重判 |
最常见的错是把三种混成一种:要么一律忽略,把真正的数据矛盾一起吞了;要么一律拒绝,把正常的重试当成故障上报,客服与运维两头挨打。这里还有一条要在对表时确认:对方对“被拒绝”的回执怎么理解,是当失败继续重发,还是当完成停止重发——这个差别直接决定第二行该不该用。
五、第四层结果回放:唯一能让调用方无感的一格
前三层都在解决“不要重复执行”,第四层解决的是另一个问题:重复回调到达时,直接返回末次的处理结果,而不是重新执行业务。回放的对象是首次处理留下的结果对象与状态码,所以它的成立条件很硬——首次处理必须把结果落成可取回的东西。
四件事要单独设计:
- 回放什么:首次的结果对象与对外状态码,字段与首次那次完全一致,不能重新组装一套等价但不同的结构
- 保留多久:至少覆盖对方的最长重发窗口,而窗口长度属于上游行为,需要在对表时确认
- 结果为空时能不能回放:首次处理成功但业务结果本身为空,要能把它和“从没处理过”区分开,否则空结果会被当成未处理而重新执行
- 首次处理中途失败时能不能回放:此时没有完整结果可给,这一层必须明确不回放,退回第三层的处置
“再执行一遍然后把结果返回”不叫结果回放,它只是把重复执行的概率压低了一点。只有做到这一层,调用方才对重试无感:它收到的是同一个答案,看不出自己是第一次还是第三次。反过来说,如果你们的重试策略里还需要业务侧配合“这次别执行”,那说明第四层还没落地。
六、叠加顺序是设计项,再加一份上线清单与四件对表事项
四层各自正确不等于整体正确,顺序颠倒会放行本应拦下的请求。一个具体情形:先判状态机再判唯一约束时,两个并发请求都在状态机上读到旧状态,都认为“这次迁移合法”,于是双双通过,直到写库才有一个被约束挡下——而这时业务动作可能已经执行了。正确顺序是识别、约束、状态机、回放在前,副作用在后:先确认是不是同一次,再让库把并发切成一条,然后才判这次迁移允许不允许,允许才执行,执行完把结果落下来供后续回放。
上线校验清单,至少覆盖六项:
- 业务键稳定性抽样核对:取同一动作的多次上报比对键是否一致
- 约束冲突路径被测试覆盖:而不是只在生产环境第一次遇到
- 非法迁移有留痕与告警:能区分是正常重试还是数据矛盾
- 结果回放的命中与未命中分别有指标:未命中升高说明回放对象的保留期或写入有问题
- 摘要参与字段清单进代码评审:改字段时清单要同步改
- 对账口径与上游一致:处理次数、成功次数、回放次数的定义双方对齐
作为乙方,与客户对表时必须问四件事,四件都要落成书面约定:他们的重试策略与超时上限是什么;幂等键用哪个字段、是否在所有通道里稳定携带;期望收到什么回执;以及回执的含义是“已收到”还是“已完成”。最后这条最要紧,因为它决定第五层要不要建、建了能不能真的让对方停下来。重复回调从来不是偶发意外,它是至少一次投递的正常产物;洹拓在评审这类接口时只问一句——你这几层分别兜哪一格,说得出来就上,说不出来就先别上。