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

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

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

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

相关推荐
铁皮饭盒1 小时前
网页端, 40mb离线模型, 自动抠图, 不用 Python,不用服务器,不用 API Key, 不用显卡
前端·javascript·后端
yxlalm1 小时前
零基础快速上手Trae创建Java项目
java·人工智能
ly76891 小时前
磁盘 I/O 延迟突增:用 iostat、blktrace 与火焰图定位到具体调用栈
java·linux·前端·数据库·iostat·磁盘 i/o·blktrace
泡海椒1 小时前
jquick-pdf 核心原理解析:基于 HTML 模板动态渲染 PDF 的实现逻辑
java·开发语言·pdf
IT_陈寒1 小时前
Redis并发写入踩坑记录:别让超时设置坑了你
前端·人工智能·后端
Bs_MoneyMagnet1 小时前
基于springboot+vue的图书馆预约系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·毕业设计·图书管理系统·计算机毕业设计
console.log('npc')2 小时前
03 — 核心框架:App 与中间件
后端·node.js·express
小蒜学长2 小时前
基于小程序的绘画作品创作与分享社区系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·绘画作品·创作分享社区
金玉满堂@bj2 小时前
# Java文件打包成可执行JAR包(两种方式:原生javac\+jar命令 / Maven)
java