支付系统分布式回调幂等

wen java案例 2

本文目录导读:

支付系统分布式回调幂等

  1. 问题的根源:为什么需要幂等?
  2. 核心设计原则:全局唯一业务凭证
  3. 主流的实现方案(从上到下优先级)
  4. 需要处理的高危异常场景
  5. 最佳实践推荐

这是一个非常经典且高优的系统设计问题,支付回调(Webhook/Notify)的特点是:第三方支付平台为了保证资金安全,会以极高的频率、极长的时间跨度反复回调幂等是支付系统的刚性需求。

下面我们从问题根源设计原则核心落地方案以及高并发下的坑四个维度深度解析。


问题的根源:为什么需要幂等?

回调可能重复的原因:

  1. 网络超时(最常见):支付平台发通知,服务端处理成功但ACK超时,支付平台重发。
  2. 支付平台重试机制:为防止丢失,通常有 1s、5s、30s、1min、10min、1h 的阶梯重试。
  3. 系统崩溃重启:收到通知后,内存状态丢失,恢复后再次收到相同通知。
  4. 客户端重试:用户主动刷新页面、重复扫码。

后果:如果不做幂等,一次支付成功可能导致多次发券、多次加余额、重复创建订单。


核心设计原则:全局唯一业务凭证

幂等的核心是:“同一笔交易,处理多次,结果与处理一次完全一致”

定义:P = f(T) ,对于同一个输入 T(交易ID),无论执行多少次 f(处理逻辑),结果 P 永远相同。


主流的实现方案(从上到下优先级)

方案 1:数据库唯一键去重(最推荐、最可靠)

这是支付系统的黄金标准,利用数据库的 UNIQUE 约束强制保证。

  • 表结构设计:在订单表或回调记录表中,以 transaction_id(支付平台订单号)或 order_no + type 建立唯一索引。
  • 流程
    1. 收到回调 order_no=A
    2. INSERT INTO callback_log (order_no, status) VALUES (‘A’, ‘SUCCESS’)
    3. 若插入成功:说明首次处理 → 执行业务(加余额)。
    4. 若插入失败(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 分布式锁 + 原子操作(适用于高吞吐、弱一致性)

适用于对最终一致性要求高,但对瞬间强一致性要求稍低的场景(如加积分、发优惠券)。

  • 流程
    1. 使用 SET key NX EX 5 命令尝试获取锁(key = pay_lock:{order_no})。
    2. 未获取到锁 → 视为重复回调,直接返回。
    3. 获取到锁 → 检查 Redis 中是否已存在 pay_done:{order_no} 标记。
    4. 不存在 → 执行业务 → 设置 pay_done 标记。
    5. 释放锁。
// 伪代码: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唯一键插入业务处理(本地事务)返回成功

这套模型是经过微信支付、支付宝等千万级订单系统验证过的。

抱歉,评论功能暂时关闭!