时序图栏目 ·

支付回调时序图怎么画:重复回调、幂等、签名校验、重试机制怎么表达

面向研发/测试/架构:把支付平台回调的重复通知、幂等处理、签名校验、重试与补偿用 UML 时序图画清楚。含规范、反例、检查清单与落地画法。

先给结论:支付回调的“核心难点”,时序图里要画出来的只有 4 件事

如果你要画一张能交付给研发/测试/风控/运营都看得懂的支付回调时序图,别一上来就把所有字段和所有分支都堆上去。先把下面 4 件事画清楚,你的图就已经“像专业系统”了:

  1. 签名校验在最前:回调一进来,先验签/验时间窗/验重放,再谈业务。
  2. 幂等的“判重键”明确:用什么唯一键判重?(常见:notify_id/transaction_id/out_trade_no 组合)判重放在哪一层?(DB 唯一索引/幂等表/状态机)
  3. “第一次处理”和“重复回调”分开表达:第一次会落库、发事件;重复回调通常只读判重、直接 ACK。
  4. ACK 与业务处理解耦:对很多支付平台来说,“你回 200/成功字符串”就代表它不再重试。所以 ACK 什么时候返回、返回什么必须在图里明确,否则线上会出事故。

后面所有细节(订单校验、金额对账、补偿、告警、人工介入)都是在这四件事上扩展。

你想省时间的话:可以直接用这个在线时序图生成器把骨架画出来,再补字段和分支: 手打时序图 - 时序图在线制作

左侧编辑器里可以点选“生命线/消息/组合片段”,右侧实时预览;写完一键自动排版,并可导出 SVG/PNG/JPEG/draw.io,适合丢到 PRD、设计评审、技术方案里。


一、你到底在画什么:支付“回调”不是一次请求,而是一段可重放的协议

很多人画支付回调时序图的第一反应是:

  • 支付平台 → 你:回调
  • 你 → 支付平台:200
  • 你:更新订单

这张图在“演示”层面没问题,但在“线上落地”层面几乎没有任何价值,因为它漏掉了支付回调的协议本质:

  • 回调会重复:平台会在 N 分钟/小时内重试多次(指数退避或固定间隔),直到你返回“成功”。
  • 回调可能乱序:先到 SUCCESS 后到 CLOSED(少见但确实存在,或来自不同通道/补单)。
  • 回调可能并发:同一个 notify_id 同时打到两台机器(LB/重试/网络抖动)。
  • 回调可能被伪造/重放:不验签、不验时间窗,就等于把“改订单状态”的按钮暴露到公网。

所以这篇文章的目标是:教你把“可重放协议 + 幂等消费 + 状态机”画成一张能指导实现与测试的 UML 时序图


二、时序图参与者怎么选:别把“系统”画成一个方块

支付回调一张图常见参与者(生命线)建议至少拆到这些:

  • 支付平台(Pay Provider):发起回调通知的系统
  • 回调接入层(Callback API / Webhook Endpoint):对外暴露的 HTTP 接口,做验签、解析、ACK
  • 幂等/去重存储(Idempotency Store):可以是 DB 唯一索引、幂等表、Redis 锁(但建议以 DB 为准)
  • 支付单/订单服务(Payment/Order Service):做业务校验、状态机推进
  • 数据库(Payment DB / Order DB):承载状态变更与对账字段
  • 事件/消息系统(MQ/EventBus,可选):把“支付成功事件”下发给发货、权益、通知等
  • 告警/监控(Alerting,可选):验签失败、金额不一致、状态非法跃迁等

为什么要拆?因为支付回调“画清楚”的关键,是把责任边界画清楚

  • 谁负责验签?
  • 谁负责判重?
  • 谁负责状态机?
  • 谁决定 ACK 的时机?

这些边界决定了你后面写代码怎么分层、测例怎么设计、故障怎么定位。


三、推荐的“最小可交付”时序图骨架(适用于绝大多数支付平台)

