SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息

我的springboot的版本是2.1.3 rocketmq-spring-boot-starter版本是2.2.0

之前我的博文手把手带你 SpringBoot 2.X 整合 RocketMq 实现了rocketmq的异步消息生产消费和顺序消息生产和消费 今天来学习一下RocketMQ事务消息的发送消费。

RocketMQ的事务消息分为3种状态,分别是提交状态回滚状态中间状态

TransactionStatus.CommitTransaction: 提交事务,它允许消费者消费此消息。
TransactionStatus.RollbackTransaction: 回滚事务,它代表该消息将被删除,不允许被消费。
TransactionStatus.Unknown: 中间状态,它代表需要检查消息队列来确定状态。

事务消息在解决分布式事务的场景中感觉还是很有用的,虽然我们现在项目的分布式事务是通过Seata来实现的,但是通过事务消息或者消息的最终一次性也是可以的。
事务消息总共分为3个阶段:发送Prepared消息执行本地事务发送确认消息。这三个阶段是前后关联的,只有发送Prepared消息成功,才会执行本地事务,本地事务返回的状态是提交,那么就会发送最终的确认消息。如果在结束消息事务时,本地事务状态失败,那么Broker回查线程定时(默认1分钟)扫描每个存储事务状态的表格文件,如果是已经提交或者回滚的消息直接跳过,如果是Prepared状态则会向生产者发起一个检查本地事务的请求。

一 代码实现

我对我之前的代码进行了修改,先大致看一下代码结构
SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息_第1张图片

增加了 数据库的连接,mapper,service等

数据库创建两个表

CREATE TABLE `order_entity` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `order_type` varchar(50) DEFAULT NULL COMMENT '订单类型',
  `order_no` varchar(50) DEFAULT NULL COMMENT '订单编号',
  `price` decimal(10,2) DEFAULT NULL COMMENT '订单总价',
  `user_id` int(11) DEFAULT NULL  COMMENT '下单用户id',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;


CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL COMMENT '姓名',
  `age` int(10) DEFAULT NULL COMMENT '年龄',
  `account` decimal(10,2) DEFAULT NULL COMMENT '账户余额',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

用户实体

package com.example.entity;

/**
 * @author guog
 * @create 2021-04-21 17:39
 */
public class User {
    String name;
    Integer id;
    Integer age;
    Double account;// 用户账户

    public Double getAccount() {
        return account;
    }

    public void setAccount(Double account) {
        this.account = account;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }
}

UserMapper

@Mapper
public interface UserMapper extends BaseMapper<User> {

}

UserMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper
	namespace="com.example.mapper.UserMapper">
</mapper>

订单实体

package com.example.entity;


/**
 * @author guog
 * @create 2021-04-22 11:32
 */
public class OrderEntity {
    Integer id;
    Integer userId;//下单用户id
    String orderType;
    String orderNo;
    Double price;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getOrderType() {
        return orderType;
    }

    public void setOrderType(String orderType) {
        this.orderType = orderType;
    }

    public String getOrderNo() {
        return orderNo;
    }

    public void setOrderNo(String orderNo) {
        this.orderNo = orderNo;
    }

    public Double getPrice() {
        return price;
    }

    public void setPrice(Double price) {
        this.price = price;
    }

    public Integer getUserId() {
        return userId;
    }

    public void setUserId(Integer userId) {
        this.userId = userId;
    }
}

订单mapper

@Mapper
public interface OrderMapper extends BaseMapper<OrderEntity> {

}

OrderMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper
	namespace="com.example.mapper.OrderMapper">

</mapper>

在之前的testController 中加一个增加订单的方法

    /**
     * 模拟事务消息
     *
     * 步骤一 创建订单
     * 功能:发送事务消息
     */
    @GetMapping("addOrder")
    public String addOrder() {
        OrderEntity order=new OrderEntity();
        order.setOrderNo("11111");
        order.setUserId(1);
        order.setPrice(200.00);
        TransactionSendResult sendResult= rocketMqHelper.transactionSend("TX_ORDER_ADD",MessageBuilder.withPayload(order).build(),order);
        String sendStatus = sendResult.getSendStatus().name();
        String localTXState = sendResult.getLocalTransactionState().name();
        System.out.println("sendStatus---"+sendStatus);
        System.out.println("localTXState---"+localTXState);
        return "success";
    }

