别把幂等性做成开关:一次代购系统支付回调的架构重构
本文适合正在处理分布式事务或支付回调幂等性的后端开发者。如果你只关注业务逻辑,可以跳过代码部分直接看思路和结论。
卡点:不是没做幂等,是只做了一半
去年接手一个反向海淘代购系统的支付模块时,生产环境出现了个诡异的问题:用户支付成功后,订单状态偶尔会从“已支付”回退到“待支付”,然后又被推回“已支付”。排查后发现,根源不在支付网关,而在我们自己的回调处理逻辑。
问题出在幂等性设计的边界上:我们只防了同一状态下的重复请求,没防跨状态的重复请求。换句话说,幂等性被做成了一个“开关”——请求来了,查一下有没有处理过,处理过就跳过。但订单状态机是流转的,同一个支付回调在订单的不同状态下到达,含义完全不同。
现有方案为什么不够好
市面上常见的幂等性方案主要有三种,但用在代购系统的支付回调场景里,各有各的坑。
**MySQL行锁 + 唯一索引**:最朴素的做法。在支付回调表上建唯一索引(order_id + transaction_id),利用数据库的行锁防重。但问题是,订单状态更新和幂等记录写入不是原子操作。高并发下,两个请求同时查到“未处理”,都去更新订单状态,然后才有一个插入幂等记录失败。订单状态被更新两次,结果不可控。
**Redis分布式锁**:用SETNX加锁,处理完释放。看起来解决了并发问题,但锁的超时时间是个死结。锁时间太短,业务还没处理完锁就过期了;锁时间太长,Redis宕机恢复后锁还卡着。而且锁只能防并发,防不了网络重试带来的“过去式”请求——一个支付回调延迟了30分钟才到达,订单状态早就变了,锁照样能拿到,但业务逻辑已经不对了。
**状态机校验**:在更新订单状态前,先校验当前状态是否允许跳转到目标状态。这个思路是对的,但很多实现只在校验通过后更新状态,没考虑校验和更新之间的并发窗口。
技术怎么降低这个门槛:Redis Lua脚本 + 状态机
问题的本质不是没做幂等,而是幂等性设计必须把“业务状态”和“操作请求”绑定在一起判断。一个请求是否该被处理,取决于“当前业务状态”和“请求携带的目标状态”是否匹配,而不只是“这个请求有没有来过”。
我们最终落地的是Redis Lua脚本 + 状态机校验的方案。核心思路:把幂等性判断、状态校验、状态更新、幂等记录写入,全部塞进一个Lua脚本里,保证原子性。
```lua
-- 幂等性校验 + 状态机更新Lua脚本
-- KEYS[1]: 订单状态Redis Key (order:{order_id}:status)
-- KEYS[2]: 幂等记录Redis Key (idempotent:{transaction_id})
-- ARGV[1]: 当前订单状态
-- ARGV[2]: 目标状态
-- ARGV[3]: 幂等请求ID
-- ARGV[4]: 幂等超时时间 (秒)
-- 1. 幂等性检查:请求是否已处理
local processed = redis.call('GET', KEYS[2])
if processed then
return 0 -- 已处理,直接返回
end
-- 2. 状态机校验:当前状态是否允许跳转到目标状态
local current_status = redis.call('GET', KEYS[1])
if current_status ~= ARGV[1] then
return -1 -- 状态不匹配,返回错误
end
-- 3. 更新状态 + 写入幂等记录(原子操作)
redis.call('SET', KEYS[1], ARGV[2])
redis.call('SETEX', KEYS[2], ARGV[4], '1')
return 1 -- 处理成功
```
这个脚本解决了三个问题:
PHP端的调用代码也很简洁:
```php
// 支付回调处理
$orderId = $payload['order_id'];
$transactionId = $payload['transaction_id'];
$expectedStatus = 'pending_payment'; // 期望的当前状态
$targetStatus = 'paid'; // 目标状态
$result = Redis::eval(
$luaScript,
2, // KEYS数量
"order:{$orderId}:status",
"idempotent:{$transactionId}",
$expectedStatus,
$targetStatus,
$transactionId,
3600 // 幂等记录保留1小时
);
if ($result === 0) {
// 已处理过,忽略
return;
}
if ($result === -1) {
// 状态不匹配,记录异常日志
Log::warning("支付回调状态不匹配", [
'order_id' => $orderId,
'expected' => $expectedStatus,
'actual' => Redis::get("order:{$orderId}:status")
]);
return;
}
// 处理成功,执行后续业务逻辑
```
实际效果如何
这套方案上线后,支付回调相关的订单状态异常从每周3-5起降到了接近零。更重要的是,它让幂等性设计从“防重复”升级到了“防乱序”——不仅防并发,还防跨状态的错误重试。
当然,Redis Lua脚本不是银弹。它的局限性也很明显:
选型决策的trade-off
回头看,如果让我重新选,我可能还是会走Redis Lua这条路,但会在以下场景考虑替代方案:
最后说一句:幂等性设计的关键不是“怎么防重”,而是“什么情况下该处理,什么情况下不该处理”。把业务状态和操作请求绑定在一起判断,比单纯记一个“已处理”标记要可靠得多。
---
做了十年电商后端,参与过 Taocarts 代购系统和 AuctionGIt 日本竞拍平台(60+拍卖网站统一对接)的开发。有问题欢迎交流。