下面给你一个可以直接照着画的骨架。你不需要把它原封不动抄进代码,但建议把它画进图里。

3.1 主流程(首次回调,处理成功并 ACK)

关键点:ACK(成功响应)要出现在“幂等落库 + 状态机推进”之后,还是之前?

  • 如果你先 ACK 再处理:延迟更低,但一旦处理失败,你也许再也收不到平台重试(平台认为你成功了)。
  • 如果你先处理再 ACK:更安全,但要控制处理时延,避免平台超时导致重复回调并发。

更通用的做法是:

  • 接入层做“轻量且确定性”的步骤:验签、判重、写入回调记录(或幂等 key),然后推进状态机;
  • 下游耗时操作(发货、通知、权益)通过 MQ 异步。

在时序图里体现为:

  1. Pay Provider → Callback API:POST /pay/notify
  2. Callback API → Callback API:验签/验时间窗/解析
  3. Callback API → Idempotency Store:tryInsert(notify_id)(DB 唯一索引/幂等表)
  4. Idempotency Store → Callback API:insert ok
  5. Callback API → Payment Service:handleNotification(parsed)
  6. Payment Service → Payment DB:查询支付单/订单 + 状态机校验
  7. Payment Service → Payment DB:更新状态(例如 UNPAID -> PAID)+ 记录平台交易号/到账时间/金额
  8. Payment Service → MQ(可选):发布 PaymentSucceeded
  9. Payment Service → Callback API:处理结果 success
  10. Callback API → Pay Provider:返回 200 + success body

你画的时候要注意两个表达:

  • tryInsert 这一箭头是整张图的灵魂:它把“重复回调”从灾难变成常态。
  • 状态更新最好画成一条事务性的动作(可以用注释写“transaction”),不要分散成多次更新。

3.2 重复回调(幂等命中,快速 ACK)

重复回调的正确姿势通常是:

  • 仍然验签(防伪造)
  • 判重命中 → 不再推进业务 → 直接 ACK

在时序图里建议用一个 loop 组合片段表达平台重试:

  • loop [until ack success or retry limit reached]
    • Pay Provider → Callback API:同样的通知(同一个 notify_id
    • Callback API:验签
    • Callback API → Idempotency Store:tryInsert(notify_id)
    • alt
      • [insert ok]:走主流程
      • [duplicate key]:Callback API → Pay Provider:返回 success(快速)

这段 alt 非常重要:它把“第一次”和“第 N 次”合在同一张图里,看的人一眼明白系统为什么不会重复发货/重复入账。


四、幂等到底用什么做“判重键”:画图前先把规则写成一句话

支付回调幂等不是一句“我们做了幂等”就完了。你至少要能回答:

同一件回调事件,你用什么唯一键定义它?

常见选择(按推荐程度从高到低,具体还要看平台字段):

  1. 支付平台的通知唯一号:例如 notify_id(如果平台保证唯一且每次重试不变)
  2. 平台交易号transaction_id(通常全局唯一)
  3. 商户订单号 + 通知类型 + 支付状态out_trade_no + event_type + trade_state
  4. 回调原文 body 的 hash:作为兜底(但会受字段顺序、空格、签名字段变化影响,不优雅)

4.1 推荐画法:幂等表(或唯一索引)+ 状态机双保险

最稳的实现通常是两层:

  • 幂等层:防“同一回调事件”重复执行notify_id 唯一)
  • 状态机层:防“不同事件导致非法跃迁”(例如已经 PAID 了不允许再回到 UNPAID)

这两层在时序图里的表现是:

  • tryInsert(notify_id) 成功 ≠ 一定能更新订单成功
  • 更新订单时仍要做 validateStateTransition()

你可以在 Payment Service 内部加一个注释框:

  • note over Payment Service: 状态机规则:UNPAID -> PAID -> REFUNDED / CLOSED ...

