架构模式:Saga、Outbox 与 Strangler Fig
这一课你将能做到
- 用 Saga 模式实现"提交-判题-更新练习记录-发通知"的分布式事务
- 用 Outbox 模式保证事件消息"发了就存,存了必达"
- 用 Strangler Fig 模式把老单体系统逐步拆成微服务
为什么要学
架构风格告诉你"怎么搭骨架",架构模式告诉你"遇到具体问题怎么解决"。判题平台里,一次提交要动数据库、调判题引擎、写练习记录、发 WebSocket,这些步骤跨了多个服务,怎么保证一致性?Outbox 解决"消息发了但数据库没存"的问题,Strangler Fig 解决"老系统不敢拆"的问题。
先建立图像
Saga Outbox Strangler Fig
────── ────── ─────────────
1. 提交入库 业务操作 + Outbox 事件 老单体
2. 发 JudgeEvent → 同事务写入 ↓ 新功能走新服务
3. 判题完成 ← 消费 Outbox 轮询投递 MQ 老功能逐步迁移
4. 更新记录 ← 消费 ↓ ↓
5. 通知用户 ← 消费 事件最终一致 新服务完全接管
跟着做
步骤 1:Saga 编排——判题分布式事务
Saga 把大事务拆成小事务,每步失败都有补偿:
public class SubmissionSaga {
private final SubmissionRepository submissionRepo;
private final JudgeClient judgeClient;
private final PracticeRecordService practiceService;
private final NotificationService notifyService;
public void execute(SubmitCommand cmd) {
// 步骤 1:保存提交
Submission submission = submissionRepo.save(Submission.create(cmd));
try {
// 步骤 2:发起判题
JudgeResult result = judgeClient.judge(submission);
submission.markAs(result);
// 步骤 3:更新练习记录
practiceService.record(cmd.userId(), cmd.lessonId(), result);
// 步骤 4:通知用户
notifyService.notifyJudged(cmd.userId(), result);
} catch (Exception e) {
// 补偿:标记提交为失败,不发通知
submission.markAs(SubmissionStatus.FAILED);
submissionRepo.save(submission);
throw new SubmissionSagaException("判题流程失败", e);
}
}
}
每个步骤是本地事务,失败时执行补偿操作(回滚已完成的步骤)。
步骤 2:Outbox 模式——事件可靠投递
判题完成事件要发 MQ,但"先存数据库"和"后发 MQ"之间可能宕机。Outbox 把事件和业务数据放在同一个本地事务里:
@Entity
public class OutboxEvent {
@Id private Long id;
private String aggregateType; // "Submission"
private Long aggregateId; // submissionId
private String eventType; // "JudgeFinished"
private String payload; // JSON 事件体
private LocalDateTime createdAt;
private Boolean published; // 是否已投递 MQ
}
@Service
public class JudgeService {
private final SubmissionRepository submissionRepo;
private final OutboxEventRepository outboxRepo;
@Transactional
public void finishJudge(SubmissionId id, JudgeResult result) {
Submission s = submissionRepo.findById(id).orElseThrow();
s.markAs(result);
submissionRepo.save(s);
// 同事务写入 Outbox,保证原子性
OutboxEvent event = new OutboxEvent("Submission", id.value(), "JudgeFinished", toJson(result));
outboxRepo.save(event);
}
}
@Component
public class OutboxPublisher {
@Scheduled(fixedDelay = 1000)
public void publishPendingEvents() {
List<OutboxEvent> pending = outboxRepo.findByPublishedFalse();
for (OutboxEvent e : pending) {
mqTemplate.send(e.getEventType(), e.getPayload());
e.markPublished();
outboxRepo.save(e);
}
}
}
数据库提交成功 = Outbox 事件一定存在,轮询任务保证事件最终发到 MQ。
步骤 3:Strangler Fig——逐步替换老系统
判题平台老版本是单体,新版本要拆成微服务。Strangler Fig(绞杀者无花果)模式让新老共存,逐步替换:
// 老单体 Controller(保留)
@RestController
@RequestMapping("/api/v1/submissions")
public class LegacySubmissionController {
// 老逻辑,逐步废弃
}
// 新服务 Controller
@RestController
@RequestMapping("/api/v2/submissions")
public class SubmissionController {
private final SubmitUseCase submitUseCase;
@PostMapping
public ResponseEntity<SubmissionId> submit(@RequestBody SubmitRequest req) {
return ResponseEntity.ok(submitUseCase.handle(req.toCommand()));
}
}
// API 网关路由策略
@Component
public class SubmissionRouter {
public String route(HttpServletRequest request) {
String version = request.getHeader("X-API-Version");
if ("v2".equals(version)) return "http://new-service/api/v2/submissions";
return "http://legacy-monolith/api/v1/submissions"; // 默认走老系统
}
}
新功能走新服务,老功能逐步迁移,直到老系统完全下线。
讲清楚要点
- Saga:跨服务的本地事务链 + 补偿机制,适合长流程(提交→判题→通知)。
- Outbox:事件和业务数据同事务写入,轮询投递,保证消息不丢。
- Strangler Fig:新老系统共存,通过路由逐步替换,降低重构风险。
- Saga 的补偿不是回滚:补偿是"业务意义上的撤销",不是数据库回滚。比如"已通知"的补偿是"发撤销通知",不是删除通知记录。
- Outbox 的轮询可以优化:用 CDC(Change Data Capture)如 Debezium 监听 binlog,代替定时轮询。
你可能会懵的地方
- Saga 和本地事务什么区别? 本地事务是 ACID,Saga 是最终一致性。Saga 每一步是本地事务,但整体没有全局锁,靠补偿保证最终一致。
- Outbox 表会不会越来越大?
会。需要定期清理已投递的事件,或者把历史事件归档到冷库。表结构要加索引(
published + created_at)。 - Strangler Fig 会不会导致双写问题? 会。迁移期间要保证新老系统数据一致,通常用 CDC 或双写 + 对账。判题平台里,提交记录先双写,稳定后切读,最后下线老系统。
- Saga 的补偿失败怎么办? 补偿也要重试,重试失败要告警人工介入。补偿逻辑要幂等,防止重复执行。
- 初学者什么时候该用这些模式? 单体项目不需要 Saga;微服务跨库才需要。Outbox 在"发消息"场景通用。Strangler Fig 只在"拆老系统"时用。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 跨服务直接调 RPC 当本地事务用 | 网络分区导致不一致 | Saga:本地事务 + 补偿 |
| 先发 MQ 再存数据库 | MQ 成功但 DB 失败,事件丢失 | Outbox:同事务写入,轮询投递 |
| 一次性重写老系统 | 风险高,周期长 | Strangler Fig:逐步替换,先共存 |
| Saga 补偿直接删数据 | 破坏审计和追溯 | 补偿是业务撤销,不是物理删除 |
| Outbox 用定时任务但无索引 | 扫表慢,拖垮数据库 | 加 (published, created_at) 复合索引 |
卡点急救
- Saga 步骤太多,补偿复杂:把 Saga 步骤画成状态机图,每个状态对应一个补偿动作;用框架(如 Temporal、Camunda)管理状态流转。
- Outbox 轮询延迟高:改用 CDC 工具(Debezium + Kafka Connect)监听 binlog,实时投递。
- Strangler Fig 路由规则复杂:用 API 网关(如 Spring Cloud Gateway)统一路由,按 header、路径、权重逐步切流。
面试常问
- Saga 和 2PC 什么区别? 答:2PC 是强一致,但协调者单点、阻塞资源、性能差;Saga 是最终一致,每步本地事务,失败补偿。判题平台用 Saga,因为判题耗时长,不能锁住数据库等资源。
- Outbox 模式怎么保证消息不丢? 答:事件和业务数据同事务写入 Outbox 表,数据库提交成功 = 事件一定存在;后台任务轮询未投递事件,发到 MQ 后标记已投递。即使服务宕机,重启后继续投递。
- Strangler Fig 怎么保证数据一致性? 答:迁移期双写新老系统,用 CDC 或定时对账保证一致;切读时先小流量验证,再全量切换;下线前确保老系统无写入。
自测三问
- 判题平台"提交→判题→更新记录→通知"四步,用 Saga 怎么设计补偿?
- Outbox 模式为什么不用"先发 MQ 再存数据库"?
- Strangler Fig 迁移期间,如果新老系统数据不一致,怎么发现和处理?
小结一句话
Saga 让跨服务流程最终一致,Outbox 让事件消息可靠投递,Strangler Fig 让老系统安全退休。
延伸
- 《微服务架构设计模式》Chris Richardson,第 4 章(Saga)、第 3 章(Outbox)
- Strangler Fig - Martin Fowler
- Debezium 官方文档
- 下一课:领域驱动设计——实体、值对象、聚合与限界上下文。