支付回调时序图怎么画:重复回调、幂等、签名校验、重试机制怎么表达
面向研发/测试/架构:把支付平台回调的重复通知、幂等处理、签名校验、重试与补偿用 UML 时序图画清楚。含规范、反例、检查清单与落地画法。
先给结论:支付回调的“核心难点”,时序图里要画出来的只有 4 件事
如果你要画一张能交付给研发/测试/风控/运营都看得懂的支付回调时序图,别一上来就把所有字段和所有分支都堆上去。先把下面 4 件事画清楚,你的图就已经“像专业系统”了:
- 签名校验在最前:回调一进来,先验签/验时间窗/验重放,再谈业务。
- 幂等的“判重键”明确:用什么唯一键判重?(常见:
notify_id/transaction_id/out_trade_no组合)判重放在哪一层?(DB 唯一索引/幂等表/状态机) - “第一次处理”和“重复回调”分开表达:第一次会落库、发事件;重复回调通常只读判重、直接 ACK。
- 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 异步。
在时序图里体现为:
- Pay Provider → Callback API:
POST /pay/notify - Callback API → Callback API:验签/验时间窗/解析
- Callback API → Idempotency Store:
tryInsert(notify_id)(DB 唯一索引/幂等表) - Idempotency Store → Callback API:insert ok
- Callback API → Payment Service:
handleNotification(parsed) - Payment Service → Payment DB:查询支付单/订单 + 状态机校验
- Payment Service → Payment DB:更新状态(例如
UNPAID -> PAID)+ 记录平台交易号/到账时间/金额 - Payment Service → MQ(可选):发布
PaymentSucceeded - Payment Service → Callback API:处理结果 success
- 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(快速)
- Pay Provider → Callback API:同样的通知(同一个
这段 alt 非常重要:它把“第一次”和“第 N 次”合在同一张图里,看的人一眼明白系统为什么不会重复发货/重复入账。
四、幂等到底用什么做“判重键”:画图前先把规则写成一句话
支付回调幂等不是一句“我们做了幂等”就完了。你至少要能回答:
同一件回调事件,你用什么唯一键定义它?
常见选择(按推荐程度从高到低,具体还要看平台字段):
- 支付平台的通知唯一号:例如
notify_id(如果平台保证唯一且每次重试不变) - 平台交易号:
transaction_id(通常全局唯一) - 商户订单号 + 通知类型 + 支付状态:
out_trade_no + event_type + trade_state - 回调原文 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 的唯一索引/幂等表
五、签名校验怎么画才不含糊:不仅是“验签”,还要画“验什么”和“失败怎么回”
支付回调里,签名校验至少包含三类校验:
- 验签(authenticity):RSA/HMAC/证书链
- 验时间窗(freshness):
timestamp在容忍窗口内(防重放) - 验业务一致性(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 successelse [db error]:ACK fail + alert
Q5:怎么在时序图里表现“回调原文要入库用于审计/对账”?
你可以在 Callback API 到 DB 的动作旁边加一个注释:
store raw payload + headers + verify result
或者单独画一条消息:
- Callback API → Audit DB:
saveRawNotification()
不要小看这一笔:很多支付事故最后靠的就是“你当时收到的原文到底是什么”。
十一、把这张图快速画出来的方法(更适合写方案/写文档的人)
如果你经常要写技术方案、对外对接文档、测试用例说明,建议你把“支付回调时序图”做成一个可复用模板:
- 先搭生命线:Pay Provider / Callback API / Idempotency Store / Payment Service / DB / MQ / Alerting
- 再放 3 个组合片段:
alt:验签失败 vs 通过alt:幂等插入成功 vs duplicateloop:平台重试
- 最后补关键字段(写在消息注释里就够):
notify_id、transaction_id、out_trade_no、total_amount、merchant_id/app_id
你可以用这个在线生成器做“模板化绘图”:
- 左侧编辑器点选生命线/消息/组合片段(alt/loop/par/opt),不用从零找语法
- 右侧实时预览,边改边看,适合在评审会议现场调整
- 自动排版,减少“线条打架”
- 导出 SVG/PNG/JPEG 直接贴文档;导出 draw.io 方便团队二次编辑
- 也支持用 AI 先生成一版,再由你把幂等键、ACK 规则、状态机规则改成你们系统的真实口径
入口在这(带上本文专属标识,方便你回头找):
十二、最后再强调一次:你这张图的“验收标准”
画完后问自己三句话:
- 测试能不能只看这张图就写出覆盖“重复回调/并发/验签失败/金额不一致/状态非法跃迁”的用例?
- 研发能不能只看这张图就写出“幂等表 + 状态机 + 异步事件”的落地实现?
- 线上出了问题(重复扣款/重复发货/平台一直重试),你能不能只靠图定位“该看哪一段日志/哪一个存储/哪一个告警”?
如果答案都是“能”,这张支付回调时序图就不是“看起来对”,而是真的能用。