文章指出周期扣款不能对所有失败统一定时重试,应区分临时故障、余额不足、授权失效和重复处理,并设置不同策略。支付请求还应使用幂等键,防止状态延迟或重复调用造成二次扣款。
订阅计费这类系统,演示时看起来很简单,一旦进入生产环境,就会立刻变成一个分布式系统问题。核心原因在于:支付通道的失败并非非黑即白,而你的重试逻辑必须反映这种复杂性。
以 Direct Debit 为例,扣款失败可能代表几种截然不同的情况:余额不足、授权已过期或被取消、账户已不存在,或者银行端发生技术故障。
如果对这些情况一视同仁,统一在 24 小时后重试,最终可能会连续数周反复请求一个已经失效的授权。更糟的是,你可能会重试一笔实际上已在银行端成功、只是结果尚未回传的付款。
更合理的思维模型,是对失败进行分类:
瞬时故障(技术错误、超时)→ 尽快重试,可以安排在几小时内
余额不足 → 可以重试,但应根据常见的发薪周期设置退避时间,而不是采用固定间隔
授权无效或已取消 → 不要重试,立即将问题告知客户
重复请求或已经处理 → 完全不要重试,这正是 idempotency key 发挥作用的地方
每次发起付款的 API 调用都应该携带由客户端生成的 idempotency key,而不是由服务端生成。这样,当网络请求超时、客户端随后发起重试时,就不会产生两笔扣款。
与银行卡支付相比,这一点对于银行支付通道更为重要,因为后者的反馈周期更长。如果提交 Direct Debit 请求时 API 调用超时,你确实无法知道银行是否已经收到了请求。如果没有 idempotency key 就盲目重试,可能会从真实客户的真实银行账户中重复扣款。这种故障的严重程度,远远高于一次银行卡扣款失败。
轮询付款状态一直有效,直到它突然失效。可扩展的模式,是把 Webhook 当作状态转换的事实来源,例如 submitted → confirmed → paid,或者 submitted → failed → retried。
同时,你需要确保系统能够应对 Webhook 乱序到达或重复投递。保存 event ID,并基于它进行去重。永远不要假设 Webhook 只会投递一次,因为在实际环境中,它几乎从来都无法做到 exactly-once。
订阅业务中,相当一部分客户流失并非源于主动取消,而是因为付款失败后一直没有得到解决。
如果你的重试逻辑悄无声息地失败了三次,随后直接取消订阅,却从未提醒客户,那么你实际上是在以收入为代价,换取后端实现的简单。
优秀的系统会把后端重试逻辑与主动的客户通知结合起来,例如发送“你的付款失败了,请更新付款信息”之类的消息,并确保消息能在账户真正失效之前送达。
这些问题并非支付系统独有。任何需要处理不可靠投递、局部失败和最终一致性的系统,面对的都是同一类问题。只不过在支付场景中,一旦处理错误,后果会立即反映在某个人的银行余额上,因此风险格外直观。
这也是创业者故事中反复出现的主题:真正区分一个能够规模化发展的订阅业务和一个在不知不觉中因客户流失而持续失血的业务,通常正是那些并不光鲜的故障处理工作。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。