支付回调被重推三次:幂等表、状态机与退避重试怎么配合

格 格达软件 2026-10-24 0 次阅读 AI 生成

凌晨两点,仓库的人在群里贴了张出库单截图:「这单怎么出了两次库?」翻日志,那条支付成功回调在 40 分钟里被推了 3 次,每次都完整走了一遍「改订单状态 → 通知仓库 → 加积分」。钱只收了一次,货发了两份。

只要接过支付,这个场景早晚会撞上。它不是因为代码写得烂,是回调这个机制本身只承诺「至少送达一次」。

一、重复回调是协议的一部分,不是意外

支付平台推回调的逻辑大致是:发 HTTP 请求 → 等你返回约定的应答 → 没等到就排下一次重推。判断「没等到」的条件有几种:

  • 你在超时时间内没返回(微信 V3 大约是 5 秒,各家不同)
  • 你返回了,但连接在下游被 reset,平台没收到
  • 你返回了 200,但 body 不是约定的格式(微信要 JSON,支付宝要纯文本 success)

注意第二种:你已经处理成功了,但应答在网络上丢了。平台认为失败,继续重推。所以「我处理成功」和「平台知道我处理成功」是两件事,中间隔着一次不保证送达的网络传输。

重推的节奏各家不同,规律一致:前密后疏,覆盖 24 到 48 小时。写代码时不用背具体间隔,只要记住:同一条通知,你可能会在几天后还收到一次。

渠道 成功应答 你的处理时间预算
微信支付 V3 HTTP 200 + {"code":"SUCCESS","message":"成功"} 约 5 秒
支付宝 HTTP 200 + 纯文本 success 秒级
Stripe HTTP 2xx 秒级

(具体重试次数和间隔以各家文档为准,这里不抄表格,因为会变。)

「5 秒」这个预算很关键,后面讲重试时会回来算这笔账。

二、幂等键选错,后面全是补丁

先说最常见的错误:拿商户订单号 out_trade_no 当幂等键。

看起来没毛病——一个订单只付一次款。但现实会打脸:

  1. 用户下单后支付超时,重新发起支付。如果你的系统复用了同一个 out_trade_no,同一个订单号会对应两个渠道交易号。用订单号做唯一键,第二笔钱收了,记录却插不进去。
  2. 退款回调也带订单号,但退款可能有多次(部分退款),一次退款一个 refund_no。
  3. 渠道侧的 transaction_id / trade_no 才是「这一笔钱」的唯一身份,它在你调下单接口时由渠道生成,全局唯一。

所以幂等键应该是 (merchant_id, channel, channel_trade_no)。三个字段一起进唯一索引,支持你接多个商户号、多个渠道。

退款回调另建一张表,唯一键用 refund_no,不要和支付回调共用一张表去拼状态。混在一起唯一键会打架。

三、错误写法:先查后插

// 反面教材,别抄
@Transactional
public void handle(String rawBody) {
    PayNotify n = parse(rawBody);

    // 问题在这一行
    PayCallbackLog existing = logMapper.findByTradeNo(n.getChannelTradeNo());
    if (existing != null) {
        return;                       // 认为处理过了
    }

    orderService.markPaid(n.getOutTradeNo());   // 发货、加积分
    logMapper.insert(new PayCallbackLog(n));
}

findByTradeNo 返回 null 不代表真的没有。两个线程同时进来,都在自己的事务快照里查,都查到 null,都往下执行 markPaid,都去 insert。唯一索引能挡住第二次 insert,但 markPaid 已经执行完了——货已经发出去两单。

更隐蔽的一点:如果隔离级别是 REPEATABLE READ(MySQL 默认),两个事务的快照互相看不见,这个竞态稳定复现。改成 READ COMMITTED 也只是把窗口缩窄,没消除。

判重这件事只能靠数据库的原子约束,不能靠「查一下」。查询只能用来快速返回,不能用来做决策。

四、用唯一索引做原子抢占

表结构:

