大型后台管理系统如何把业务操作做可靠(4):退款超时后的幂等与对账
客服提交订单 O42 的 40 元退款,随后浏览器提示超时。服务端可能尚未接到请求,也可能已经收到支付渠道的退款成功结果。第一次提交的操作标识 R17 必须能跨过这次超时,否则客服重试时,系统无法区分原退款和一笔新退款。
操作标识在什么时候生成
第一次确认金额与理由时生成 R17,将它和提交中的草稿一起保存。再次提交同一份请求沿用 R17。用户明确要另发一笔退款时才生成新标识。服务端以租户、调用方的稳定身份、操作类型和操作标识建立唯一约束,并保存规范化后的业务参数指纹。金额和目标订单属于指纹,重试时变化的链路追踪 ID 不属于指纹。
| 已有记录 | 传入参数 | 处理 |
|---|---|---|
| 无 | 合法 | 创建操作并保存结果或任务地址 |
| 有,正在处理 | 相同 | 返回原任务及当前状态 |
| 有,已有终态 | 相同 | 返回原业务结果 |
| 有 | 不同 | 报操作标识冲突 |
先校验当前主体有权使用这个操作入口和查看对应结果。能够重放结果,并不代表已撤权的账号仍能读到它。Stripe 和 AWS 的经验说明,同一标识应代表调用方同一次意图,幂等记录要与本地副作用一起提交。Stripe 的幂等 API 设计 · AWS Builders’ Library 的安全重试设计
用唯一约束挡住同时到达的请求
两个请求可能同时查不到 R17。单独写一次查询再插入业务记录,会有两个请求都通过的窗口。示例伪代码假设操作表、额度和待投递表共享同一个事务。唯一键冲突方按数据库的可见性规则读取已经提交的记录,若当前事务快照看不到,就结束事务后重新读取。
submitRefund(actor, order, key, input):
checkAccess(actor, order)
scope = (actor.tenant, actor.id, "refund", key)
digest = fingerprint(order.id, validatedInput(input))
transaction:
claim = insertOperationIfAbsent(scope, digest)
if claim.alreadyExists:
return replayAfterMatchingFingerprint(scope, digest)
reserveRefundCapacity(order.id, input.amount)
refund = insertRefund(order.id, input, status = "accepted")
linkOperationToRefund(claim.id, refund.id)
insertOutbox("executeRefund", refund.id)
commit
return statusUrl(refund.id)
冲突分支读取原记录后必须比较指纹,再返回已有结果。若事务失败,操作记录、额度占用和待投递事件一起撤销。投递器在发布消息后可能还没标记完成就崩溃,所以消费者会看到重复消息。处理端根据退款 ID 取得处理资格,并按既有状态恢复。调用外部服务时保留同一渠道操作标识,数据库内去重无法覆盖外部调用成功后本地崩溃的窗口。
渠道结果未知时先查证
处理端发出渠道退款请求,若超时,把内部状态记为待确认。渠道提供商户退款号查询或幂等重试时,用原标识确认或重试。渠道结果确认成功后,按退款 ID 条件更新状态,将占用转为已退并写审计。若渠道明确确认未执行且不会稍后成功,再释放占用。无法自动确认时,进入对账或人工处理队列,界面继续显示待确认。
客户端可对可重试错误做有上限的指数退避,加入随机抖动。若操作已经受理,优先查询任务地址。服务端应约定去重记录的保留期。超过保留期仍有争议时,通过持久退款记录或渠道流水查询原交易,避免因键已清理而把旧请求当成新请求。跨渠道重试的前提是已经知道原渠道结果。
为每个未知结果准备恢复入口
把可疑的成功窗口逐项列出来。它们的共同特征是工作已经发生,但最后一步确认尚未送达。负责恢复的一方应持有能在相应系统里找到原操作的标识。
| 失败位置 | 已有证据 | 恢复动作 |
|---|---|---|
| 浏览器等待超时 | 原请求标识 R17 | 同参数重试,或查询 R17 |
| 业务事务已提交,投递器未发送 | 待投递记录 | 后台继续投递 |
| 已发送,未确认投递完成 | 同一待投递记录 | 允许重投,消费端识别 |
| 渠道已执行,本地未记录 | 原渠道退款号 | 查渠道,按结果补写本地状态 |
| 长时间无法查证 | 请求和查询轨迹 | 保持待确认,交给对账 |
客户端查不到 R17 时,也要区分「当前账号不能看」「记录已超过保留期」和「有权查询且系统没有该记录」。如果依然在幂等保留期内,可以用同一个键重提。新键代表新业务意图,界面应避免把所有失败提示都做成重新创建按钮。
让异步回调和主动查询汇入同一处
有时渠道通知先到,主动查询也随后返回同一个成功。把两条路径汇入同一份结果处理逻辑,在事务里锁定退款记录。先验证渠道流水和业务金额,再检查当前状态能否接受这项结果。成功的终态更新及额度转换只能执行一次。查询结果和回调冲突时保存证据并升级处理,别用到达时间决定谁覆盖谁。
applyProviderResult(refundId, evidence):
transaction:
refund = lockRefund(refundId)
verifyProviderEvidence(refund, evidence)
if refund.matchesFinalResult(evidence):
return refund.result
if refund.isFinal:
recordReconciliationIssue(refund, evidence)
return requiresReview()
if evidence.confirmsSuccess:
moveReservedAmountToRefunded(refund)
recordRefundSucceeded(refund, evidence)
else if evidence.confirmsNoEffect:
releaseReservedAmount(refund)
recordRefundFailed(refund, evidence)
else:
recordPendingConfirmation(refund)
渠道说「未查到」是否代表确定未执行,取决于渠道查询契约和延迟。为自动查证设最大等待、重试间隔和人工接管条件。审计保留渠道流水、验证时点、状态转移和人工决议。这样客服能解释待确认,财务也能从本地记录追到下游证据。
验收每个可能崩溃的位置
并发发两次同标识请求,数据库只应创建一笔退款。换金额再用原标识,应被拒绝。随后分别模拟受理响应丢失、消息重复、渠道已成功但本地未记结果。最后核对渠道流水、内部退款金额和订单额度,不能只以页面出现成功提示为通过。
总结
退款重试依赖贯穿客户端、业务数据库和渠道的操作身份。事务保护本地提交,渠道查询和对账确认跨系统结果。任何一段还不能确认时,都应保留可查记录和对应额度。