凌晨两点,仓库的人在群里贴了张出库单截图:「这单怎么出了两次库?」翻日志,那条支付成功回调在 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 当幂等键。
看起来没毛病——一个订单只付一次款。但现实会打脸:
- 用户下单后支付超时,重新发起支付。如果你的系统复用了同一个
out_trade_no,同一个订单号会对应两个渠道交易号。用订单号做唯一键,第二笔钱收了,记录却插不进去。 - 退款回调也带订单号,但退款可能有多次(部分退款),一次退款一个
refund_no。 - 渠道侧的
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。 老生常谈,但每年都还有人栽。
这套东西的落地顺序是:先保证不重复执行(唯一索引),再保证不丢(本地重试 + 兜底监控),最后才是处理得快。顺序颠倒,就会在凌晨两点被叫起来。