返回博客

第三方 API 已成功但本地事务回滚,钱出去了记录没了

接到一起客服工单:用户说退款没到账,运营去后台一查,订单状态还是「退款中」,于是重新发起退款——结果对方收到了两笔钱。翻日志发现,第一次退款微信那边早就返回了 transaction_id,但紧接着本地 UPDATE 因为约束冲突抛了异常,catch 块直接 rollback(),什么都没留下。

现象

  • 微信退款接口返回 successtransaction_id 有值,钱已到用户卡上。
  • 本地退款记录 status 仍为 1(退款中),transaction_id 字段为空。
  • 日志里能看到「外部调用成功」,随后是一条约束异常,最后是 rollback。
  • 运营无法区分「退款失败」和「退款成功但未记录」,只能重试,造成多退。

根因

PHP 的数据库事务是本地原子操作,外部 HTTP 调用不在任何事务边界内。当执行顺序如下:

  1. $weChatService->refund(...) → 微信侧资金已划转,返回 transaction_id
  2. $db->name('p_order_refund')->update([...]) → 本地写入抛出异常
  3. catch$db->rollback() → 事务内所有操作撤销

外部副作用(钱)无法被 rollback 撤销,但本地 rollback 把「记录这次外部成功」的写操作也一起撤掉了。系统进入一种最糟糕的状态:既没有成功记录,也没有失败标记,只有一条停在「处理中」的僵尸记录。

坑:rollback 不能撤回已发生的外部副作用

外部 API(支付、退款、短信、推送)一旦调用成功,其效果就永久存在于第三方系统中。在 catch 里 rollback 只是让本地 DB 回到调用前的状态——而外部世界早已改变。这导致本地状态与真实世界出现不可调和的分歧,且没有任何痕迹

正解

在 catch 块的 rollback 之后,在事务外单独写一条「补偿记录」,把已知的外部成功事实(transaction_id、金额、时间戳)和一个哨兵状态(status=5 待确认)持久化下来,供运营手动核对。

$refundResult = $weChatService->refund([
    'out_refund_no' => $refundNo,
    'amount'        => intval(bcmul($refundAmountYuan, '100')),
]);
 
$transactionId = $refundResult['transaction_id'] ?? null;
$refundTime    = date('Y-m-d H:i:s');
 
$db->startTrans();
try {
    $db->name('order_refund')
        ->where('id', $refundId)
        ->update([
            'status'         => 2,           // 退款成功
            'refund_time'    => $refundTime,
            'transaction_id' => $transactionId,
        ]);
 
    // ...其他关联写操作
 
    $db->commit();
} catch (\Throwable $e) {
    $db->rollback();
    Log::error("外部退款已成功但本地写入失败: refundId={$refundId}, txn={$transactionId}, err=" . $e->getMessage());
 
    // 补偿:在事务外单独写哨兵状态,保留外部成功的证据
    try {
        $db->name('order_refund')
            ->where('id', $refundId)
            ->update([
                'status'         => 5,       // 待确认(哨兵)
                'refund_time'    => $refundTime,
                'transaction_id' => $transactionId,
                'audit_remark'   => '外部退款已成功但本地写入失败,需人工确认:' . $e->getMessage(),
            ]);
    } catch (\Throwable $ex) {
        Log::error("补偿写入也失败,需立即人工介入: refundId={$refundId}, " . $ex->getMessage());
    }
    return;
}
正解:rollback 后在事务外写哨兵状态

补偿写入不能在原事务内——事务已经失败,写进去也会被回滚。必须在 rollback() 之后,以普通 UPDATE(无事务包裹)把外部成功的原始凭据写进哨兵字段。哨兵状态(status=5)与「成功」和「失败」都不同,专门用于触发人工核对流程。

配套运营机制

  • 后台管理页面增加 status=5 过滤入口,让运营能第一时间看到所有需核对记录。
  • 核对通过后由运营手动将状态从 5 改为 2(成功),并留下操作备注。
  • 加一条定时任务:每小时扫描 status=5 且停留超过 1 小时的记录,触发告警。

一句话外卖

外部 API 成功后本地事务失败,不要只 rollback——在事务外补写一条带哨兵状态的记录,把「不可撤销的外部事实」保留下来,让系统有机会被人工或自动恢复,而不是静默丢失。