联合订单并发退款:一次分布式锁冲突的排查与思考

一、问题场景

在电商/酒店等业务中,常见"联合订单"模式:用户一次下单产生一个主单(合并支付),主单下挂多个子单(如多个房间、多件商品)。退款时,每个子单可能独立触发退款。

某天线上告警:一个主单下 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串行化 长期方案,退款量大
下游失败重试 能推动支付团队改造
增大延迟间隔 紧急修复,快速止血

七、设计原则总结

  1. 上下游并发粒度必须对齐。 上游按什么粒度发请求,下游按什么粒度加锁,必须在接口契约中明确。否则上游以为能并发,下游实际上互斥,就会产生冲突。

  2. 谁有全局视角,谁来聚合。 同一个主单下的多个子单退款,业务服务拥有全局信息,应该负责聚合后统一发起,而不是让每个子单独立调用。

  3. 分布式锁的 fail-fast 要区分场景。 防重复提交用快速失败是对的,但对合法的顺序请求也快速失败就是误杀。可以通过请求中的幂等标识区分"重复提交"和"同单多笔退款"。

  4. 异步延迟 ≠ 并发控制。 schedule(delay) 只是"大概率错开",不保证互斥。真正需要串行的场景,应该用队列、信号量等确定性机制,而不是靠"延迟够大应该不会撞"。

相关推荐
用户4745189475101 小时前
全链路日志追踪利器:trace-spring-boot-starter 实战指南
java
acx匿1 小时前
【Windows10 下 JDK17 环境变量配置超详细教程(ZIP 版)】
java·jdk
Renhao-Wan1 小时前
Java 算法实践(七):动态规划
java·算法·动态规划
新缸中之脑2 小时前
Sonnet 4.6 vs Opus 4.6
java·开发语言
曹牧2 小时前
Java:@RequestBody 和 @RequestParam混合使用
java·开发语言
甲枫叶2 小时前
【claude+weelinking产品经理系列16】数据可视化——用图表讲述产品数据的故事
java·人工智能·python·信息可视化·产品经理·ai编程
苡~3 小时前
【openclaw+claude】手机+OpenClaw+Claude实现远程AI编程系列大纲
java·前端·人工智能·智能手机·ai编程·claude api
毕设源码-赖学姐3 小时前
【开题答辩全过程】以 基于java电脑售后服务管理系统设计为例,包含答辩的问题和答案
java·开发语言
我是秦始皇v我5003 小时前
CSDN:Java开发者的成长沃土
java