补偿机制:分布式系统中出事了我兜底的设计哲学——从本地回滚到Saga编排的完整实践
补偿:别等炸了才想兜底 某一天凌晨,运维群里弹出一条告警:订单服务返回码全是 500,错误日志里赫然写着 库存扣减失败,事务已提交。排查一圈发现——库存服务超时了,但订单服务的本地事务已经提交,用户钱扣了,货没发出去。 这不是什么玄幻剧情。只要你的系统一次操作涉及两个以上的外部依赖,它就一定会发生。 问题的根儿不在于某个服务挂了,而在于挂了之后没人善后。这就是补偿机制要解决的事。 📌 前置知识:本文假设读者已经知道数据库事务 ACID 的基本概念、分布式系统中"网络不可靠"的前提。如果对分布式事务的 2PC / TCC / Saga 还没概念,建议先翻一下本站的《分布式事务基础》和《TCC + Saga》两篇。 什么是补偿机制 先给个直接的定义: 补偿(Compensation) 是一系列操作,用于撤销一个已经部分执行或完全执行的业务流程,使系统回到业务上可接受的一致状态。 注意两个关键词: 撤销——不是 “取消”,是"对已经产生的副作用进行逆操作"。扣掉的库存加回去,冻结的额度解冻,发的优惠券标记作废。 业务上可接受——补偿之后的状态不一定等于执行之前的状态。比如退款流水里多了一条退款记录,这不是脏数据,这是业务可审计的中间态,本来就是设计的一部分。 补偿 ≠ 回滚(Rollback)。回滚是数据库层的物理操作,依赖 undo log,对业务透明;补偿是业务层的逻辑操作,需要开发者显式编写逆操作代码。 > ⚠️ 新手提示:把补偿理解成 Ctrl+Z 不准确。Ctrl+Z 是"回到上一步",补偿是"把已经造成的后果消弭掉"——相当于打翻了水杯,Ctrl+Z 是水自动回到杯子里(物理回滚),补偿是拿抹布擦干净桌子然后重新倒一杯(业务补救)。 补偿思维从本地就开始了 很多人觉得补偿是"分布式事务"才碰的东西,其实本地代码里到处都是补偿的影子,只是你没把它当成一个专门的概念。 场景一:文件操作的"撤销三部曲" public void processFile(String srcPath, String destPath) { File backupFile = null; File tempFile = null; try { // 步骤1:创建备份 backupFile = new File(srcPath + ".bak"); Files.copy(Path.of(srcPath), backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING); // 步骤2:处理并写入临时文件 tempFile = new File(destPath + ".tmp"); try (var reader = new BufferedReader(new FileReader(srcPath)); var writer = new BufferedWriter(new FileWriter(tempFile))) { String line; while ((line = reader.readLine()) != null) { writer.write(transform(line)); writer.newLine(); } } // 步骤3:原子替换目标文件 Files.move(tempFile.toPath(), Path.of(destPath), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (Exception e) { // 补偿逻辑:清掉所有中间产物 if (tempFile != null && tempFile.exists()) { tempFile.delete(); // 撤销步骤2 } if (backupFile != null && backupFile.exists()) { try { Files.move(backupFile.toPath(), Path.of(srcPath), StandardCopyOption.REPLACE_EXISTING); // 撤销步骤1 } catch (IOException ex) { log.error("连备份恢复都失败了,手动处理吧...", ex); } } throw new ProcessingException("文件处理失败,已尽力回滚", e); } } 这段代码没什么高深的,但仔细看它的结构——每执行一步,catch 里就有对应的逆操作。这就是补偿机制的最朴素形态: ...
