本文目录导读:

这是一个非常经典且高优的系统设计问题,支付回调(Webhook/Notify)的特点是:第三方支付平台为了保证资金安全,会以极高的频率、极长的时间跨度反复回调。幂等是支付系统的刚性需求。
下面我们从问题根源、设计原则、核心落地方案以及高并发下的坑四个维度深度解析。
问题的根源:为什么需要幂等?
回调可能重复的原因:
- 网络超时(最常见):支付平台发通知,服务端处理成功但ACK超时,支付平台重发。
- 支付平台重试机制:为防止丢失,通常有 1s、5s、30s、1min、10min、1h 的阶梯重试。
- 系统崩溃重启:收到通知后,内存状态丢失,恢复后再次收到相同通知。
- 客户端重试:用户主动刷新页面、重复扫码。
后果:如果不做幂等,一次支付成功可能导致多次发券、多次加余额、重复创建订单。
核心设计原则:全局唯一业务凭证
幂等的核心是:“同一笔交易,处理多次,结果与处理一次完全一致”。
定义:P = f(T) ,对于同一个输入 T(交易ID),无论执行多少次 f(处理逻辑),结果 P 永远相同。
主流的实现方案(从上到下优先级)
方案 1:数据库唯一键去重(最推荐、最可靠)
这是支付系统的黄金标准,利用数据库的 UNIQUE 约束强制保证。
- 表结构设计:在订单表或回调记录表中,以
transaction_id(支付平台订单号)或order_no+type建立唯一索引。 - 流程:
- 收到回调
order_no=A。 INSERT INTO callback_log (order_no, status) VALUES (‘A’, ‘SUCCESS’)。- 若插入成功:说明首次处理 → 执行业务(加余额)。
- 若插入失败(Duplicate Key ):说明已处理过 → 直接返回成功给支付平台(不执行业务)。
- 收到回调
-- 核心表结构 CREATE TABLE `payment_callback_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL, `transaction_id` varchar(64) NOT NULL, -- 支付平台唯一ID `status` tinyint(4) DEFAULT NULL, `created_at` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_transaction_id` (`transaction_id`) -- 幂等约束 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
// 伪代码:Spring Boot + MyBatis
public void handleCallback(PayNotify notify) {
try {
// 1. 尝试插入打点记录(利用UNIQUE约束)
callbackLogMapper.insert(new CallbackLog(notify.getTransactionId()));
} catch (DuplicateKeyException e) {
// 2. 重复回调,直接响应成功(避免死循环)
log.warn("重复回调,忽略处理: {}", notify.getTransactionId());
return;
}
// 3. 首次处理,执行核心业务(更新订单状态、加金币等)
orderService.updateOrderToPaid(notify.getOrderNo());
}
方案 2:Redis 分布式锁 + 原子操作(适用于高吞吐、弱一致性)
适用于对最终一致性要求高,但对瞬间强一致性要求稍低的场景(如加积分、发优惠券)。
- 流程:
- 使用
SET key NX EX 5命令尝试获取锁(key =pay_lock:{order_no})。 - 未获取到锁 → 视为重复回调,直接返回。
- 获取到锁 → 检查 Redis 中是否已存在
pay_done:{order_no}标记。 - 不存在 → 执行业务 → 设置
pay_done标记。 - 释放锁。
- 使用
// 伪代码:Redis 实现分布式幂等
public boolean processWithRedis(PayNotify notify) {
String lockKey = "pay:lock:" + notify.getOrderNo();
String doneKey = "pay:done:" + notify.getOrderNo();
// 1. 获取分布式锁(防止并发)
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
return false; // 有人正在处理,放弃或等待
}
try {
// 2. 检查是否已经处理过
if (redisTemplate.hasKey(doneKey)) {
return true; // 幂等,直接返回成功
}
// 3. 执行业务
doBusiness(notify);
// 4. 标记完成(TTL 设为 1 天)
redisTemplate.opsForValue().set(doneKey, "1", 1, TimeUnit.DAYS);
return true;
} finally {
redisTemplate.delete(lockKey); // 释放锁
}
}
方案 3:数据库乐观锁(适用于状态机明确的场景)
如果业务表本身有“状态”字段且更新是幂等的,可以利用乐观锁。
-- 更新条件增加版本号或状态判断 UPDATE `orders` SET `status` = 'PAID', `version` = `version` + 1, `payed_at` = NOW() WHERE `order_no` = 'A' AND `status` = 'WAIT_PAY'; -- 核心:只有待支付才能更新成已支付 -- 如果影响行数为0:说明已支付过(幂等)或订单不存在。
需要处理的高危异常场景
并发冲突(高并发下重复回调)
场景:支付平台在1ms内发了两次相同的回调,两个线程同时进来。 解决:
- 数据库唯一键:MySQL InnoDB 行锁会自动让第二个插入失败,天然安全。
- Redis方案:必须使用
SET NX锁,且锁粒度必须是订单号,注意锁要有超时时间(防死锁)。
业务执行成功,但标记写入失败
场景:加钱成功,但更新 callback_log 或写入 Redis 时宕机。
解决:
- 必须使用本地事务:将“插入回调日志”和“更新订单金额”放在同一个
@Transactional中,两者要么都成功,要么都失败。 - 如果使用了分库分表(事务无法跨库),则需要引入 TCC 或 本地消息表。
不一致(恶意伪造)
场景:攻击者模拟支付平台回调,修改金额。 解决:
- 签名验证(必须):验证回调中的
sign字段,且使用商户秘钥。 - 金额核对:回调中的
pay_amount必须严格等于订单创建时的total_fee,多一分钱都拒绝。
支付平台回调乱序(状态机错乱)
场景:支付成功 回调先到,紧接着 支付失败 回调来了。
解决:
- 状态机校验:不允许状态“倒流”。
- 如果状态已经是
PAID,收到FAIL回调 → 直接忽略。 - 如果状态是
WAIT_PAY,收到FAIL回调 → 更新为失败。
- 如果状态已经是
最佳实践推荐
| 维度 | 推荐方案 | 原因 |
|---|---|---|
| 幂等控制核心 | 数据库唯一键(方案1) | 最稳定,天然ACID,无并发问题 |
| 性能加速层 | Redis Pre-Check | 在插入DB前,先用Redis挡掉90%的重复请求,降低DB压力 |
| 多系统协调 | 乐观锁 + 状态机 | 防止状态回退 |
| 终极兜底 | 定时对账 + 补偿Job | 发现漏单或重复扣款,人工或自动修复 |
最终架构图:
回调请求 → 签名校验 → Redis 预检幂等 → DB唯一键插入 → 业务处理(本地事务) → 返回成功
这套模型是经过微信支付、支付宝等千万级订单系统验证过的。