2. 下单 / 统一支付 / 退款补偿全链路详解
本文档是 SmartLive 项目核心交易链路的深度拆解,适合面试 10 分钟讲解版本。
涵盖:防丢单设计 → MQ 异步扣减库存 → 支付原子防超扣校验(乐观锁 / SQL 条件) → 多渠道统一账务流水 → 主动退款 / 已支付未使用过期自动退款的状态流转与资金补偿。
1. 链路总览图
普通下单:
统一支付:
订单超时取消:
主动取消 / 退款补偿:
已支付未使用到期自动退款:
补充说明:
- 支付成功后会根据商品
validityType回填validStartTime / expireTime,后续临期提醒和过期退款都基于这两个时间字段判断。 orderSoonExpireJobHandler会扫描未来 3 天内即将过期、且状态仍为PAID的订单,提前发送系统提醒。orderExpireJobHandler会扫描status = PAID && expireTime <= now的未核销订单,调用orderService.expired()将订单置为EXPIRED,再复用sendRefundMessage()进入钱包退款补偿链路。
2. 每一步的技术细节与面试追问
Step 1~4:普通订单防重与异步创建
做了什么:
- 计算业务重号:基于
user_id和source_id在 DB 去重拦截(剔除已取消/已过期),发现重复直接返回并释放 Redis 库存。 - 插入订单表(状态
UNPAID)。 - 落库成功后发送两条 MQ 消息:
- 到
PRODUCT_STOCK_EXCHANGE异步驱动上游完成实际库存扣减。 - 到
ORDER_DELAY_EXCHANGE投递一个 TTL 15分钟的死信延迟队列。
- 到
面试追问与回答:
| 问题 | 回答 |
|---|---|
| 落库前为什么不利用唯一索引防重,而是用 DB Count? | 我们对 orderId 本身有唯一索引,防的是机器故障重复生成单。但这里的 Count 拦截的是“同一个用户短时间内对同一个商品点多次产生多张不同单号的待支付订单”。以此防用户端滥刷。 |
为什么普通下单不走同步扣库存,而去发消息 PRODUCT_STOCK_EXCHANGE? | 遵循核心微服务解耦与性能提升原则。订单中心只管建单成功,把具体扣商品域或店铺域库存的繁琐操作放到 MQ 里去,既提升下单接口 QPS,又能在商品服务短暂不可用时利用 MQ 的 Retry 机制做故障隔离。 |
| 创建失败后 Redis 的兜底补偿怎么做的? | 如果 Spring Data JPA save 操作失败抛出异常,业务代码捕获后立刻远程调用恢复 Redis 中拦截用的预扣库存和防重令牌(order:status:{id})。 |
Step 5:站内/站外统一支付路由与原子防超扣
做了什么:
在 Wallet 模块维护统一钱包:
- 站外支付:不论是微信还是支付宝,支付成功回调后,统一转换为系统标准的
WalletTransaction流水录入,抹平了三方差异,统一了系统的财务对账口径。 - 站内余额支付:采用极其严苛的原子更新 SQL:
UPDATE user_wallet SET balance = balance - ?, version = version + 1 WHERE user_id = ? AND balance >= ?
面试追问与回答:
| 问题 | 回答 |
|---|---|
| 钱包扣减余额是如何处理并发请求,防止钱扣成负数的? | 不在 Java 层面查出 Balance 进行减法再 Set 保存。而是把条件 balance >= amount 下推到 MySQL 的 Update 语句作为前置防御,利用 InnoDB 的记录行级排它锁,属于绝对的天然乐观防御,防超扣 100% 安全。 |
| 如果用户同一时间双端发起多次余额支付,会不会被扣多次? | 一方面订单的回调状态机只会承接一次(状态=PAID短路),另一方面对于扣减 SQL 带有 version = version + 1 乐观锁防重特征。 |
Step 6~9:支付回调、销量维护与有效期准备
做了什么:
OrderServiceImpl.paySuccess()被回调执行。- 判断状态,如果
== PAID则直接返回1(本地重试防并发)。 - 根据关联商品的规则表(
ValidityType= 1 固定日期 或 = 2 购买后N天),计算出该笔订单的有效期时间并打入订单记录。 - 使用
incrementSales方法将销量双写:同时更新 Redis 销量排行计数器,和标记 Dirty 等待定时刷入数据库。 - 这一步会给后续“临期提醒”和“已支付未使用自动退款”准备判断依据:后面的定时任务都只看
status与expireTime。
面试追问与回答:
| 问题 | 回答 |
|---|---|
| 支付回调接口怎么防止被第三方接口超时重发导致多次加销量? | 我们有经典的状态机前置约束判定 if (order.getStatus() == OrderStatusConstants.PAID) return;。配合数据库针对该行的行级排他锁或状态条件,拦截重复消息继续向下执行,从而做到了严密的幂等性。 |
| 为什么在订单付款后才加销量,而不是下单时加? | 这是典型的业务交易指标防刷设计。如果点击下单就给销量+1,恶意竞争者可以疯狂下无效单但不付款,挤占榜单。仅针对真实成交(PAID/VERIFIED)才加销,且退款后必定扣销(decrementSales),确保榜单金矿的含金量。 |
Step 10~14:主动退款、过期未使用退款与回滚补偿链路
做了什么:
- 订单系统内主动或超时发起
cancel / refund操作:先通过状态机变更status -> CANCELLED/REFUNDED。 - 如果是取消:将原商品关联的活动库存远程重置(比如秒杀活动名额放归)。如果原状态是已支付,调用退款流程。
- 如果是“已支付但未核销且已过期”的订单:由
orderExpireJobHandler定时扫描后调用orderService.expired(id),将状态改为EXPIRED,再继续走库存回收与退款补偿。 - 将已加的 Redis / DB 店铺与商品销量执行
decrementSales平账。 - 投递核心 MQ
OrderRefundMessage,支付钱包中心订阅此消息后,执行原路加款回退balance = balance + refundAmount。 - 对于即将过期但尚未到期的订单,
orderSoonExpireJobHandler还会提前 3 天通过系统通知提醒用户尽快使用,减少无感过期带来的投诉。
面试追问与回答:
| 问题 | 回答 |
|---|---|
| 取消订单和退款退单在具体实现上有什么差别? | 取消包含纯粹未支付的订单废弃(仅恢复库存/优惠券等),如果是已支付发起取消,底层逻辑会顺带触发 sendRefundMessage 走资金逆向。单纯的 Refund 是确定性的已支付资金逆向(需更新 refundTime 等特定流转)。两者共享库存与金额的回滚模型。 |
| 已支付但一直没用、过期了怎么办? | 这条不是靠用户手动触发,而是靠 XXL-JOB 的 orderExpireJobHandler 定时扫描 status = PAID && expireTime <= now 的未使用订单,自动把状态改成 EXPIRED,并复用 sendRefundMessage 异步退款到钱包,形成“支付成功 → 到期未使用 → 自动退款”的逆向闭环。 |
| 为什么退款发 MQ 异步通知,不直接 RPC 调了钱包接口退款? | 资金退款不是强实时诉求。如果是 RPC 同步退,一旦钱包服务挂机打满,退款大面积超时会拖死订单核心服务。转交 MQ 发出退款指令队列,确保哪怕钱包服务暂挂,订单层依然能秒级响应终端用户“退款受理中”,而资金一定会依靠 MQ 可靠性重试原封不动退给用户(最终一致性)。 |
| 过期未使用自动退款会不会把已经核销的单子也退掉? | 不会。定时任务只扫描 status = PAID 的订单,已核销订单状态是 VERIFIED,不会命中过期退款任务。所以“已支付未使用”和“已核销已履约”在状态机层面天然分开。 |
3. 整条链路涉及的核心技术点汇总
┌─────────────────────────────────────────────────────┐
│ 技术点对照表 │
├──────────────┬──────────────────────────────────────┤
│ 数据库与 SQL │ · DB Count 拦截一人重复起多单 │
│ │ · Balance >= Amount 数据库行锁防超扣 │
│ │ · 状态机约束防并发幂等问题 (`oldStatus`)│
├──────────────┼──────────────────────────────────────┤
│ 消息队列 MQ │ · Direct 交换机异步下发扣减库存 │
│ │ · Dead Letter 延迟队列执行超时清理判定 │
│ │ · 最终一致性退款的补偿 MQ 信道 │
│ │ · 临期提醒 / 过期退款的异步通知闭环 │
├──────────────┼──────────────────────────────────────┤
│ 业务解耦设计 │ · 订单层与资金层(Wallet)的高度物理分割 │
│ │ · 统一汇聚站内战外流水记录,抹平账务差 │
├──────────────┼──────────────────────────────────────┤
│ 幂等保障 │ · 回调通知拦截重入短路 │
│ │ · 资金账目增加与扣减强绑定业务状态唯一单│
└──────────────┴──────────────────────────────────────┘4. 面试 10 分钟讲述模板
讲述思路:先描述建单的边界防卫,再讲钱包支付的极致并发安全,最后收尾到退款补偿的架构鲁棒性。
开场(1 分钟)
"在 SmartLive 平台里,我负责了核心的交易正逆向闭环,主要指:订单异步落库、统一支付路由台以及全自动退款补偿体系。这套体系的设计初衷,是为了把繁重的金融资金核算从主链上剥开,将高频交易做到极度性能化与防脱钩错账保护。"
正向建单:解耦与兜底防护(3 分钟)
"普通下单进来后,我先用 count 根据 userId 和 sourceId 做恶意灌水防护,插入表后我们并不进行耗时的远程RPC扣库存,而是甩两条 MQ:一条去要求上架商品端减库,一条发给死信队列卡死 15 分钟失效期。万一数据库瞬时拒绝连接落单失败了,我还用 try-catch 搭配远端去重置 Redis 所预锁的库存,做到天衣无缝的资源复原。"
统一支付:防并发超扣与流水收口(3 分钟)
"到了支付收银台,无论是接了微信、支付宝还是本站余额。我不去散落写账单,所有的三方统统拉拢进 WalletTransaction 表里作为对账快照。而站内余额支付并发最高也最危险,我摒弃了 Java 服务拿出来算出结果再去 Update,而是采用
UPDATE balance = balance - X WHERE balance >= X将所有的超扣防线死死交给 InnoDB 的行级排他锁,彻底断绝数字变成负数的可能。"
逆向环节:主动退款 + 到期未使用自动退款(2 分钟)
"用户如果主动发起退款,或者订单已经支付但到期一直没核销,我都会走同一套逆向补偿模型。前者由接口直接触发,后者由 XXL-JOB 的
orderExpireJobHandler定时扫描status = PAID && expireTime <= now自动触发。第一步先通过状态机截断外部更新;接着把 Redis 和数据库里已经累计的商品、店铺销量做反向扣减平账;最后发一张『退款通知』到 MQ,异步交给钱包中心原路退回用户余额。这样订单域不会被退款慢链路拖死,但资金最终一定会回到账户。"
总结亮点(1 分钟)
"这条交易线,本质就是通过 天然行锁抗并发漏洞,通过 MQ 拆分解耦上下游延迟,再借由纯粹的状态机限制一切可能发生的黑客篡改重播与幂等威胁。做到金融系统的基本要求:不重买,不超卖,不错退。"
5. 高频追问速查表
| 追问方向 | 关键问题 | 核心回答 |
|---|---|---|
| 库联一致 | 下单发了防重检查,但发 MQ 时系统挂了咋办? | 本项目发 MQ 配有基础的异步重试;如果要金融级强一致,我们可通过“本地消息表”或者“RocketMQ 事务消息”将订单事务与发送投递紧绑定。 |
| 防超扣 | SQL 拼接的 balance >= amount 这个真的够吗? | 对于钱包扣减是够的。InnoDB UPDATE 会走当前读(Current Read),自动加上 X 排它锁。无论其他事务多高并发被唤醒进入执行时,都要基于最新行的余额核对 >= amount 这个准入条件,从而保证数据强约束。 |
| 数据清洗 | 你有支付过期时间策略,那过期没付怎么关单? | 发了延时队列 (15-30min),一旦消费者拿到消息,用主键二次查库发现仍是 UNPAID,直接触发我核心服务内的 cancel 方法,把没付款的库存用 MQ 释放掉。 |
| 过期退款 | 那已支付但一直没使用、最后过期的订单怎么处理? | 支付成功时会写入 expireTime。后面由 XXL-JOB 的 orderExpireJobHandler 每天扫描 status = PAID && expireTime <= now 的未使用订单,统一转为 EXPIRED,回收活动库存、回滚销量,并通过 OrderRefundMessage 异步退款到钱包。 |
| 幂等重放 | 三方支付网络波动给你回调了 5 次? | 第一次进 paySuccess() 时,我便根据 SQL 或 Java 取回的原状态判断,一旦 != UNPAID 或 == PAID 则马上返回 return 1; 阻断其向下重刷,完美拦截网络重放攻击。 |
6. 扩展:这条链路和八股的关联
订单支付退款链路 ←→ 八股知识点映射
MySQL 数据库底层八股:
├── 行级锁(X锁)在 Update WHERE 上的悲观防超卖表现机制
├── 并发与隔离级别(Read Committed 对 count防重判断下的影响)
└── 乐观锁实现(基于 Version 字段自增及重试)
MQ 高级应用框架八股:
├── MQ 解耦强一致还是弱一致的方案选择(订单与库存解耦)
├── 消息持久化投递及防网络闪断丢消息
└── 延迟队列在电商过期关单业务的具体使用场景与瓶颈
支付与幂等抽象八股:
├── 状态机模型(如枚举控制 UNPAID 往 PAID/REFUNDED 必须具有不可回撤性)
├── 第三方回调与本地防重放应对机制
├── XXL-JOB 在“临期提醒 / 过期未使用自动退款”中的职责边界
└── 如何利用本地事务原子性替换分布式两阶段提交(2PC)解决对账分歧
企业级架构设计八股:
├── 金融账户记账隔离设计(支付手段与资金载体的抽象转化化解模块交叠)
└── Write-Through 在热数据销量统计上的落点与使用一句话总结:传统单体查个全表
insert就完事,而在微服务里我们需要明白的精髓是:通过 MQ 将建库与缩库脱钩、通过数据库自身的天堑锁死超扣问题、通过状态分发制止幂等重入! 带着这套思想,你面试时面对支付核心流程和故障容错才能有的放矢。