rocketMqHelper 工具类增加发送事务消息方法 transactionSend

    /**
     * 发送事务消息
     *
     * @param topic
     * @param message
     * @param arg
     */
    public TransactionSendResult transactionSend(String topic, Message<?> message,Object arg) {
        LOG.info("发送事务消息,topic:" + topic );
        TransactionSendResult sendResult=rocketMQTemplate.sendMessageInTransaction(topic,message,arg);
        return sendResult;
    }

接下来我们创建一个消息的监听器(消费者),这个和普通消息的监听器一样我只是加了操作数据库的代码,代码如下:

/**
 * @author guog
 * @create 2021-04-21 17:40
 */
@Component
@RocketMQMessageListener(consumerGroup = "${rocketmq.producer.groupName1}", topic = "TX_ORDER_ADD",consumeMode = ConsumeMode.ORDERLY)
public class OrderMqListener implements RocketMQListener<OrderEntity> {


    @Autowired
    private UserMapper userMapper;
    
@Override
@Transactional
public void onMessage(OrderEntity order) {
                System.out.println("接收到消息,开始消费..OrderType:" + order.getOrderType() + ",OrderNO:" + order.getOrderNo());
                 // 一般真实环境这里消费前,得做幂等性判断,防止重复消费
    // 方法一:如果你的业务中有某个字段是唯一的,有标识性,如订单号,那就可以用此字段来判断
    // 方法二:新建一张消费记录表t_mq_consumer_log,字段consumer_key是唯一性,能插入则表明该消息还未消费,往下走,否则停止消费
    // 我个人建议用方法二,根据你的项目业务来定义key,这里我就不做幂等判断了,因为此案例只是模拟,重在分布式事务
                User user=userMapper.selectById(order.getUserId());
                user.setAccount(user.getAccount()-order.getPrice());
                userMapper.updateById(user);
        }
 }

除了消费者之外,我们还需要创建事务消息生产者端的消息监听器,注意是生产者,不是消费者,我们需要实现的是RocketMQLocalTransactionListener接口,代码如下:

/**
 * 订单事务消息生产监听
 * @author guog
 * @create 2021-04-21 17:40
 */
@Component
@RocketMQTransactionListener
public class OrderTXMsgListener  implements RocketMQLocalTransactionListener {

    /**
     * 日志
     */
    private static final Logger log = LoggerFactory.getLogger(OrderTXMsgListener.class);
    private static final Gson GSON = new Gson();

    @Autowired
    private OrderService orderService;

    /**
     * 步骤二:
     * 描述:mq收到事务消息后,开始执行本地事务
     * @Transactional:开启本地事务
     * @param msg
     * @param arg
     * @return
     */

    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        log.info(">>>> TX message listener execute local transaction, message={},args={} <<<<",msg,arg);
        // 执行本地事务
        RocketMQLocalTransactionState result = RocketMQLocalTransactionState.COMMIT;
        try {
            String jsonString = new String((byte[]) msg.getPayload(), StandardCharsets.UTF_8);
            OrderEntity order = GSON.fromJson(jsonString, OrderEntity.class);
            orderService.addOrder(order);
        } catch (Exception e) {
            log.error(">>>> exception message={} <<<<",e.getMessage());
            result = RocketMQLocalTransactionState.UNKNOWN;
        }
        return result;
    }

    /**
     * 步骤四
     * 描述:mq回调检查本地事务执行情况
     * @param msg
     * @return
     */

    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        log.info(">>>> TX message listener check local transaction, message={} <<<<",msg.getPayload());
        // 检查本地事务
        RocketMQLocalTransactionState result = RocketMQLocalTransactionState.COMMIT;
        try {
            String jsonString = new String((byte[]) msg.getPayload(), StandardCharsets.UTF_8);
            OrderEntity order = GSON.fromJson(jsonString, OrderEntity.class);
            List<OrderEntity> list = orderService.selectOrder(order);
            if(list.size()<=0){
                result = RocketMQLocalTransactionState.UNKNOWN;
            }

        } catch (Exception e) {
            // 异常就回滚
            log.error(">>>> exception message={} <<<<",e.getMessage());
            result = RocketMQLocalTransactionState.ROLLBACK;
        }
        return result;
    }
}

