事务消息核心原理:解决售货柜下单、扣库存、支付数据一致性
作者:黒漂技术佬 适用读者:了解RocketMQ基本用法,想搞懂分布式事务消息的同学
一、分布式事务的难题
先抛一个售货柜场景,你感受一下问题有多棘手。
用户扫码开门,拿走一瓶可乐,关门扣款。后台流程:
markdown
1. 订单服务:创建订单记录
2. 库存服务:扣减可乐库存
3. 出货服务:向设备下发出货指令
这三个操作分布在三个服务、三个数据库。你要保证:要么全成功,要么全回滚。但分布式环境下,网络会抖、服务会挂、数据库会超时------你怎么保证三件事一起成功或一起失败?
1.1 传统方案的困境
| 方案 | 做法 | 问题 |
|---|---|---|
| 2PC / XA | 协调者问所有参与者"能不能提交",全部同意才提交 | 性能极差(长时间锁资源),不适合微服务 |
| TCC | Try-Confirm-Cancel三阶段,每个服务写三套逻辑 | 开发成本极高,每个操作要写3个方法 |
| 本地消息表 | 本地事务+消息表同库写入,定时任务扫表发MQ | 实现复杂,有延迟,要维护消息表 |
| 最大努力通知 | 发完消息就不管了 | 不可靠,可能丢数据 |
RocketMQ的事务消息提供了一种更优雅的方案------不需要额外消息表,不写TCC三套逻辑,利用MQ自身的两阶段提交机制搞定。
二、RocketMQ事务消息原理
2.1 核心思路:两阶段提交
事务消息的精髓在于**Half消息(半消息)**机制。简单说:
- 第一阶段 :生产者先发一条"半消息"给Broker,Broker存下来但对消费者不可见
- 第二阶段:生产者执行本地事务,根据结果决定是"提交"还是"回滚"这条半消息
小白疑问:什么叫"对消费者不可见"?
半消息写入Broker后,不会被构建到ConsumeQueue中(或者标记为不可消费),消费者拉取不到。只有当生产者确认"提交"后,半消息才变成正常消息,消费者才能消费。
2.2 完整流程图解
sql
Producer Broker Consumer
│ │ │
│ ① 发送Half消息 │ │
│─────────────────────────→│ │
│ │ 存储Half消息 │
│ │ (消费者不可见) │
│ ② Half消息发送成功 │ │
│←─────────────────────────│ │
│ │ │
│ ③ 执行本地事务 │ │
│ (写数据库等) │ │
│ │ │
│ ④ 本地事务成功→Commit │ │
│ 本地事务失败→Rollback │ │
│─────────────────────────→│ │
│ │ │
│ 如果Commit: │ │
│ │ ⑤ 半消息变为正常消息 │
│ │─────────────────────────→│ 消费者消费
│ │ │
│ 如果Rollback: │ │
│ │ ⑤ 删除半消息(标记删除) │
│ │ │ 消费者无感知
│ │ │
│ 如果超时未确认: │ │
│ │ ⑥ Broker主动回查 │
│←─────────────────────────│ │
│ ⑦ 回查本地事务状态 │ │
│─────────────────────────→│ │
│ 返回COMMIT/ROLLBACK/UNKNOWN │
2.3 事务回查机制
如果第④步因为网络问题,生产者没有告诉Broker是提交还是回滚,Broker不会让这条消息永远悬着。Broker会定期回查生产者:"你那条半消息的本地事务到底成功了没?"
生产者收到回查请求后,检查本地事务状态,返回三种结果之一:
- COMMIT_MESSAGE:本地事务成功,提交半消息
- ROLLBACK_MESSAGE:本地事务失败,回滚半消息
- UNKNOWN:不确定,Broker稍后再回查
默认回查15次,如果15次都返回UNKNOWN,Broker就回滚这条消息。
这个设计很巧妙:即使生产者宕机了,Broker通过回查也能最终确定消息状态,不会出现"半消息永远悬着"的问题。
三、代码实现
3.1 事务消息生产者
java
@Slf4j
@Service
public class OrderTransactionProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 发送事务消息
* @param order 订单信息
*/
public void sendOrderTransactionMessage(Order order) {
Message<Order> message = MessageBuilder
.withPayload(order)
.setHeader("KEYS", order.getOrderId())
.build();
// 事务消息专用方法:syncSendOrderTransaction
// 参数1:topic
// 参数2:消息体
// 参数3:事务参数(传给本地事务执行器的arg参数)
TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(
"transaction_order_topic",
message,
order // 这个参数会传给 executeLocalTransaction 方法的 arg
);
log.info("事务消息发送完成: orderId={}, localTransactionState={}",
order.getOrderId(), result.getLocalTransactionState());
}
}
3.2 事务监听器:核心逻辑
这是事务消息的心脏------两个方法分别处理"执行本地事务"和"事务回查"。
java
@Slf4j
@Component
@RocketMQTransactionListener
public class OrderTransactionListener
implements RocketMQLocalTransactionListener {
@Autowired
private OrderMapper orderMapper;
@Autowired
private InventoryService inventoryService;
/**
* 执行本地事务(半消息发送成功后调用)
* 这里做实际的业务操作:创建订单 + 扣库存
*/
@Override
public RocketMQLocalTransactionState executeLocalTransaction(
Message msg, Object arg) {
Order order = (Order) arg;
String orderId = order.getOrderId();
try {
log.info("执行本地事务: orderId={}", orderId);
// ① 创建订单
orderMapper.insert(order);
// ② 扣减库存
boolean stockOk = inventoryService.deduct(
order.getProductId(), order.getQuantity());
if (!stockOk) {
// 库存不足,回滚
log.warn("库存不足,回滚事务: orderId={}", orderId);
return RocketMQLocalTransactionState.ROLLBACK;
}
// 本地事务成功,提交半消息
log.info("本地事务成功,提交半消息: orderId={}", orderId);
return RocketMQLocalTransactionState.COMMIT;
} catch (Exception e) {
log.error("本地事务执行异常,回滚: orderId={}", orderId, e);
return RocketMQLocalTransactionState.ROLLBACK;
}
}
/**
* 事务回查(Broker长时间未收到确认时调用)
* 根据本地事务实际状态返回结果
*/
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
// 从消息中提取orderId
String orderId = (String) msg.getHeaders().get("KEYS");
log.info("收到事务回查请求: orderId={}", orderId);
// 查数据库:订单是否已经创建
Order order = orderMapper.selectByOrderId(orderId);
if (order != null && order.getStatus() != null) {
// 订单已存在且状态有效,说明本地事务成功了
log.info("回查: 订单已存在, 返回COMMIT: orderId={}", orderId);
return RocketMQLocalTransactionState.COMMIT;
}
// 订单不存在,说明本地事务没成功
log.info("回查: 订单不存在, 返回ROLLBACK: orderId={}", orderId);
return RocketMQLocalTransactionState.ROLLBACK;
}
}
3.3 消费者(出货服务)
消费者端和普通消费者一模一样,无感知事务消息的存在:
java
@Slf4j
@Component
@RocketMQMessageListener(
topic = "transaction_order_topic",
consumerGroup = "shipment-transaction-consumer-group",
messageModel = MessageModel.CLUSTERING
)
public class ShipmentTransactionConsumer
implements RocketMQListener<Order> {
@Autowired
private DeviceService deviceService;
@Override
public void onMessage(Order order) {
log.info("出货服务收到事务消息: orderId={}, deviceId={}",
order.getOrderId(), order.getDeviceId());
// 消费到这条消息,说明本地事务一定成功了
// 安全地执行出货
deviceService.sendShipmentCommand(
order.getDeviceId(),
order.getProductId(),
order.getQuantity()
);
log.info("出货指令已发送: orderId={}", order.getOrderId());
}
}
四、无人售货柜场景实战
把上面的代码串起来,看售货柜完整的事务消息流程。
4.1 业务场景
用户开门拿货 → 创建订单 + 扣库存(本地事务)→ 确认后 → 下发出货指令
核心要求:订单和库存要么同时成功,要么同时失败。出货指令只有在订单和库存都成功后才发送。
4.2 为什么用事务消息而不是普通消息?
用普通消息的方案:
java
// 方案A:先写数据库再发消息
orderMapper.insert(order);
inventoryService.deduct(...);
rocketMQTemplate.syncSend("shipment_topic", order);
// 问题:发消息失败怎么办?订单和库存已经改了,没法回滚
java
// 方案B:先发消息再写数据库
rocketMQTemplate.syncSend("shipment_topic", order);
orderMapper.insert(order);
// 问题:消息发出去了,数据库写入失败,出货服务已经在出货了,但订单不存在
用事务消息的方案:
java
// 事务消息:先发半消息 → 执行本地事务 → 根据结果提交/回滚半消息
rocketMQTemplate.sendMessageInTransaction(
"transaction_order_topic", message, order);
这样保证了:本地事务和消息发送的原子性。半消息提交了,本地事务一定成功了;本地事务失败了,半消息被回滚,消费者收不到消息。
4.3 异常场景分析
| 异常 | 处理方式 | 数据一致性 |
|---|---|---|
| 半消息发送失败 | 本地事务不执行,无影响 | 一致 |
| 半消息发送成功,本地事务失败 | 返回ROLLBACK,半消息删除 | 一致 |
| 半消息发送成功,本地事务成功,提交确认丢失 | Broker回查→返回COMMIT→半消息提交 | 一致 |
| 消费者消费失败 | RocketMQ重试机制,多次失败进死信队列 | 最终一致 |
| 生产者宕机 | Broker回查超时→15次后回滚 | 一致(出货指令不发) |
事务消息不能保证100%的强一致性,但能保证最终一致性。对于售货柜场景足够了------最坏情况是出货指令晚一点发送,不会出现"扣了库存没出货"或"出了货没扣库存"的错位。
五、事务消息 vs 普通消息+本地消息表
两种方案都能解决分布式事务问题,对比一下:
| 对比项 | 事务消息 | 本地消息表 |
|---|---|---|
| 实现复杂度 | 中等(实现两个方法) | 高(维护消息表+定时任务) |
| 消息延迟 | 极低(本地事务成功即提交) | 有延迟(定时任务扫描间隔) |
| 额外存储 | 不需要额外表 | 需要一张本地消息表 |
| 数据库压力 | 无额外压力 | 消息表读写增加压力 |
| 适用场景 | RocketMQ原生支持,推荐 | 不支持事务消息的MQ |
| 回查机制 | Broker自动回查 | 无,靠定时任务补偿 |
结论:用RocketMQ就选事务消息,没必要再造本地消息表的轮子。
六、事务消息使用注意事项
6.1 本地事务要幂等
回查机制可能导致 executeLocalTransaction 被调用时业务已经执行过(比如第一次Commit确认丢失,Broker回查时本地事务其实已经成功了)。所以本地事务要做好幂等:
java
public RocketMQLocalTransactionState executeLocalTransaction(
Message msg, Object arg) {
Order order = (Order) arg;
// 幂等检查:订单是否已存在
if (orderMapper.selectByOrderId(order.getOrderId()) != null) {
return RocketMQLocalTransactionState.COMMIT;
}
// 正常执行
orderMapper.insert(order);
inventoryService.deduct(order.getProductId(), order.getQuantity());
return RocketMQLocalTransactionState.COMMIT;
}
6.2 回查方法要查数据库,不要依赖内存
checkLocalTransaction 方法在生产者重启后会被调用,内存状态丢失。必须查数据库判断本地事务状态。
6.3 事务消息不适合高吞吐场景
事务消息比普通消息多了一轮交互(半消息→确认),性能有损耗。不要在心跳上报、日志类高频场景用事务消息,那是对性能的浪费。事务消息只用在需要保证数据一致性的关键业务上。
6.4 Topic要单独隔离
事务消息的Topic不要和普通消息混用,避免管理混乱。建议命名带 transaction_ 前缀。
七、总结
事务消息的核心就是两阶段提交 + 事务回查:
sql
Half消息(不可见)→ 执行本地事务 → Commit/Rollback → 消费者可见/不可见
│
超时未确认?
│
Broker回查本地事务状态
三个角色各司其职:
- 生产者:执行本地事务 + 返回事务状态
- Broker:存半消息 + 定期回查 + 根据确认决定消息可见性
- 消费者:正常消费,无感知事务过程
售货柜场景里,下单→扣库存→出货这条链路用事务消息,保证"扣了库存一定会出货,没扣库存一定不出货"。这是分布式系统数据一致性的经典解法,也是RocketMQ区别于其他MQ的杀手锏。