分布式事务,Java后端面试必问,也是很多团队"能聊不能做"的话题。
我们有个场景:用户下单后,要同时扣减库存、增加积分、发一条订单消息。以前的做法是在一个方法里同步做三件事,事务能覆盖,但问题是:库存服务和积分服务是独立部署的,根本不在一个数据库里,本地事务管不了它们。
后来用了RocketMQ的事务消息,把这层窗户纸捅破了。这篇文章把原理、代码、踩坑全写出来。
先说结论:RocketMQ事务消息解决的是"本地事务和消息发送的一致性"问题,不是万能的分布式事务方案。 它适合"先改本地库,再发消息通知其他服务"的场景,不适合需要强一致性的资金类业务。
分布式事务为什么难
先看问题本质。下单这个动作涉及三个系统:
- 订单库:写订单表
- 库存库:扣减库存
- 积分库:增加积分
它们不在一个数据库,甚至不在一个服务里。你不可能用一个本地事务把三个库包起来。
常见的伪方案是"先发消息,再写库"或者"先写库,再发消息",各有问题:
- 先发消息,再写库:消息发出去了,数据库写失败了,下游服务收到消息却查不到数据
- 先写库,再发消息:数据库写成功了,消息发送失败了,下游服务永远不知道这笔订单
RocketMQ事务消息的思路:把"写本地库"和"发消息"绑成一个事务,要么都成功,要么都失败。
RocketMQ事务消息的原理
原理分三步:
第一步:发送"半消息"(Half Message)。 这个消息先发送到RocketMQ,但对消费者不可见。消息处于"半消息"状态,消费者收不到。
第二步:执行本地事务。 半消息发送成功后,业务代码开始执行本地事务(写订单表、扣库存)。
第三步:提交或回滚。 本地事务执行完,根据结果告诉RocketMQ:事务成功就commit,消息变成对消费者可见;事务失败就rollback,消息直接丢弃。
万一第二步本地事务执行过程中宕机了,没人告诉RocketMQ结果怎么办? RocketMQ会回查------过一段时间没收到commit/rollback,就反向调用你的业务代码,问"你那个事务到底成没成?"这就是事务消息的回查机制。
流程画出来:
业务系统 RocketMQ
│ ①发送半消息 ────────────►│
│ │ 消息半可见
│ ◄─────── 返回成功 ───────│
│ ②执行本地事务 │
│ ③commit/rollback ─────►│
│ │ 可见/丢弃
│ ◄─── 回查(宕机时)──────│
代码实现
第一步:定义事务监听器
事务消息的关键是实现TransactionListener接口,里面有两个方法:
executeLocalTransaction:执行本地事务,返回事务状态checkLocalTransaction:回查方法,告诉RocketMQ本地事务到底成没成
java
@Component
public class OrderTransactionListener implements TransactionListener {
@Resource
private OrderMapper orderMapper;
// 执行本地事务
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
// 从消息里解析订单数据
Order order = parseOrder(msg);
// 写订单表(本地事务)
orderMapper.insert(order);
// 本地事务成功,提交消息
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
log.error("本地事务执行失败", e);
// 本地事务失败,回滚消息
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
// 回查:本地事务到底成没成
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// 根据消息里的订单号查数据库
String orderNo = parseOrderNo(msg);
Order order = orderMapper.selectByOrderNo(orderNo);
if (order != null) {
// 订单存在,说明本地事务成功了,提交消息
return LocalTransactionState.COMMIT_MESSAGE;
}
// 订单不存在,说明本地事务没成功,回滚
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
第二步:发送事务消息
java
@Service
public class OrderService {
@Resource
private RocketMQTemplate rocketMQTemplate;
@Resource
private OrderTransactionListener orderTransactionListener;
public void createOrder(Order order) {
// 构建消息
Message message = MessageBuilder
.withPayload(order)
.setHeader("orderNo", order.getOrderNo())
.build();
// 发送事务消息:先发半消息,再执行本地事务
rocketMQTemplate.sendMessageInTransaction(
"order-topic",
message,
order, // 这个参数会传给executeLocalTransaction的arg
orderTransactionListener
);
// 注意:这个方法返回后,本地事务已经执行完了
}
}
关键在sendMessageInTransaction:它内部会先发半消息,然后调用executeLocalTransaction执行本地事务,根据结果commit或rollback。这一切对调用方是透明的,你只需要实现好监听器。
第三步:消费者正常消费
消费者那边不用做任何特殊处理,消息提交后就正常消费:
java
@Component
@RocketMQMessageListener(topic = "order-topic", consumerGroup = "stock-consumer")
public class StockConsumer implements RocketMQListener<Order> {
@Override
public void onMessage(Order order) {
// 扣减库存
stockService.deduct(order.getSkuId(), order.getCount());
}
}
踩过的坑
这套方案落地过程中,有几个坑是真实踩过的。
坑1:回查接口必须幂等。 回查可能被调用多次(网络超时、重试),你的checkLocalTransaction必须设计成幂等的------查一次订单表,有就有,没有就没有,别做任何写操作。我们一开始在回查里加了日志统计,结果回查频繁时日志量爆炸。
坑2:回查的实现不能依赖内存状态。 很多人写回查时从内存变量里判断事务结果,这是错的。回查可能在进程重启之后才来,内存里什么都没有了。 必须查数据库、查外部存储,从持久化状态判断。
坑3:本地事务的边界要清晰。 executeLocalTransaction里执行的不只是"写订单表",如果还调用了外部接口,外部调用失败不算本地事务失败------RocketMQ回滚不了外部系统的数据。 所以本地事务里只放本地数据库操作,外部操作放消息之后的消费端去做。
坑4:回查超时时间要合理。 RocketMQ默认回查间隔和次数是有限的(默认15次,间隔由broker配置),如果你本地事务执行时间特别长,可能超过回查窗口。我们的经验是:本地事务尽量短,超过5秒的事务要慎重评估。
坑5:别在事务消息里做太重的操作。 有同事把发短信、调第三方API也塞进executeLocalTransaction,导致本地事务长时间不返回,RocketMQ等不及就开始回查,回查又查不到记录,消息被误回滚。事务消息只负责"写库+通知",重操作一律放消费端异步做。
什么时候用它,什么时候别用
适合用RocketMQ事务消息的场景:
- 先写本地库,再通知其他服务(订单→库存、订单→积分、用户→积分)
- 数据一致性要求"最终一致"就够,不需要强一致
- 有现成的RocketMQ集群
不适合的场景:
- 资金类业务,要求强一致性(扣款+加款必须同时成功)------这种得上Seata之类的分布式事务框架,或者干脆走单库
- 需要"即时一致"的场景,事务消息有提交延迟,下游看不到是正常的
一句话:事务消息适合"本地事务+异步通知"的最终一致场景,别指望它解决所有分布式事务问题。
结尾
RocketMQ事务消息的本质,是把"本地事务"和"消息发送"用半消息+回查机制绑在一起,保证"要么都成功,要么都失败"。
这个方案最值钱的地方在于:代码侵入小,思路清晰,而且不用引入额外的分布式事务框架。 我们订单、积分、库存三个系统,就靠它解决了数据一致性问题,一年多没出过事。
如果这篇对你有用,点个关注。下一篇写全链路追踪------我们排查线上问题从"翻日志"到"秒级定位",靠的就是这个。