CREATE TABLE `pay_callback_log` (
  `id`               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `merchant_id`      VARCHAR(32)  NOT NULL DEFAULT 'default',
  `channel`          VARCHAR(16)  NOT NULL COMMENT 'wxpay/alipay/stripe',
  `channel_trade_no` VARCHAR(64)  NOT NULL COMMENT '渠道侧交易号,幂等键',
  `out_trade_no`     VARCHAR(64)  NOT NULL COMMENT '商户订单号',
  `trade_state`      VARCHAR(16)  NOT NULL COMMENT 'SUCCESS/REFUND/CLOSED',
  `amount`           DECIMAL(18,2) NOT NULL COMMENT '回调金额,用于和订单金额比对',
  `status`           TINYINT      NOT NULL DEFAULT 0 COMMENT '0=待处理 1=成功 2=死信',
  `retry_count`      INT          NOT NULL DEFAULT 0,
  `next_retry_at`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `raw_body`         TEXT         NOT NULL COMMENT '原始报文,用于重放和对账',
  `created_at`       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at`       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_trade` (`merchant_id`, `channel`, `channel_trade_no`),
  KEY `idx_retry` (`status`, `next_retry_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

raw_body 存原始报文,出了问题能直接重放。这一列救命的时候是真救命。

抢占逻辑,靠 DuplicateKeyException:

public Long reserve(String channel, String rawBody) {
    PayNotify n = PayNotify.parse(channel, rawBody);
    try {
        // 单条 INSERT,自动提交,本身就是原子的
        jdbc.update("""
            INSERT INTO pay_callback_log
                (merchant_id, channel, channel_trade_no, out_trade_no,
                 trade_state, amount, status, next_retry_at, raw_body)
            VALUES (?, ?, ?, ?, ?, ?, 0, NOW(), ?)
            """,
            n.getMerchantId(), channel, n.getChannelTradeNo(), n.getOutTradeNo(),
            n.getTradeState(), n.getAmount(), rawBody);
        return jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
    } catch (DuplicateKeyException e) {
        return null;   // 唯一键冲突 = 已经有人抢到了
    }
}

两个细节。

不要把这个方法放进 @Transactional。在 Spring 的事务里捕获 DuplicateKeyException 之后继续干活,事务已经被标记成 rollback-only,最后提交时会抛 UnexpectedRollbackException。这个坑我在真实项目里见过两次,两次都排查了一下午。抢占必须是独立事务,或者说干脆不开启事务。

别用 INSERT IGNORE。它确实能返回 0/1 来区分,但把「字段超长」「类型不匹配」这类错误一起吞了,降级成警告。哪天字段长度改了,报文被悄悄截断,连个日志都没有。

也别用 INSERT ... ON DUPLICATE KEY UPDATE 来判断是不是新插入。它的 affected rows 语义是 1(插入)/ 2(更新)/ 0(更新但值没变),而且受 JDBC 连接串 useAffectedRows 参数影响,不同驱动版本表现不完全一致,容易写错。

五、别把业务逻辑塞进抢占事务

上面那版代码有个隐患:如果把 INSERT 和业务处理包在同一个事务里,会发生什么?

T1 拿到了唯一索引上的锁,开始执行 markPaid、调仓库接口(假设 800ms)。T2 同时到达,INSERT 撞上唯一键,InnoDB 不会立刻抛错——它要等 T1 提交或回滚才能确定最终结果。T2 就这么阻塞着,T3、T4 也一样。

并发 100 个重复回调,后 99 个全部卡在锁等待上。默认 innodb_lock_wait_timeout 是 50 秒,超过才报错。而渠道那边 5 秒就超时了,全量重推,雪上加霜。

结论:抢占和业务处理必须在不同事务里。抢占是一条自动提交的 INSERT,抢完立刻释放;业务处理单独开事务。

那怎么知道业务处理失败了要重试?靠 status 和 next_retry_at 两个字段,配合一个定时任务。

六、状态机:让业务更新自己也幂等

幂等表只保证「同一个回调不会被执行两次」,但如果重试任务和同步处理撞在一起,或者回调乱序到达,业务更新自己也得幂等。

用状态机收口,核心是「带前置条件的 UPDATE + 判断影响行数」:

// 只有 PENDING 的订单能变成 PAID。影响行数就是这次迁移有没有发生
int updated = jdbc.update("""
    UPDATE orders
       SET status = 'PAID', paid_at = NOW(), channel_trade_no = ?
     WHERE out_trade_no = ? AND status = 'PENDING'
    """, log.getChannelTradeNo(), log.getOutTradeNo());

if (updated == 1) {
    // 只有真正迁移成功的那一次,才触发下游
    deliveryService.notify(log.getOutTradeNo());
    pointService.add(log.getOutTradeNo());
} else {
    handleStaleCallback(log);   // 见下表
}

updated == 0 有三种情况,处理方式完全不同,必须分开:

订单当前状态 渠道交易号对比 含义 处理
PAID 相同 重复回调 直接返回成功,什么都不做
PAID 不同 用户重复支付 发起原路退款,告警
CLOSED 任意 订单关闭后用户才付成功 发起原路退款,告警

第三种是真实会发生的。用户下单,页面挂在那没付,30 分钟后订单被关单任务关掉;第 35 分钟用户点了支付并且成功。渠道不管你的订单状态,钱照收,回调照推。这时候如果还按「收到支付成功就发货」的逻辑走,就会给一个已关闭的订单发货。

正确的做法只有一个:退款。

private void handleStaleCallback(PayCallbackLog log) {
    Order o = orderMapper.findByNo(log.getOutTradeNo());
    if (o == null) {
        throw new IllegalStateException("order not found: " + log.getOutTradeNo());
    }
    if ("PAID".equals(o.getStatus())) {
        if (log.getChannelTradeNo().equals(o.getChannelTradeNo())) {
            return;                                     // 真的重复,忽略
        }
        refundService.submit(o.getOutTradeNo(), log.getChannelTradeNo(), "重复支付");
        alert.warn("duplicate payment", o.getOutTradeNo());
        return;
    }
    if ("CLOSED".equals(o.getStatus())) {
        refundService.submit(o.getOutTradeNo(), log.getChannelTradeNo(), "订单已关闭");
        alert.warn("paid after close", o.getOutTradeNo());
        return;
    }
    // 其他中间状态(比如 REFUNDING),按业务定义处理
    throw new IllegalStateException("unexpected order status: " + o.getStatus());
}

还有一处必须校验:金额。

if (order.getAmount().compareTo(log.getAmount()) != 0) {
    // 金额对不上,不要自愈,直接进死信,人工查
    throw new AmountMismatchException(order.getOutTradeNo(), order.getAmount(), log.getAmount());
}

不做这一步,万一报文被篡改或者串单,你会按错误金额发货。

七、重试:不要在回调线程里循环

见过这样的代码:

// 千万别这么写
for (int i = 0; i < 3; i++) {
    try {
        deliveryService.notify(orderNo);
        return "success";
    } catch (Exception e) {
        Thread.sleep(1000);
    }
}
return "fail";

问题是这 3 秒全算在你那 5 秒预算里,而且第三次失败之后还是得返回 fail,前面的重试等于白做。更糟的是它把回调线程占住了,回调是突发流量,占线程就是占连接池。

正确做法:落库之后重试交给后台任务,退避间隔按次数拉开。

退避表(单位:秒):

第 1 次失败 → 10s
第 2 次      → 30s
第 3 次      → 60s
第 4 次      → 300s
第 5 次      → 900s
第 6 次      → 1800s
第 7 次      → 3600s
第 8 次      → 7200s
超过 8 次    → 死信,告警

累计大约 3.6 小时,足够覆盖绝大多数下游抖动(数据库主从切换、下游发版)。再往后基本就是真出问题了,需要人看。

实现上用「租约」而不是 SELECT ... FOR UPDATE SKIP LOCKED,原因是不占数据库锁、多实例部署天然安全:

public void handleOne(Long logId) {
    // 租约:把 next_retry_at 推到 60 秒后,抢到的才能处理。
    // 这条 UPDATE 是原子的,10 个实例同时抢,只有 1 个返回 1。
    int leased = jdbc.update("""
        UPDATE pay_callback_log
           SET next_retry_at = DATE_ADD(NOW(), INTERVAL 60 SECOND)
         WHERE id = ? AND status = 0 AND next_retry_at <= NOW()
        """, logId);
    if (leased == 0) {
        return;             // 被别人抢了,或者已经处理完了
    }

    try {
        txTemplate.executeWithoutResult(s -> processBusiness(logId));
        jdbc.update("UPDATE pay_callback_log SET status = 1 WHERE id = ?", logId);
    } catch (Exception e) {
        int retry = jdbc.queryForObject(
            "SELECT retry_count FROM pay_callback_log WHERE id = ?", Integer.class, logId);

        if (retry >= MAX_RETRY) {
            jdbc.update("UPDATE pay_callback_log SET status = 2 WHERE id = ?", logId);
            alert.error("callback dead letter", logId, e);
        } else {
            long delay = BACKOFF_SECONDS[retry];
            jdbc.update("""
                UPDATE pay_callback_log
                   SET retry_count = retry_count + 1,
                       next_retry_at = DATE_ADD(NOW(), INTERVAL ? SECOND)
                 WHERE id = ?
                """, delay, logId);
        }
    }
}

注意这里的 txTemplate。processBusiness 如果写成同类里的 @Transactional 方法,Spring AOP 走的是代理,自调用不生效,事务根本不会开。这是另一个高频坑。用 TransactionTemplate,或者把方法拆到另一个 Bean 里。

扫表的任务:

@Scheduled(fixedDelay = 5000)
public void retryLoop() {
    List<Long> ids = jdbc.queryForList("""
        SELECT id FROM pay_callback_log
         WHERE status = 0 AND next_retry_at <= NOW()
         ORDER BY next_retry_at
         LIMIT 200
        """, Long.class);
    ids.forEach(this::handleOne);
}

idx_retry(status, next_retry_at) 这个联合索引必须有。status = 0 的记录如果积压到几十万条,没有索引就是全表扫,每 5 秒扫一次全表,数据库会被自己拖死。

多实例同时扫到同一批 id 不是问题,handleOne 里的租约 UPDATE 保证只有一个能真正执行。这正是用租约而不是 SKIP LOCKED 的好处——SKIP LOCKED 要 MySQL 8.0+,而且要放在事务里用,多一层心智负担。

八、回调接口的返回策略

回到最开始那个 5 秒预算。策略是分级的:

阶段 失败时返回 理由
验签失败 fail 可能是伪造请求,没必要落库
落库失败 fail 记录都没存下来,只能靠渠道重推
落库成功,业务失败 success 记录已在本地,自己的重试任务更可控

第三行是关键取舍。

返回 success 意味着放弃渠道的重推,你赌自己的重试机制靠谱。渠道的重推间隔是分钟到小时级的,指望它做重试,用户那边要等很久才能看到订单状态变化,而且渠道重推次数有限,用完就没了。

自己重试的代价是:你得保证 status = 0 的记录一定会被扫到。所以需要一个兜底监控——status = 0 且 created_at 超过 1 小时 的记录数,大于 0 就告警。这条监控能兜住「重试任务挂了」这种最坏情况。

如果你不想维护这套重试机制,也可以让业务失败时返回 fail,靠渠道重推。但要有心理准备:第一次间隔可能就是十几秒,后面拉长到几分钟、几小时,24 小时后彻底放弃。

九、几个真会让人半夜被叫起来的细节

验签必须用原始报文。 常见错误是 @RequestBody PayNotify notify,然后拿对象重新序列化去验签。字段顺序、空白、转义只要有一处不同,签名就对不上。用 @RequestBody String rawBody,验完再解析。

@PostMapping("/pay/callback/{channel}")
public ResponseEntity<String> callback(@PathVariable String channel,
                                       @RequestBody String rawBody,
                                       @RequestHeader Map<String, String> headers) {
    if (!signVerifier.verify(channel, rawBody, headers)) {
        return ResponseEntity.ok(failBody(channel));
    }
    // 验签通过后再 reserve -> handleOne
}

幂等表会膨胀。 一天 100 万条回调,raw_body 平均 1.5KB,一天就是 1.5GB。处理成功的记录把 raw_body 清掉:

UPDATE pay_callback_log SET raw_body = '' WHERE status = 1 AND updated_at < NOW() - INTERVAL 1 DAY;

再考虑按月归档冷存储。注意 MySQL 分区表要求唯一索引必须包含分区键,加了 created_at 做分区键会破坏 uk_trade 的定义——所以更实际的做法是归档,不是原地分区。

日志里别打密钥。 验签失败时把完整 header 和 body 打进日志,很容易把 app_secret、用户手机号、卡号后四位一起泄出去。打长度和哈希就够了。

回调接口要限流。 渠道侧出问题时会补推大量积压通知,一瞬间把连接池打满,顺带把你其他接口也拖挂。给回调路径单独配一个线程池或信号量,比事后救火便宜。

金额用 BigDecimal,别用 double。 老生常谈,但每年都还有人栽。

这套东西的落地顺序是:先保证不重复执行(唯一索引),再保证不丢(本地重试 + 兜底监控),最后才是处理得快。顺序颠倒,就会在凌晨两点被叫起来。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。