网关里只有一次Capture,Magento却有两张Invoice。时间戳只差不到一秒:一条由PayPal Webhook触发,另一条来自客户支付完成后的return流程。两个进程都在检查order->canInvoice(),检查时都为true,随后各自创建并保存Invoice。单靠canInvoice不是并发锁。
先确认是不是同一个外部交易
SELECT entity_id,increment_id,state,total_paid FROM sales_order WHERE increment_id='100000456'; SELECT entity_id,increment_id,order_id,state,grand_total,transaction_id FROM sales_invoice WHERE order_id=/* order entity id */; SELECT transaction_id,parent_id,txn_id,txn_type,is_closed,additional_information FROM sales_payment_transaction WHERE order_id=/* order entity id */;
字段随版本不同,重点是对比PayPal capture ID。两张Invoice若对应同一个capture ID,就是幂等缺失;若网关真的扣了两次,应先按支付事故流程处理。
外部事件ID必须唯一
建立独立webhook_event表,provider + event_id或capture_id加唯一索引。收到回调时先尝试插入“processing”记录,唯一键冲突说明已经处理,直接返回2xx。不要等创建Invoice以后才记录,因为并发窗口已经打开。
UNIQUE(provider, external_event_id) status: processing -> succeeded / failed store payload_hash, not unnecessary full sensitive payload
订单级操作放进同一事务或锁
取得幂等记录后,对目标订单加数据库行锁,再重新加载订单和Payment状态。在锁内再次检查已有transaction与Invoice。不同项目可使用SELECT ... FOR UPDATE、应用锁或串行队列,但必须保证两个入口共用同一把锁。
BEGIN; SELECT entity_id FROM sales_order WHERE entity_id=? FOR UPDATE; -- 重新检查capture transaction与invoice -- 仅在不存在时创建invoice COMMIT;
不要长时间持锁调用PayPal API。外部网络请求放锁外,锁内只做本地状态判断和短事务。
return与webhook统一到一个业务服务
最容易出问题的是两个Controller各写一套“创建Invoice”逻辑。应把capture确认映射成同一个命令,例如ProcessCapturedPayment(orderId, captureId),两个入口都调用它,由命令内部执行幂等和锁。
失败重试怎么设计
- 事件记录processing超时后允许受控接管,而不是永久跳过
- 创建Invoice成功但更新事件状态失败时,重试先查capture_id关联结果
- 网关通知乱序时,AUTHORIZE和CAPTURE分别按状态机处理
- 返回2xx只代表已接受;不可恢复格式错误另行告警
- 重复事件返回成功,避免PayPal持续重试放大流量
并发测试
在测试环境用两个并发请求携带同一event_id和capture_id,同时触发return与webhook。最终只能有一张Invoice、一个Capture transaction,另一个请求应命中幂等记录正常结束。再模拟第一个进程在Invoice保存后崩溃,确认第二次能识别已完成结果。
支付场景的“先查再写”如果没有锁,不具备幂等性。检查和创建必须放在同一个并发边界里。

