一、问题场景
在电商/酒店等业务中,常见"联合订单"模式:用户一次下单产生一个主单(合并支付),主单下挂多个子单(如多个房间、多件商品)。退款时,每个子单可能独立触发退款。
某天线上告警:一个主单下 3 个子单同时触发退款,其中 2 个失败,日志显示:
makefile
退款失败: 主单号xxx
支付中台日志:
ini
订单正在退款中,请求被拦截,mergeOrderNo=xxx
二、问题模型
抽象后的系统结构:
scss
┌─────────────────────────────────────────────────┐
│ 业务服务(上游) │
│ │
│ 子单A ──→ refund(子单A, 主单号) ──┐ │
│ 子单B ──→ refund(子单B, 主单号) ──┼──→ Feign │
│ 子单C ──→ refund(子单C, 主单号) ──┘ │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 支付服务(下游) │
│ │
│ Redis Lock (key = 主单号, ttl = 30s) │
│ if (!lock()) return fail; // 快速失败 │
│ try { │
│ 创建退款流水 → 调用三方支付API(2~5s) │
│ } finally { │
│ unlock(); │
│ } │
└─────────────────────────────────────────────────┘
核心矛盾:上游按子单粒度并发请求,下游按主单粒度加互斥锁。
三、支付服务为什么要在主单维度加锁
在分析上游问题之前,先理解下游支付服务的设计初衷------这把锁不是"过度防御",而是必须存在的。
3.1 保护的核心逻辑:累计退款金额校验
支付服务的退款方法中有一段典型的"先读后写"逻辑:
java
// 1. 读:查询该主单下历史已退款成功的流水
List<RefundRecord> refundRecordList = refundRecordDao.listRefundFlow(mergeOrderNo, ...);
// 2. 算:累加已退金额 + 本次退款金额
Long refundAmount = currentRefundAmount;
for (RefundRecord record : refundRecordList) {
refundAmount += record.getActualAmount();
}
// 3. 判:总退款不能超过支付金额
if (refundAmount > totalPrice) {
return fail("部分退款金额已超出实际退款金额");
}
// 4. 写:创建新的退款流水记录
refundRecordDao.save(newRefundRecord);
这是一个非原子的 读-算-判-写 操作。如果不加锁,并发请求会读到相同的"已退金额",各自判断都不超额,然后各自写入------导致超额退款:
ini
并发场景(无锁):主单总价 500 元
子单A:读已退=0,本次退200,0+200=200 < 500 ✅ → 写入流水
子单B:读已退=0(A还没写入),本次退200,0+200=200 < 500 ✅ → 写入流水
子单C:读已退=0(A、B都没写入),本次退200,0+200=200 < 500 ✅ → 写入流水
实际退款:600 元 > 总价 500 元 → 资金损失!
3.2 为什么锁的粒度必须是主单而非子单
因为校验的对象是主单级别的累计退款总额。联合支付在三方支付渠道(微信/支付宝)只有一笔交易,退款也基于这一笔交易,退款总额不能超过支付总额。
如果锁在子单维度,子单 A 和子单 B 的退款可以并发执行,累计金额校验照样会被穿透:
css
锁在子单维度(错误):
子单A拿到锁A ──→ 读主单已退=0,校验通过 ──→ 写入
子单B拿到锁B ──→ 读主单已退=0,校验通过 ──→ 写入 (A、B互不阻塞)
仍然超额退款!
因为支付是一笔交易,所以退款校验必须按这一笔交易来互斥。锁的粒度由数据的一致性边界决定,不由业务的调用粒度决定。
3.3 锁本身没问题,问题在哪
支付服务的锁设计是合理且必要的。真正的问题是:
- 上游不感知下游的互斥约束,把同一主单的多个子单退款当作独立请求并发发出
- 锁的 fail-fast 策略没有区分"重复提交"和"同单多笔合法退款",一律拒绝
理解了这一点,才能选择正确的修复方向------问题不在于"锁该不该加",而在于"上游该不该并发调"。
四、为什么会失败
4.1 并发时序
业务服务使用异步延迟调度来"错开"3个子单的退款请求:
java
// 原始设计:基于ID取模计算延迟
long delay = 1000 + (orderId % 20) * 100; // 范围 1.0s ~ 2.9s
executor.schedule(() -> doRefund(子单), delay, TimeUnit.MILLISECONDS);
看起来做了错峰,但实际上同一主单下的子单 ID 通常连续,取模后差值仅为 1,相邻子单延迟间隔只有 100ms:
ini
t=1.7s 子单A开始退款 → 拿到锁 → 调用支付宝退款(耗时~3s)...
t=1.8s 子单B开始退款 → 拿锁失败 ❌ → 直接返回失败
t=1.9s 子单C开始退款 → 拿锁失败 ❌ → 直接返回失败
t=4.7s 子单A退款完成 → 释放锁(但B、C已经失败,无重试)
100ms 的间隔对于一次需要 2~5 秒的三方支付调用来说,形同虚设。
4.2 问题本质:两层并发粒度不匹配
上游视角:3个独立的子单退款,互不相干,可以并发
下游视角:3个请求操作同一笔支付流水,必须串行
这是分布式系统中典型的并发粒度不匹配问题------上下游对"什么可以并发、什么必须互斥"的认知不一致。
五、架构反思:一开始不应该这样设计
退一步看,联合订单下多个子单退款,本质上是对同一笔支付的多次部分退款。理想的设计应该是:
推荐设计:上游聚合,下游单次
scss
┌──────────────────────────────────────┐
│ 业务服务 │
│ │
│ 收集所有需退款的子单 │
│ ↓ │
│ 聚合为一次退款请求 │
│ refund(主单号, [子单A, 子单B, 子单C]) │
└──────────────────────────────────────┘
│
▼ 只调一次
┌──────────────────────────────────────┐
│ 支付服务 │
│ 加锁 → 批量退款 → 释放锁 │
└──────────────────────────────────────┘
原则:谁拥有全局视角,谁来聚合。 业务服务知道一个主单下有哪些子单需要退款,应该在业务层收集汇总后统一发起,而不是让每个子单各自为战。
反模式:上游分散,下游兜底
上游:每个子单独立调用退款接口
下游:用分布式锁保证串行 + 快速失败
这种设计把并发控制的责任推给了下游,但下游用的是 fail-fast 策略(拿不到锁就直接拒绝),并没有真正"兜住"。
六、已有设计下的修复思路
如果重构成本太高,无法短期内改为聚合模式,可以按以下优先级选择修复方案:
方案一:MQ 顺序消息串行化(推荐)
css
子单A退款事件 ──┐
子单B退款事件 ──┼──→ MQ (按主单号分区) ──→ 消费者串行处理
子单C退款事件 ──┘
通过 MQ 的分区有序消费,天然保证同一主单下的退款请求串行执行。
- 优点:确定性串行,不依赖时间间隔;消费失败自动重试
- 缺点:引入 MQ 依赖,链路变长
原始设计 vs MQ 串行化
原始设计:ScheduledThreadPoolExecutor + 延迟调度
java
// 线程池配置:4个核心线程
ScheduledExecutorService executor = new ScheduledThreadPoolExecutor(4);
// 每个子单各自计算延迟后投入调度队列
long delay = 1000 + (orderId % 20) * 100;
executor.schedule(() -> doRefund(子单), delay, TimeUnit.MILLISECONDS);
schedule() 只控制"最早什么时候开始",不会等前一个任务完成。线程池有 4 个线程,三个任务到时间后各自分配一个线程同时执行:
ini
ScheduledThreadPoolExecutor(4个线程):
t=1.7s → 线程1 执行任务A(调支付API,耗时~3s)
t=1.8s → 线程2 执行任务B(A还没完,但线程2空闲,直接开始)
t=1.9s → 线程3 执行任务C(A、B都没完,线程3也空闲)
三个任务几乎同时在跑 → 本质上就是并发
→ 同时请求支付服务 → Redis锁冲突
即使把间隔增大到 5 秒(orderId % 5 * 5000),也只是"靠时间差模拟串行"------如果某次退款耗时超过 5 秒,后续任务仍然会撞锁。而且任务失败后没有重试机制,直接丢失。
MQ 串行化:顺序消息 + 有序消费
以 RocketMQ 为例,核心是两端配合:
生产者: 用 syncSendOrderly(topic, message, hashKey) 发送顺序消息,相同 hashKey(主单号)的消息路由到同一个 Queue。
java
// 改造前:投入线程池延迟调度
executor.schedule(() -> doRefund(子单A), delay, TimeUnit.MILLISECONDS);
// 改造后:发送顺序消息,按主单号路由
mqUtil.sendOrderly("refund_topic", JSON.toJSONString(refundDTO), orderMainNo);
消费者: 设置 consumeMode = ConsumeMode.ORDERLY,保证同一个 Queue 内一条消息消费完成(ACK)后才会取下一条。
java
@RocketMQMessageListener(
consumerGroup = "early_checkout_refund_group",
topic = "refund_topic",
consumeMode = ConsumeMode.ORDERLY, // 关键:顺序消费
maxReconsumeTimes = 3 // 失败自动重试3次
)
public class RefundListener extends BdwMqListener {
@Override
public void bdwOnMessage(MessageExt messageExt) {
String body = new String(messageExt.getBody(), StandardCharsets.UTF_8);
OrderRefundDTO refundDTO = JSON.parseObject(body, OrderRefundDTO.class);
// 调用退款服务(同一主单下的消息严格串行执行到这里)
orderMainService.refundPartOrder(refundDTO);
}
}
执行时序对比:
ini
原始设计(线程池延迟调度):
t=1.7s 线程1→退款A开始 ──→ 拿到锁 ──→ 调支付API(3s)...
t=1.8s 线程2→退款B开始 ──→ 拿锁失败 ❌ 退款丢失
t=1.9s 线程3→退款C开始 ──→ 拿锁失败 ❌ 退款丢失
t=4.7s 线程1→退款A完成 ──→ 释放锁(B、C已失败,无人重试)
MQ顺序消息:
Queue内消息:[A] → [B] → [C]
t=0s 取出A → 调支付服务退款A → 写入流水 → ACK
t=3s A完成,取出B → 读到A的流水,金额正确 → 退款B → ACK
t=6s B完成,取出C → 读到A+B的流水,金额正确 → 退款C → ACK
✅ 严格串行,金额准确,失败可重试
两种方案的本质区别:
| 线程池 + 延迟 | MQ 顺序消息 | |
|---|---|---|
| 串行保证 | 靠"延迟够大不会撞"(概率性) | 上一条ACK后才消费下一条(确定性) |
| 失败处理 | catch 后记日志,退款丢失 | RocketMQ 自动重试(maxReconsumeTimes) |
| 金额准确性 | 可能并发读到脏数据 | 严格有序,每次读到最新流水 |
| 依赖 | 仅JVM内线程池 | 需要 MQ 中间件 |
方案二:下游支持失败重试
支付服务改造锁策略,将 fail-fast 改为 spin-wait + retry:
java
// 改造前:快速失败
if (!lock()) return fail;
// 改造后:等待重试
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
if (lock()) break;
Thread.sleep(5000); // 等待5秒后重试
}
if (!locked) return fail;
- 优点:对上游透明,不需要改业务代码
- 缺点:占用线程资源,需要支付团队配合
方案三:增大延迟间隔(最小改动)
调整上游的延迟策略,拉大子单间隔:
java
// 修改前:间隔 100ms,几乎等于并发
long delay = 1000 + (orderId % 20) * 100; // 1.0s ~ 2.9s
// 修改后:间隔 5s,大于单次退款耗时
long delay = 1000 + (orderId % 5) * 5000; // 1s ~ 21s
修改后时序:
ini
t=1s 子单A开始退款 → 拿到锁 → 处理中...
t=6s 子单B开始退款 → 子单A已完成 → 拿到锁 ✅
t=11s 子单C开始退款 → 子单B已完成 → 拿到锁 ✅
- 优点:改动一行代码,风险最低
- 缺点:依赖取模分布,理论上仍有碰撞可能;延迟变大影响退款时效
方案对比
| 方案 | 可靠性 | 改动量 | 时效性 | 适用场景 |
|---|---|---|---|---|
| MQ串行化 | 高 | 中 | 中 | 长期方案,退款量大 |
| 下游失败重试 | 高 | 中 | 高 | 能推动支付团队改造 |
| 增大延迟间隔 | 中 | 低 | 中 | 紧急修复,快速止血 |
七、设计原则总结
-
上下游并发粒度必须对齐。 上游按什么粒度发请求,下游按什么粒度加锁,必须在接口契约中明确。否则上游以为能并发,下游实际上互斥,就会产生冲突。
-
谁有全局视角,谁来聚合。 同一个主单下的多个子单退款,业务服务拥有全局信息,应该负责聚合后统一发起,而不是让每个子单独立调用。
-
分布式锁的 fail-fast 要区分场景。 防重复提交用快速失败是对的,但对合法的顺序请求也快速失败就是误杀。可以通过请求中的幂等标识区分"重复提交"和"同单多笔退款"。
-
异步延迟 ≠ 并发控制。
schedule(delay)只是"大概率错开",不保证互斥。真正需要串行的场景,应该用队列、信号量等确定性机制,而不是靠"延迟够大应该不会撞"。