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

一、问题场景

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

某天线上告警:一个主单下 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) 只是"大概率错开",不保证互斥。真正需要串行的场景,应该用队列、信号量等确定性机制,而不是靠"延迟够大应该不会撞"。

相关推荐
xyhuix3 分钟前
Spring+Quartz实现定时任务的配置方法
java
分享牛6 分钟前
Operaton入门到精通22-Operaton 2.0 升级指南:Spring Boot 4 核心变更详解
java·spring boot·后端
jinanmichael6 分钟前
SpringBoot 如何调用 WebService 接口
java·spring boot·后端
深蓝轨迹7 分钟前
吃透 Spring Boot dataSource与Starter
java·spring boot·笔记·后端
spring2997929 分钟前
springboot和springframework版本依赖关系
java·spring boot·后端
文公子WGZ18 分钟前
将java 21切换成java 25
java·开发语言
一直都在57219 分钟前
Java序列化和反序列化
java·开发语言
AI精钢24 分钟前
OpenLobster 的优势与劣势:一次面向 OpenClaw 用户的架构审视
java·微服务·架构·ai agent·mcp·openclaw·openlobster
MonkeyKing_sunyuhua28 分钟前
本地将镜像打包推送到阿里云的镜像服务器
java·服务器·阿里云
飞Link31 分钟前
Kafka~本地Python Kafka发送数据,服务端Kafka消费不到
java·分布式·kafka