Saga分布式事务:长事务的拆解与补偿设计
一、一笔"消失的退款"引发的架构反思
2020年双11,用户申请退款一个包含3件商品的订单------总金额586元。系统流程是这样的:
1. 退款审批通过
2. 退款到支付账户 成功(586元已退回)
3. 返还库存 成功(库存+3)
4. 回退优惠券 失败(优惠券已过期,接口报错)
5. 回退积分 未执行(流程中断)
用户收到了退款,但优惠券和积分没退回,客诉电话打爆了。
更严重的是------退款和返还库存已经执行了,优惠券回退失败后,没有回滚前面的操作。
这就是长事务的问题:在微服务架构中,一个业务操作横跨多个服务,每个服务独立数据库,传统的ACID事务无法跨服务生效。Saga模式正是为解决这类问题而生的。
二、Saga模式核心原理
2.1 什么是Saga
Saga是Hector Garcia-Molina和Kenneth Salem在1987年提出的长事务解决方案。核心理念:将一个跨多个服务的分布式事务拆解为一系列本地事务,每个本地事务有对应的补偿事务,用于回滚。
Saga事务 = T1 → T2 → T3 → ... → Tn
补偿链 = C1 ← C2 ← C3 ← ... ← Cn
其中:
Ti:正向事务(执行业务操作)
Ci:补偿事务(撤销Ti的效果)
与ACID事务的本质区别:
| 维度 | ACID事务 | Saga事务 |
|---|---|---|
| 范围 | 单数据库内 | 跨多个服务/数据库 |
| 隔离性 | 强隔离(Serializable等) | 无隔离保证(需业务处理) |
| 回滚方式 | 自动ROLLBACK | 手动执行补偿事务 |
| 锁持有时间 | 毫秒/秒级 | 分钟/小时级 |
| 一致性保证 | 强一致 | 最终一致 |
2.2 两种协调模式
编排式(Choreography)------去中心化:
服务A ──发布事件──→ Event Bus ──订阅──→ 服务B
│
└──订阅──→ 服务C
服务A失败 → 发布补偿事件 → 各服务自行补偿
优点 :松耦合,简单流程实现快
缺点:流程分散难追踪,循环依赖风险,修改流程需要改多个服务
编排式(Orchestration)------中心化:
┌─────────────────┐
│ Saga编排器 │
│ (Orchestrator) │
└────┬───┬───┬────┘
│ │ │
┌────▼─┐─▼───▼────┐
│ 服务A │ 服务B │ 服务C│
└───────┴───────┴──────┘
优点 :流程集中管理,账本清晰,灵活编排
缺点:编排器是单点,服务间仍存耦合
选型建议:对于复杂业务流程(如退款涉及5个以上服务),优先选用编排式。
三、实战案例:退款系统的Saga实现
3.1 业务场景
退款流程涉及4个微服务:
步骤1:退款审批服务 ── 审批通过,标记退款单状态
步骤2:支付网关服务 ── 调用第三方退款接口,退还资金
步骤3:库存服务 ── 归还已售库存
步骤4:营销服务 ── 退回优惠券和积分
任何一个步骤失败,都需要补偿前面已执行的步骤。
3.2 Saga编排器实现
java
// Saga事务状态机
public enum SagaStatus {
STARTED,
APPROVAL_COMPLETED, // 审批完成
REFUND_COMPLETED, // 退款完成
INVENTORY_RESTORED, // 库存已归还
COUPON_RESTORED, // 优惠券已归还
COMPLETED, // 全部完成
// 补偿状态
COMPENSATING_REFUND, // 正在补偿退款
COMPENSATING_INVENTORY, // 正在补偿库存
FAILED // 失败
}
// Saga编排器核心类
@Component
public class RefundSagaOrchestrator {
@Autowired
private RefundApprovalService approvalService;
@Autowired
private PaymentGatewayService paymentService;
@Autowired
private InventoryService inventoryService;
@Autowired
private MarketingService marketingService;
@Autowired
private SagaLogRepository sagaLogRepository;
/**
* 编排退款Saga事务
*/
public void executeRefundSaga(RefundRequest request) {
String sagaId = UUID.randomUUID().toString();
SagaLog sagaLog = SagaLog.create(sagaId, "REFUND", request.getRefundId());
sagaLogRepository.save(sagaLog);
try {
// T1: 退款审批
sagaLog.appendStep("APPROVAL", SagaStepStatus.RUNNING);
approvalService.approve(request);
sagaLog.appendStep("APPROVAL", SagaStepStatus.COMPLETED);
// T2: 执行退款(关键步骤)
sagaLog.appendStep("REFUND", SagaStepStatus.RUNNING);
paymentService.refund(request);
sagaLog.appendStep("REFUND", SagaStepStatus.COMPLETED);
// T3: 归还库存
sagaLog.appendStep("INVENTORY", SagaStepStatus.RUNNING);
inventoryService.restore(request);
sagaLog.appendStep("INVENTORY", SagaStepStatus.COMPLETED);
// T4: 回退营销资源
sagaLog.appendStep("MARKETING", SagaStepStatus.RUNNING);
marketingService.restoreCouponAndPoints(request);
sagaLog.appendStep("MARKETING", SagaStepStatus.COMPLETED);
// 全部完成
sagaLog.complete();
} catch (SagaStepException e) {
// 触发补偿流程
logger.error("Saga事务失败,开始补偿: sagaId={}, failedStep={}",
sagaId, e.getStepName());
sagaLog.setFailedStep(e.getStepName());
compensate(sagaLog, request);
}
sagaLogRepository.save(sagaLog);
}
/**
* 补偿逻辑:从失败步骤的前一步开始,逆序补偿
*/
private void compensate(SagaLog sagaLog, RefundRequest request) {
List<SagaStep> completedSteps = sagaLog.getCompletedSteps();
Collections.reverse(completedSteps); // 逆序
for (SagaStep step : completedSteps) {
try {
switch (step.getStepName()) {
case "MARKETING":
// 补偿:扣回已发放的优惠券和积分
marketingService.compensateRestore(request);
sagaLog.appendStep("MARKETING_COMPENSATE", SagaStepStatus.COMPLETED);
break;
case "INVENTORY":
// 补偿:重新扣减已归还的库存
inventoryService.compensateRestore(request);
sagaLog.appendStep("INVENTORY_COMPENSATE", SagaStepStatus.COMPLETED);
break;
case "REFUND":
// 补偿:调用支付网关冲正(最关键的补偿)
paymentService.compensateRefund(request);
sagaLog.appendStep("REFUND_COMPENSATE", SagaStepStatus.COMPLETED);
break;
case "APPROVAL":
// 补偿:回退审批状态
approvalService.compensateApprove(request);
sagaLog.appendStep("APPROVAL_COMPENSATE", SagaStepStatus.COMPLETED);
break;
}
} catch (Exception e) {
// 补偿也失败 → 进入人工处理
logger.error("补偿失败,需人工介入: sagaId={}, step={}",
sagaLog.getSagaId(), step.getStepName());
sagaLog.setStatus(SagaStatus.MANUAL_REQUIRED);
alertService.sendAlert("Saga补偿失败需人工处理", sagaLog);
return; // 停止补偿
}
}
sagaLog.setStatus(SagaStatus.COMPENSATED);
}
}
3.3 各服务的本地事务与补偿
java
// 支付网关服务:正向操作 + 补偿操作
@Service
public class PaymentGatewayService {
@Autowired
private PaymentRepository paymentRepo;
@Autowired
private AlipayClient alipayClient;
/**
* 正向操作:调用第三方退款接口
*/
@Transactional
public void refund(RefundRequest request) {
String transactionId = request.getRefundId();
// 幂等检查
if (paymentRepo.existsByTransactionId(transactionId, "REFUND")) {
logger.info("退款已处理: transactionId={}", transactionId);
return;
}
// 调用支付宝退款接口
AlipayTradeRefundRequest aliReq = new AlipayTradeRefundRequest();
aliReq.setOutTradeNo(request.getOrderId());
aliReq.setRefundAmount(request.getAmount().toString());
aliReq.setOutRequestNo(request.getRefundId()); // 幂等键
AlipayTradeRefundResponse resp = alipayClient.execute(aliReq);
if (!resp.isSuccess()) {
throw new SagaStepException("REFUND", "退款调用失败: " + resp.getSubMsg());
}
// 记录退款成功
PaymentRecord record = PaymentRecord.builder()
.transactionId(transactionId)
.orderId(request.getOrderId())
.amount(request.getAmount())
.type("REFUND")
.build();
paymentRepo.save(record);
}
/**
* 补偿操作:调用冲正接口(或人工处理)
*/
@Transactional
public void compensateRefund(RefundRequest request) {
String transactionId = "CP-" + request.getRefundId();
// 幂等检查
if (paymentRepo.existsByTransactionId(transactionId, "REFUND_COMPENSATE")) {
return;
}
// 重新发起收款(退款的对冲操作)
// 注意:不是所有支付渠道都支持冲正,此处需根据渠道评估
try {
AlipayTradeCreateRequest aliReq = new AlipayTradeCreateRequest();
aliReq.setOutTradeNo(request.getRefundId());
aliReq.setTotalAmount(request.getAmount().toString());
aliReq.setSubject("退款冲正");
AlipayTradeCreateResponse resp = alipayClient.execute(aliReq);
if (!resp.isSuccess()) {
throw new RuntimeException("冲正失败: " + resp.getSubMsg());
}
PaymentRecord record = PaymentRecord.builder()
.transactionId(transactionId)
.orderId(request.getOrderId())
.amount(request.getAmount())
.type("REFUND_COMPENSATE")
.build();
paymentRepo.save(record);
} catch (Exception e) {
throw new SagaStepException("REFUND_COMPENSATE", "冲正失败: " + e.getMessage());
}
}
}
// 库存服务:正向操作 + 补偿操作
@Service
public class InventoryService {
@Autowired
private InventoryRepository inventoryRepo;
@Autowired
private InventoryLogRepository logRepo;
/**
* 正向操作:归还库存
*/
@Transactional
public void restore(RefundRequest request) {
String logId = "INV-" + request.getRefundId();
// 幂等检查
if (logRepo.existsById(logId)) {
return;
}
// 归还每个SKU的库存
for (OrderItem item : request.getItems()) {
Inventory inv = inventoryRepo.findBySkuId(item.getSkuId());
inv.setAvailable(inv.getAvailable() + item.getQuantity());
inventoryRepo.save(inv);
}
// 记录日志
logRepo.save(InventoryLog.builder()
.logId(logId)
.refundId(request.getRefundId())
.type("RESTORE")
.build());
}
/**
* 补偿操作:重新扣减库存
*/
@Transactional
public void compensateRestore(RefundRequest request) {
String logId = "INV-CP-" + request.getRefundId();
if (logRepo.existsById(logId)) {
return;
}
for (OrderItem item : request.getItems()) {
Inventory inv = inventoryRepo.findBySkuId(item.getSkuId());
if (inv.getAvailable() < item.getQuantity()) {
// 库存不足(补偿期间有新订单消耗了库存)
throw new SagaStepException("INVENTORY_COMPENSATE",
"补偿扣减失败,SKU=" + item.getSkuId() + " 库存不足");
}
inv.setAvailable(inv.getAvailable() - item.getQuantity());
inventoryRepo.save(inv);
}
logRepo.save(InventoryLog.builder()
.logId(logId)
.refundId(request.getRefundId())
.type("COMPENSATE")
.build());
}
}
3.4 Saga日志(事务账本)设计
sql
-- Saga事务日志主表
CREATE TABLE saga_log (
saga_id VARCHAR(64) PRIMARY KEY,
saga_type VARCHAR(50) NOT NULL, -- 事务类型:REFUND, CREATE_ORDER
business_id VARCHAR(64) NOT NULL, -- 业务ID
status VARCHAR(30) NOT NULL, -- STARTED, COMPLETED, COMPENSATING, FAILED
failed_step VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
INDEX idx_business (business_id),
INDEX idx_status (status)
);
-- Saga步骤明细表
CREATE TABLE saga_step (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
saga_id VARCHAR(64) NOT NULL,
step_name VARCHAR(50) NOT NULL, -- APPROVAL, REFUND, INVENTORY, MARKETING
step_type VARCHAR(20) NOT NULL, -- FORWARD, COMPENSATE
status VARCHAR(20) NOT NULL, -- RUNNING, COMPLETED, FAILED
request_data TEXT, -- 请求快照
response_data TEXT, -- 响应数据
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_saga (saga_id)
);
java
// Saga日志实体
@Data
public class SagaLog {
private String sagaId;
private String sagaType;
private String businessId;
private SagaStatus status;
private String failedStep;
private List<SagaStep> steps = new ArrayList<>();
public static SagaLog create(String sagaId, String sagaType, String businessId) {
SagaLog log = new SagaLog();
log.sagaId = sagaId;
log.sagaType = sagaType;
log.businessId = businessId;
log.status = SagaStatus.STARTED;
return log;
}
public void appendStep(String stepName, SagaStepStatus stepStatus) {
steps.add(SagaStep.builder()
.sagaId(this.sagaId)
.stepName(stepName)
.stepStatus(stepStatus)
.timestamp(System.currentTimeMillis())
.build());
}
public List<SagaStep> getCompletedSteps() {
return steps.stream()
.filter(s -> s.getStepStatus() == SagaStepStatus.COMPLETED
&& !s.getStepName().contains("COMPENSATE"))
.collect(Collectors.toList());
}
}
四、Saga的关键工程挑战
4.1 问题1:补偿也失败怎么办?
场景:退款已执行,补偿库存时发现库存不足(补偿期间新订单消耗了库存)。
解决方案:
方案A:重试 + 延时
├── 补偿失败后,间隔递增重试(1s, 5s, 30s, 5min)
├── 超过最大重试次数后标记人工处理
└── 发送告警通知运维
方案B:预占 + 超时释放
├── 正向操作时预占资源而非实际扣减
├── 补偿时释放预占即可,不会失败
└── 资源预占有超时机制,防止长时间占用
方案C:降级补偿
├── 无法完美补偿时,记录差异
├── 后续通过对账系统修复
└── 例如:库存不足 → 记录待补货,后续采购后自动处理
java
// 带重试的补偿执行器
@Component
public class RetryCompensationExecutor {
private static final int[] RETRY_DELAYS = {1000, 5000, 30000, 300000}; // 毫秒
@Autowired
private AlertService alertService;
public void executeWithRetry(Runnable compensation, String sagaId, String stepName) {
for (int i = 0; i < RETRY_DELAYS.length; i++) {
try {
compensation.run();
logger.info("补偿成功: sagaId={}, step={}, retry={}", sagaId, stepName, i);
return;
} catch (Exception e) {
logger.warn("补偿失败,准备重试: sagaId={}, step={}, retry={}",
sagaId, stepName, i);
if (i < RETRY_DELAYS.length - 1) {
try {
Thread.sleep(RETRY_DELAYS[i]);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
}
// 所有重试都失败 → 人工介入
logger.error("补偿最终失败,需人工介入: sagaId={}, step={}", sagaId, stepName);
alertService.sendCriticalAlert(
String.format("Saga补偿失败需人工介入: sagaId=%s, step=%s", sagaId, stepName)
);
}
}
4.2 问题2:空补偿(T未执行,C被调用)
场景:Try操作超时(实际未执行),但Saga编排器超时判定失败,触发Cancel。Cancel调用了一个不存在的Try结果。
java
// 补偿操作必须处理空补偿
@Transactional
public void compensateRefund(RefundRequest request) {
String transactionId = request.getRefundId();
// 检查正向操作是否已执行
PaymentRecord forwardRecord = paymentRepo.findByTransactionId(transactionId);
if (forwardRecord == null) {
// 空补偿:正向操作未执行,记录空补偿日志,直接返回成功
logger.info("空补偿: 正向操作未执行, transactionId={}", transactionId);
paymentRepo.save(PaymentRecord.builder()
.transactionId("NULL-" + transactionId)
.type("NULL_COMPENSATION")
.remark("空补偿:正向操作未执行")
.build());
return; // 返回成功,不抛异常
}
// 正常补偿逻辑...
}
4.3 问题3:悬挂(C比T先到)
场景:Try操作网络延迟严重,Cancel先执行完毕,后来Try才到达。
java
@Transactional
public void refund(RefundRequest request) {
String transactionId = request.getRefundId();
// 检查是否已执行过补偿
PaymentRecord compensateRecord = paymentRepo.findByTransactionId("CP-" + transactionId);
if (compensateRecord != null) {
// 悬挂:补偿已执行,拒绝正向操作
logger.warn("悬挂检测: 补偿已执行,拒绝正向操作, transactionId={}", transactionId);
throw new IllegalStateException("补偿已执行,正向操作被拒绝");
}
// 空补偿检查
PaymentRecord nullRecord = paymentRepo.findByTransactionId("NULL-" + transactionId);
if (nullRecord != null) {
logger.warn("空补偿已记录,拒绝正向操作, transactionId={}", transactionId);
throw new IllegalStateException("空补偿已记录,正向操作被拒绝");
}
// 正常正向逻辑...
}
4.4 问题4:部分失败状态的恢复
java
// 定时任务:扫描未完成的Saga,尝试恢复
@Scheduled(fixedDelay = 60000) // 每分钟
public void recoverStuckSagas() {
// 查询超过10分钟仍未完成的Saga
List<SagaLog> stuckSagas = sagaLogRepository.findStuckSagas(10);
for (SagaLog sagaLog : stuckSagas) {
logger.warn("发现滞留Saga: sagaId={}", sagaLog.getSagaId());
if (sagaLog.getFailedStep() != null) {
// 有失败的步骤,重新触发补偿
RefundRequest request = sagaLogRepository.loadRequest(sagaLog.getSagaId());
compensate(sagaLog, request);
} else {
// 状态待定,可能网络超时,检查各步骤实际执行状态
checkAndRecover(sagaLog);
}
}
}
五、Saga的隔离性策略
Saga不提供ACID的I(隔离性),可能产生脏读、不可重复读、幻读等并发问题。
5.1 语义锁(Semantic Lock)
java
// 在业务层面加语义锁,防止并发修改
@Transactional
public void restore(RefundRequest request) {
// SELECT ... FOR UPDATE 行级锁
Inventory inv = inventoryRepo.findBySkuIdForUpdate(item.getSkuId());
// 此时其他Saga不能修改此库存
inv.setAvailable(inv.getAvailable() + item.getQuantity());
inventoryRepo.save(inv);
}
5.2 交换式更新(Commutative Update)
java
// 将操作设计为可交换的(顺序无关)
// 错误:SET available = 100(覆盖)
// 正确:SET available = available + 1(原子加法)
@Transactional
public void restore(RefundRequest request) {
// 原子操作,多个Saga并发执行不影响结果
inventoryRepo.atomicAdd(item.getSkuId(), item.getQuantity());
}
5.3 重读值(Reread Value)
java
// 在补偿或更新时,重新读取当前值,基于最新状态决策
@Transactional
public void compensateRestore(RefundRequest request) {
Inventory inv = inventoryRepo.findBySkuId(item.getSkuId());
// 重新检查:补偿时库存可能已变化
if (inv.getAvailable() < item.getQuantity()) {
// 降级处理,记录差异
shortageLogRepository.save(ShortageLog.builder()
.skuId(item.getSkuId())
.shortQuantity(item.getQuantity())
.refundId(request.getRefundId())
.status("PENDING")
.build());
return;
}
inv.setAvailable(inv.getAvailable() - item.getQuantity());
inventoryRepo.save(inv);
}
六、总结
Saga模式的核心是一句话:把长事务拆成小事务,每个小事务带个"后悔药"(补偿)。
Saga vs TCC vs 事务消息 选型对比:
| 方案 | 复杂度 | 一致性 | 隔离性 | 适用场景 |
|---|---|---|---|---|
| Saga编排式 | 中 | 最终一致 | 弱 | 复杂业务流程,服务较多 |
| Saga编排式 | 低 | 最终一致 | 弱 | 简单流程,2-3个服务参与 |
| TCC | 高 | 准实时 | 中 | 支付、资金等强一致性要求 |
| 事务消息 | 中 | 最终一致 | 弱 | 异步解耦、削峰填谷 |
落地检查清单:
- 每个正向操作必须有对应的补偿操作
- 补偿操作必须支持空补偿(正向未执行时也能返回成功)
- 补偿操作必须支持防悬挂(补偿已执行时拒绝正向操作)
- 所有操作必须幂等(通过唯一事务ID保证)
- Saga日志必须完整记录每一步的状态(正向和补偿)
- 补偿失败时要有完善的重试和告警机制
- 定时扫描滞留Saga,自动恢复或告警
务实的建议:
Saga模式的复杂度不低,但它解决的是跨服务事务这个"无解问题"中的最佳折中方案。不要试图在所有场景都用Saga------对于2-3个服务参与的简单流程,事务消息可能足够。对于强一致性要求(如资金扣减),考虑TCC。只有复杂的、多步骤的长业务流程,Saga才是最合适的选择。
架构的价值在于用可控的复杂度换可靠的一致性,而不是追求技术上的完美。
作者:架构实战团队
日期:2026-07-02
标签:#Saga #分布式事务 #长事务 #补偿 #最终一致性 #微服务