写了个订单的OrderService

/**
 * @author guog
 * @create 2021-04-26 16:18
 */
@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;


    @Transactional
    public void addOrder(OrderEntity order) {
        orderMapper.insert(order);
    }

    public  List<OrderEntity> selectOrder(OrderEntity order) {
        QueryWrapper<OrderEntity> query=new QueryWrapper<>();
        query.in("order_no", order.getOrderNo());
        List<OrderEntity> list = orderMapper.selectList(query);
        return list;
    }
}

application.yml

server:
  port: 8088
#rocketmq配置
rocketmq:
  name-server: 127.0.0.1:9876
  # 生产者配置
  producer:
    isOnOff: on
    # 发送同一类消息的设置为同一个group,保证唯一
    group: user-rocketmq-group
    groupName: user-rocketmq-group

    group1: order-rocketmq-group
    groupName1: order-rocketmq-group
    # 服务地址
    namesrvAddr: 127.0.0.1:9876
    # 消息最大长度 默认1024*4(4M)
    maxMessageSize: 4096
    # 发送消息超时时间,默认3000
    sendMsgTimeout: 3000
    # 发送消息失败重试次数,默认2
    retryTimesWhenSendFailed: 2

spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    url: jdbc:mysql://127.0.0.1:3306/rocket_deom?useUnicode=true&useSSL=false&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&serverTimezone=GMT%2b8
    username: root
    password: 123456
    hikari:
      connection-test-query: SELECT 1
      driver-class-name: com.mysql.cj.jdbc.Driver
      connection-timeout: 60000
      minimum-idle: 5
      maximum-pool-size: 15
      idle-timeout: 600000
      max-lifetime: 1200000
      auto-commit: true

