事件驱动与一致性:Outbox、幂等与重试
约 1578 字大约 5 分钟
布欧-Lewyon
2026-05-16
首页 › Spring Boot › 架构师进阶 › 事件驱动与一致性:Outbox、幂等与重试
本篇服务 L4 架构师。假设已了解
@Transactional(声明式事务)与消息队列基础(RabbitMQ 与 Kafka 集成(在新窗口打开))。
在微服务或跨模块场景下,本地事务无法覆盖远程操作——更新数据库后发消息可能消息没发出去,发了消息但消费失败后回滚数据库也已来不及。本节探讨 Spring Boot 生态中处理这种"分布式事务"问题的常用模式。
本地事务 + 消息的困境
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // ① 写入 DB
messageQueue.send("order.created", order); // ② 发消息——如果失败,① 回滚
}这个代码的问题:
- 写入数据库成功,发消息失败:事务回滚,消息丢失
- 消息发送成功,事务回滚:消息发出去了但业务没提交——下游收到假事件
- 消息中间件挂了:无法下单
解决方案是让消息发送与数据库操作处于同一个本地事务的保证之下——即 Outbox 模式。
Outbox(发件箱)模式
原理:不直接发送消息,而是将消息写入与业务数据同库的 outbox 表中(同一个数据库事务);再由一个独立的定时任务或 CDC(Change Data Capture)机制将 outbox 表中的记录投递到消息队列。
// 步骤 1:业务操作 + outbox 记录在同一个事务中
@Transactional
public void createOrder(Order order) {
// 业务数据
orderRepository.save(order);
// Outbox 记录
OutboxMessage message = OutboxMessage.create(
"order.created",
objectMapper.writeValueAsString(order)
);
outboxRepository.save(message);
}-- outbox 表结构
CREATE TABLE outbox_messages (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL, -- 聚合类型(order / payment)
aggregate_id VARCHAR(50), -- 聚合 ID
event_type VARCHAR(100) NOT NULL, -- 事件类型(order.created)
payload JSONB NOT NULL, -- 事件体
created_at TIMESTAMP NOT NULL,
processed_at TIMESTAMP, -- 投递成功时间
retry_count INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'PENDING' -- PENDING / SENT / FAILED
);// 步骤 2:定时任务投递(也可由 Debezium 等 CDC 工具完成)
@Component
public class OutboxRelay {
private final OutboxRepository outboxRepository;
private final RabbitTemplate rabbitTemplate;
@Scheduled(fixedDelay = 2000) // 每 2 秒轮询
@Transactional
public void relayOutbox() {
List<OutboxMessage> pending = outboxRepository
.findPendingMessages(PageRequest.of(0, 100));
for (OutboxMessage msg : pending) {
try {
rabbitTemplate.convertAndSend(msg.getEventType(), msg.getPayload());
msg.markSent(); // 标记已投递
} catch (Exception e) {
msg.incrementRetry();
log.warn("Outbox 投递失败: {}", msg.getId(), e);
}
}
}
}架构视角:Outbox vs 两阶段提交
| 方案 | 一致性保证 | 吞吐影响 | 复杂度 |
|---|---|---|---|
| JTA/XA 两阶段提交 | 强一致性 | 高(锁时延长) | 高 |
| Outbox + 定时任务 | 最终一致性(一般秒级) | 低 | 中 |
| Outbox + CDC(Debezium) | 最终一致性(延迟更低) | 极低(读 binlog) | 高 |
| 事务消息(RocketMQ) | 最终一致性 | 低 | 中(依赖特定 MQ) |
L4 关注:事务消息(RocketMQ)和 Outbox + CDC 是业界主流方案。Spring Boot 生态推荐 Outbox——不绑定特定 MQ,且实现清晰可审计。关键点:轮询间隔决定一致性的延迟——2 秒的延迟比大多数业务场景可容忍的范围短。
幂等性
Outbox 保证了至少一次投递(At-Least-Once),消费方需要处理重复消息——这就是幂等。
去重表方案
@Transactional
public void handleOrderCreated(OrderCreatedEvent event) {
// 幂等检查——利用数据库唯一约束
boolean exists = processedEventRepository.existsByIdempotentKey(event.getIdempotentKey());
if (exists) {
log.info("重复事件,跳过: {}", event.getIdempotentKey());
return;
}
// 业务处理
orderService.processCreated(event.getOrderId());
// 记录已处理
processedEventRepository.save(new ProcessedEvent(event.getIdempotentKey()));
}CREATE TABLE processed_events (
idempotent_key VARCHAR(100) PRIMARY KEY, -- 业务唯一键
processed_at TIMESTAMP NOT NULL
);业务主键方案
如果业务本身有自然唯一键(如订单号、支付流水号),可以用 INSERT ... ON CONFLICT DO NOTHING(PostgreSQL)或 INSERT IGNORE(MySQL)做幂等——省去去重表。
@Transactional
public void handlePaymentCallback(String paymentNo, BigDecimal amount) {
// payment_no 有唯一约束
paymentRepository.insertWithUniqueCheck(paymentNo, amount);
// 如果已存在,insert 静默跳过
}架构视角:幂等策略选择
| 方案 | 实现成本 | 适用场景 |
|---|---|---|
| 去重表 | 低,一张表 + 单条查询 | 通用,几乎所有事件 |
| 业务唯一键 | 零额外存储 | 业务本身有唯一流水号 |
| Redis SETNX | 低(无数据库写压力) | 短暂的去重窗口(TTL > 消息重试时间窗) |
| Token(前端生成) | 前端需配合 | 写接口防重复提交 |
L4 关注:幂等和重试是配套的——下游不做幂等,上游就不该重试。发消息的场景中,需要在消息体中携带
idempotentKey(通常是 UUID),让消费方可以用它去重。消息队列配置的消息去重(如 Kafka 的enable.idempotence)只保证生产端不重复发送,不保证消费端不重复处理。
Saga:Long-Running 事务模式
当跨多个服务的一致性需求不能用 Outbox 解决(例如"下单+扣库存+扣余额"跨三个系统),考虑 Saga 模式:
Saga 编排器
├── step 1: 订单服务 → "创建订单(待支付)"
├── step 2: 库存服务 → "锁定库存"
├── step 3: 支付服务 → "扣款"
│
└── 任一失败 → 执行补偿
├── step 2 补偿: 释放库存
└── step 1 补偿: 取消订单Saga 不在 Spring Boot 框架内置,需要自行实现或使用 Axon Framework / Eventuate Tram 等。
架构视角:Saga 的难点不是实现框架——而是补偿操作的幂等与完备性。如果"释放库存"补偿也失败怎么办?补偿本身也需要重试和幂等。实际项目中,建议从"仅 Outbox"开始,只有当补偿操作也能被幂等治理时才引入 Saga。
小结
- Outbox 模式:业务数据与消息在同一本地事务中写入
outbox表,定时任务或 CDC 负责投递——实现最终一致性。 - 幂等是消费端防重的基石——去重表、业务主键、Redis SETNX 是三种常见实现。
- Saga 是跨服务的长事务模式,核心是编排 + 补偿,但补偿的完备性是最大挑战。
- 架构视角:不要在单体应用过早引入分布式事务方案。先问自己:① 消息丢失真的不可接受吗?② 消费方不幂等吗?③ 是否可以接受秒级延迟?——大多数场景 Outbox + 幂等已经足够。
- 思考任务:为项目中一个关键的异步链路(如"订单创建 → 发送通知")实现 Outbox 模式:建表、修改 Service 层、编写
@Scheduled投递器、消费方做幂等校验。
上一节:性能与容量
下一节:平台与工程治理
