从零开始搓出来的一个 Payment系统

Yuan.Sn

最近给 springMall 完善了一下支付系统,接入了 Alipay + Stripe 两种支付方式。本来也没什么好说的,无非就是看看文档 调调 SDK 的事情。But!多种支付方式的接入,也意味着 掉单、双重支付、竞态退款... 问题的出现。为此,深入冲浪了一波, 查了不少资料和文献, 手搓出来了一套 三重校验扭转机制的 PaymentSystem

PaymenSystem的整体架构

graph TB
    subgraph 用户侧
        User["👤 用户"]
        Frontend["🎨 Vue3 前端"]
    end

    subgraph 后端
        Controller["Controller 层"]
        Service["Service 层"]
        Mapper["Mapper 层"]
        DB["MySQL 8"]
    end

    subgraph 中间件
        Redis["Redis 分布式锁"]
        RabbitMQ["RabbitMQ 延迟队列"]
    end

    subgraph 第三方
        Alipay["🏦 支付宝"]
        Stripe["💳 Stripe"]
    end

    User --> Frontend
    Frontend -->|REST API| Controller
    Controller --> Service
    Service --> Mapper --> DB
    Service --> Redis
    Service --> RabbitMQ
    Service --> Alipay
    Service --> Stripe
    Alipay -->|Webhook| Controller
    Stripe -->|Webhook| Controller

先整体简单的过一遍,其实说白了就是:①前端点击支付 => ②后端创建order,然后发送一个用于 支付超时取消订单的 OrderCloseConsumer消息队列,通过用户选择的支付方式调用第三方 SDK 创建支付订单, 然后重点来了! 支付订单的创建会启用 Redis 分布式锁(一层防护),保证同一个订单不会被创建多种不同的支付方式订单,然后支付订单创建的过程中, 也会执行 关闭其他支付方式的 PENDING 记录的程序closeAllPendingPayments(OrderNo)(二层防护),防止Redis锁失效;②支付创建完后,再发送一个TTL=5min,用于支付校验的PaymentCheckConsumer消息队列, 如果没有收到回调,主动向API发起校验,并检查是否有重复支付的情况,有则退款最新之前的所有支付(三层防护); ③收到支付几个webhook回调后更新order状态,完整订单支付。

什么,还是一脸懵,拆开来讲下吧🤪

双重支付的四层扭转机制

第一层:分布式锁(Redis SETNX)——先把门锁上

最直接的,用 Redis 分布式锁把同一订单的支付创建串行化:

1
2
3
4
5
6
7
8
9
10
String lockKey = "lock:Alipay:create:" + orderNo;
String lockValue = redisDistributedLock.tryLock(lockKey, 60, TimeUnit.SECONDS);
if (lockValue == null) {
throw new BusinessException(ResultCode.PAYMENT_LOCK_FAILED);
}
try {
return doCreateAlipayInternal(orderNo, userId);
} finally {
redisDistributedLock.unlock(lockKey, lockValue);
}

通过SET key uuid NX EX 60 上锁,Lua 脚本原子释放。30 秒 TTL 兜底。

第二层:支付互斥(closeAllPendingPayments())——切换支付方式时先查后关

用户从 Alipay 双向切 Stripe 时,不能直接把旧的支付单关掉就完事。万一用户已经在支付宝那边付了呢?

关单之前,先去第三方查一下实际状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void closeOnePayment(PaymentDO payment) {
// 先问问第三方:这笔钱到底付没付?
boolean isPaidAtThirdParty = queryThirdPartyStatus(payment);

if (isPaidAtThirdParty) {
// 卧槽,已经付了!赶紧补单,然后阻断新支付的创建
payment.setPaymentStatus(PaymentStatus.SUCCESS.name());
paymentMapper.updateById(payment);
orderMapper.updateStatus(payment.getOrderNo(), OrderStatus.PAID.getCode());
throw new BusinessException(ResultCode.PAYMENT_ALREADY_PAID_BY_OTHER_METHOD);
}

// 没付,放心关掉
closeAtThirdParty(payment);
}

宁可阻断新支付也不能双重扣款。

第三层:竞态自动退款——CLOSED 的单子收到钱了

