RocketMQ原生API实现事务消息

178 阅读7分钟

一、RocketMQ原生API使用

1 事务消息

这个事务消息是RocketMQ提供的一个非常有特色的功能。

​ 首先,我们了解下什么是事务消息。官网的介绍是:事务消息是在分布式系统中保证最终一致性的两阶段提交的消息实现。他可以保证本地事务执行与消息发送两个操作的原子性,也就是这两个操作一起成功或者一起失败。

​ 其次,我们来理解下事务消息的编程模型。事务消息只保证消息发送者的本地事务与发消息这两个操作的原子性,因此,事务消息的示例只涉及到消息发送者,对于消息消费者来说,并没有什么特别的。

事务消息生产者的案例见:org.apache.rocketmq.example.transaction.TransactionProducer

事务消息的关键是在TransactionMQProducer中指定了一个TransactionListener事务监听器,这个事务监听器就是事务消息的关键控制器。源码中的案例有点复杂,我这里准备了一个更清晰明了的事务监听器示例

2 使用限制

  • 1、事务消息不支持延迟消息和批量消息。

  • 2、为了避免单个消息被检查太多次而导致半队列消息累积,我们默认将单个消息的检查次数限制为 15 次,但是用户可以通过 Broker 配置文件的 transactionCheckMax参数来修改此限制。如果已经检查某条消息超过 N 次的话( N = transactionCheckMax ) 则 Broker 将丢弃此消息,并在默认情况下同时打印错误日志。用户可以通过重写 AbstractTransactionCheckListener 类来修改这个行为。

  • 3、事务消息将在 Broker 配置文件中的参数 transactionMsgTimeout 这样的特定时间长度之后被检查。当发送事务消息时,用户还可以通过设置用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS 来改变这个限制,该参数优先于 transactionMsgTimeout 参数。

  • 4、事务性消息可能不止一次被检查或消费。

  • 5、提交给用户的目标主题消息可能会失败,目前这依日志的记录而定。它的高可用性通过 RocketMQ 本身的高可用性机制来保证,如果希望确保事务消息不丢失、并且事务完整性得到保证,建议使用同步的双重写入机制。

  • 6、事务消息的生产者 ID 不能与其他类型消息的生产者 ID 共享。与其他类型的消息不同,事务消息允许反向查询、MQ服务器能通过它们的生产者 ID 查询到消费者。

3 实现机制

image.png

事务消息机制的关键是在发送消息时,会将消息转为一个half半消息,并存入RocketMQ内部的一个 RMQ_SYS_TRANS_HALF_TOPIC 这个Topic,这样对消费者是不可见的。再经过一系列事务检查通过后,再将消息转存到目标Topic,这样对消费者就可见了

上面的图例和文字可能比较生硬,以一个实际案例来说明,比如A给B转账,先将扣款消息发送到MQ,然后MQ确认已收到消息,再进行对A扣款,给B增加先不考虑了。

1、将扣款数据消息发动到MQ(对应上图的第1步骤,也就是发送half消息)

2、给A扣款,这时候如果MQ确认收到消息则向监听器的executeLocalTransaction()方法发送消息(对应上图第2步骤)

2-1、如果收到消息、则进行本地事务处理(上图第3步骤);未收到则忽略

2-2、如果MQ收到half消息,但是MQ宕机还没来得及发送,RocketMQ这里有一个补偿机制,他会去扫描自己处于half状态的消息,如果我们一直没有对这个消息执行commit或rollback操作,超过了一定的时间,他就会回调看看你这个消息什么情况,你生产者到底是打算commit这个消息,还是打算rollback这个消息?

3、本地事务执行给A进行扣款,扣款成功则COMMIT,失败则ROLLBACK(上图第4步骤)

3-1、如果MQ收到COMMIT,则认为事务执行成功会将消息提交到topic进行正常消费

3-2、如果MQ收到ROLLBACK,则任务事务执行失败会将half消息直接丢弃

4、如果本地事务宕机或者未给MQ回复、MQ则会再次进行回查(上图第5步骤),会查进入到了方法,判断本地事务成功还是失败(第6步骤),然后进行COMMIT或者ROLLBACK(第7步骤)

如果MQ中的消息一直处于half状态,过了一定的超时时间就会发现这个half消息有问题,会回调你的生产者系统接口。

此时你要判断一下,如果本地事务执行成功了,那你就得再次执行commit请求,反之则再次执行rollback请求。

这个MQ的回调就是一个补偿机制,如果你的half消息响应没收到,或者rollback、commit请求没发送成功,MQ都会来找你询问后续如何处理。

再假设一种场景,如果生产者系统收到了half消息写入成功的响应了,同时尝试执行自己本地事务,然后根据失败或者成功去执行rollback或者commit请求,发送给MQ了。很不巧,mq在这个时候挂掉了,导致rollback或者commit请求发送失败,怎么办?

这种情况的话,那就等MQ自己重启了,重启之后他会扫描half消息,然后还是通过上面说到的补偿机制,去回调你的接口。

4 消息发送

1、首先需要创建一个消息事务处理监听器(具体实现在下面)

TransactionListener transactionListener = new TransactionListenerImpl();

2、普通消息不同的是创建事务使用TransactionMQProducer来指定消息组

TransactionMQProducer producer = new TransactionMQProducer("please_rename_unique_group_name");

3、指定发送的线程池producer.setExecutorService