4.2 反例:只用 Redis 锁做幂等

很多人图里画:

  • Callback API → Redis:SETNX lock:out_trade_no
  • 成功就处理

问题是:

  • 锁过期/网络抖动会导致重复处理
  • Redis 不保证强一致,重启/主从切换可能丢锁
  • 你最终仍要落 DB,DB 才是事实来源

**修正画法:**如果你确实需要 Redis 来削峰(例如回调并发巨大),也要把它画成“辅助”,并且 DB 唯一约束仍然存在:

  • opt [high qps]:先拿 Redis 锁(快速失败)
  • 但“决定性判重”仍然是 DB 的唯一索引/幂等表

五、签名校验怎么画才不含糊:不仅是“验签”,还要画“验什么”和“失败怎么回”

支付回调里,签名校验至少包含三类校验:

  1. 验签(authenticity):RSA/HMAC/证书链
  2. 验时间窗(freshness)timestamp 在容忍窗口内(防重放)
  3. 验业务一致性(integrity):订单号存在、金额/币种一致、商户号/应用 ID 匹配

5.1 在时序图里用 alt 把失败分支画清楚

建议你画一个紧凑的 alt

  • alt [signature invalid]
    • Callback API → Alerting:记录原文 + 关键字段 + 签名失败原因
    • Callback API → Pay Provider:返回 fail(或非 200)
  • else [timestamp expired / replay suspected]
    • Callback API → Alerting:replay 告警
    • Callback API → Pay Provider:返回 fail
  • else [ok]
    • 进入幂等/业务处理

这里有一个很现实的取舍:

  • 你返回“失败”,平台通常会继续重试(这能帮你在短时间修复配置错误后补到通知)。
  • 但如果失败是“确定性失败”(比如攻击流量、明显伪造),持续重试会制造噪音。

因此建议你在实现与图里都写清楚:

  • 失败是否要重试(由你返回的响应决定)
  • 什么时候升级告警(例如 5 分钟内连续失败阈值)

六、重试机制怎么画:重试的是“平台”,不是你(但你也可能有补拉)

“支付回调重试”常被误解成“我们服务端要重试调用支付平台”。

更常见的是:

  • 平台重试通知你(webhook retry)
  • 你为了稳妥,可能还有一个“补拉/补单”任务:主动去平台查单

这两类重试建议在时序图里分别表达。

6.1 平台重试:用 loop 表达

画法要点:

  • loop [retry until ack success]
  • 在 loop 内部,把“重复回调的幂等命中”画出来(见 3.2)

6.2 你的补拉:用 opt 或单独一张图表达

当你担心“回调丢失”或“回调失败一直重试打爆你”的时候,通常会做补拉:

  • 定时任务扫描 UNPAID 超时的订单
  • 调用支付平台的 query 接口确认真实状态

这部分建议用 opt [callback missing or failed]

  • Scheduler → Payment Service:扫描待确认订单
  • Payment Service → Pay Provider:query(out_trade_no)
  • Pay Provider → Payment Service:返回状态
  • alt
    • [paid]:推进状态机(和回调同一套逻辑)
    • [unpaid/closed]:保持或关闭

**重点:**补拉推进状态机必须复用“幂等 + 状态机”逻辑,不要写两套。


七、把“幂等 + 状态机 + 并发”画得更像真实系统:三种你一定会遇到的场景

场景 1:两次回调并发到达(同一个 notify_id)

你可以在图里画两条并行的消息流(或者用 par):

  • par [concurrent callbacks]
    • Callback API(实例A) → Idempotency Store:tryInsert → ok
    • Callback API(实例B) → Idempotency Store:tryInsert → duplicate

然后让实例 B 直接 ACK success。

这能帮助团队对齐一个关键认识:

  • 幂等不是“避免并发”,而是“允许并发但保证只做一次有效处理”。

场景 2:回调先 ACK 了,但业务处理失败