最骚的场景来了:后端这边已经把 Alipay 支付单 CLOSED 了,但用户还在支付宝页面上,手速够快的话还是能完成支付的。支付宝的回调延迟了几秒才到……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Webhook 处理中
if (PaymentStatus.CLOSED.name().equals(payment.getPaymentStatus())
&& ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus))) {
// 检测到竞态!支付单已关闭但用户居然付成功了
log.warn("已关闭的支付单收到支付成功通知,将自动退款 - 支付流水号: {}", outTradeNo);

// 保存第三方交易号(退款要用)
payment.setTradeNo(tradeNo);
paymentMapper.updateById(payment);

// 全额退款,原路返回
boolean refundSuccess = refund(refundNo, tradeNo, payment.getAmount(), "支付单已关闭,自动退款");
if (refundSuccess) {
payment.setPaymentStatus(PaymentStatus.REFUNDED.name());
paymentMapper.updateById(payment);
}
return "success";
}

Stripe 那边也有对应的处理,逻辑一样:CLOSED 状态收到 checkout.session.completed 事件 → 自动退款。

sequenceDiagram
    participant Backend as ⚙️ 后端
    participant Alipay as 🏦 支付宝
    participant DB as 💾 数据库

    Note over Backend: 用户切换支付方式,关闭旧支付
    Backend->>Alipay: 关闭支付
    Backend->>DB: UPDATE payment (status=CLOSED)

    Note over Alipay: 但用户还在支付宝页面
    Note over Alipay: 点了"确认支付"

    Alipay->>Backend: Webhook: TRADE_SUCCESS
    Backend->>DB: 查询 payment → status=CLOSED
    Backend->>Backend: 检测到竞态!
    Backend->>Alipay: 发起全额退款
    Backend->>DB: UPDATE payment (status=REFUNDED)
    Note over Backend: 钱原路退回,危机解除

第四层:多重支付检测——终极兜底

万一前面三层都没挡住(理论上不太可能,但防御性编程嘛),还有个兜底机制:

1
2
3
4
5
6
7
8
9
10
11
12
private void detectAndRefundDuplicatePayments(String orderNo) {
List<PaymentDO> successPayments = paymentMapper.findSuccessListByOrderNo(orderNo);
if (successPayments == null || successPayments.size() <= 1) {
return; // 正常情况,只有一笔成功支付
}

log.warn("检测到多重支付!订单号: {}, SUCCESS 支付数量: {}", orderNo, successPayments.size());
// 保留最新的那笔,其余全部退款
for (int i = 0; i < successPayments.size() - 1; i++) {
paymentService.refundByPaymentNo(successPayments.get(i).getPaymentNo(), "多重支付自动退款");
}
}

Webhook掉单的扭转机制——PaymentCheckConsumer消息队列

支付系统最核心的信息传递靠的是第三方支付机构的 Webhook 回调。但这东西很tm玄学,遇到一点网络波动就接收不到消息了。Webhook 丢了,哪怕是付了钱,但订单还是"未支付"状态,这就掉单了。

解决方案:延迟消息 + 主动查询

思路很简单:不完全信任回调,5min TTL后主动去查一下

用 RabbitMQ 的死信队列(DLX)实现延迟消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
支付创建

├── 事务提交后发送延迟消息 (afterCommit)
│ ↓
│ payment.delay.queue (TTL=5min)
│ ↓ (5分钟后消息过期,变成死信)
│ payment.check.queue
│ ↓
│ PaymentCheckConsumer 消费
│ ├── PENDING → 查第三方
│ │ ├── 已付款 → 补单 + 多重支付检测
│ │ └── 未付款 → 关单 + 恢复库存 + 取消订单
│ └── 非PENDING → 幂等跳过

有几个很"优雅"的设计:

①事务提交后再发消息 —— 保证支付成功创建

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional(rollbackFor = Exception.class)
public AlipayPaymentVO createAlipay(...) {
// 创建支付记录
paymentMapper.insert(payment);

// 关键:事务提交后再发消息!
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
paymentMessageProducer.sendPaymentCheckDelayMessage(orderNo, paymentId);
}
});
}

afterCommit()的设计,是防止事务回滚 但消息已经发出去的情况。如果支付记录没能成功创建,PaymentCheck 会直接报错。

②消费侧的事务隔离——编程式事务

1
2
3
4
5
6
7
8
9
// 数据库操作用编程式事务
transactionTemplate.executeWithoutResult(status -> {
payment.setPaymentStatus(PaymentStatus.SUCCESS.name());
paymentMapper.updateById(payment);
orderMapper.updateStatus(finalOrderNo, OrderStatus.PAID.getCode());
});