mybatis-plus:
  mapper-locations:
    -    classpath*:com/example/mapper/*Mapper.xml
  type-aliases-package: com.sxmpx.entity
  global-config:
    db-config:
      db-type: mysql
      id-type: auto
      field-strategy: default
      capital-mode: true

二、消息事务测试

2.1 演示正常的流程

现在数据库加一个用户 并且用户的账户为500元
SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息_第2张图片
订单表为空
SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息_第3张图片

生成一个订单号为11111的订单 订单总价为200元 并且下单用户是上面创建的用户 user_id为1
SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息_第4张图片

首先还是正常的启动项目,访问addOrder方法,在执行本地事务方法中正常情况下返回的值是COMMIT,即提交事务,这种情况下消费者会直接消费消息,而略过检查本地事务的方法。调用该接口,项目日志输出如下:

SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息_第5张图片

通过日志分析可以看出,在执行完本地事务方法之后,返回的本地事务状态是COMMIT_MESSAGE,接着消费者消费消息,和我们的预期是一样的。

2021-04-27 14:06:59.198  INFO 8436 --- [nio-8088-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2021-04-27 14:06:59.198  INFO 8436 --- [nio-8088-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2021-04-27 14:06:59.203  INFO 8436 --- [nio-8088-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 5 ms
2021-04-27 14:06:59.229  INFO 8436 --- [nio-8088-exec-1] com.example.utils.RocketMqHelper         : 发送事务消息,topic:TX_ORDER_ADD
2021-04-27 14:06:59.442  INFO 8436 --- [nio-8088-exec-1] com.example.Listener.OrderTXMsgListener  : >>>> TX message listener execute local transaction, message=GenericMessage [payload=byte[71], headers={rocketmq_TOPIC=TX_ORDER_ADD, rocketmq_FLAG=0, id=b896a47c-d3df-d955-bd36-d551d80bc400, contentType=application/json;charset=UTF-8, rocketmq_TRANSACTION_ID=7F00000120F418B4AAC288ECC9210000, timestamp=1619503619442}],args=com.example.entity.OrderEntity@3498d027 <<<<
2021-04-27 14:06:59.450  INFO 8436 --- [nio-8088-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2021-04-27 14:06:59.539  INFO 8436 --- [nio-8088-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
sendStatus---SEND_OK
localTXState---COMMIT_MESSAGE
接收到消息,开始消费..OrderType:null,OrderNO:11111

用户的余额变成了300元
SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息_第6张图片
订单表里加了一条
SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息_第7张图片

2.2 模仿本地事务执行失败

简单改造下执行本地事务的方法,让直接返回 RocketMQLocalTransactionState.UNKNOWN

    /**
     * 步骤二:
     * 描述:mq收到事务消息后,开始执行本地事务
     * @Transactional:开启本地事务
     * @param msg
     * @param arg
     * @return
     */

    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        log.info(">>>> TX message listener execute local transaction, message={},args={} <<<<",msg,arg);
        // 执行本地事务
        RocketMQLocalTransactionState result = RocketMQLocalTransactionState.COMMIT;
        try {
            String jsonString = new String((byte[]) msg.getPayload(), StandardCharsets.UTF_8);
            OrderEntity order = GSON.fromJson(jsonString, OrderEntity.class);
            orderService.addOrder(order);
        } catch (Exception e) {
            log.error(">>>> exception message={} <<<<",e.getMessage());
            result = RocketMQLocalTransactionState.UNKNOWN;
        }
        return RocketMQLocalTransactionState.UNKNOWN;//改造这里直接返回UNKNOWN
    }

这样因为发生异常,该方法返回的结果是UNKNOWN,根据上文的分析,执行本地事务方法之后应该会执行检查本地事务方法,如果查询到有这条订单的order_no的数据 返回COMMIT 消费者继续消费信息

/**
     * 步骤四
     * 描述:mq回调检查本地事务执行情况
     * @param msg
     * @return
     */

    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        log.info(">>>> TX message listener check local transaction, message={} <<<<",msg.getPayload());
        // 检查本地事务
        RocketMQLocalTransactionState result = RocketMQLocalTransactionState.COMMIT;
        try {
            String jsonString = new String((byte[]) msg.getPayload(), StandardCharsets.UTF_8);
            OrderEntity order = GSON.fromJson(jsonString, OrderEntity.class);
            List<OrderEntity> list = orderService.selectOrder(order);
            if(list.size()<=0){
                result = RocketMQLocalTransactionState.UNKNOWN;
            }

        } catch (Exception e) {
            // 异常就回滚
            log.error(">>>> exception message={} <<<<",e.getMessage());
            result = RocketMQLocalTransactionState.ROLLBACK;
        }
        return result;
    }

清空订单表,重启项目之后,再次调用一下接口,查看日志输出如下:
SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息_第8张图片

