TAOCARTS 知识

别把幂等性做成开关:一次代购系统支付回调的架构重构

2026-06-26 系统功能介绍

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

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

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

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

现有方案为什么不够好

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

**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 -- 处理成功

```

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

  • **防并发**:Lua脚本在Redis中是原子执行的,不会有并发窗口。
  • **防跨状态重试**:通过ARGV[1] 传入期望的当前状态,只有状态匹配才执行更新。一个延迟了30分钟的“支付成功”回调,如果订单状态已经变成“已发货”,脚本会返回 -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脚本不是银弹。它的局限性也很明显:

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

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

  • **订单量极小(日均 < 100单)**:MySQL唯一索引 + 应用层状态机校验就够了,没必要引入Redis。
  • **团队Redis运维能力弱**:可以考虑用MySQL行锁 + 乐观锁,虽然性能差一些,但运维成本低。
  • **状态机极其复杂(50+ 状态)**:建议把状态机逻辑放到业务代码里,Lua脚本只做幂等性校验和状态更新,状态转移规则由应用层判断。
  • 最后说一句:幂等性设计的关键不是“怎么防重”,而是“什么情况下该处理,什么情况下不该处理”。把业务状态和操作请求绑定在一起判断,比单纯记一个“已处理”标记要可靠得多。

    ---

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