producer.setExecutorService(executorService);

4、设置消息的处理监听器producer.setTransactionListener

producer.setTransactionListener(transactionListener);

5、使用producer.sendMessageInTransaction()发送消息

producer.sendMessageInTransaction(msg, null);

最终实现代码如下:

public class TransactionProducer {
    public static void main(String[] args) throws MQClientException, InterruptedException {
        TransactionListener transactionListener = new TransactionListenerImpl();
        TransactionMQProducer producer = new TransactionMQProducer("please_rename_unique_group_name");
        ExecutorService executorService = new ThreadPoolExecutor(2, 5, 100, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(2000), new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r);
                thread.setName("client-transaction-msg-check-thread");
                return thread;
            }
        });

        producer.setExecutorService(executorService);
        producer.setTransactionListener(transactionListener);
        producer.start();

        String[] tags = new String[] {"TagA", "TagB", "TagC", "TagD", "TagE"};
        for (int i = 0; i < 10; i++) {
            try {
                Message msg =
                    new Message("TopicTest1234", tags[i % tags.length], "KEY" + i,
                        ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
                SendResult sendResult = producer.sendMessageInTransaction(msg, null);
                System.out.printf("%s%n", sendResult);

                Thread.sleep(10);
            } catch (MQClientException | UnsupportedEncodingException e) {
                e.printStackTrace();
            }
        }

        for (int i = 0; i < 100000; i++) {
            Thread.sleep(1000);
        }
        producer.shutdown();
    }
}

消息处理监听器transactionListener

  • executeLocalTransaction:半消息发送成功触发此方法来执行本地事务
  • checkLocalTransaction:broker将发送检查消息来检查事务状态,并将调用此方法来获取本地事务状态

checkLocalTransaction,消息会检查最多15次,如果15次任然未被确认提交或者回滚,则会进入死信队列

@RocketMQTransactionListener(rocketMQTemplateBeanName = "rocketMQTemplate")
public class TransactionListenerImpl implements RocketMQLocalTransactionListener {
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
        String tags=message.getHeaders().get("tag").toString();
        //TagA的消息会立即被消费者消费到
        if(StringUtils.contains(tags,"TagA")){
            return RocketMQLocalTransactionState.COMMIT;
            //TagB的消息会被丢弃
        }else if(StringUtils.contains(tags,"TagB")){
            return RocketMQLocalTransactionState.ROLLBACK;
            //其他消息会等待Broker进行事务状态回查。
        }else{
            return RocketMQLocalTransactionState.UNKNOWN;
        }

    }

    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
        String tags=message.getHeaders().get("tag").toString();
        //TagC的消息过一段时间会被消费者消费到
        if(StringUtils.contains(tags,"TagC")){
            return RocketMQLocalTransactionState.COMMIT;
            //TagD的消息也会在状态回查时被丢弃掉
        }else if(StringUtils.contains(tags,"TagD")){
            return RocketMQLocalTransactionState.ROLLBACK;
            //剩下TagE的消息会在多次状态回查后最终丢弃
        }else{
            return RocketMQLocalTransactionState.UNKNOWN;
        }
    }
}

5 ACL权限控制

​ 权限控制(ACL)主要为RocketMQ提供Topic资源级别的用户访问控制。用户在使用RocketMQ权限控制时,可以在Client客户端通过 RPCHook注入AccessKey和SecretKey签名;同时,将对应的权限控制属性(包括Topic访问权限、IP白名单和AccessKey和SecretKey签名等)设置在$ROCKETMQ_HOME/conf/plain_acl.yml的配置文件中。Broker端对AccessKey所拥有的权限进行校验,校验不过,抛出异常; ACL客户端可以参考:org.apache.rocketmq.example.simple包下面的AclClient代码。

注意,如果要在自己的客户端中使用RocketMQ的ACL功能,还需要引入一个单独的依赖包

<dependency> 
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-acl</artifactId> 
    <version>4.9.1</version> 
</dependency>
    

​ 而Broker端具体的配置信息可以参见源码包下docs/cn/acl/user_guide.md。主要是在broker.conf中打开acl的标志:aclEnable=true。然后就可以用plain_acl.yml来进行权限配置了。并且这个配置文件是热加载的,也就是说要修改配置时,只要修改配置文件就可以了,不用重启Broker服务。我们来简单分析下源码中的plan_acl.yml的配置:

#全局白名单,不受ACL控制

#通常需要将主从架构中的所有节点加进来

globalWhiteRemoteAddresses:

- 10.10.103.*

- 192.168.0.*

accounts:

#第一个账户

- accessKey: RocketMQ

  secretKey: 12345678

  whiteRemoteAddress:

  admin: false 

  defaultTopicPerm: DENY #默认Topic访问策略是拒绝

  defaultGroupPerm: SUB #默认Group访问策略是只允许订阅

  topicPerms:

  - topicA=DENY #topicA拒绝发布和订阅消息

  - topicB=PUB|SUB #topicB允许发布和订阅消息

  - topicC=SUB #topicC只允许订阅

  groupPerms:

  # the group should convert to retry topic

  - groupA=DENY

  - groupB=PUB|SUB

  - groupC=SUB

#第二个账户,只要是来自192.168.1.*的IP,就可以访问所有资源

- accessKey: rocketmq2

  secretKey: 12345678

  whiteRemoteAddress: 192.168.1.*

  # if it is admin, it could access all resources

  admin: true