RocketMQ 之 事务消息

1,665 阅读5分钟

1、概述

目前主流的 MQ 中,只有 RocketMQ 提供了事务消息,用于支持消息发送与本地事务的最终一致。避免消息发送成功,但是本地事务缺失败的情况。

2、应用场景

例如订单子系统创建订单,需要将订单数据下发到其他系统。大部分时候会用 MQ 解耦,将订单数据写入 MQ,供其他系统消费。用 MQ 解耦的话,可能存在 订单数据入库 了,但是消息发送失败了的情况。此时可以用 事务消息 来确保消息发送和订单数据入库 2 个动作的最终一致性。

3、发送事务消息 demo

3.1、创建 TransactionListener


public class TransactionListenerImpl implements TransactionListener {

    private AtomicInteger transactionIndex = new AtomicInteger(0);

    private ConcurrentHashMap<String, Integer> localTrans = new ConcurrentHashMap<>();

    @Override

    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {

    // todo 执行本地事务,一般而言,业务代码都会放在这里

    int value = transactionIndex.getAndIncrement();

    int status = value % 3;

    localTrans.put(msg.getTransactionId(), status);

    // todo 不管如何执行本地事务,一般都是返回 UNKNOW.

    // todo 本地事务执行成功与否,交由事务回查来判断

    return LocalTransactionState.UNKNOW;

    }

    @Override

    public LocalTransactionState checkLocalTransaction(MessageExt msg) {

    // todo 事务消息回查

    Integer status = localTrans.get(msg.getTransactionId());

    if (null != status) {

        switch (status) {

        case 0:
            return LocalTransactionState.UNKNOW;
        case 1:
            return LocalTransactionState.COMMIT_MESSAGE;
        case 2:
            return LocalTransactionState.ROLLBACK_MESSAGE;
        default:
            return LocalTransactionState.COMMIT_MESSAGE;
        }
    }

    return LocalTransactionState.COMMIT_MESSAGE;

    }

}

3.2、生产者发送事务消息


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");

        // 一个 producer 只能设置一个 事务监听器

        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();

    }

}

4、事务消息原理

4.1、流程概览

image.png

4.2、producer 发送半消息

  1. producer 发送事务消息时,会用同步发送。

  2. 消息属性 添加 TRAN_MSG=true, 标识该消息为事务消息;

  3. 消息属性添加 PGROUP=${生产者}, 以方便 broker 消息回查

  4. broker 端收到消息后,根据 TRAN_MSG 值判断是事务消息。则将消息转存到 topic: RMQ_SYS_TRANS_HALF_TOPIC queueId: 0 下。

broker 端代码位置 SendMessageProcessor#sendMessage()


public class SendMessageProcessor extends AbstractSendMessageProcessor implements NettyRequestProcessor {

    private RemotingCommand sendMessage(final ChannelHandlerContext ctx,
    final RemotingCommand request,
    final SendMessageContext sendMessageContext,
    final SendMessageRequestHeader requestHeader) throws RemotingCommandException {

    // todo 事务消息标识
    String traFlag = oriProps.get(MessageConst.PROPERTY_TRANSACTION_PREPARED);
    if (traFlag != null && Boolean.parseBoolean(traFlag)
    && !(msgInner.getReconsumeTimes() > 0 && msgInner.getDelayTimeLevel() > 0)) { //For client under version 4.6.1

        if (this.brokerController.getBrokerConfig().isRejectTransactionMessage()) {
            response.setCode(ResponseCode.NO_PERMISSION);
            response.setRemark(
            "the broker[" + this.brokerController.getBrokerConfig().getBrokerIP1()
            + "] sending transaction message is forbidden");
            return response;

        }
        // TODO 转存 半消息
        putMessageResult = this.brokerController.getTransactionalMessageService().prepareMessage(msgInner);

    }

}

4.3、producer 执行本地事务

半消息成功发送到 broker 后,producer 会开始执行本地事务。

所谓的本地事务就是我们的业务逻辑,即 TransactionListener#executeLocalTransaction()

一般而言,该返回无特殊情况,都要返回 LocalTransactionState.UNKNOW

本地事务最终的执行结果,应该由消息回查来确认

4.4、producer 结束事务

  1. producer 在执行完本地事务后,单向发送 消息给 broker, 告诉 broker 本次事务的执行结果,即 TransactionListener#executeLocalTransaction() 的返回值。

    代码位置: DefaultMQProducerImpl#sendMessageInTransaction()

  2. 如果本地事务状态是 commit