// 事务成功后才 ack
channel.basicAck(deliveryTag, false);

为何不用 @Transactional?因为 basicAck() 不在事务管理范围内。如果用声明式事务,可能出现"事务还没提交但消息已经 ack 了"的情况。用 TransactionTemplate 编程式事务 + 手动 ack,保证先提交数据库,再确认消息

补偿机制的两阶段设计

补偿逻辑分两步走:

  • 第一步(事务内):数据库状态更新、库存恢复——这些必须原子性
  • 第二步(事务外):调第三方 HTTP 接口关单——不能放事务里,不然长事务持锁,数据库连接池分分钟耗尽
sequenceDiagram
    participant Consumer as PaymentCheckConsumer
    participant DB as 💾 数据库
    participant Alipay as 🏦 第三方

    Note over Consumer: 5min后收到补偿消息
    Consumer->>DB: 查询支付记录
    DB-->>Consumer: status=PENDING

    Consumer->>Alipay: 查询实际支付状态

    alt 第三方已支付
        Alipay-->>Consumer: TRADE_SUCCESS
        Consumer->>DB: BEGIN TX
        Consumer->>DB: payment → SUCCESS
        Consumer->>DB: order → PAID
        Consumer->>DB: COMMIT
        Consumer->>Consumer: 多重支付检测(事务外)
    else 第三方未支付
        Alipay-->>Consumer: TRADE_NOT_EXIST
        Consumer->>DB: BEGIN TX
        Consumer->>DB: payment → FAILED
        Consumer->>DB: 恢复库存
        Consumer->>DB: order → CANCELLED
        Consumer->>DB: COMMIT
        Consumer->>Alipay: 关闭其他PENDING支付(事务外)
    end

    Consumer->>Consumer: ack

两条超时链路的协作——closeAllPendingPayments(OrderNo)与PaymentCheckConsumer

系统里有两条独立的超时链路,各管各的:

链路 触发时机 延迟时间 职责
订单超时关闭 创建订单时 15分钟 订单级别的兜底,检查是否还有 PENDING 支付
掉单补偿 创建支付时 5分钟 支付级别的补偿,主动查第三方

一个订单可能创建多次支付(切换支付方式),每次支付都有自己的 5 分钟补偿窗口。而 15 分钟的订单超时是最终兜底——如果所有支付都失败了,15 分钟到了订单自动取消、库存释放。

状态机:

stateDiagram-v2
    [*] --> PENDING: 创建支付
    PENDING --> SUCCESS: Webhook回调 / 掉单补偿
    PENDING --> CLOSED: 切换支付方式
    PENDING --> FAILED: 补偿发现未支付
    SUCCESS --> REFUNDED: 退款
    CLOSED --> REFUNDED: 竞态退款
stateDiagram-v2
    [*] --> UNPAID: 创建订单
    UNPAID --> PAID: 支付成功
    UNPAID --> CANCELLED: 超时/取消
    PAID --> SHIPPED: 发货
    SHIPPED --> COMPLETED: 确认收货

番外:一个有趣的循环依赖

写着写着发现了一个经典的循环依赖问题:

  • AlipayPaymentServiceImpl 创建支付时需要调 PaymentCloseService 关闭历史支付
  • PaymentCloseServiceImpl 关闭支付时需要调 AlipayPaymentService 去查询和关闭第三方

Spring 默认不允许循环依赖,查了一下,用 @Lazy 延迟注入的方式解决了:

1
2
3
4
5
6
7
@Autowired
public PaymentCloseServiceImpl(PaymentMapper paymentMapper,
@Lazy AlipayPaymentService alipayPaymentService,
@Lazy StripeService stripeService,
OrderMapper orderMapper) {
// ...
}

@Lazy 会注入一个代理对象,真正使用时才去容器里取实际 Bean。简单粗暴但有效。

SUMMARY

整个支付系统的防御体系:

层次 机制 防的是什么
第 1 层 Redis 分布式锁 并发创建支付
第 2 层 先查后关(支付互斥) 切换支付方式时的双重扣款
第 3 层 竞态自动退款 CLOSED 后仍收到成功回调
第 4 层 多重支付检测 终极兜底,以防万一
第 5 层 掉单补偿(5min 延迟查询) Webhook 丢失或延迟
第 6 层 订单超时关闭(15min) 所有支付都失败时的订单兜底

参考资料

Comments