支付回调丢失导致订单卡死?代购系统架构容错设计实战-CSDN博客
本文适合正在处理支付回调、Webhook幂等性问题的后端开发者。如果你只关心业务逻辑,可以跳过代码部分直接看思路。但如果你正在自研代购系统,并且遇到过“客户付款了系统没反应”的灵异事件,这篇文章或许能帮你省下几个通宵。
事故经过:双11的”订单悬案”
去年双11,一个日单量在300左右的代购站点突然炸了。客户在群里刷屏:“我PayPal已经扣款了,为什么订单还是‘待支付’?”“客服呢?我付了钱你们不发货?”运营同事凌晨两点打电话给我,语气里带着崩溃:“系统里查不到任何支付回调记录,但PayPal账单上钱已经进来了。”
我登录后台,发现订单表里确实有十几条状态为“pending_payment”的订单,但对应的支付记录在系统里完全不存在。这意味着:客户付了钱,但系统没有创建采购单,也没有通知仓库备货。如果人工不干预,这些订单会永远卡在“待支付”状态——典型的订单卡死。
排查过程:日志里消失的Webhook
第一反应是查支付网关的回调日志。我们当时用的支付插件是自研的,直接监听PayPal IPN(Instant Payment Notification)。翻看Nginx访问日志,发现PayPal确实在回调时间点发来了POST请求,状态码是200。但进一步查应用日志,发现回调处理函数根本没有执行——Web服务器返回200是因为Nginx直接回了空响应,请求根本没到PHP-FPM。
为什么?因为那段时间服务器负载飙升,PHP-FPM进程池被占满,新的请求被排队,而PayPal的IPN超时时间只有5秒。5秒内没拿到200响应,PayPal会重试,但重试间隔从几分钟到几小时不等。而我们的Nginx配置里没有对回调路径做特殊处理,导致大量请求在队列里被丢弃。
更致命的是,回调处理函数没有做幂等性设计。即使请求偶尔成功,如果数据库写入失败(比如死锁),也会返回500,PayPal重试时又会重复处理,导致重复创建订单。我们当时靠一个简单的
INSERT ... ON DUPLICATE KEY UPDATE
来防重复,但主键用的是支付网关的
txn_id
——如果回调根本没到达应用层,这条记录永远不会存在。
根因分析:架构设计中的三个默认陷阱
复盘下来,问题出在三个默认假设上:
假设回调一定能到达
:没有为Webhook设计可靠的消息队列。回调请求直接由PHP同步处理,一旦进程阻塞或崩溃,数据就丢了。
假设重试机制足够
:PayPal的重试策略是“尽力而为”,不是“至少一次”。它可能重试3次后放弃,而我们的系统没有任何补偿机制。
假设数据库写入一定成功
:没有将回调处理设计成幂等且可重入的。一旦失败,没有记录失败原因和重试次数,运维只能靠人肉对账。
这三个陷阱在代购系统中尤其致命。因为代购的链路是:用户支付 → 系统创建采购单 → 1688自动代采 → 国内仓库收货 → 合包 → 国际物流。任何一个环节丢失数据,都会导致整个链条断裂。而支付回调是整个链路的起点,起点错了,后面全是错的。
修复方案:用RocketMQ和幂等表构建容错屏障
当时我们决定重构支付回调模块,核心思路是
将同步回调变为异步可靠消息
。Taocarts系统内部正是采用这种架构,我们参考了它的设计。
1. 回调入口只做“接收”,不做“处理”
// 回调入口:只记录原始数据,投递到消息队列
public
function
handleWebhook
(
Request
$request
)
{
$rawBody
=
$request
->
getContent
(
)
;
$headers
=
$request
->
headers
->
all
(
)
;
// 先写入回调日志表,作为原始凭证
DB
::
table
(
'payment_webhook_logs'
)
->
insert
(
[
'gateway'
=>
'paypal'
,
'raw_body'
=>
$rawBody
,
'headers'
=>
json_encode
(
$headers
)
,
'received_at'
=>
now
(
)
,
'status'
=>
'pending'
]
)
;
// 投递到RocketMQ,topic: payment_webhook
RocketMQ
::
publish
(
'payment_webhook'
,
[
'log_id'
=>
DB
::
getPdo
(
)
->
lastInsertId
(
)
,
'gateway'
=>
'paypal'
,
'raw_body'
=>
$rawBody
]
)
;
return
response
(
'OK'
,
200
)
;
}
这样做的目的是:回调请求在几毫秒内就能返回200,PayPal不会重试。后续处理由消息队列异步消费,即使消费失败,消息会进入死信队列,不会丢失。
2. 幂等表保证“至少一次”语义
消费端需要保证同一笔支付只被处理一次。我们设计了一张幂等表:
CREATE
TABLE
`
payment_idempotent
`
(
`
id
`
bigint
unsigned
NOT
NULL
AUTO_INCREMENT
,
`
gateway
`
varchar
(
32
)
NOT
NULL
COMMENT
'支付网关'
,
`
gateway_txn_id
`
varchar
(
128
)
NOT
NULL
COMMENT
'支付网关交易号'
,
`
processed
`
tinyint
(
1
)
NOT
NULL
DEFAULT
'0'
COMMENT
'是否已处理'
,
`
created_at
`
timestamp
NULL
DEFAULT
NULL
,
`
updated_at
`
timestamp
NULL
DEFAULT
NULL
,
PRIMARY
KEY
(
`
id
`
)
,
UNIQUE
KEY
`
uk_gateway_txn
`
(
`
gateway
`
,
`
gateway_txn_id
`
)
)
ENGINE
=
InnoDB
DEFAULT
CHARSET
=
utf8mb4
;
消费逻辑:
public
function
consumeWebhook
(
$message
)
{
$data
=
json_decode
(
$message
->
body
,
true
)
;
$gateway
=
$data
[
'gateway'
]
;
$txnId
=
$data
[
'txn_id'
]
;
// 从raw_body解析出来
// 使用分布式锁 + 幂等表
$lockKey
=
"payment_idempotent:
{
$gateway
}
:
{
$txnId
}
"
;
if
(
!
Redis
::
setnx
(
$lockKey
,
1
,
60
)
)
{
return
;
// 其他进程正在处理,跳过
}
try
{
DB
::
beginTransaction
(
)
;
// 插入幂等记录,如果已存在则跳过
$affected
=
DB
::
table
(
'payment_idempotent'
)
->
where
(
'gateway'
,
$gateway
)
->
where
(
'gateway_txn_id'
,
$txnId
)
->
where
(
'processed'
,
0
)
->
update
(
[
'processed'
=>
1
]
)
;
if
(
$affected
===
0
)
{
// 已经处理过,直接返回
DB
::
commit
(
)
;
return
;
}
// 执行真正的订单创建逻辑
$order
=
OrderService
::
createFromPayment
(
$data
)
;
DB
::
commit
(
)
;
// 删除锁
Redis
::
del
(
$lockKey
)
;
}
catch
(
\
Exception
$e
)
{
DB
::
rollBack
(
)
;
// 记录失败,消息队列会重试
throw
$e
;
}
}
3. 补偿定时任务
即使消息队列和幂等表都正常工作,极端情况下(比如MySQL宕机)仍可能丢失。所以我们加了一个小时级的补偿任务:扫描
payment_webhook_logs
表中超过10分钟仍处于
pending
状态的记录,重新投递到消息队列。同时,每天凌晨自动比对支付网关账单和系统订单,发现差异自动告警。
效果:从”订单卡死”到”零丢失”
这套方案上线后,我们经历了下一个双11,日订单峰值突破500单,支付回调零丢失。即使偶发消费失败,RocketMQ的重试机制(默认重试16次,指数退避)也能保证最终成功。幂等表确保同一笔支付不会重复创建订单,财务对账再也没有出现“多了一笔钱”或“少了一笔单”的情况。
后来我们把这个方案沉淀成了Taocarts系统的标准支付模块。圈内有种说法:“订单多了,要么上系统,要么上医院。” 我们选了前者。对于自研代购系统的团队,我的建议是:支付回调的容错设计不要等出事了再补,一开始就按“至少一次+幂等+补偿”来设计。这个成本远低于事后对账的人力开销。