这是最危险、也最容易被忽略的事故源。

如果你决定“先 ACK”,那你必须在图里补上兜底:

  • Callback API → Pay Provider:success(先回)
  • Callback API → Payment Service:handle
  • alt [handle failed]
    • Payment Service → Alerting:告警
    • Payment Service → Scheduler:标记需要补拉/人工介入

并且要在图里写一句注释:

  • note: ack success means provider stops retrying; ensure internal recovery path

如果你没有这条恢复路径,建议不要先 ACK。

场景 3:重复回调携带“不同状态”(例如 SUCCESS 后又来一条 CLOSED)

这时真正保护你的是状态机,不是幂等。

画法:

  • 在 Payment Service 内部加 validateTransition(current, incoming)
  • alt
    • [valid transition]:更新
    • [invalid transition]:记录异常 + 仍然 ACK(避免平台重试风暴)或返回 fail(希望平台重试等待你修复)

这里的策略取决于平台与业务:

  • 如果状态不一致可能是“暂时不一致”(比如平台最终会给正确状态),可以返回 fail 让它重试。
  • 如果你判断是“脏数据/攻击/重复事件”,可以 ACK 并告警。

无论你选哪种,必须写进图里,不然测试不知道该怎么测。


八、反例合集:你画的图“看起来对但不专业”,通常是因为这些点

下面这些反例建议你拿去对照自己已有的时序图。

反例 1:图里没有“幂等存储”,只有一句“幂等处理”

  • 症状:评审时大家各自脑补幂等方式,实现时分歧巨大。
  • 修正:把 tryInsert(notify_id) 或 “DB unique constraint” 画成明确的消息。

反例 2:验签画在“更新订单之后”

  • 症状:安全/风控一票否决。
  • 修正:验签必须在任何业务副作用之前(包括写幂等表)。

反例 3:ACK 的时机不明确

  • 症状:线上出现“平台一直重试”或“回调丢了再也补不回来”。
  • 修正:把 ACK 单独画成一条消息,并写清返回内容(有的平台要求 body 必须是 success)。

反例 4:把“发货/权益/短信”画在同一个同步链路里

  • 症状:回调接口超时,平台重试并发,压力翻倍。
  • 修正:把这些动作画成 MQ 异步(或 opt 的异步触发),回调链路只做确定性落库。

反例 5:没有“金额/币种/商户号一致性校验”

  • 症状:对账出事故,或者被利用做“金额篡改”攻击(验签不等于验业务一致)。
  • 修正:在 Payment Service 内部加一个校验步骤,并在 alt [mismatch] 分支里明确告警与处理。

九、检查清单:一张合格的支付回调时序图,至少要能回答这些问题

你可以把下面清单当成评审 checklist(也适合测试写用例)。

9.1 安全与协议

  • 验签画出来了:用的算法/证书/密钥来源(至少写注释)
  • 有时间窗/重放保护(timestamp/nonce)或明确说明没有
  • 失败分支怎么回?返回什么会触发平台重试?

9.2 幂等与并发

  • 幂等键是什么?(notify_id/transaction_id/组合键)
  • 判重在哪里做?(DB 唯一索引/幂等表/锁)
  • 重复回调会不会重复发货/重复记账?图里能看出来吗?
  • 并发两次回调到不同实例,是否仍然安全?

9.3 状态机与数据一致性

  • 订单/支付单状态机规则写清楚了(至少写“允许的跃迁”)
  • 金额/币种/商户号一致性校验画出来了
  • 业务处理失败后的恢复路径(补拉/人工介入)画出来了

9.4 性能与可观测

  • 回调链路是否只做“确定性、短耗时”动作
  • 是否有关键日志点:原文、签名结果、幂等命中、状态跃迁
  • 是否有告警:验签失败、金额不一致、非法跃迁、重复率异常

十、FAQ(测试和研发最常问的那些)

Q1:重复回调到底要不要再验签?

