TAOCARTS 知识

支付网关幂等性实战:一次对账事故引发的架构改造

2026-06-26 博客文章

本文适合正在处理支付网关回调幂等性问题的后端开发者。如果只关注业务逻辑,可以跳过代码部分直接看思路。

事故经过:月底对账,一笔订单在账单里出现两次

一个代购站点月底对账,财务发现一笔订单在支付网关的账单里出现了两次——同一笔金额、同一个支付流水号,但系统只记录了一次支付。这意味着网关重试后,回调被重复消费,但业务侧没有做幂等处理,订单状态被覆盖成"已支付"两次,金额对不上。

这不是个例。月流水几十万的代购站点,因没做自动对账,年底发现支付通道手续费多扣了近万——其中相当一部分来自重复回调导致的重复扣款。

根因分析:回调不做幂等,网关重试就是灾难

支付网关的超时重试机制本身没问题——网络抖动时重试是保证交付的手段。问题出在回调处理没有幂等设计。常见的做法是:

```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,一起来完善。

---