RocketMQ事务消息:我把分布式事务这层窗户纸捅破了

12 阅读6分钟

分布式事务,Java后端面试必问,也是很多团队"能聊不能做"的话题。

我们有个场景:用户下单后,要同时扣减库存、增加积分、发一条订单消息。以前的做法是在一个方法里同步做三件事,事务能覆盖,但问题是:库存服务和积分服务是独立部署的,根本不在一个数据库里,本地事务管不了它们。

后来用了RocketMQ的事务消息,把这层窗户纸捅破了。这篇文章把原理、代码、踩坑全写出来。

先说结论:RocketMQ事务消息解决的是"本地事务和消息发送的一致性"问题,不是万能的分布式事务方案。 它适合"先改本地库,再发消息通知其他服务"的场景,不适合需要强一致性的资金类业务。


分布式事务为什么难

先看问题本质。下单这个动作涉及三个系统:

  1. 订单库:写订单表
  2. 库存库:扣减库存
  3. 积分库:增加积分

它们不在一个数据库,甚至不在一个服务里。你不可能用一个本地事务把三个库包起来。

常见的伪方案是"先发消息,再写库"或者"先写库,再发消息",各有问题:

  • 先发消息,再写库:消息发出去了,数据库写失败了,下游服务收到消息却查不到数据
  • 先写库,再发消息:数据库写成功了,消息发送失败了,下游服务永远不知道这笔订单

RocketMQ事务消息的思路:把"写本地库"和"发消息"绑成一个事务,要么都成功,要么都失败。


RocketMQ事务消息的原理

原理分三步:

第一步:发送"半消息"(Half Message)。 这个消息先发送到RocketMQ,但对消费者不可见。消息处于"半消息"状态,消费者收不到。

第二步:执行本地事务。 半消息发送成功后,业务代码开始执行本地事务(写订单表、扣库存)。

第三步:提交或回滚。 本地事务执行完,根据结果告诉RocketMQ:事务成功就commit,消息变成对消费者可见;事务失败就rollback,消息直接丢弃。

万一第二步本地事务执行过程中宕机了,没人告诉RocketMQ结果怎么办? RocketMQ会回查——过一段时间没收到commit/rollback,就反向调用你的业务代码,问"你那个事务到底成没成?"这就是事务消息的回查机制。

流程画出来:

业务系统                 RocketMQ
   │ ①发送半消息 ────────────►│
   │                        │ 消息半可见
   │ ◄─────── 返回成功 ───────│
   │ ②执行本地事务           │
   │ ③commit/rollback ─────►│
   │                        │ 可见/丢弃
   │ ◄─── 回查(宕机时)──────│

代码实现

第一步:定义事务监听器

事务消息的关键是实现TransactionListener接口,里面有两个方法:

  • executeLocalTransaction:执行本地事务,返回事务状态
  • checkLocalTransaction:回查方法,告诉RocketMQ本地事务到底成没成
@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;
    }
}

第二步:发送事务消息

@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。这一切对调用方是透明的,你只需要实现好监听器。

第三步:消费者正常消费

消费者那边不用做任何特殊处理,消息提交后就正常消费:

@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事务消息的本质,是把"本地事务"和"消息发送"用半消息+回查机制绑在一起,保证"要么都成功,要么都失败"。

这个方案最值钱的地方在于:代码侵入小,思路清晰,而且不用引入额外的分布式事务框架。 我们订单、积分、库存三个系统,就靠它解决了数据一致性问题,一年多没出过事。

如果这篇对你有用,点个关注。下一篇写全链路追踪——我们排查线上问题从"翻日志"到"秒级定位",靠的就是这个。