要。原因很简单:

  • 如果你为了省 CPU 不验签,只靠幂等表判重,那攻击者可以绕过幂等键(换个订单号/交易号),仍然能打到你的业务层。
  • 验签是“是否信任这条消息”的门槛;幂等是“是否已经处理过”的门槛;两者解决的问题不同。

Q2:幂等只做在支付回调接口层可以吗?

不建议只做在接口层。

  • 接口层幂等能防重复执行,但如果还有“补拉查单、人工补单、消息重放”等入口,你会在别的入口再次踩坑。

更稳的做法是:

  • 接口层做“事件幂等”(notify_id)
  • 业务层做“状态机幂等”(不允许重复跃迁,或重复跃迁不产生副作用)

Q3:平台要求回调必须返回 200 和固定字符串,我业务还没处理完怎么办?

两条路:

  • 路 A(更安全):回调链路只做“落库 + 推状态机 + 发事件”,保证足够快,然后返回成功。
  • 路 B(更快但要兜底):先 ACK,然后把通知写入队列异步处理;但必须补上“处理失败的恢复路径”,否则你会遇到“ACK 了但处理失败,平台不再通知”的坑。

不管选哪条,把 ACK 的时机画清楚,测试才能覆盖。

Q4:我用唯一索引插入幂等表,插入失败要不要当错误?

插入失败如果是“duplicate key”,通常不是错误,而是正常的重复回调

  • 你应该把它记为“幂等命中”,并快速 ACK。
  • 但如果是“DB 异常/超时”,那才是错误,需要返回 fail 触发平台重试,并告警。

在图里建议把这两种失败拆成不同分支:

  • alt [duplicate]:ACK success
  • else [db error]:ACK fail + alert

Q5:怎么在时序图里表现“回调原文要入库用于审计/对账”?

你可以在 Callback API 到 DB 的动作旁边加一个注释:

  • store raw payload + headers + verify result

或者单独画一条消息:

  • Callback API → Audit DB:saveRawNotification()

不要小看这一笔:很多支付事故最后靠的就是“你当时收到的原文到底是什么”。


十一、把这张图快速画出来的方法(更适合写方案/写文档的人)

如果你经常要写技术方案、对外对接文档、测试用例说明,建议你把“支付回调时序图”做成一个可复用模板:

  1. 先搭生命线:Pay Provider / Callback API / Idempotency Store / Payment Service / DB / MQ / Alerting
  2. 再放 3 个组合片段:
    • alt:验签失败 vs 通过
    • alt:幂等插入成功 vs duplicate
    • loop:平台重试
  3. 最后补关键字段(写在消息注释里就够):notify_idtransaction_idout_trade_nototal_amountmerchant_id/app_id

你可以用这个在线生成器做“模板化绘图”:

  • 左侧编辑器点选生命线/消息/组合片段(alt/loop/par/opt),不用从零找语法
  • 右侧实时预览,边改边看,适合在评审会议现场调整
  • 自动排版,减少“线条打架”
  • 导出 SVG/PNG/JPEG 直接贴文档;导出 draw.io 方便团队二次编辑
  • 也支持用 AI 先生成一版,再由你把幂等键、ACK 规则、状态机规则改成你们系统的真实口径

入口在这(带上本文专属标识,方便你回头找):

手打时序图 - 时序图在线制作


十二、最后再强调一次:你这张图的“验收标准”

画完后问自己三句话:

  1. 测试能不能只看这张图就写出覆盖“重复回调/并发/验签失败/金额不一致/状态非法跃迁”的用例?
  2. 研发能不能只看这张图就写出“幂等表 + 状态机 + 异步事件”的落地实现?
  3. 线上出了问题(重复扣款/重复发货/平台一直重试),你能不能只靠图定位“该看哪一段日志/哪一个存储/哪一个告警”?

如果答案都是“能”,这张支付回调时序图就不是“看起来对”,而是真的能用。