之前在开发一个电商支付系统时,我们团队遇到了一个棘手的问题:用户下单后,需要同时完成订单创建和库存扣减两个操作,这两个操作分别在不同的微服务中。如果不保证这两个操作的原子性,就可能出现订单创建了但库存没扣减,或者库存扣减了但订单创建失败的情况。
这不就是典型的分布式事务问题吗?于是我们选择了 RocketMQ 的事务消息来解决。但使用过程中发现,RocketMQ 的事务消息虽然强大,却也有不少让人头疼的地方。今天就和大家分享一下 RocketMQ 事务消息的那些坑,以及我们探索过的其他解决方案。
RocketMQ 事务消息原理简介
在讨论缺点前,先快速过一遍 RocketMQ 事务消息的工作原理。
RocketMQ 事务消息的核心流程是:
- 发送"半消息"到 MQ 服务器,这条消息对消费者不可见(半消息:暂不对消费者可见的消息,用于标记事务中间状态)
- 执行本地事务
- 根据本地事务结果,提交或回滚半消息
- 如果因为网络等原因,MQ 没收到提交或回滚指令,会定期回查事务状态
听起来很完美,但实际使用中却暴露出了一系列问题...
RocketMQ 事务消息的 5 大缺点
1. 事务回查机制存在不确定性
我们在一个订单系统中使用 RocketMQ 事务消息时,发现了一个让人意外的问题:当网络波动导致提交事务确认消息失败时,RocketMQ 会启动事务回查机制,但这个回查间隔和次数是有限制的。
根据 RocketMQ 源码(TransactionConfig 配置类),事务回查是以指数方式增长的:默认从 10 秒开始,之后每次间隔翻倍,如 10s→20s→40s→80s,直到达到最大回查次数(默认 15 次)。这一机制设计在大多数场景下工作良好,但在某些长事务场景中会带来问题。
我们曾遇到一个场景:某个订单处理过程中,因为数据库临时负载过高,事务处理时间超过了预期,而此时 RocketMQ 的回查次数已耗尽,导致系统误判事务失败并回滚了消息,最终造成了数据不一致。
缓解方案:可以通过配置transactionCheckInterval
(自定义回查间隔)和transactionCheckMax
(最大回查次数)来适配长事务场景。对于可能执行时间较长的事务,建议适当增加回查次数并调整回查间隔。
2. 性能瓶颈明显
当系统并发量上升时,RocketMQ 的事务消息性能下降明显。我们的测试数据显示:
在普通消息模式下,单 broker 可以轻松处理 10000TPS 的吞吐量,但切换到事务消息后,相同硬件条件下只能达到约 2000-3000TPS。具体性能对比数据如下:
指标 | 普通消息 | 事务消息 | 差距 |
---|---|---|---|
平均响应时间(RT) | 约 2ms | 约 5-8ms | 增加 3-6ms |
单 broker 最大 TPS | 10000+ | 2000-3000 | 下降约 70% |
CPU 使用率(峰值) | 40% | 70% | 增加 30% |
磁盘 I/O | 中等 | 高 | 增加约 50% |
这种性能下降是因为每个事务消息都需要额外的半消息存储、状态管理和可能的回查操作。
半消息虽然存储在 CommitLog 中,与普通消息使用相同的存储机制,但它们增加了额外的 I/O 开销:
- 半消息需要持久化到磁盘,增加写入负担
- Broker 需要定期扫描未决事务消息,增加读取压力
- 半消息状态变更(提交/回滚)需要额外的操作记录
我们曾在双十一活动期间遇到过一次严重的性能问题:系统订单量突增,RocketMQ 的事务消息处理能力成为了整个系统的瓶颈,导致订单处理出现明显延迟。
缓解方案:针对性能问题,可以采取以下措施:
- 增加 RocketMQ 集群节点数量,分散事务消息处理压力
- 对非核心业务流程,考虑使用普通消息+本地消息表的方式,降低中间件负担
- 合理规划消息发送批次,避免瞬时高峰
3. 事务状态存储机制的局限性
RocketMQ 将事务状态保存在 CommitLog 中,通过持久化确保状态不丢失。然而,在特定场景下仍可能发生数据不一致:当使用异步复制模式时,如果主节点宕机且未将事务状态同步到从节点,状态信息可能丢失。普通的 Broker 重启不会导致事务状态丢失,因为状态已持久化到磁盘。
在一次系统故障中,我们经历了主从切换导致部分事务状态丢失的问题,最终不得不进行了长达 4 小时的数据修复工作。
缓解方案:对于关键业务,建议使用同步复制模式(SYNC_MASTER)确保事务状态在多个节点间的一致性。同时,可以实现额外的事务状态持久化机制,如在业务数据库中记录消息发送状态。
故障处理指南:当遇到事务状态异常时,可通过 RocketMQ 提供的管理工具进行排查:
# 查看指定消息的事务状态
sh mqadmin -n 127.0.0.1:9876 checkTransactionState -g {生产者组名} -i {事务消息ID}
# 手动触发事务回查
sh mqadmin -n 127.0.0.1:9876 updateTransactionState -g {生产者组名} -i {事务消息ID} -s {状态:0提交/1回滚}
通过这些命令可以检查和纠正事务状态,避免长时间的人工数据修复。
4. 开发和调试复杂度高
使用 RocketMQ 事务消息需要实现 TransactionListener 接口,包括 executeLocalTransaction 和 checkLocalTransaction 两个方法。开发人员需要确保这两个方法的逻辑一致性,这增加了代码复杂度。
例如,我们曾有一个新团队成员在实现事务监听器时,checkLocalTransaction 方法的逻辑与 executeLocalTransaction 不一致,导致在网络波动时系统出现了数据不一致的问题。排查这类问题非常困难,因为它们往往只在特定条件下才会出现。
public class OrderTransactionListener implements TransactionListener {
private static final Logger log = LoggerFactory.getLogger(OrderTransactionListener.class);
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
// 执行本地事务
OrderService orderService = (OrderService)arg;
String orderId = msg.getKeys();
// 创建订单
boolean result = orderService.createOrder(orderId);
if (result) {
// 成功后更新订单状态为已确认
orderService.updateOrderStatus(orderId, OrderStatus.CONFIRMED);
log.info("订单创建成功,orderId={}", orderId);
return LocalTransactionState.COMMIT_MESSAGE;
} else {
log.warn("订单创建失败,orderId={}", orderId);
return LocalTransactionState.ROLLBACK_MESSAGE;
}
} catch (Exception e) {
log.error("Execute local transaction failed, msgId={}", msg.getTransactionId(), e);
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// 回查本地事务状态
String orderId = msg.getKeys();
try {
// 查询订单状态而非简单检查订单是否存在
OrderStatus status = orderService.getOrderStatus(orderId);
// 记录回查次数,便于排查异常情况
int checkTimes = getCheckTimes(msg);
log.info("事务回查,orderId={}, 当前状态={}, 回查次数={}", orderId, status, checkTimes);
if (status == OrderStatus.CONFIRMED) {
return LocalTransactionState.COMMIT_MESSAGE;
} else if (status == OrderStatus.FAILED) {
return LocalTransactionState.ROLLBACK_MESSAGE;
} else {
// 订单创建中或状态不明确,继续回查
// 如果回查次数过多,可考虑特殊处理,避免无限回查
if (checkTimes > 10) {
log.warn("回查次数过多,orderId={},强制回滚", orderId);
return LocalTransactionState.ROLLBACK_MESSAGE;
}
return LocalTransactionState.UNKNOW;
}
} catch (Exception e) {
log.error("Check transaction status failed, msgId={}, orderId={}",
msg.getTransactionId(), orderId, e);
// 注意:这里返回UNKNOW将导致继续回查,直到回查次数耗尽
return LocalTransactionState.UNKNOW;
}
}
// 从消息属性中获取回查次数
private int getCheckTimes(MessageExt msg) {
String checkTimesStr = msg.getProperty("CHECK_TIMES");
int checkTimes = 0;
if (checkTimesStr != null) {
checkTimes = Integer.parseInt(checkTimesStr);
}
return checkTimes;
}
}
缓解方案:建议在数据库设计时增加事务状态表,明确记录每个业务操作的事务状态,而不是依赖于对象是否存在的简单判断。同时,可以开发统一的事务管理框架,减少重复编码,确保 executeLocalTransaction 和 checkLocalTransaction 的一致性。
5. 和业务代码耦合紧密
RocketMQ 事务消息要求将业务逻辑和消息发送逻辑紧密结合,这导致业务代码和中间件代码高度耦合。当我们需要更换消息中间件或调整业务逻辑时,改动范围会非常大。
缓解方案:可以通过引入事务抽象层,将业务逻辑与消息发送逻辑解耦。例如,定义统一的事务接口,通过适配器模式适配不同的消息中间件,使业务代码只需关注业务流程,不直接依赖特定的消息中间件实现。
实际案例:订单-库存系统事务问题及解决方案
我们的电商系统需要在用户下单时同时处理订单创建和库存扣减,这两个操作分别在不同的微服务中。起初我们使用 RocketMQ 事务消息来确保这两个操作的一致性:
问题展现:
在使用过程中,我们遇到了以下问题:
- 高并发场景下,RocketMQ 事务消息处理能力成为瓶颈
- 事务回查机制在网络不稳定时可能导致误判
- 订单服务和消息中间件逻辑高度耦合,难以维护
- 当需要扩展流程(如增加积分服务)时,改动范围大
- 库存扣减失败时的补偿流程复杂,需要额外的消息处理逻辑
解决方案:
经过研究,我们尝试了以下解决方案:
- 优化 RocketMQ 配置:增加 broker 数量,调整事务回查参数
- 在数据库中增加明确的事务状态字段,确保回查准确性
- 最终,我们决定用 Seata 的 AT 模式替代 RocketMQ 事务消息,效果更好(详见下一节)
其他事务消息实现方案对比
1. Kafka 事务消息
Kafka 自 0.11 版本开始支持事务,允许原子性地向多个分区写入消息。
优点:
- 支持向多个 Topic/Partition 原子性写入
- 性能相对较高
- 配置简单,使用方便
- 结合幂等性 Producer 功能(幂等性:同一操作执行多次和执行一次效果相同),可保证单分区内消息不重复,提升生产者端可靠性
缺点:
- 仅保证消息生产的原子性,无法关联本地事务(如数据库操作)
- 不支持与外部系统的事务集成,无法实现"先执行本地事务,再根据结果提交消息"的场景
- 恢复机制不如 RocketMQ 完善,缺少事务状态回查能力
2. RabbitMQ 事务机制
RabbitMQ 提供了两种事务机制:传统的 AMQP 事务和轻量级的 Publisher Confirmation。
优点:
- 配置简单,易于使用
- Publisher Confirmation 机制性能较高
- 与 Spring AMQP 集成良好
缺点:
- AMQP 事务模式性能较差
- 不支持分布式事务
- 无回查机制,可靠性较低
- Publisher Confirmation 是异步确认机制,使用批量发送时需额外处理部分消息失败的情况(如记录未确认消息 ID,通过死信队列重试)
3. Seata 分布式事务框架
Seata 提供了多种分布式事务模式,包括 AT、TCC、Saga 和 XA 模式。对于消息事务一致性问题,我们主要关注 AT 模式和 Saga 模式。
优点:
- 全面的分布式事务解决方案
- 多种事务模式可选
- 与业务解耦程度高
- 性能相对较好
缺点:
- 部署复杂度高,需要额外维护 Seata 服务器
- 学习曲线陡峭
- AT 模式对数据库有特定要求:必须支持 ACID、表必须有主键(用于生成反向 SQL)
- AT 模式生成的反向 SQL 可能扩大行锁范围(如更新所有字段),导致长事务场景下的并发性能下降
故障排查指南:当 Seata 事务出现异常时,可通过以下方式排查:
# 查看全局事务状态
curl -X GET 'http://seata-server-ip:8091/api/v1/transaction/globalTransaction?pageNum=1&pageSize=10&xid=xxx'
# 查看具体的事务日志
cat ${SEATA_HOME}/logs/seata-server.log | grep '全局事务ID'
# 查看数据库undo_log表检查回滚记录
SELECT * FROM undo_log WHERE xid='xxx';
这些命令可以帮助开发人员快速定位事务异常的根本原因。
本地消息表方案示例
对于非核心业务场景,我们采用了本地消息表方案,这种方案的实现相对简单:
// 本地消息表设计
@Entity
@Table(name = "local_message")
public class LocalMessage {
@Id
private String messageId; // 消息ID,唯一标识
private String topic; // 消息主题
private String body; // 消息内容
private Integer status; // 状态:0-待发送,1-已发送,2-发送失败
private Integer retryCount; // 重试次数
private Date createTime; // 创建时间
private Date updateTime; // 更新时间
// getters and setters
}
// 业务服务中使用本地消息表
@Service
@Transactional
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private LocalMessageRepository messageRepository;
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void createOrder(Order order) {
// 1. 开启本地事务
try {
// 2. 创建订单
orderRepository.save(order);
// 3. 创建本地消息
LocalMessage message = new LocalMessage();
message.setMessageId(UUID.randomUUID().toString());
message.setTopic("order-topic");
message.setBody(JSON.toJSONString(order));
message.setStatus(0); // 待发送
message.setRetryCount(0);
message.setCreateTime(new Date());
message.setUpdateTime(new Date());
messageRepository.save(message);
// 事务提交后,消息和订单都会保存
} catch (Exception e) {
// 本地事务回滚,订单和消息都不会保存
throw new RuntimeException("创建订单失败", e);
}
}
}
// 定时任务扫描未发送的消息
@Component
public class MessageSender {
@Autowired
private LocalMessageRepository messageRepository;
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Scheduled(fixedRate = 10000) // 每10秒执行一次
public void sendMessage() {
List<LocalMessage> messages = messageRepository.findByStatusAndRetryCountLessThan(0, 5);
for (LocalMessage message : messages) {
try {
// 发送消息
rocketMQTemplate.convertAndSend(message.getTopic(), message.getBody());
// 更新消息状态
message.setStatus(1); // 已发送
message.setUpdateTime(new Date());
messageRepository.save(message);
} catch (Exception e) {
// 发送失败,增加重试次数
message.setRetryCount(message.getRetryCount() + 1);
message.setUpdateTime(new Date());
if (message.getRetryCount() >= 5) {
message.setStatus(2); // 发送失败
}
messageRepository.save(message);
log.error("消息发送失败, messageId=" + message.getMessageId(), e);
}
}
}
}
本地消息表方案的核心思想是将消息发送与本地事务绑定,确保消息不会丢失。虽然不能保证强一致性,但可以实现最终一致性(最终一致性:系统在经过一段时间后最终达到数据一致状态),适合非核心业务场景。
实战选择:我们的最终方案
经过对比和实测,我们最终在系统中采用了不同场景不同方案的组合:
- 对于订单-库存-支付等关键业务流程:使用 Seata 的 AT 模式,保证数据一致性的同时,与业务代码解耦
- 对于非核心业务如通知、日志等:使用普通的 RocketMQ 消息+本地消息表方案,简化开发的同时保证最终一致性
- 对于特殊高性能场景:使用 Kafka 事务消息,满足高吞吐量需求
我们选择 Seata AT 模式的考虑因素包括:
- 现有微服务架构已使用 Spring Cloud,与 Seata 集成较为简便
- 主要业务数据库均为 MySQL,符合 AT 模式的基础要求
- 技术团队对 Java 生态系统较为熟悉,Seata 作为国产框架文档和社区支持较好
- 相比事务消息方案,Seata 提供更完整的分布式事务保障
- 我们的大部分事务场景为短事务,AT 模式的锁扩大影响可控
对于长事务或跨异构系统的场景(如涉及 MySQL 和 Redis 的复合操作),我们选择了 Seata 的 Saga 模式(柔性事务:通过补偿机制而非锁机制实现一致性的事务类型),通过状态机定义服务编排和补偿逻辑,确保数据最终一致性。
在实施过程中,我们还总结了一些经验:
- 事务消息适用于强一致性要求较低的场景,而对于核心业务流程可考虑使用更严格的分布式事务方案
- 对核心业务采用更严格的事务保证,非核心业务可适当放宽要求
- 在设计阶段就考虑分布式事务问题,而不是等系统上线后再临时解决
总结
让我们用表格形式总结一下各种事务消息方案的对比:
特性/方案 | RocketMQ 事务消息 | Kafka 事务消息 | RabbitMQ 事务 | Seata AT 模式 | Seata Saga 模式 | 本地消息表 |
---|---|---|---|---|---|---|
性能 | 中等 | 高 | 低(AMQP 事务)/中(确认模式) | 中等 | 高 | 高 |
可靠性 | 高 | 中 | 中 | 高 | 高 | 中 |
开发复杂度 | 高 | 中 | 低 | 中 | 高 | 中 |
维护成本 | 中 | 低 | 低 | 高 | 高 | 低 |
与本地事务集成 | 支持 | 不支持 | 不支持 | 支持 | 支持 | 支持 |
回查机制 | 有 | 无 | 无 | 有 | 有 | 无(定时任务) |
补偿机制 | 依赖回查 | 无 | 无 | 自动生成反向 SQL | 手动定义补偿接口 | 应用层重试 |
最终一致性 | 是 | 是(仅消息) | 否 | 是(两阶段) | 是(柔性事务) | 是 |
适用场景 | 一般分布式事务 | 高性能消息事务 | 简单消息事务 | 短事务/同构系统 | 长事务/异构系统/需人工干预 | 非核心业务 |
并发性能 | 受半消息影响 | 高 | 中 | 受全局锁影响 | 高 | 高 |
感谢您耐心阅读到这里!如果觉得本文对您有帮助,欢迎点赞 👍、收藏 ⭐、分享给需要的朋友,您的支持是我持续输出技术干货的最大动力!
如果想获取更多技术深度解析,欢迎点击头像关注我,后续会每日更新高质量技术文章,陪您一起进阶成长~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。