微服务拆分后的事务困境
单体应用中一个本地事务就能保证的数据一致性,在微服务架构下变成了跨库、跨服务的分布式事务问题。典型场景:订单服务创建订单后需要调用库存服务扣减库存、调用支付服务冻结资金,任何一个环节失败都需要回滚已执行的操作。Spring Boot 3.x生态下,Seata的AT模式提供了对业务代码侵入最低的方案,而基于消息队列的最终一致性方案则适合对实时性要求不高的场景。两种方案的选型和配置细节差异较大,本文逐项拆解。
Seata AT模式原理与Spring Boot 3.x集成
AT模式的核心是两阶段提交的自动化。一阶段拦截业务SQL,生成前镜像(Before Image)和后镜像(After Image)存入undo_log表,同时获取全局锁;二阶段提交则异步清理undo_log,回滚则用前镜像做补偿。业务代码只需要加一个@GlobalTransactional注解。
Seata Server部署(2.2.0版本):
# docker-compose.yml
version: '3.8'
services:
seata-server:
image: seataio/seata-server:2.2.0
ports:
- "8091:8091"
- "7091:7091"
environment:
- SEATA_IP=seata-server
- STORE_MODE=db
volumes:
- ./seata-config:/seata-server/resources
Spring Boot 3.x客户端配置:
// pom.xml
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
// application.yml
seata:
enabled: true
application-id: order-service
tx-service-group: my-tx-group
service:
vgroup-mapping:
my-tx-group: default
registry:
type: nacos
nacos:
server-addr: nacos:8848
namespace: seata
group: SEATA_GROUP
config:
type: nacos
nacos:
server-addr: nacos:8848
namespace: seata
每个参与分布式事务的数据库需要创建undo_log表:
-- undo_log 建表语句(MySQL)
CREATE TABLE IF NOT EXISTS undo_log (
id BIGINT NOT NULL AUTO_INCREMENT,
branch_id BIGINT NOT NULL,
xid VARCHAR(128) NOT NULL,
context VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL,
log_status INT NOT NULL,
log_created DATETIME NOT NULL,
log_modified DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
业务代码实战:订单-库存-支付三服务联动
订单服务中,业务入口加@GlobalTransactional注解:
@Service
@Slf4j
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private InventoryClient inventoryClient;
@Autowired
private PaymentClient paymentClient;
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
@Transactional(rollbackFor = Exception.class)
public OrderDTO createOrder(CreateOrderRequest request) {
// 1. 创建订单
Order order = new Order();
order.setUserId(request.getUserId());
order.setProductId(request.getProductId());
order.setQuantity(request.getQuantity());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.CREATED);
orderMapper.insert(order);
log.info("订单创建成功, orderId={}", order.getId());
// 2. 扣减库存(远程调用)
inventoryClient.deduct(request.getProductId(), request.getQuantity());
log.info("库存扣减成功");
// 3. 冻结资金(远程调用)
paymentClient.freeze(request.getUserId(), request.getAmount());
log.info("资金冻结成功");
return OrderDTO.fromEntity(order);
}
}
库存服务和支付服务的扣减/冻结方法同样需要加@Transactional注解。Seata AT模式通过DataSource代理自动拦截SQL,生成undo_log,无需业务代码手动处理回滚。当任何环节抛出异常,Seata协调器自动调用各分支的undo逻辑做补偿。
消息最终一致性方案:本地消息表+RocketMQ
AT模式虽然代码侵入低,但全局锁机制在高并发场景下会成为瓶颈。对实时性要求不严格的场景,本地消息表+消息队列的最终一致性方案更合适。核心思路:业务操作和消息记录在同一个本地事务中写入,后台定时任务扫描消息表并投递到MQ。
@Service
public class OrderServiceV2 {
@Autowired
private OrderMapper orderMapper;
@Autowired
private OutboxMessageMapper outboxMapper;
@Transactional(rollbackFor = Exception.class)
public OrderDTO createOrderWithOutbox(CreateOrderRequest request) {
// 1. 创建订单
Order order = new Order();
order.setUserId(request.getUserId());
order.setProductId(request.getProductId());
order.setQuantity(request.getQuantity());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.CREATED);
orderMapper.insert(order);
// 2. 写入本地消息表(同一事务)
OutboxMessage msg = new OutboxMessage();
msg.setTopic("order-created");
msg.setKey(String.valueOf(order.getId()));
msg.setBody(JSON.toJSONString(new OrderEvent(order)));
msg.setStatus(OutboxStatus.PENDING);
msg.setRetryCount(0);
msg.setNextRetryAt(LocalDateTime.now());
outboxMapper.insert(msg);
return OrderDTO.fromEntity(order);
}
}
消息投递调度器:
@Component
@Slf4j
public class OutboxMessageScheduler {
@Autowired
private OutboxMessageMapper outboxMapper;
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Scheduled(fixedDelay = 5000) // 每5秒扫描
@Transactional
public void sendPendingMessages() {
List<OutboxMessage> messages = outboxMapper.selectPending(
OutboxStatus.PENDING,
LocalDateTime.now(),
50 // 每批最多50条
);
for (OutboxMessage msg : messages) {
try {
SendResult result = rocketMQTemplate.syncSend(
msg.getTopic(),
MessageBuilder.withPayload(msg.getBody())
.setKeys(msg.getKey())
.build(),
3000 // 超时3秒
);
if (result.getSendStatus() == SendStatus.SEND_OK) {
msg.setStatus(OutboxStatus.SENT);
outboxMapper.updateById(msg);
}
} catch (Exception e) {
msg.setRetryCount(msg.getRetryCount() + 1);
if (msg.getRetryCount() >= 5) {
msg.setStatus(OutboxStatus.FAILED);
log.error("消息投递失败5次,标记为FAILED, id={}", msg.getId());
} else {
// 指数退避
msg.setNextRetryAt(LocalDateTime.now().plusMinutes(
(long) Math.pow(2, msg.getRetryCount())
));
}
outboxMapper.updateById(msg);
}
}
}
}
两种方案选型对比
| 维度 | Seata AT模式 | 本地消息表+MQ |
|---|---|---|
| 一致性保证 | 强一致(同步回滚) | 最终一致(异步补偿) |
| 代码侵入 | 低(注解驱动) | 中(需消息表+调度) |
| 性能 | 全局锁竞争下吞吐受限 | 无锁,吞吐高 |
| 适用场景 | 资金、库存等强一致需求 | 通知、日志等可延迟场景 |
| 故障恢复 | Seata Server自动回滚 | 消息重试+人工补偿 |
实际项目中两种方案通常并存:核心交易链路用Seata AT保证强一致,非核心的异步通知、数据同步等用消息最终一致性方案。关键是根据业务SLA明确每种场景的一致性需求,不要一刀切。
Seata生产环境踩坑记录
坑1:全局锁超时。默认全局锁超时60秒,长事务容易超时。调大lock-retry-times和lock-retry-interval:seata.client.lock.retry-times=30, seata.client.lock.retry-interval=10。
坑2:undo_log序列化兼容。字段增删改后,旧undo_log反序列化失败。上线时先禁用分布式事务,手动清理undo_log后再启用。或配置jackson序列化忽略未知属性。
坑3:Seata Server单点故障。生产环境至少部署3个Seata Server节点,使用Nacos做注册中心和配置中心,数据库存储Transaction日志。Seata Server本身无状态,任意节点宕机不影响运行中的全局事务。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/springboot3x-fen-bu-shi-shi-wu-shi-zhan-seataat-mo-shi-pei/