Spring Cloud Stream + RocketMq实现事务性消息

上一篇Spring Cloud消息中间件抽象Stream介绍了Spring Cloud Stream的rabbitmq例子,本文介绍Spring Cloud Stream实现事务性消息的例子。

什么是事务性消息

通过场景来看:
生成订单记录 -> MQ -> 增加积分,我们需要保证消息的发送与订单数据的插入要么都成功,要么都失败。
在这里插入图片描述
我们是应该先创建订单,还是先发送MQ消息?
1、先发送MQ消息:如果消息发送成功,而订单创建失败,没办法把消息收回来。
2、先创建订单:如果订单创建成功后MQ消息发送失败,抛出异常,因为两个操作在一个事务代码块中,所以订单数据会回滚。
但是网络是不稳定的,如果MQ端确实收到了这条消息,只是返回给客户端的响应丢失了,就出现跟1一样的问题。

这就是事务性消息的需求:本地事务 和 消息的发送 需要具有原子性。

RocketMQ事务性消息原理

RocketMQ支持这种事务性消息,它的主要逻辑分为两个流程:
在这里插入图片描述

  • 事务消息发送及提交
    1、发送 half消息
    2、MQ服务端 响应消息写入发送结果
    3、根据发送结果执行 本地事务 (如果写入失败,此时half消息 不可见, 本地逻辑不执行)
    4、根据本地事务状态执行 Commit 或者 Rollback (Commit操作生成消息索引,消息对消费者 可见 )

  • 回查流程:
    1、对于长时间没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次 回查
    2、Producer收到回查消息,检查回查消息对应的 本地事务状态
    3、根据本地事务状态,重新 Commit 或者 Rollback

逻辑时序图:
在这里插入图片描述

消费端一致性实现思路

从上面的原理可以发现 事务消息 仅仅只是保证本地事务和MQ消息发送形成整体的 原子性 ,而投递到MQ服务器后,并无法保证消费者一定能消费成功。
消费者消费失败 后的处理方式,建议时记录异常信息然后 人工处理, 并不建议回滚上游服务数据,我们可以利用MQ的两个特性 重试 和 死信队列 来协助消费者处理。

生产端编码实践

安装并运行rocketmq,可以参考:https://zhuanlan.zhihu.com/p/85500306

github源码地址: https://github.com/guzhangyu/learn-spring-cloud/tree/master/spring-cloud-stream/spring-cloud-stream-transaction-sender

pom依赖

<dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>2.0.3</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
            <!--            <version>2.2.1.RELEASE</version>-->
        </dependency>

application.yaml配置

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/test?serverTimeZone=UTC&useSSL=false&allowPublicKeyRetrieval=true
    username: root
    password: root
  cloud:
    stream:
      rocketmq:
        binder:
          name-server: 192.168.2.174:9876
          enable-msg-trace: true
        bindings:
          output:
            producer:
              group: erp
              transactional: true
      bindings:
        output:
          destination: update-account-score


mybatis:
  type-aliases-package: com.learn.springcloud.dao
  mapper-locations: classpath:mybatis/mapper/*.xml

消息发送代码

发送消息的类

import com.learn.springcloud.dao.OrderMapper;
import com.learn.springcloud.entity.Order;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.Date;

/**
 * @Author zhangyugu
 * @Date 2020/8/13 3:45 下午
 * @Version 1.0
 */
@Service
public class RocketMqTestService {

    @Autowired
    RocketMQTemplate rocketMQTemplate;

    @Autowired
    OrderMapper orderMapper;

    @Transactional(rollbackFor = Exception.class)
    public void testTransaction() {
        Order order = new Order();
        order.setTradeId(1L);
        order.setItemId(1L);
        order.setItemName("item1");
        order.setItemPrice(new BigDecimal("0.3"));
        order.setNum(4);
        order.setAccountId(1L);
        order.setGmtCreate(new Date());
        orderMapper.insert(order);

        // 事务id
        String transactionId = "trans-1";
        rocketMQTemplate.sendMessageInTransaction("erp",
                "update-account-score",
                MessageBuilder.withPayload(order)
                .setHeader(RocketMQHeaders.TRANSACTION_ID, transactionId)
                .setHeader("share_id", 3).build(),
                4L
        );
        System.out.println(" prepare 消息发送成功");
        // 这里消息发送只是prepare发送,
        // 后面消息队列中prepare成功后,在TestTransactionListener中的executeLocalTransaction的方法中决定是否要提交本地事务
    }
}

发送之后用于控制原子性的类

import com.alibaba.fastjson.JSON;
import com.learn.springcloud.dao.OrderMapper;
import com.learn.springcloud.entity.Order;
import org.apache.rocketmq.spring.annotation.RocketMQTransactionListener;
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionListener;
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionState;
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;

import java.util.List;

/**
 * @Author zhangyugu
 * @Date 2020/8/13 3:55 下午
 * @Version 1.0
 */
@RocketMQTransactionListener(txProducerGroup = "erp")
public class TestTransactionListener implements RocketMQLocalTransactionListener {

    @Autowired
    OrderMapper orderMapper;


