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

卡点:不是没做幂等,是只做了一半

去年接手一个反向海淘代购系统的支付模块时,生产环境出现了个诡异的问题:用户支付成功后,订单状态偶尔会从“已支付”回退到“待支付”,然后又被推回“已支付”。排查后发现,根源不在支付网关,而在我们自己的回调处理逻辑。

问题出在幂等性设计的边界上:我们只防了同一状态下的重复请求,没防跨状态的重复请求。换句话说,幂等性被做成了一个“开关”——请求来了,查一下有没有处理过,处理过就跳过。但订单状态机是流转的,同一个支付回调在订单的不同状态下到达,含义完全不同。

现有方案为什么不够好

市面上常见的幂等性方案主要有三种,但用在代购系统的支付回调场景里,各有各的坑。

MySQL行锁 + 唯一索引:最朴素的做法。在支付回调表上建唯一索引(order_id + transaction_id),利用数据库的行锁防重。但问题是,订单状态更新和幂等记录写入不是原子操作。高并发下,两个请求同时查到“未处理”,都去更新订单状态,然后才有一个插入幂等记录失败。订单状态被更新两次,结果不可控。

Redis分布式锁:用SETNX加锁,处理完释放。看起来解决了并发问题,但锁的超时时间是个死结。锁时间太短,业务还没处理完锁就过期了;锁时间太长,Redis宕机恢复后锁还卡着。而且锁只能防并发,防不了网络重试带来的“过去式”请求——一个支付回调延迟了30分钟才到达,订单状态早就变了,锁照样能拿到,但业务逻辑已经不对了。

状态机校验:在更新订单状态前,先校验当前状态是否允许跳转到目标状态。这个思路是对的,但很多实现只在校验通过后更新状态,没考虑校验和更新之间的并发窗口。

技术怎么降低这个门槛:Redis Lua脚本 + 状态机

问题的本质不是没做幂等,而是幂等性设计必须把“业务状态”和“操作请求”绑定在一起判断。一个请求是否该被处理,取决于“当前业务状态”和“请求携带的目标状态”是否匹配,而不只是“这个请求有没有来过”。

我们最终落地的是Redis 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  -- 处理成功

这个脚本解决了三个问题:

  • 防并发:Lua脚本在Redis中是原子执行的,不会有并发窗口。
  • 防跨状态重试:通过ARGV[1] 传入期望的当前状态,只有状态匹配才执行更新。一个延迟了30分钟的“支付成功”回调,如果订单状态已经变成“已发货”,脚本会返回 -1,不会错误地回退状态。
  • 防重复处理:幂等记录写入和状态更新在同一个原子操作里,不会出现“状态更新了但幂等记录没写入”的情况。

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脚本不是银弹。它的局限性也很明显:

  • 脚本复杂度不能太高:Lua脚本在Redis中是阻塞执行的,太复杂的逻辑会影响Redis的整体性能。我们的脚本只做状态校验和更新,业务逻辑(如发送通知、更新库存)放在脚本之后。
  • 依赖Redis可用性:Redis宕机会导致幂等性失效。我们做了Redis主从 + Sentinel高可用,同时在数据库层面保留了唯一索引作为兜底。
  • 状态机定义需要维护:订单状态流转图要清晰,每个状态允许跳转到哪些目标状态,需要在脚本的ARGV参数中传递。状态多了,维护成本会上升。

选型决策的trade-off

回头看,如果让我重新选,我可能还是会走Redis Lua这条路,但会在以下场景考虑替代方案:

  • 订单量极小(日均 < 100单):MySQL唯一索引 + 应用层状态机校验就够了,没必要引入Redis。
  • 团队Redis运维能力弱:可以考虑用MySQL行锁 + 乐观锁,虽然性能差一些,但运维成本低。
  • 状态机极其复杂(50+ 状态):建议把状态机逻辑放到业务代码里,Lua脚本只做幂等性校验和状态更新,状态转移规则由应用层判断。

最后说一句:幂等性设计的关键不是“怎么防重”,而是“什么情况下该处理,什么情况下不该处理”。把业务状态和操作请求绑定在一起判断,比单纯记一个“已处理”标记要可靠得多。


做了十年电商后端,参与过 Taocarts 代购系统和 AuctionGIt 日本竞拍平台(60+拍卖网站统一对接)的开发。有问题欢迎交流。


yanmoheluo
1 声望1 粉丝