串行外部 API 中途失败,积分已扣券没发——如何用补偿表兜住「双失」
做积分兑换功能时踩了一个经典坑:先调第三方积分系统扣分,再调第三方券系统发券,两步必须同时成功,否则用户积分就无声无息地消失了。数据库事务在这里救不了你——ROLLBACK 管不到已经飞出去的 HTTP 请求。
真正让我慌的不是「发券失败」,而是「发券失败 → 尝试回滚积分 → 回滚 API 也超时了」。这时候既没有事务,也没有重试队列,用户的积分凭空蒸发,客服根本查不到。
现象
用户点击兑换,偶发性收到「兑换失败」提示,但积分已经扣掉;重新操作积分依然是扣后的数字;后台日志只有一行 couponSend timeout,没有任何补偿记录。
根因
两次外部 API 调用之间没有原子性保障:
扣积分(外部 API A) → 发券(外部 API B)
成功 失败 / 超时
↓
积分已扣,券未发,状态不一致
数据库事务只能回滚本地写操作;外部 API 已经执行的副作用,ROLLBACK 无法触及。原有代码在 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),避免锁等待把其他请求全堵死。
$stepADone = false标志精确控制「只在 A 已执行时才补偿」- 补偿表持久化「回滚失败」记录,资产不丢、可审计
- 定时任务指数退避重试
pending记录,3 次后转failed人工介入 - 先扣后给——失败只会是「用户保住资产」而不是「用户白嫖」
两步 API 都成功后,用于展示「剩余积分」的数字应当重新查询外部系统,而不是用调用前的快照减去扣除量——并发场景下快照可能已经过期,算出来的余额会是错的。
一句话外卖
串行外部 API 没有原子性,用布尔标志区分「已执行步骤」,用补偿表托底「回滚也失败」,顺序永远是「先扣后给」——失败只能让用户保住资产,不能让用户白嫖。