Java 微服务分布式事务实践:Outbox、幂等与 Saga
微服务拆分以后,事务边界也会被拆开。单体应用中,一个 @Transactional 就能覆盖订单、库存、优惠券等多张表;微服务架构中,这些数据可能分布在不同服务、不同数据库里,本地事务不再天然覆盖整个业务流程。
这篇文章围绕 Java 后端常见的下单场景,梳理分布式事务的工程实践。重点讨论三个问题:
-
为什么不要轻易追求强一致分布式事务?
-
如何用 Outbox 模式保证本地事务和消息发送一致?
-
如何用幂等和 Saga 处理跨服务业务流程?
一、先明确事务边界
假设一个电商下单流程包含:
-
订单服务创建订单。
-
库存服务扣减库存。
-
优惠券服务核销优惠券。
-
支付服务创建支付单。
在单体应用里,可能会写成这样:
@Transactional
public Long createOrder(CreateOrderCommand command) {
orderRepository.save(order);
stockRepository.deduct(command.getSkuId(), command.getQuantity());
couponRepository.use(command.getCouponId());
paymentRepository.createPayment(order.getId(), order.getAmount());
return order.getId();
}
拆成微服务后,订单、库存、优惠券、支付分别拥有自己的数据库。订单服务的 @Transactional 只能保证订单库本地事务,不可能直接覆盖库存库和支付库。
这时如果继续用同步远程调用拼出一个大事务,很容易遇到这些问题:
-
订单创建成功,但库存扣减失败。
-
库存扣减成功,但订单服务返回超时。
-
支付单创建成功,但消息发送失败。
-
重试导致库存重复扣减。
-
某个下游慢接口拖住整个下单链路。
分布式事务的第一步不是选择框架,而是重新设计业务一致性模型。
二、强一致与最终一致
事务一致性大致可以分为两类:
| 类型 | 特点 | 适用场景 |
|---|---|---|
| 强一致 | 所有参与方同时成功或同时失败 | 金融记账、资金划拨等极高一致性场景 |
| 最终一致 | 短时间内允许中间状态,最终通过补偿和重试达成一致 | 订单、库存、通知、积分、优惠券等业务流程 |
很多互联网业务并不要求每一步在同一毫秒内同时完成,而是要求状态可追踪、失败可恢复、重复执行不出错。
例如下单后可以先创建 待确认 订单,再异步完成库存锁定、优惠券核销和支付单创建。如果中间某一步失败,订单状态变为 创建失败 或触发补偿。
这种思路比把所有服务绑在一个强一致事务里更适合高并发微服务系统。
三、不要让远程调用参与本地事务太久
很多初期微服务代码会这样写:
@Transactional
public Long createOrder(CreateOrderCommand command) {
Order order = Order.create(command);
orderRepository.save(order);
stockClient.deduct(command.getSkuId(), command.getQuantity());
couponClient.use(command.getCouponId());
order.markConfirmed();
return order.getId();
}
这段代码的问题是,本地数据库事务在等待远程服务返回。只要库存服务或优惠券服务变慢,订单库连接就会长时间被占用。高并发下,连接池很快被打满。
更稳妥的方式是缩短本地事务,只在事务内完成本服务的数据变更和事件记录:
@Transactional
public Long createOrder(CreateOrderCommand command) {
Order order = Order.createPending(command.getUserId(), command.getSkuId(), command.getQuantity());
orderRepository.save(order);
outboxRepository.save(OutboxEvent.create(
"ORDER_CREATED",
order.getId().toString(),
JsonUtils.toJson(new OrderCreatedEvent(order.getId(), order.getSkuId(), order.getQuantity()))
));
return order.getId();
}
远程调用从本地事务中移出,由消息消费者或 Saga 流程继续推进。
四、Outbox 模式:解决本地事务和消息发送的一致性
一个常见问题是:订单保存成功后,发送消息失败怎么办?
如果代码写成这样:
@Transactional
public Long createOrder(CreateOrderCommand command) {
Order order = Order.create(command);
orderRepository.save(order);
messageProducer.send("order.created", new OrderCreatedEvent(order.getId()));
return order.getId();
}
即使订单事务提交成功,消息发送也可能因为网络问题失败。最终结果是订单存在,但下游服务不知道订单已经创建。
Outbox 模式的核心思想是:在同一个本地事务中同时写业务表和消息表。事务提交后,再由后台任务扫描消息表并投递到 MQ。
表结构示例:
CREATE TABLE outbox_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL, aggregate_type VARCHAR(64) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, payload TEXT NOT NULL, status VARCHAR(32) NOT NULL, retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_event_id (event_id), KEY idx_status_next_retry_time (status, next_retry_time) );
创建订单时写入 Outbox:
@Transactional
public Long createOrder(CreateOrderCommand command) {
Order order = Order.createPending(command.getUserId(), command.getSkuId(), command.getQuantity());
orderRepository.save(order);
OrderCreatedEvent event = new OrderCreatedEvent(
order.getId(),
order.getUserId(),
order.getSkuId(),
order.getQuantity()
);
OutboxEvent outboxEvent = OutboxEvent.newEvent(
"ORDER",
order.getId().toString(),
"ORDER_CREATED",
JsonUtils.toJson(event)
);
outboxEventRepository.save(outboxEvent);
return order.getId();
}
后台投递任务:
@Component
public class OutboxEventPublisher {
private final OutboxEventRepository outboxEventRepository;
private final MessageProducer messageProducer;
public OutboxEventPublisher(
OutboxEventRepository outboxEventRepository,
MessageProducer messageProducer) {
this.outboxEventRepository = outboxEventRepository;
this.messageProducer = messageProducer;
}
@Scheduled(fixedDelay = 1000)
public void publish() {
List<OutboxEvent> events = outboxEventRepository.findPendingEvents(100);
for (OutboxEvent event : events) {
try {
messageProducer.send(event.getEventType(), event.getEventId(), event.getPayload());
event.markPublished();
} catch (Exception ex) {
event.markFailedAndScheduleRetry();
}
outboxEventRepository.save(event);
}
}
}
这种方式保证:只要订单事务提交成功,待发送消息一定存在于数据库里。即使 MQ 短暂不可用,后台任务也可以持续重试。
五、Outbox 投递要考虑并发抢占
如果订单服务部署多个实例,每个实例都有定时任务扫描 Outbox 表,就可能出现同一条消息被多个实例同时投递。
常见解决方式有三种:
| 方案 | 思路 |
|---|---|
| 悲观锁 | 查询时使用 SELECT ... FOR UPDATE SKIP LOCKED |
| 状态抢占 | 先把 PENDING 更新为 PROCESSING,更新成功者获得处理权 |
| 分布式任务 | 使用调度平台分片处理 |
状态抢占示例:
UPDATE outbox_event SET status = 'PROCESSING', updated_at = NOW() WHERE id = ? AND status = 'PENDING';
Java 代码只处理更新成功的记录:
public void publishOne(Long eventId) {
boolean locked = outboxEventRepository.markProcessing(eventId);
if (!locked) {
return;
}
OutboxEvent event = outboxEventRepository.findById(eventId)
.orElseThrow(() -> new IllegalStateException("event not found"));
try {
messageProducer.send(event.getEventType(), event.getEventId(), event.getPayload());
outboxEventRepository.markPublished(event.getId());
} catch (Exception ex) {
outboxEventRepository.markPendingForRetry(event.getId());
}
}
即使做了抢占,也不能假设消息只会被消费一次。网络重试、MQ 重投、消费者异常都可能导致重复消息。因此消费者必须幂等。
六、幂等:允许重复,但结果不变
幂等是微服务分布式事务中非常重要的基础能力。它的目标是:同一请求或同一消息执行多次,最终结果和执行一次一致。
库存扣减消费者示例:
@Component
public class StockDeductConsumer {
private final ProcessedMessageRepository processedMessageRepository;
private final StockRepository stockRepository;
public StockDeductConsumer(
ProcessedMessageRepository processedMessageRepository,
StockRepository stockRepository) {
this.processedMessageRepository = processedMessageRepository;
this.stockRepository = stockRepository;
}
@Transactional
public void onMessage(OrderCreatedEvent event, String messageId) {
if (processedMessageRepository.existsByMessageId(messageId)) {
return;
}
Stock stock = stockRepository.findBySkuIdForUpdate(event.getSkuId())
.orElseThrow(() -> new BusinessException("库存不存在"));
stock.deduct(event.getQuantity());
stockRepository.save(stock);
processedMessageRepository.save(new ProcessedMessage(messageId));
}
}
幂等表结构:
CREATE TABLE processed_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, message_id VARCHAR(64) NOT NULL, consumer_group VARCHAR(64) NOT NULL, processed_at DATETIME NOT NULL, UNIQUE KEY uk_message_consumer (message_id, consumer_group) );
更严格的写法是先插入幂等记录,依赖唯一索引防重:
@Transactional
public void onMessage(OrderCreatedEvent event, String messageId) {
boolean firstConsume = processedMessageRepository.tryInsert(messageId, "stock-service");
if (!firstConsume) {
return;
}
stockRepository.deduct(event.getSkuId(), event.getQuantity());
}
业务幂等也可以通过业务唯一键实现,例如:
-
订单号唯一。
-
支付流水号唯一。
-
优惠券核销记录唯一。
-
库存冻结记录按
orderId + skuId唯一。
七、Saga:把大事务拆成一组可补偿步骤
Saga 适合处理一个业务流程跨多个服务,每个步骤都有自己的本地事务,并且失败时可以执行补偿动作。
以下单流程为例:
| 步骤 | 正向操作 | 补偿操作 |
|---|---|---|
| 1 | 创建订单 | 取消订单 |
| 2 | 锁定库存 | 释放库存 |
| 3 | 核销优惠券 | 退回优惠券 |
| 4 | 创建支付单 | 关闭支付单 |
Saga 有两种常见实现方式:
| 模式 | 特点 |
|---|---|
| 编排式 Saga | 有一个协调器控制流程,状态清晰,适合复杂业务 |
| 协同式 Saga | 服务之间通过事件驱动推进,耦合低,但链路理解成本高 |
编排式 Saga 示例:
@Service
public class OrderSagaService {
private final StockClient stockClient;
private final CouponClient couponClient;
private final PaymentClient paymentClient;
private final OrderRepository orderRepository;
public void confirmOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new BusinessException("订单不存在"));
try {
stockClient.freeze(order.getOrderNo(), order.getSkuId(), order.getQuantity());
couponClient.use(order.getOrderNo(), order.getCouponId());
paymentClient.create(order.getOrderNo(), order.getPayAmount());
order.markConfirmed();
orderRepository.save(order);
} catch (Exception ex) {
compensate(order);
order.markFailed(ex.getMessage());
orderRepository.save(order);
}
}
private void compensate(Order order) {
paymentClient.closeQuietly(order.getOrderNo());
couponClient.returnQuietly(order.getOrderNo(), order.getCouponId());
stockClient.releaseQuietly(order.getOrderNo(), order.getSkuId());
}
}
实际生产中,补偿动作要做到可重试、可幂等、可观测。补偿失败不能只打印日志,否则系统会出现长期悬挂的中间状态。
八、库存扣减建议使用冻结模型
直接扣减库存容易让补偿变复杂。更常见的做法是把库存拆成可用库存和冻结库存。
下单未支付:可用库存减少,冻结库存增加 支付成功:冻结库存减少,已售库存增加 订单取消:冻结库存减少,可用库存增加
库存表可以这样设计:
CREATE TABLE sku_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, available_quantity INT NOT NULL, frozen_quantity INT NOT NULL, sold_quantity INT NOT NULL, version INT NOT NULL, UNIQUE KEY uk_sku_id (sku_id) );
冻结库存 SQL:
UPDATE sku_stock SET available_quantity = available_quantity - ?, frozen_quantity = frozen_quantity + ?, version = version + 1 WHERE sku_id = ? AND available_quantity >= ?;
释放库存 SQL:
UPDATE sku_stock SET available_quantity = available_quantity + ?, frozen_quantity = frozen_quantity - ?, version = version + 1 WHERE sku_id = ? AND frozen_quantity >= ?;
为了保证幂等,可以增加库存流水表:
CREATE TABLE stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, sku_id BIGINT NOT NULL, flow_type VARCHAR(32) NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_order_sku_type (order_no, sku_id, flow_type) );
这样同一个订单的冻结、释放、确认动作都能通过唯一索引防止重复执行。
九、失败重试要有退避和上限
最终一致依赖重试,但重试不能无限密集。错误的重试策略会把临时故障放大成雪崩。
推荐策略:
-
使用指数退避,例如 1 秒、5 秒、30 秒、2 分钟。
-
设置最大重试次数。
-
超过次数后进入死信队列或人工处理表。
-
区分可重试错误和不可重试错误。
-
记录最后一次失败原因。
Outbox 重试字段可以包含:
retry_count next_retry_time last_error_message status
状态流转示例:
PENDING -> PROCESSING -> PUBLISHED PENDING -> PROCESSING -> PENDING PENDING -> PROCESSING -> DEAD
进入 DEAD 状态的消息需要有运营或研发处理入口,至少可以查看事件内容、失败原因、重试次数和关联业务主键。
十、状态机比布尔字段更适合长流程
跨服务流程不要只用一个 success 布尔字段表示结果。订单这类长流程更适合使用明确状态机。
订单状态示例:
| 状态 | 含义 |
|---|---|
| PENDING | 订单已创建,等待确认 |
| STOCK_LOCKED | 库存已锁定 |
| COUPON_USED | 优惠券已核销 |
| CONFIRMED | 下单流程确认成功 |
| PAID | 支付成功 |
| CANCELED | 订单已取消 |
| FAILED | 创建失败,需要补偿或人工确认 |
状态变更应该有明确的入口:
public class Order {
private OrderStatus status;
public void markStockLocked() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("只有待确认订单可以锁定库存");
}
this.status = OrderStatus.STOCK_LOCKED;
}
public void markConfirmed() {
if (status != OrderStatus.COUPON_USED) {
throw new IllegalStateException("订单状态不允许确认");
}
this.status = OrderStatus.CONFIRMED;
}
public void markFailed(String reason) {
if (status == OrderStatus.PAID) {
throw new IllegalStateException("已支付订单不能标记失败");
}
this.status = OrderStatus.FAILED;
this.failReason = reason;
}
}
清晰的状态机能让补偿、重试、人工处理都有依据。
十一、生产落地检查清单
分布式事务方案上线前,可以按下面清单检查:
| 检查项 | 要点 |
|---|---|
| 事务边界 | 本地事务只覆盖本服务数据库,不在事务内等待慢远程调用 |
| 消息一致性 | 业务数据和 Outbox 事件在同一个本地事务提交 |
| 并发投递 | 多实例扫描 Outbox 时有抢占机制 |
| 消费幂等 | 消费者使用唯一键或幂等表防止重复执行 |
| 补偿动作 | Saga 每个关键步骤都有可重试的补偿接口 |
| 状态机 | 业务状态清晰,允许中间态存在且可追踪 |
| 重试策略 | 有退避、有上限、有死信或人工处理机制 |
| 监控告警 | 监控待发送消息数、死信数、补偿失败数、长时间中间态订单 |
| 对账任务 | 定期扫描异常状态并自动修复或生成处理单 |
| 业务语义 | 明确哪些场景必须强一致,哪些可以最终一致 |
总结
微服务分布式事务的核心不是让多个数据库重新变成一个大事务,而是把业务流程拆成可追踪、可重试、可补偿的一组本地事务。
Outbox 模式解决本地事务和消息发送的一致性,幂等机制解决重复投递和重复消费,Saga 负责组织跨服务流程和补偿动作。配合清晰的状态机、重试策略、监控告警和对账任务,最终一致方案可以在复杂业务中保持足够高的可靠性和可维护性。
更多推荐
所有评论(0)