2021-04-27 14:24:14.855  INFO 20668 --- [nio-8088-exec-1] com.example.utils.RocketMqHelper         : 发送事务消息,topic:TX_ORDER_ADD
2021-04-27 14:24:15.038  INFO 20668 --- [nio-8088-exec-1] com.example.Listener.OrderTXMsgListener  : >>>> TX message listener execute local transaction, message=GenericMessage [payload=byte[71], headers={rocketmq_TOPIC=TX_ORDER_ADD, rocketmq_FLAG=0, id=0c1dc077-ca38-08a1-b0f1-65444f3a8a42, contentType=application/json;charset=UTF-8, rocketmq_TRANSACTION_ID=7F00000150BC18B4AAC288FC96700000, timestamp=1619504655038}],args=com.example.entity.OrderEntity@47bc7591 <<<<
2021-04-27 14:24:15.047  INFO 20668 --- [nio-8088-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2021-04-27 14:24:15.141  INFO 20668 --- [nio-8088-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
sendStatus---SEND_OK
localTXState---UNKNOW
2021-04-27 14:24:59.156  INFO 20668 --- [pool-1-thread-1] com.example.Listener.OrderTXMsgListener  : >>>> TX message listener check local transaction, message=[123, 34, 105, 100, 34, 58, 110, 117, 108, 108, 44, 34, 117, 115, 101, 114, 73, 100, 34, 58, 49, 44, 34, 111, 114, 100, 101, 114, 84, 121, 112, 101, 34, 58, 110, 117, 108, 108, 44, 34, 111, 114, 100, 101, 114, 78, 111, 34, 58, 34, 49, 49, 49, 49, 49, 34, 44, 34, 112, 114, 105, 99, 101, 34, 58, 50, 48, 48, 46, 48, 125] <<<<
接收到消息,开始消费..OrderType:null,OrderNO:11111

通过日志分析可以看出,在执行完本地事务方法之后,返回的本地事务状态是UNKNOW,接着执行检查本地事务方法,如果查询到有这条订单的order_no的数据 返回COMMIT 消费者继续消费信息,和我们的预期是一样的。

2.3 模仿本地事务回滚

简单改造下执行本地事务的方法,让直接返回RocketMQLocalTransactionState.ROLLBACK,表示该消息将被删除,不允许被消费。

/**
     * 步骤二:
     * 描述:mq收到事务消息后,开始执行本地事务
     * @Transactional:开启本地事务
     * @param msg
     * @param arg
     * @return
     */

    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        log.info(">>>> TX message listener execute local transaction, message={},args={} <<<<",msg,arg);
        // 执行本地事务
        RocketMQLocalTransactionState result = RocketMQLocalTransactionState.COMMIT;
        try {
            String jsonString = new String((byte[]) msg.getPayload(), StandardCharsets.UTF_8);
            OrderEntity order = GSON.fromJson(jsonString, OrderEntity.class);
            orderService.addOrder(order);
        } catch (Exception e) {
            log.error(">>>> exception message={} <<<<",e.getMessage());
            result = RocketMQLocalTransactionState.UNKNOWN;
        }
        return RocketMQLocalTransactionState.ROLLBACK;
    }

再次调用一下接口,查看日志输出如下:
SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息_第9张图片
通过日志分析可以看出,在执行完本地事务方法之后,返回的本地事务状态是ROLLBACK,消费者并没有消费这条消息

这里你可能质疑,前面的发送和本地事务都没啥问题,要么commit要么rollback,但如果这里消费失败怎么办呢?其实这里会产生问题的几率几乎不存在,首先RocketMQ就是高可用的,要真的你系统很庞大很庞大,你可以集群;再者,这里消费成功与否,源码内部已做处理,只要没异常,就会进行消费,而且它也有重试机制;最后,这里消费逻辑你可以扩展,当消费不成功时,你可以把该记录保存下来,定时提醒或人工去处理

我模拟了下消费端消费抛出个异常,并发消费模型中,消息消费失败默认会重试 16 次(16次可能是之前的版本,我测试的4.8.0 可以重试34次),每一次的间隔时间不一样;而顺序消费,如果一条消息消费失败,则会一直消费,直到消费成功。故在顺序消费使用过程中,应用程序需要区分系统异常、业务异常,如果是不符合业务规则导致的异常,则重试多少次都无法消费成功,这个时候一定要告警机制,及时进行人为干预,否则消费会积压会发现消费端会一直重试进行消费,由此可见确实 这里消费逻辑你可以扩展,当消费不成功时,你可以把该记录保存下来,定时提醒或人工去处理

好了RocketMQ的事务消息先演示到这里 有任何不对的地方,评论区见!

最后配个图
SpringBoot 2.X 整合 rocketmq-spring-boot-starter 2.2.0 实现事务消息_第10张图片

你可能感兴趣的:(RocketMQ,java,分布式,消息队列)