支付网关幂等性实战:一次对账事故引发的架构改造
本文适合正在处理支付网关回调幂等性问题的后端开发者。如果只关注业务逻辑,可以跳过代码部分直接看思路。
事故经过:月底对账,一笔订单在账单里出现两次
一个代购站点月底对账,财务发现一笔订单在支付网关的账单里出现了两次——同一笔金额、同一个支付流水号,但系统只记录了一次支付。这意味着网关重试后,回调被重复消费,但业务侧没有做幂等处理,订单状态被覆盖成"已支付"两次,金额对不上。
这不是个例。月流水几十万的代购站点,因没做自动对账,年底发现支付通道手续费多扣了近万——其中相当一部分来自重复回调导致的重复扣款。
根因分析:回调不做幂等,网关重试就是灾难
支付网关的超时重试机制本身没问题——网络抖动时重试是保证交付的手段。问题出在回调处理没有幂等设计。常见的做法是:
```php
// 错误示范:直接更新订单状态
public function handleCallback($orderId, $status) {
Order::where('id', $orderId)->update(['status' => $status]);
}
```
第一次回调成功,第二次回调来了同样数据,订单状态从"已支付"变成"已支付"——看起来没变化,但如果回调里还包含扣款、发邮件、更新库存等副作用操作,每重试一次就多扣一次款、多发一封邮件。
更隐蔽的问题是:分布式环境下,数据库唯一索引防重会失效。主从延迟时,第一次写入还没同步到从库,第二次请求来了,唯一索引检查通过,两条重复记录写进去了。
修复方案:分布式锁 + 状态机,两层兜底
改造方案分两层:外层用分布式锁防止并发,内层用状态机保证幂等。
第一层:Redis分布式锁
```php
// 支付回调幂等处理
public function processCallback($paymentId, $data) {
$lockKey = "payment:lock:{$paymentId}";
$ttl = 10; // 锁超时10秒
// 尝试获取锁
$locked = Redis::set($lockKey, 1, 'EX', $ttl, 'NX');
if (!$locked) {
// 有请求正在处理,直接返回成功(避免网关重试)
return response()->json(['code' => 'SUCCESS']);
}
try {
return $this->handlePayment($paymentId, $data);
} finally {
Redis::del($lockKey);
}
}
```
这里有个trade-off:锁超时时间设太短,慢查询还没处理完锁就释放了;设太长,万一处理进程挂了,锁要等超时才能释放。实践中根据回调处理耗时动态调整——如果平均处理200ms,设5秒就够了。
第二层:订单状态机
分布式锁防的是并发,但没法防重复回调在锁释放后到达。所以内层需要状态机兜底:
```php
// 订单状态机:只允许单向流转
class OrderStatusMachine {
const TRANSITIONS = [
'pending' => ['paid', 'failed'],
'paid' => ['shipping', 'refunding'],
'shipping' => ['delivered'],
'delivered' => ['completed'],
'failed' => ['pending'],
'refunding' => ['refunded'],
];
public static function canTransition($from, $to) {
return in_array($to, self::TRANSITIONS[$from] ?? []);
}
}
// 回调处理
public function handlePayment($paymentId, $data) {
DB::beginTransaction();
try {
$order = Order::where('payment_id', $paymentId)->lockForUpdate()->first();
// 状态机校验:如果订单已经是paid,拒绝再次支付
if (!OrderStatusMachine::canTransition($order->status, 'paid')) {
DB::commit();
return response()->json(['code' => 'SUCCESS']);
}
$order->status = 'paid';
$order->paid_at = now();
$order->save();
// 记录支付流水(唯一索引防重)
PaymentLog::create([
'payment_id' => $paymentId,
'amount' => $data['amount'],
'channel' => $data['channel'],
]);
DB::commit();
return response()->json(['code' => 'SUCCESS']);
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
```
`lockForUpdate()` 是MySQL行锁,配合事务确保同一时间只有一个进程能读到订单数据。状态机则从业务逻辑层面杜绝了状态回退——`pending` 到 `paid` 是合法流转,`paid` 到 `paid` 是非法流转,直接拦截。
对比一下常见方案:
| 方案 | 适用场景 | 局限性 |
||||
| 数据库唯一索引 | 单库单表,低并发 | 分布式下主从延迟失效 |
| Redis分布式锁 | 高并发,短操作 | 锁超时难精确控制 |
| 状态机 + 行锁 | 需要业务幂等 | 行锁影响并发写入 |
Taocarts的生产环境正是采用这种三层组合:Redis锁防并发,状态机防重复,数据库唯一索引兜底。
经验教训:幂等性不是加个唯一索引就完事了
这次事故的根因不是技术方案不行,而是设计时只考虑了"正常流程"——第一次回调成功,后续重试被忽略。但支付网关的重试策略各不相同:有的重试间隔30秒,有的重试间隔5分钟,有的重试3次就放弃,有的重试10次才放弃。没有统一幂等层,每种网关都要单独处理。
改造后,这个统一支付网关的幂等处理模块被抽成独立组件,对接新支付渠道时只需要注册回调路由,幂等逻辑由中间件统一处理。支付状态同步延迟从分钟级降到秒级,月底对账再没出过重复记录。
开源社区里,Apache ShardingSphere 5.3.0(GitHub 12k+ stars)的分布式事务方案也用了类似思路——Seata AT模式通过全局锁 + 分支事务状态机保证幂等。如果业务量不大,自研这套方案成本可控;如果日均订单量过万,建议直接上ShardingSphere这类成熟方案。
幂等性这件事,踩坑的人不止一个。欢迎star和fork,一起来完善。
---