第三方 API 已成功但本地事务回滚,钱出去了记录没了
接到一起客服工单:用户说退款没到账,运营去后台一查,订单状态还是「退款中」,于是重新发起退款——结果对方收到了两笔钱。翻日志发现,第一次退款微信那边早就返回了 transaction_id,但紧接着本地 UPDATE 因为约束冲突抛了异常,catch 块直接 rollback(),什么都没留下。
现象
- 微信退款接口返回
success,transaction_id有值,钱已到用户卡上。 - 本地退款记录
status仍为 1(退款中),transaction_id字段为空。 - 日志里能看到「外部调用成功」,随后是一条约束异常,最后是 rollback。
- 运营无法区分「退款失败」和「退款成功但未记录」,只能重试,造成多退。
根因
PHP 的数据库事务是本地原子操作,外部 HTTP 调用不在任何事务边界内。当执行顺序如下:
$weChatService->refund(...)→ 微信侧资金已划转,返回transaction_id$db->name('p_order_refund')->update([...])→ 本地写入抛出异常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——在事务外补写一条带哨兵状态的记录,把「不可撤销的外部事实」保留下来,让系统有机会被人工或自动恢复,而不是静默丢失。