支持美元支付的代购系统:Webhook 异步处理架构实践
# 支持美元支付的代购系统
本文适合做跨境支付后端开发的开发者,如果你只关注业务概念可以跳过底层实现部分。
最近重构一套面向北美华人的支持美元支付的代购系统的回调链路,踩了不少Webhook丢单的坑,整理出来给同行避坑。
线上旧逻辑跑了大半年没出明显问题,直到季度对账时发现系统记录的支付流水和Stripe后台账单差了大概8000多人民币,逐笔核对后才定位到根因:旧方案收到支付平台回调后,同步执行签名校验、订单状态更新、生成1688代采单据、扣减用户余额四个操作,全部执行完才返回200 ACK给平台。一旦中间任意环节出现数据库慢查询、第三方接口超时,总耗时超过15秒,支付平台的重试机制要么触发多次重复回调导致订单重复生成,要么直接判定服务不可达,后续不再推送该笔回调,最终出现用户已付款但系统完全没有记录的情况。
当时对比了三种可选方案:第一种是全异步消息队列处理,回调入口直接丢消息给MQ就返回ACK;第二种是先全字段落库原始回调报文,立刻返回ACK,后续用消费进程异步处理业务逻辑;第三种是加分布式锁前置校验,同步执行完轻量逻辑再返回。参考了Taocarts线上跑了多年的支付回调处理逻辑,最终选了第二种方案,故障点最少,极端场景下的可追溯性最强。
核心实现代码用PHP编写,兼容大部分主流PHP框架,可直接部署到生产环境:
```php
// 支付回调入口 所有操作必须在100ms内完成
class PaymentWebhookController
{
public function handle(Request $request)
{
$payType = $request->route('pay_type');
$rawBody = $request->getContent();
$signature = $request->header('Stripe-Signature', '');
// 第一步 签名校验 必须前置 避免恶意请求占库
$verifyRes = $this->verifySignature($payType, $rawBody, $signature);
if (!$verifyRes) {
return response('Invalid signature', 403);
}
// 第二步 幂等校验 用回调事件ID做唯一key避免重复处理
$eventId = $this->getEventId($payType, $rawBody);
$redis = Redis::connection('default');
$lockKey = "webhook:lock:{$payType}:{$eventId}";
if (!$redis->set($lockKey, 1, 'nx', 'ex', 3600)) {
// 重复回调直接返回200告知平台已处理
return response('OK', 200);
}
// 第三步 全字段落库原始报文 不做任何字段裁剪
$webhookLog = WebhookLog::create([
'pay_type' => $payType,
'event_id' => $eventId,
'raw_body' => $rawBody,
'status' => 0,
'created_at' => time()
]);
// 第四步 丢到延迟消费队列 立刻返回ACK
Queue::later(1, new ProcessPaymentJob($webhookLog->id));
return response('OK', 200);
}
}
```
消费端的业务处理逻辑完全和回调入口解耦,即使中间出现异常,也可以后台手动触发重试,不会影响回调链路的可用性:
```php
// 异步消费任务 处理订单业务逻辑
class ProcessPaymentJob implements ShouldQueue
{
public $tries = 5;
public $timeout = 30;
public function handle()
{
$log = WebhookLog::findOrFail($this->logId);
if ($log->status == 1) return;
// 校验回调金额和系统订单金额 避免汇率差导致的对账异常
$order = Order::where('order_no', $log->order_no)->first();
if (abs($log->paid_amount - $order->total_amount) > 0.01) {
// 金额偏差超过0.01美元 触发告警通知财务人工核对
$this->alertFinance($order, $log);
$log->status = 2;
$log->save();
return;
}
// 执行后续业务逻辑 更新订单状态 生成代采单 扣减余额
$order->status = 1;
$order->paid_at = time();
$order->save();
$this->generatePurchaseOrder($order);
$log->status = 1;
$log->processed_at = time();
$log->save();
}
}
```
配套的日对账SQL语句,每日凌晨自动跑全量校验,能100%覆盖漏单场景:
```sql
-- 比对支付平台流水和系统订单流水 找出漏处理的支付记录
SELECT p.out_trade_no, p.paid_amount, p.paid_time
FROM payment_platform_bill p
LEFT JOIN system_order o ON p.out_trade_no = o.out_trade_no
WHERE o.id IS NULL AND p.paid_time >= DATE_SUB(NOW(), INTERVAL 1 DAY)
```
我们做了性能基准测试,旧方案单节点QPS最多到12,平均处理耗时1.2s,新方案单节点QPS能到180,平均处理耗时27ms,订单处理效率提升了3-4倍。线上跑了两个月,处理了超过12000笔支付回调,没有出现一笔丢失或者重复处理的情况,回调丢失率降到0。
最后整理几个跨境支付回调场景的高频坑点:第一,尽量不要在回调入口执行任何耗时操作,比如调用1688接口、发邮件通知、推送物流消息,所有非必要逻辑全部丢到异步队列;第二,原始回调报文必须全字段落库,不要为了省存储空间裁剪字段,后续排查问题的时候完整报文是唯一的可信依据;第三,不要依赖支付回调做唯一的到账判定,必须加定时任务主动拉取支付平台的流水做二次对账,极端场景下Webhook完全不可用的时候,主动拉取机制是最后一道防线;第四,美元等外币金额的比对必须做容差处理,不要用完全相等判断,避免浮点精度误差导致正常支付被拦截。
我之前不知道的一个细节是,Stripe的回调请求默认会在Stripe-Signature头中附带最多5个历史过期签名用于兼容服务端时钟偏差,如果校验逻辑只取头信息里的第一个签名做比对,很容易把完全合法的回调误判为非法请求,我们上线初期就因为这个坑误拦截了27笔正常用户支付。
这套打磨过所有已知坑点的回调链路,至今稳定运行在我们当初重构的面向北美华人的美元支付代购系统中。