    /**
     * rocketmq 消息发送成功之后,提交本地事务
     */
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
        Order order = JSON.parseObject(new String((byte[])message.getPayload()), Order.class);
        Long args = (Long) o;
        System.out.println(String.format("half message\npayload:%s, arg:%s, transactionId:%s", order, args, message.getHeaders().get(RocketMQHeaders.TRANSACTION_ID)));
        return RocketMQLocalTransactionState.COMMIT;
    }

    /**
     * rocketmq 回查时,告诉它要提交,还是回滚
     * @param message
     * @return
     */
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
        Order order = JSON.parseObject(new String((byte[])message.getPayload()), Order.class);
        List<Order> orders = orderMapper.queryByTradeAndItem(order.getTradeId(), order.getItemId());

        // 根据message去查询本地事务是否执行成功,如果成功,则commit
        if(orders.size() > 0){
            return RocketMQLocalTransactionState.COMMIT;
        }else {
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }
}

运行结果

half message
payload:Order(id=null, tradeId=1, itemId=1, itemName=item1, itemPrice=0.3, num=4, accountId=1, gmtCreate=Sat Aug 15 13:53:14 CST 2020, gmtModify=null), arg:4, transactionId:trans-1
 prepare 消息发送成功

从这个运行结果可以看出,在消息发送之后,收到rocketmq的发送结果通知后才提交的本地事务。

消费端编码实践

github源码地址: https://github.com/guzhangyu/learn-spring-cloud/tree/master/spring-cloud-stream/spring-cloud-stream-transaction-receiver

pom依赖

 <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>2.0.3</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
        </dependency>

application.yaml 配置

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/test?serverTimeZone=UTC&useSSL=false&allowPublicKeyRetrieval=true
    username: root
    password: root

  cloud:
    stream:
      rocketmq:
        binder:
          name-server: 192.168.2.174:9876
          enable-msg-trace: true
        bindings:
          input:
            consumer:
              delayLevelWhenNextConsume: -1
      bindings:
        input:
          destination: update-account-score
          group: erp
          consumer:
            concurrency: 20
            maxAttempts: 2
        inputDlq:
          destination: update-account-score
          group: '%DLQ%${spring.cloud.stream.bindings.input.group}'
          consumer:
            concurrency: 20

mybatis:
  type-aliases-package: com.learn.springcloud.dao
  mapper-locations: classpath:mybatis/mapper/*.xml

消息接收代码

import com.learn.springcloud.dao.AccountMapper;
import com.learn.springcloud.entity.Account;
import com.learn.springcloud.entity.Order;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.messaging.SubscribableChannel;
import org.springframework.transaction.annotation.Transactional;

/**
 * @Author zhangyugu
 * @Date 2020/8/13 4:21 下午
 * @Version 1.0
 */
@EnableBinding(TestInput.class)
@Slf4j
public class TestConsumer {

    @Autowired
    AccountMapper accountMapper;


    @StreamListener(TestInput.TEST_INPUT)
    @Transactional(rollbackFor = Exception.class)
    public void input(Order order) {
//        throw new IllegalArgumentException("测试失败");

        Account account = accountMapper.selectById(order.getAccountId());
        if(account == null){
            throw new IllegalArgumentException("该用户不存在: " + order.getAccountId());
        }

        account.setScore(account.getScore() + 1);
        accountMapper.updateScore(account);
    }

    @StreamListener(TestInput.TEST_DLQ_INPUT)
    @Transactional(rollbackFor = Exception.class)
    public void dlqInput(Order order) {
//        throw new IllegalArgumentException("dlq测试失败");
        log.error("update-account-score失败:{}", order);
    }
}

interface TestInput {
    String TEST_INPUT = "input";

    String TEST_DLQ_INPUT = "inputDlq";

    @Input(TEST_INPUT)
    SubscribableChannel input();

    @Input(TEST_DLQ_INPUT)
    SubscribableChannel inputDlq();
}

疑问阐述

1、yaml配置中的group 和 destination到底有什么用?
结合RocketMq-console-ng可以发现,在发送的时候topic用的是destination,但是进入重试队列或者死信队列时却用的是%RETRY%group 和 %DLQ%group。

从RocketMq的概念来看:
Producer Group 标识发送同一类消息的Producer,通常发送逻辑一致。发送普通消息时,仅标识使用,并无特别用处。若事务消息,如果发送某条消息的producer-A宕机,使得事务消息一直处于PREPARED状态并超时,则broker会回查同一个group的其他producer,确认这条消息应该commit 还是 rollback。

Consumer Group标识一类Consumer的集合名称,这类Consumer通常消费一类消息,且消费逻辑一致。同一个Consumer Group下的各个实例将共同消费topic的消息,起到负载均衡的作用。

注:RocketMQ要求同一个Consumer Group的消费者必须要拥有相同的注册信息,即必须要听一样的topic(并且tag也一样)。

Topic 标识一类消息的逻辑名称,消息的逻辑管理单位。无论消息生产还是消费,都需要指定Topic。

从这段话来看,发送的时候destination作为topic;但是消费的时候如果失败,则会根据group作为topic名称的构造依据,来新建重试和死信队列。

2、用来控制发送消息 和 本地事务 的原子性的注解RocketMQTransactionListener 并没有体现destination,而仅仅到group级别,这是何解?难道要在方法内部判断是哪个topic的消息?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值