贷款回调重试如何防止重复放款
围绕回调超时后重试造成重复放款的问题,示例使用事件去重、贷款唯一约束、支付幂等键和事务管理。代码还通过状态记录跟踪放款进度。
AI 深度解读
Webhook 重试通常只是多发一封邮件,放到贷款系统里,却可能把同一笔钱打出去两次。原文用约 70 行 Python 和 SQLite 演示了一套防重方案:事件去重、贷款唯一约束、固定付款幂等键,以及只向前推进的状态。
它值得读的地方,是把「收到重复通知」和「实际重复付款」拆成了不同问题。作者报告了并发、超时和乱序测试,但这仍是一个教学示例。理解每道防线保护什么,比直接复制代码更有用。
为什么事件去重挡不住第二笔付款
Webhook 通常采用至少一次投递(at least once):发送方会努力把消息送到,却不保证只送一次。原文以 Stripe 为例,称其生产模式下会退避重试失败投递,最长持续三天。
重复主要来自三条路径:处理太慢触发重试;第一次还没处理完,第二次就进入另一个 worker;上游重新审批,为同一笔贷款生成一个全新的事件 ID。
前两种可以用事件 ID 去重,第三种不行。两条消息虽然不同,指向的却是同一笔贷款。
事件唯一解决重复投递,业务唯一解决重复执行。
作者用两张表分别保存这两种约束。下面这段代码把防重规则交给数据库执行:
import sqlite3, uuid
SCHEMA = """
CREATE TABLE IF NOT EXISTS webhook_events (
event_id TEXT PRIMARY KEY,
received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS disbursements (
loan_id TEXT PRIMARY KEY, -- 每笔贷款永远只允许一次放款
amount_cents INTEGER NOT NULL,
status TEXT NOT NULL, -- pending -> sent -> paid | failed
idempotency_key TEXT NOT NULL UNIQUE,
provider_ref TEXT
);
"""
关键是 loan_id TEXT PRIMARY KEY。即使上游换了事件 ID,同一贷款也插不进第二条放款记录。金额则使用整数分 amount_cents,避免浮点表示带来的金额误差。
这个设计有明确前提:一笔贷款只有一次放款。原文 FAQ 补充,分批放款应使用「贷款 ID + 放款序号」作为唯一约束,否则合法的后续批次也会被挡住。
为什么要先落库,再让 worker 付款
作者要求在同一个事务里完成两件事:记录事件 ID,创建放款记录。这样可以避免事件已经被标记处理过,付款任务却没有保存成功。
SQLite 示例使用 BEGIN IMMEDIATE 提前取得写锁;重复事件回滚后返回 200,让发送方停止重试。同一贷款的新事件则通过 INSERT OR IGNORE 跳过放款插入。迁移到 Postgres 时,原文建议在普通事务中使用 INSERT ... ON CONFLICT DO NOTHING。
付款请求不放在 Webhook handler 里。handler 提交数据库后尽快响应,worker 再读取 pending 记录调用支付服务,减少网络耗时引发的重试。
但真正危险的是:服务商已经创建付款,返回结果却在网络中丢了。此时本地仍然是 pending,直接重新付款就可能重复出款。
下面的 worker 用固定幂等键处理这个窗口:
def send_pending(db, provider):
"""Worker:推送待放款记录,每次重试复用同一个幂等键。"""
rows = db.execute("SELECT loan_id, amount_cents, idempotency_key FROM disbursements "
"WHERE status = 'pending'").fetchall()
for loan_id, amount, key in rows:
try:
ref = provider.create_payout(amount, idempotency_key=key)
except TimeoutError:
continue # 保持 pending,下次仍使用相同的键重试
db.execute("UPDATE disbursements SET status='sent', provider_ref=? "
"WHERE loan_id=? AND status='pending'", (ref, loan_id))
重点看 provider.create_payout(amount, idempotency_key=key)。handler 创建记录时把键固定为 f"disburse-{loan_id}",worker 每次都读取并复用它。服务商识别到同一个键,应返回原来的付款结果。
本地唯一约束只能管自己的数据库。跨过网络之后,还需要付款服务商兑现幂等承诺,而且必须确认它保留幂等键多久。重试超过保留期,不能继续默认同一个键仍然有效。
付款状态为什么不能跟着消息随意覆盖
付款请求发送成功后,状态通知仍可能延迟或乱序。例如先收到 paid,再收到较晚到达的 failed。
作者定义了排序表:
ORDER = {"pending": 0, "sent": 1, "paid": 2, "failed": 2}
新状态的序号必须大于当前状态,才允许更新。因此路径是 pending → sent → paid | failed;paid 和 failed 同级,谁先落库,另一个就不能覆盖它。被忽略的通知返回 200。
更新 SQL 还把读到的旧状态放进 WHERE 条件,防止读取与写入之间的并发变化被覆盖。
另一个窗口是状态通知先到了,本地却尚未保存 provider_ref。示例查不到付款 ID 时返回 404,希望服务商稍后重试。这依赖服务商确实会对这种响应重投,不能脱离实际协议使用。
作者报告了五类测试:同一事件重复投递、并发投递、新事件 ID 对应同一贷款、付款超时后重试,以及 paid 后收到迟到的 failed。其中,10 个线程同时投递同一事件,最终只创建一个放款任务和一笔付款。
这些测试使用会遵守幂等键、能模拟响应丢失的假服务商。它们验证了示例逻辑,没有验证真实支付渠道的全部行为。
我的判断
我认可这套方案对职责的划分:数据库约束负责挡住重复业务记录,服务商幂等键负责挡住重复外部操作。相比应用层先查询、再判断能不能插入,唯一约束更适合处理并发竞争。
但它不能直接充当生产级付款系统。
首先,「状态只前进」需要服从真实服务商的状态模型。示例将 paid 和 failed 都设为终态,实际效果是先接受的终态获胜。原文没有证明,这条规则能适配不同渠道的纠错通知和其他状态。
其次,worker 没有领取任务的机制。多个 worker 可以同时读到同一条 pending,最终付款安全仍依赖服务商幂等,而不是本地调度保证。示例也没有完整处理非超时错误、重试退避及人工处置。
最后,不能从五项测试推出「不会重复付款」的无条件承诺。原文没有真实服务商对照数据,也没有长期运行数据;生产经验和效果判断需要保留这个边界。
原文后半部分推荐了多家开发服务商,并注明规模、成功率和报价属于企业自报。这些商业信息不能反过来证明代码可靠。对程序员更有价值的是作者列出的补充措施:先验签并拒绝过期通知,事件记录覆盖重试窗口,每天对账,并检查长期停留在 pending 的任务。
能拿来做什么
- 在 Node Webhook 接口里同时设计事件唯一键和业务唯一键,并把事件登记与任务创建放进同一事务。遇到分批放款,先明确每个批次的业务标识。
- 把外部付款调用移到 worker,提前保存固定幂等键。接入渠道时核实键的有效期、参数一致性要求,以及超时后如何查询原付款。
- 按原文五类故障编写测试,尤其模拟「服务商成功,但客户端没有收到响应」。正常请求返回成功,验证不了这个危险窗口。
- 根据渠道协议制定状态转换和重试策略。事件 ID 至少保留最长重试窗口并留出余量;原文提到不少团队保留 30 至 90 天,同时每日对账并对卡住的任务告警。