    a. broker 恢复原 topic,queueId, consumer 可以正常消费事务消息 b. 把消息放入 RMQ_SYS_TRANS_OP_HALF_TOPIC 并通过设置 tags = d 标识该消息已被删除

  3. 如果本地事务状态是 rollback

    a. 把消息放入 RMQ_SYS_TRANS_OP_HALF_TOPIC 并通过设置 tags = d 标识该消息已被删除

  4. 如果本地事务状态是 unknow, 不做任何处理

注: 2~4 步的代码位置:EndTransactionProcessor#processRequest()

4.5、broker 定时回查事务

《4.2 producer 发送半消息》 中,有提到, 本地事务的结果,正常都是返回 LocalTransactionState.UNKNOW, 然后通过 broker 端的回查,来决定消息是提交还是回滚。下面来看下 broker 端如何做定时回查事务的

代码位置: TransactionalMessageCheckService#onWaitEnd()

  1. 每隔 60s 执行一次事务回查

  2. 拉取 半消息(RMQ_SYS_TRANS_HALF_TOPIC), 并利用 RMQ_SYS_TRANS_OP_HALF_TOPIC 做消息去重,进行消息回查

  3. (半消息存储时间 - 当前时间) 必须大于 事务超时时间(默认 6s), 且该消息的回查次数小于 15. 才允许执行事务消息回查

  4. broker 将半消息再次放入半消息主题,然后向 producer 单向发送事务回查消息

  5. producer 执行 TransactionListenerImpl#checkLocalTransaction() 然后单向发送消息通知 broker 本地事务的执行结果

Q&A

Q: 为什么第 4 步中,broker 要将半消息再次放入半消息主题?

A: 为了支持事务消息回查重试

Q: 为什么会将删除的或消费完的半消息写入 RMQ_SYS_TRANS_OP_HALF_TOPIC

A: 为了 broker 端对 半消息去重. 防止重复消费

Q: producer 端在哪里处理 broker 发送的事务回查消息

A: ClientRemotingProcessor#processRequest()

5、2PC 与 RocketMQ

RocketMQ 的事务消息,本质是采用了 2PC 协议。这里介绍 2PC 协议 与 RocketMQ 如何运用 2PC 协议。

5.1、2PC 前提

2PC 协议是有一定的前提条件的,我们来看下该协议的前提条件。

  1. 分布式系统中, 存在 1 个节点作为协调者, 多个节点作为 参与者,且节点之间可以互相通信

  2. 所有节点(不是仅参与者节点?)采用 预写式日志(WAL), 日志不会因为节点挂掉,而导致丢失

  3. 节点不会永久损坏,可恢复

RocketMQ 中运用

描述ApplicationRocketMQMysql
角色协调者参与者参与者
预写日志预写 producer 发送的半消息事务未提交,但日志已被写入到 redo, binlog
可恢复可恢复可恢复可恢复

5.2、基本算法

2PC 协议分为 提交请求阶段提交阶段

5.2.1、提交请求阶段

  1. 协调者向各个参与者询问是否可以执行提交操作, 并开始等待各个参与者的响应。

  2. 各个参与者收到请求, 预写日志

  3. 各个参与者响应协调者发起的询问。返回是同意还是不同意

5.2.2、提交阶段

成功

当协调者收到所有参与者节点的 '同意'

  1. 协调者向所有参与者发出 '提交' 请求

  2. 参与者提交事务

  3. 协调者收到所有参与者的反馈,完成事务.

失败

当协调者收到某个参与者节点的 '不同意'

  1. 协调者向所有参与者发出 '回滚' 请求

  2. 参与者回滚事务

5.2.3、RocketMQ 中运用

image.png

5.2.4、开发注意事项

  1. producer 同步发送半消息。如果 broker 是异步刷盘,可能出现 broker 返回成功,但消息写入失败. 因此 broker 端最好用同步刷盘策略

5.2.5、FAQ

Q: broker 端半消息写入失败, 会怎么办
A: 没怎么办, 程序不接着往下面执行


Q: 本地执行事务失败,会怎么半?
A: Application 返回 UNKONW 或者 ROLLBACKBroker, 如果是 ROLLBACK, 会回滚事务(删除半消息)


Q: 在执行本地事务后,Application 一定是返回 UNKONWbroker, Mysql 在执行成功后奔溃怎么办?
A: Broker 端有消息回查机制,最多回查 15 次,可通过 transactionCheckMax 配置


Q: 本地事务比较耗时,可以另启一个线程跑吗?
A: 理论上可以. broker 端在进行消息回查时,如果事务消息存储时间与当前时间差 小于 事务的最大超时时间, (默认 6s, 可通过 transactionTimeOut 配置),则不会回查