从零开始搓出来的一个 Payment系统
最近给 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 | String lockKey = "lock:Alipay:create:" + orderNo; |
通过SET key uuid NX EX 60 上锁,Lua 脚本原子释放。30 秒 TTL 兜底。
第二层:支付互斥(closeAllPendingPayments())——切换支付方式时先查后关
用户从 Alipay 双向切 Stripe 时,不能直接把旧的支付单关掉就完事。万一用户已经在支付宝那边付了呢?
关单之前,先去第三方查一下实际状态:
1 | private void closeOnePayment(PaymentDO payment) { |
宁可阻断新支付也不能双重扣款。
第三层:竞态自动退款——CLOSED 的单子收到钱了
最骚的场景来了:后端这边已经把 Alipay 支付单 CLOSED 了,但用户还在支付宝页面上,手速够快的话还是能完成支付的。支付宝的回调延迟了几秒才到……
1 | // Webhook 处理中 |
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 | private void detectAndRefundDuplicatePayments(String orderNo) { |
Webhook掉单的扭转机制——PaymentCheckConsumer消息队列
支付系统最核心的信息传递靠的是第三方支付机构的 Webhook 回调。但这东西很tm玄学,遇到一点网络波动就接收不到消息了。Webhook 丢了,哪怕是付了钱,但订单还是"未支付"状态,这就掉单了。
解决方案:延迟消息 + 主动查询
思路很简单:不完全信任回调,5min TTL后主动去查一下。
用 RabbitMQ 的死信队列(DLX)实现延迟消息:
1 | 支付创建 |
有几个很"优雅"的设计:
①事务提交后再发消息 —— 保证支付成功创建
1 |
|
afterCommit()的设计,是防止事务回滚 但消息已经发出去的情况。如果支付记录没能成功创建,PaymentCheck 会直接报错。
②消费侧的事务隔离——编程式事务
1 | // 数据库操作用编程式事务 |
为何不用 @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 |
|
@Lazy 会注入一个代理对象,真正使用时才去容器里取实际 Bean。简单粗暴但有效。
SUMMARY
整个支付系统的防御体系:
| 层次 | 机制 | 防的是什么 |
|---|---|---|
| 第 1 层 | Redis 分布式锁 | 并发创建支付 |
| 第 2 层 | 先查后关(支付互斥) | 切换支付方式时的双重扣款 |
| 第 3 层 | 竞态自动退款 | CLOSED 后仍收到成功回调 |
| 第 4 层 | 多重支付检测 | 终极兜底,以防万一 |
| 第 5 层 | 掉单补偿(5min 延迟查询) | Webhook 丢失或延迟 |
| 第 6 层 | 订单超时关闭(15min) | 所有支付都失败时的订单兜底 |