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

📅 发布时间:2026/7/6 6:05:53 👁️ 浏览次数:
联合订单并发退款:一次分布式锁冲突的排查与思考
一、问题场景在电商/酒店等业务中常见联合订单模式用户一次下单产生一个主单合并支付主单下挂多个子单如多个房间、多件商品。退款时每个子单可能独立触发退款。某天线上告警一个主单下 3 个子单同时触发退款其中 2 个失败日志显示makefile退款失败: 主单号xxx支付中台日志ini订单正在退款中请求被拦截mergeOrderNoxxx二、问题模型抽象后的系统结构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. 读查询该主单下历史已退款成功的流水 ListRefundRecord 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本次退2000200200 500 ✅ → 写入流水 子单B读已退0A还没写入本次退2000200200 500 ✅ → 写入流水 子单C读已退0A、B都没写入本次退2000200200 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相邻子单延迟间隔只有 100msinit1.7s 子单A开始退款 → 拿到锁 → 调用支付宝退款(耗时~3s)... t1.8s 子单B开始退款 → 拿锁失败 ❌ → 直接返回失败 t1.9s 子单C开始退款 → 拿锁失败 ❌ → 直接返回失败 t4.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 个线程三个任务到时间后各自分配一个线程同时执行iniScheduledThreadPoolExecutor4个线程 t1.7s → 线程1 执行任务A调支付API耗时~3s t1.8s → 线程2 执行任务BA还没完但线程2空闲直接开始 t1.9s → 线程3 执行任务CA、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后才会取下一条。javaRocketMQMessageListener( 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原始设计线程池延迟调度 t1.7s 线程1→退款A开始 ──→ 拿到锁 ──→ 调支付API(3s)... t1.8s 线程2→退款B开始 ──→ 拿锁失败 ❌ 退款丢失 t1.9s 线程3→退款C开始 ──→ 拿锁失败 ❌ 退款丢失 t4.7s 线程1→退款A完成 ──→ 释放锁B、C已失败无人重试 MQ顺序消息 Queue内消息[A] → [B] → [C] t0s 取出A → 调支付服务退款A → 写入流水 → ACK t3s A完成取出B → 读到A的流水金额正确 → 退款B → ACK t6s B完成取出C → 读到AB的流水金额正确 → 退款C → ACK ✅ 严格串行金额准确失败可重试两种方案的本质区别线程池 延迟MQ 顺序消息串行保证靠延迟够大不会撞概率性上一条ACK后才消费下一条确定性失败处理catch 后记日志退款丢失RocketMQ 自动重试maxReconsumeTimes金额准确性可能并发读到脏数据严格有序每次读到最新流水依赖仅JVM内线程池需要 MQ 中间件方案二下游支持失败重试支付服务改造锁策略将 fail-fast 改为 spin-wait retryjava// 改造前快速失败 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修改后时序init1s 子单A开始退款 → 拿到锁 → 处理中... t6s 子单B开始退款 → 子单A已完成 → 拿到锁 ✅ t11s 子单C开始退款 → 子单B已完成 → 拿到锁 ✅优点改动一行代码风险最低缺点依赖取模分布理论上仍有碰撞可能延迟变大影响退款时效方案对比方案可靠性改动量时效性适用场景MQ串行化高中中长期方案退款量大下游失败重试高中高能推动支付团队改造增大延迟间隔中低中紧急修复快速止血七、设计原则总结上下游并发粒度必须对齐。上游按什么粒度发请求下游按什么粒度加锁必须在接口契约中明确。否则上游以为能并发下游实际上互斥就会产生冲突。谁有全局视角谁来聚合。同一个主单下的多个子单退款业务服务拥有全局信息应该负责聚合后统一发起而不是让每个子单独立调用。分布式锁的 fail-fast 要区分场景。防重复提交用快速失败是对的但对合法的顺序请求也快速失败就是误杀。可以通过请求中的幂等标识区分重复提交和同单多笔退款。异步延迟 ≠ 并发控制。schedule(delay) 只是大概率错开不保证互斥。真正需要串行的场景应该用队列、信号量等确定性机制而不是靠延迟够大应该不会撞。