返回博客

串行外部 API 中途失败,积分已扣券没发——如何用补偿表兜住「双失」

做积分兑换功能时踩了一个经典坑:先调第三方积分系统扣分,再调第三方券系统发券,两步必须同时成功,否则用户积分就无声无息地消失了。数据库事务在这里救不了你——ROLLBACK 管不到已经飞出去的 HTTP 请求。

真正让我慌的不是「发券失败」,而是「发券失败 → 尝试回滚积分 → 回滚 API 也超时了」。这时候既没有事务,也没有重试队列,用户的积分凭空蒸发,客服根本查不到。

现象

用户点击兑换,偶发性收到「兑换失败」提示,但积分已经扣掉;重新操作积分依然是扣后的数字;后台日志只有一行 couponSend timeout,没有任何补偿记录。

根因

两次外部 API 调用之间没有原子性保障:

扣积分(外部 API A)  →  发券(外部 API B)
     成功                    失败 / 超时
              ↓
         积分已扣,券未发,状态不一致

数据库事务只能回滚本地写操作;外部 API 已经执行的副作用,ROLLBACK 无法触及。原有代码在 catch 里直接 throw,根本没有补偿逻辑。

坑:catch 里直接 throw,忘了第一步已经成功
// 危险写法:catch 块没有区分「A 已成功」和「A 还没执行」
try {
    $this->apiA_deductPoints(...);   // 成功,积分已扣
    $this->apiB_sendCoupon(...);     // 失败
} catch (\Exception $e) {
    throw $e;  // 积分就这么没了
}

一旦 B 失败,A 的副作用永远无法撤销,除非你手动给 A 打补偿调用。

正解

分三层防御:

第一层:布尔标志区分已执行步骤

$pointDeducted = false;
 
try {
    $this->apiA_deductPoints($memberCode, -$cost, '积分兑换');
    $pointDeducted = true;   // 标记:A 已成功,进入"需要补偿"区间
 
    $this->apiB_sendCoupon($memberCode, $couponCode);
 
    // 两步都成功,写本地 DB
    $this->createExchangeRecord(...);
 
} catch (\Exception $e) {
    if ($pointDeducted) {
        // 只在 A 已执行时才尝试回滚
        try {
            $this->apiA_deductPoints($memberCode, +$cost, '回滚: ' . $e->getMessage());
        } catch (\Exception $rollbackEx) {
            // 第二层:回滚也失败,落补偿表
            $this->recordCompensation($userId, $cost, $couponCode, $rollbackEx->getMessage());
        }
    }
    throw $e;
}

第二层:补偿表兜住「回滚也失败」的情况

CREATE TABLE compensation_pending (
    id          VARCHAR(64)  NOT NULL PRIMARY KEY,
    user_id     VARCHAR(128) NOT NULL DEFAULT '',
    member_code VARCHAR(64)  NOT NULL DEFAULT '',
    amount      INT          NOT NULL DEFAULT 0  COMMENT '待退还积分',
    coupon_code VARCHAR(64)  NOT NULL DEFAULT '',
    type        VARCHAR(32)  NOT NULL DEFAULT 'point_rollback',
    status      VARCHAR(16)  NOT NULL DEFAULT 'pending',  -- pending/resolved/failed
    reason      VARCHAR(500) NOT NULL DEFAULT '',
    retry_count INT          NOT NULL DEFAULT 0,
    created_at  DATETIME     NOT NULL,
    updated_at  DATETIME     NOT NULL,
    KEY idx_status  (status),
    KEY idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

有了这张表,运营可以在后台一键重试所有 pending 记录;也可以挂一个定时任务做指数退避重试(建议最多 3~5 次后转 failed 人工介入)。

第三层:SELECT FOR UPDATE 防并发超卖

如果兑换还涉及本地库存(礼品剩余数量),需要在数据库事务里加悲观锁,防止同一礼品被并发兑换超出上限:

Db::startTrans();
try {
    $gift = Db::table('gift_stock')
        ->where('id', $giftId)
        ->lock(true)   // SELECT FOR UPDATE
        ->find();
 
    if ($gift['used'] >= $gift['total']) {
        throw new \Exception('库存不足');
    }
 
    // 外部 API 调用 + 补偿逻辑(见上)
 
    Db::table('gift_stock')->where('id', $giftId)->setInc('used');
    Db::commit();
} catch (\Exception $e) {
    Db::rollback();
    throw $e;
}

注意:外部 HTTP 调用在事务内会持有行锁,必须设置较短的 HTTP 超时(≤10s),避免锁等待把其他请求全堵死。

正解:布尔标志 + 补偿表 + 定时重试三件套
  1. $stepADone = false 标志精确控制「只在 A 已执行时才补偿」
  2. 补偿表持久化「回滚失败」记录,资产不丢、可审计
  3. 定时任务指数退避重试 pending 记录,3 次后转 failed 人工介入
  4. 先扣后给——失败只会是「用户保住资产」而不是「用户白嫖」
成功后要重新查,别用快照算余额

两步 API 都成功后,用于展示「剩余积分」的数字应当重新查询外部系统,而不是用调用前的快照减去扣除量——并发场景下快照可能已经过期,算出来的余额会是错的。

一句话外卖

串行外部 API 没有原子性,用布尔标志区分「已执行步骤」,用补偿表托底「回滚也失败」,顺序永远是「先扣后给」——失败只能让用户保住资产,不能让用户白嫖。