【RocketMQ 生产者和消费者】- 事务源码分析(2)


本文章基于 RocketMQ 4.9.3

1. 前言

2. TransactionalMessageCheckService

broker 在启动的时候会通过 initialTransaction 来初始化事务相关的服务,事务回查就是在 TransactionalMessageCheckService 中发起的。

java 复制代码
private void initialTransaction() {
    this.transactionalMessageService = ServiceProvider.loadClass(ServiceProvider.TRANSACTION_SERVICE_ID, TransactionalMessageService.class);
    if (null == this.transactionalMessageService) {
        // 事务消息处理类
        this.transactionalMessageService = new TransactionalMessageServiceImpl(new TransactionalMessageBridge(this, this.getMessageStore()));
        log.warn("Load default transaction message hook service: {}", TransactionalMessageServiceImpl.class.getSimpleName());
    }
    this.transactionalMessageCheckListener = ServiceProvider.loadClass(ServiceProvider.TRANSACTION_LISTENER_ID, AbstractTransactionalMessageCheckListener.class);
    if (null == this.transactionalMessageCheckListener) {
        this.transactionalMessageCheckListener = new DefaultTransactionalMessageCheckListener();
        log.warn("Load default discard message hook service: {}", DefaultTransactionalMessageCheckListener.class.getSimpleName());
    }
    this.transactionalMessageCheckListener.setBrokerController(this);
    // 事务回查服务
    this.transactionalMessageCheckService = new TransactionalMessageCheckService(this);
}

TransactionalMessageCheckService 也是一个线程,默认是 60s 会进行一次事务回查看看哪些 half 消息在事务超时时间内还没有对应的 op 消息,就认为这些 half 消息没有提交本地事务执行结果,需要进行事务回查。里面的逻辑不复杂,下面直接给出所有代码。

java 复制代码
/**
 * 事务消息回查
 */
public class TransactionalMessageCheckService extends ServiceThread {
    private static final InternalLogger log = InternalLoggerFactory.getLogger(LoggerName.TRANSACTION_LOGGER_NAME);

    private BrokerController brokerController;

    public TransactionalMessageCheckService(BrokerController brokerController) {
        this.brokerController = brokerController;
    }

    @Override
    public String getServiceName() {
        return TransactionalMessageCheckService.class.getSimpleName();
    }

    @Override
    public void run() {
        log.info("Start transaction check service thread!");
        // 事务回查时间间隔, 默认是 60s, 可以通过 broker.conf 里面的 transactionCheckInterval 来配置
        long checkInterval = brokerController.getBrokerConfig().getTransactionCheckInterval();
        while (!this.isStopped()) {
            // 这里就是等待 60s 之后进行事务回查
            this.waitForRunning(checkInterval);
        }
        log.info("End transaction check service thread!");
    }

    /**
     * 事务回查
     */
    @Override
    protected void onWaitEnd() {
        // 事务超时时间, 默认是 6s, 这个超过 6s 还没有 commit 或者 rollback 的消息会进行事务回查, 可以修改 broker.conf 里面的 transactionTimeOut
        long timeout = brokerController.getBrokerConfig().getTransactionTimeOut();
        // 事务回查最大次数, 回查超过这个次数都没有得到消息的状态, 就会丢弃消息, 可以通过 broker.conf 里面的 transactionCheckMax 配置
        int checkMax = brokerController.getBrokerConfig().getTransactionCheckMax();
        // 事务回查起始时间
        long begin = System.currentTimeMillis();
        log.info("Begin to check prepare message, begin time:{}", begin);
        // 核心逻辑, 执行事务回查
        this.brokerController.getTransactionalMessageService().check(timeout, checkMax, this.brokerController.getTransactionalMessageCheckListener());
        log.info("End to check prepare message, consumed time:{}", System.currentTimeMillis() - begin);
    }

}

事务回查的间隔可以通过 broker.conf 里面的 transactionCheckInterval 来配置,就是上两篇文章在写事务例子时配置的:

transactionCheckInterval=20000

上面就是在 TransactionListenerImpl 里面打印的输出日志,所以为什么事务第一次回查不是间隔 20s 就有了答案,因为这个 20s 只是进行事务回查的间隔时间,第一次启动之后 broker 就开始计时,而生产者是后面再启动的,所以很有可能生产者提交消息的时候 broker 的 20s 快结束了,因此会发现 half 消息还没到 6s 的超时时间,所以 broker 在第二轮的事务回查才发现有消息超时了,于是进行事务回查。

3. check 检查超时的 half 消息

3.1 分析流程

这里 RocketMQ 是用了类似双指针的方式去扫描 half 队列和 op 队列,判断 half 消息有没有超时的。看下面图的结构,opOffset 是当前 op 队列已经遍历到的位置,halfOffset 是 half 队列已经遍历到的位置。流程大概就是每一次从 opOffset 开始获取 32 条 op 消息填充到 removeMap 中,然后从 halfOffset 开始遍历 half 队列,遍历到 half 消息就判断这条消息的 offset 是不是在 removeMap 中,如果在就说明 这条 half 消息本地事务执行已经完成 ,如果不在就 判断是否需要回查,一次回查就算有再多的消息也只会回查 60s,最后遍历结束更新 halfOffset 和 opOffset 为最新的那条。

上面是大致的流程,但是具体实现还是比较复杂的,因为消息队列的特性是顺序写入,生产者的 half 消息可不会规规矩矩按写入顺序来提交 op 消息,有一些消息执行的比较慢的,虽然 halfOffset 小,但是 op 消息会在后面提交,有一些执行的比较快的就有可能比较早提交,因此就会出现下面这种情况。

绿色箭头就是半消息的提交,可以看到虽然 half 消息 2 产生比较晚,但是本地事务执行比较快,因此提交了 op 消息之后生成的 op 消息偏移量也是靠前的,注意 broker 一直运行的情况下并不会出现说当前 halfOffset1 没有在 op 队列中找到,然后会接着获取下一批 32 条 op 消息去判断,直到获取到 halfOffset1 或者超时,接下来再处理 halfOffset2 的时候发现找不到 op 消息的情况。因为上面也说了 op 消息会一直存在 removeMap 中,除非 broker 挂了这部分数据才会丢失,因为是存到内存的。

有没有一种极端情况,由于事务回查服务一次回查最多执行 60s,同时事务超时时间又设置了 100s,那么 halfOffset1 对应的本地事务执行了差不多 60s,最后一次终于获取到了对应的 op 消息,然后继续处理下一条 halfOffset2 的时候发现超时了,于是本次回查结束,更新 opOffset 为 halfOffset1 对应的那条,接下来下一次回查发现 halfOffset2 对应的 op 消息找不到了,因为是从最新的 opOffset 开始往后遍历的。

RocketMQ 对于这种情况的处理也是比较巧妙的,最后更新 opOffset 时用了一个 calculateOpOffset 方法去计算下一次要从哪里开始拉取 opOffset,这里面一句话概括就是 把一批已经处理过的 OP(操作)消息的 offset 压缩成一个"连续区间" 右边界,从而告诉 Broker: 从哪个 offset 开始,后面才是真正还没回查过的消息。比如上面的情况,处理完 halfOffset1 之后 op 消息偏移量列表是:[1,2,3,100],事务回查刚开始的 oldOffset 是 1。这个 100 就是 halfOffset1 对应的 op 消息偏移量,但是经过这个方法处理之后会返回 4,也就是下一次会从 4 开始拉取 op 消息,而不是 100,这样就避免上面的情况了。

上面如果觉得绕是正常的,下面从具体的代码来看下步骤。

3.2 代码分析

首先获取 half 消息的 topic RMQ_SYS_TRANS_HALF_TOPIC,然后获取这个 topic 下面的所有消息队列。

java 复制代码
// 首先获取半事务消息的 topic => RMQ_SYS_TRANS_HALF_TOPIC
String topic = TopicValidator.RMQ_SYS_TRANS_HALF_TOPIC;
// 然后获取这个 topic 下面的所有消息队列, 因为半事务消息默认就只有一个 queueId = 0 的队列, 所以这里的 set 大小就是 1
Set<MessageQueue> msgQueues = transactionalMessageBridge.fetchMessageQueues(topic);
if (msgQueues == null || msgQueues.size() == 0) {
   log.warn("The queue of topic is empty :" + topic);
   return;
}
log.debug("Check topic={}, queues={}", topic, msgQueues);

接下来遍历所有队列,实际上 half 消息的消息队列就一个,queueId = 0。

java 复制代码
for (MessageQueue messageQueue : msgQueues) {
	...
}

获取这个 half 消息队列下面的 op 队列,就是从 opQueueMap 中获取到,如果对这个 map 没有印象了可以看下上一篇文章最后写入 op 队列的逻辑。然后获取 halfOffset 和 opOffset,相当于双指针,从 halfOffset 开始遍历 half 消息,然后从 opOffset 开始拉取 op 消息。

java 复制代码
// 开始时间
long startTime = System.currentTimeMillis();
// 获取这个消息队列下面的 OP 队列
MessageQueue opQueue = getOpQueue(messageQueue);
// 获取内部消费者组 CID_RMQ_SYS_TRANS 消费的这个半事务消息队列偏移量
long halfOffset = transactionalMessageBridge.fetchConsumeOffset(messageQueue);
// 获取内部消费者组 CID_RMQ_SYS_TRANS 消费的这个 OP 消息队列的偏移量, 下一次拉取 OP 消息就从这里开始拉取
long opOffset = transactionalMessageBridge.fetchConsumeOffset(opQueue);
log.info("Before check, the queue={} msgOffset={} opOffset={}", messageQueue, halfOffset, opOffset);
if (halfOffset < 0 || opOffset < 0) {
    log.error("MessageQueue: {} illegal offset read: {}, op offset: {},skip this queue", messageQueue,
        halfOffset, opOffset);
    // 不合法的偏移量
    continue;
}

接下来定义两个集合 doneOpOffsetremoveMap,doneOpOffset 意思是处理这个集合中的偏移量都是已经处理过的 op 消息的偏移量,就比如上面 3.1 分析的极端情况,有一些 half 消息是提交比较早,但是本地事务处理的慢,这种情况下会出现说 offset 较小的 half 消息提交的 op 消息偏移量比较靠后,实际上遍历到这些消息都会存到 doneOpOffset 里面,代表这条 half 消息是已经 commit 或者 rollback 了。

removeMap 就表示本次还没有处理到的 half 消息,但是这条 half 消息已经 commit 或者 rollback 了,偏移量存在这个 removeMap 中, 因为 op 消息是一次性拉取 32 条的,所以需要这么一个 map 来标记哪些未处理的 half 消息是已经提交了的,比如当前的 halfOffset = 1,然后一次性拉取下来的 op 消息中大于 1 的就会把映射添加到这个集合里面。

java 复制代码
// 已经处理过的 half 消息对应的 op 消息
List<Long> doneOpOffset = new ArrayList<>();
// 本次还没有处理到的 half 消息, 如果这条 half 消息已经 commit 或者 rollback 了, 偏移量存在这个 removeMap 中, 说
// 明这条 half 消息存在一条 op 消息, 就不会再回查了
//      key: half 消息的偏移量
//      value: op 消息的偏移量
HashMap<Long, Long> removeMap = new HashMap<>();

接下来先拉取 32 条 op 消息对这两个 map 进行填充,方法是 fillOpRemoveMap。

java 复制代码
// 从 opOffset 开始获取 32 条 op 消息, 然后将这些消息对应的 [half 消息偏移量 -> op 消息偏移量] 的关系填充到 removeMap 中,
// 同时也填充 doneOpOffset, 表明这条 op 消息已经处理过
PullResult pullResult = fillOpRemoveMap(removeMap, opQueue, opOffset, halfOffset, doneOpOffset);
if (null == pullResult) {
    log.error("The queue={} check msgOffset={} with opOffset={} failed, pullResult is null",
        messageQueue, halfOffset, opOffset);
    continue;
}

下面定义几个变量,newOffset 表示处理到的最新消息的 half 消息的偏移量,i 表示当前处理到哪条 half 消息了,跟上面的 newOffset 是同步更新的。

java 复制代码
// 单线程处理
int getMessageNullCount = 1;
// 处理到的最新消息的 half 消息的偏移量
long newOffset = halfOffset;
// 从 halfOffset 开始遍历消费, 比 halfOffset 小的都是已经处理过的
long i = halfOffset;

接下来就是一个 while(true) 死循环,在里面不断拉取 op 消息检测 half 消息,但是上限是 60s,如果本次回查时间已经超过了 60s,退出。

java 复制代码
while (true) {
     // 如果本次回查时间已经超过了 60s, 退出, 一次消息回查最多回查 60s
     if (System.currentTimeMillis() - startTime > MAX_PROCESS_TIME_LIMIT) {
         log.info("Queue={} process time reach max={}", messageQueue, MAX_PROCESS_TIME_LIMIT);
         break;
     }
     ...
}

然后判断当前的 half 消息偏移量是不是在 removeMap 中,如果在说明本地事务已经执行完了,不需要回查,将这条 half 消息的 op 偏移量添加到 doneOpOffset 中,代表这条 op 消息已经处理过了。

java 复制代码
// 如果这条 half 消息已经在 removeMap 中, 说明这条消息已经 commit 或者 rollback 了
if (removeMap.containsKey(i)) {
    log.debug("Half offset {} has been committed/rolled back", i);
    System.out.printf("Half offset %d has been committed/rolled back\n", i);
    // 转移到 doneOpOffset 集合中
    Long removedOpOffset = removeMap.remove(i);
    doneOpOffset.add(removedOpOffset);
}

如果不在,那么说明这条消息要么已经提交了,但是 op 消息靠后,要么就是本地事务还没执行完或者没有明确的返回结果,这种情况下先获取 half 消息。

java 复制代码
// 从 i 开始接着顺序获取队列里面的 half 消息
GetResult getResult = getHalfMsg(messageQueue, i);
// 获取消息体
MessageExt msgExt = getResult.getMsg();

然后判断消息获取的状态,如果是获取不到消息,将获取消息为空的标记 + 1,意味者下一次还找不到消息直接退出本地回查,这个是为了提高容错,有些时候并不是 half 消息队列没有最新的消息了,而是偏移量不太对,这种情况下可以有一次机会纠正偏移量,但是如果返回结果确实是 NO_NEW_MSG,明确说明这个队列没有更新的 half 消息了,这时候就直接退出。

java 复制代码
  // 如果找不到消息, 设置标记 + 1, 这里最大次数是 1, 意味者下一次还找不到消息直接返回
    if (getMessageNullCount++ > MAX_RETRY_COUNT_WHEN_HALF_NULL) {
        break;
    }
    if (getResult.getPullResult().getPullStatus() == PullStatus.NO_NEW_MSG) {
        // 这里是偏移量对了, 但是 half 队列没有新的消息了, 当前队列结束 while 循环
        log.debug("No new msg, the miss offset={} in={}, continue check={}, pull result={}", i,
            messageQueue, getMessageNullCount, getResult.getPullResult());
        break;
    } else {
        // 偏移量有问题
        log.info("Illegal offset, the miss offset={} in={}, continue check={}, pull result={}",
            i, messageQueue, getMessageNullCount, getResult.getPullResult());
        // 更新偏移量为下一次拉取的起始偏移量
        i = getResult.getPullResult().getNextBeginOffset();
        // 更新 newOffset
        newOffset = i;
        // 继续从 newOffset 开始遍历找消息
        continue;
    }
}

到这里就是能找到 half 消息,然后看下是否需要丢弃这条消息或者说是否需要跳过这条消息,当消息回查次数达到了 15 次,后续就不会再回查,直接丢掉消息,又或者没有超过 15 次,但是这条 half 消息距离存储的时间已经过去 3 天了,RocketMQ 的 CommitLog 文件保留日期是默认 3 天,所以如果一条 half 消息都超过 3 天了还没有处理,直接跳过,这条 half 消息本体有可能都被删掉了,跳过这条消息,处理下一条。

java 复制代码
// 判断是否需要丢弃这条消息或者说是否需要跳过这条消息
if (needDiscard(msgExt, transactionCheckMax) || needSkip(msgExt)) {
    // 丢掉这条 half 消息, 实际上是将消息存在内部 topic, 也就是 TRANS_CHECK_MAX_TIME_TOPIC 中
    listener.resolveDiscardMsg(msgExt);
    // 继续处理下一条
    newOffset = i + 1;
    i++;
    continue;
}

然后判断如果当前 half 消息的存储时间超过本次事务回查的开始时间,直接退出本次回查,RocketMQ 的事务检查只会处理在当前回查开始之前提交的 half 消息。

java 复制代码
if (msgExt.getStoreTimestamp() >= startTime) {
    log.debug("Fresh stored. the miss offset={}, check it later, store={}", i,
        new Date(msgExt.getStoreTimestamp()));
    break;
}

上面几个情况都不满足,下面获取事务回查的超时时间,这个超时时间有两种设置方式,可以设置在消息的用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS 中,也可以通过 broker.conf 的 transactionTimeOut 去配置,优先级是用户属性大于 broker 配置。

java 复制代码
// 消息已经生成多长时间了
long valueOfCurrentMinusBorn = System.currentTimeMillis() - msgExt.getBornTimestamp();
// 事务回查的超时时间
long checkImmunityTime = transactionTimeout;
// 从消息属性 PROPERTY_CHECK_IMMUNITY_TIME_IN_SECONDS 获取消息检查时间, 用户自定义超时时间
String checkImmunityTimeStr = msgExt.getUserProperty(MessageConst.PROPERTY_CHECK_IMMUNITY_TIME_IN_SECONDS);
// 如果有设置这个属性, 我们本次示例没有, 一般也没有这玩意, 除非用户自己定义
if (null != checkImmunityTimeStr) {
// 转成 long 类型的 ms
checkImmunityTime = getImmunityTime(checkImmunityTimeStr, transactionTimeout);
// 如果消息存储时间小于超时时间, 说明还没有到回查的时间
if (valueOfCurrentMinusBorn < checkImmunityTime) {
    // 这里其实是将半消息又重新丢回队列里面了, 如果成功丢回, 那么跳过本条消息的处理
    if (checkPrepareQueueOffset(removeMap, doneOpOffset, msgExt)) {
        // 本条消息不处理, 继续处理下一条 half 消息
        newOffset = i + 1;
        i++;
        continue;
    }
}

可以看到,如果有设置了 CHECK_IMMUNITY_TIME_IN_SECONDS,就会将事务的超时时间更新通过 checkImmunityTimeStr 解析出来,然后再去判断当前 half 消息是不是已经超过超时时间了还没有提交,如果不是,重新将 half 消息丢回队列里面,继续处理下一条 half 消息。

那如果没有设置这个用户属性,那么直接判断当前 half 消息生成时间有没有超过了事务超时时间,默认 6s,如果没有,直接结束本次回查,下一次回查开始还是从这条消息开始遍历。

java 复制代码
else {
    // 没有设置 PROPERTY_CHECK_IMMUNITY_TIME_IN_SECONDS 属性, 直接判断有没有到回查时间了
    if ((0 <= valueOfCurrentMinusBorn) && (valueOfCurrentMinusBorn < checkImmunityTime)) {
        log.debug("New arrived, the miss offset={}, check it later checkImmunity={}, born={}", i,
            checkImmunityTime, new Date(msgExt.getBornTimestamp()));
        // 没到, 直接结束
        break;
    }
}

那如果到这里就是超时了,超时了也不一定就要立刻回查,首先你不确定在 broker 回查期间这条 half 消息有没有执行完成了,因此还得继续判断到底要不要回查。

java 复制代码
// 这里就是有可能需要事务回查了
List<MessageExt> opMsg = pullResult.getMsgFoundList();
// 到这里这条 half 消息就需要判断是否需要检查
// 1. 如果 opMsg 为空, 同时这条 half 消息存储的生成时间间隔超过了事务回查时间, 需要回查
// 2. 如果 opMsg 不为空, 但是最后一条 op 消息的生成时间距离当前超过了事务回查的时间戳, 需要回查, 这里用了一个整体
//    的概念, 首先消息不在前面查询出的 32 条 op 消息中, 同时到这里距离一开始 while 循环已经过去 transactionTimeout 了,
//    哪怕这条消息执行本地事务了, 那么对应的 op 消息的 bornTime 也一样减去 startTime 大于超时时间了, 都要进行回查的
// 3. valueOfCurrentMinusBorn <= -1, 这种应该是属于异常情况
boolean isNeedCheck = (opMsg == null && valueOfCurrentMinusBorn > checkImmunityTime)
    || (opMsg != null && (opMsg.get(opMsg.size() - 1).getBornTimestamp() - startTime > transactionTimeout))
    || (valueOfCurrentMinusBorn <= -1);

这里判断是用了一个整体的思维,首先第一个条件 opMsg == null && valueOfCurrentMinusBorn > checkImmunityTime,如果没有 op 消息了,并且当前的 half 消息已经超时,这时候需要回查,这个条件就比较直观。

第二个条件,(opMsg != null && (opMsg.get(opMsg.size() - 1).getBornTimestamp() - startTime > transactionTimeout)) 说明如果最后一条 op 消息生成时间距离当前时间已经超过了超时时间,需要回查,什么意思呢?首先前面判断的时候也说过,本次回查是不会处理那些 storeTime > startTime 的消息的,同时来到这里说明当前的 half 消息要么没有提交,要么提交了 op 消息,但是 op 消息不在 removeMap 中,同时 opMsg 最后一条消息的生成时间距离 startTime 已经超过了超时时间,那么对于当前 half 消息,不管本地事务有没有执行完成,都已经超时了,因此这时候需要回查。

最后 valueOfCurrentMinusBorn <= -1,这种应该是属于异常情况了,因为上面判断如果 msgExt.getStoreTimestamp() >= startTime 就返回,同时这个 valueOfCurrentMinusBorn = System.currentTimeMillis() - msgExt.getBornTimestamp(),而 System.currentTimeMillis() 是大于 startTime 的,应该是不会出现这种情况,除非时钟回拨?

判断完了之后就根据结果决定是不是需要回查,如果需要就先将 half 消息重新写回队列,然后发起事务回查请求,如果不需要就继续找下一批 op 消息填充 removeMap 和 doneOpOffset 继续判断当前 half 消息有没有完成本地事务。

java 复制代码
if (isNeedCheck) {
    // 先将消息重新写回 half 队列, 如果写回失败, 就不处理这条 half 消息
    if (!putBackHalfMsgQueue(msgExt, i)) {
        continue;
    }
    // 如果消息写回 half 队列成功, 通过 listener 向 producer 发送一条事务回查请求
    listener.resolveHalfMsg(msgExt);
} else {
    // 如果当前消息不需要回查, 继续找下一批 op 消息填充 removeMap 和 doneOpOffset, 注意这里 i 是没有 + 1 的
    // 所以找下一批 op 消息填充完之后还是会处理这条 half 消息, 主要是得判断下这条消息到底有没有 commit 或者 rollback
    pullResult = fillOpRemoveMap(removeMap, opQueue, pullResult.getNextBeginOffset(), halfOffset, doneOpOffset);
    System.out.println("不需要回查, 查询下一批 op 消息填充 removeMap 和 doneOpOffset: " + pullResult);
    log.debug("The miss offset:{} in messageQueue:{} need to get more opMsg, result is:{}", i,
        messageQueue, pullResult);
    continue;
}

最后就是如果需要消息回查,发送完回查请求之后,继续处理下一条 half 消息。

java 复制代码
// 继续处理下一条 half 消息
newOffset = i + 1;
i++;

当本次回查处理完成,判断是否需要更新 half 消息和 op 消息的偏移量。

java 复制代码
// 这个 half 队列消息处理完了
if (newOffset != halfOffset) {
    // 更新处理到哪个位置
    transactionalMessageBridge.updateConsumeOffset(messageQueue, newOffset);
}
// 再计算 op 消息处理到哪个位置了
long newOpOffset = calculateOpOffset(doneOpOffset, opOffset);
if (newOpOffset != opOffset) {
    // 更新偏移量
    transactionalMessageBridge.updateConsumeOffset(opQueue, newOpOffset);
}

3.3 fetchConsumeOffset 从 offsetTable 获取起始 ConsumeQueue 索引

上面 3.2 整体流程看完,这里开始就讲一下各个方法的细节,首先是 fetchConsumeOffset 从 offsetTable 获取起始 ConsumeQueue 索引,这里的索引就是上一次处理到哪个位置,如果是第一次就从 ConsumeQueue 的最小偏移量开始请求。

java 复制代码
public long fetchConsumeOffset(MessageQueue mq) {
    long offset = brokerController.getConsumerOffsetManager().queryOffset(TransactionalMessageUtil.buildConsumerGroup(),
        mq.getTopic(), mq.getQueueId());
    if (offset == -1) {
        offset = store.getMinOffsetInQueue(mq.getTopic(), mq.getQueueId());
    }
    return offset;
}

/**
 * 从 broker 的 offsetTable 缓存中查询出来消费者偏移量
 * @param group     消费者组
 * @param topic     topic
 * @param queueId   队列 ID
 * @return
 */
public long queryOffset(final String group, final String topic, final int queueId) {
    // topic@group
    String key = topic + TOPIC_GROUP_SEPARATOR + group;
    ConcurrentMap<Integer, Long> map = this.offsetTable.get(key);
    if (null != map) {
        // 根据队列 ID 获取消费偏移量
        Long offset = map.get(queueId);
        if (offset != null)
            return offset;
    }

    return -1;
}

3.4 fillOpRemoveMap 获取 op 消息填充集合

java 复制代码
/**
 * 从 pullOffsetOfOp 开始读取 OP 消息填充
 *
 * @param removeMap
 * @param opQueue        OP 消息队列
 * @param pullOffsetOfOp 从这个偏移量开始拉取消息
 * @param miniOffset     半事务消息的最低偏移量, 用来判断获取到的 OP 消息是不是已经处理过的了
 * @param doneOpOffset   已经处理的 OP 消息
 * @return 拉取到的 OP 消息
 */
private PullResult fillOpRemoveMap(HashMap<Long, Long> removeMap,
    MessageQueue opQueue, long pullOffsetOfOp, long miniOffset, List<Long> doneOpOffset) {
    // 从 pullOffsetOfOp 开始拉取 32 条 OP 消息
    PullResult pullResult = pullOpMsg(opQueue, pullOffsetOfOp, 32);
    if (null == pullResult) {
        return null;
    }
    // 偏移量不合法或者没拉到消息
    if (pullResult.getPullStatus() == PullStatus.OFFSET_ILLEGAL
        || pullResult.getPullStatus() == PullStatus.NO_MATCHED_MSG) {
        log.warn("The miss op offset={} in queue={} is illegal, pullResult={}", pullOffsetOfOp, opQueue,
            pullResult);
        // 更新下 offsetTable 里面的偏移量, key 为 topic@group, value 是下一次拉取的 ConsumeQueue 索引位置
        transactionalMessageBridge.updateConsumeOffset(opQueue, pullResult.getNextBeginOffset());
        return pullResult;
    } else if (pullResult.getPullStatus() == PullStatus.NO_NEW_MSG) {
        // 这里是没有最新的消息, 有可能这个 pullOffsetOfOp 就是 ConsumeQueue 里面的最大偏移量了, 这时候也不需要更新 offsetTable, 下一次还从这位置拉取就行
        log.warn("The miss op offset={} in queue={} is NO_NEW_MSG, pullResult={}", pullOffsetOfOp, opQueue,
            pullResult);
        return pullResult;
    }
    // 拉取到的 OP 消息
    List<MessageExt> opMsg = pullResult.getMsgFoundList();
    if (opMsg == null) {
        log.warn("The miss op offset={} in queue={} is empty, pullResult={}", pullOffsetOfOp, opQueue, pullResult);
        return pullResult;
    }
    for (MessageExt opMessageExt : opMsg) {
        // OP 消息的消息体就是对应的半消息的偏移量
        Long queueOffset = getLong(new String(opMessageExt.getBody(), TransactionalMessageUtil.charset));
        log.debug("Topic: {} tags: {}, OpOffset: {}, HalfOffset: {}", opMessageExt.getTopic(),
            opMessageExt.getTags(), opMessageExt.getQueueOffset(), queueOffset);
        // 如果带有 d 标记
        if (TransactionalMessageUtil.REMOVETAG.equals(opMessageExt.getTags())) {
            // 判断这条 half 消息偏移量是不是小于传入的最小有效的 half 消息偏移量
            if (queueOffset < miniOffset) {
                // 如果小于, 就代表这条 half 消息已经处理过了
                doneOpOffset.add(opMessageExt.getQueueOffset());
            } else {
                // 不小于, 加入 removeMap 集合中, 说明这条 half 消息已经 commit 或者 rollback 了
                // 同时这条 half 消息又是上层方法没有遍历到的 half 消息
                removeMap.put(queueOffset, opMessageExt.getQueueOffset());
            }
        } else {
            // op 队列里面混入不带 d 的消息, 不合法, 日志输出
            log.error("Found a illegal tag in opMessageExt= {} ", opMessageExt);
        }
    }
    log.debug("Remove map: {}", removeMap);
    log.debug("Done op list: {}", doneOpOffset);
    return pullResult;
}

这个方法就是拉取 op 消息填充 removeMap 和 doneOpOffset,传进来的 pullOffsetOfOp 是 op 消息开始拉取的偏移量,miniOffset 是已经处理过的 half 消息的偏移量。

首先使用 pullOpMsg 从 pullOffsetOfOp 开始拉取 32 条 OP 消息,然后如果是偏移量不合法或者没拉到匹配的消息, 更新下 offsetTable 里面的偏移量,key 为 topic@group,value 是下一次拉取的 ConsumeQueue 索引位置,如果是位置正确,但是后续没有更多 op 消息了,直接返回结果,不需要更新偏移量。

最后就是遍历所有拉取到的 op 消息,然后查询出 op 消息对应的 half 消息的偏移量,上一篇文章就已经分析过,op 消息的消息体就是对应的 half 消息的偏移量,所以这里直接解析消息体就行。获取到偏移量之后,如果这条 half 消息的偏移量小于 miniOffset,说明这条 half 消息是之前的回查就已经遍历过了,只是说上报得比较晚,这种加入 doneOpOffset 集合,如果不是就说明这条 half 消息还没遍历到,加入 removeMap 中。

然后下面来看下 getOpMessage 方法。

java 复制代码
/**
 * 获取 op 消息
 * @param queueId 队列 ID, 默认就是 0
 * @param offset  从哪个位置开始拉取
 * @param nums    拉取的条数
 * @return
 */
public PullResult getOpMessage(int queueId, long offset, int nums) {
    // CID_RMQ_SYS_TRANS
    String group = TransactionalMessageUtil.buildConsumerGroup();
    // RMQ_SYS_TRANS_OP_HALF_TOPIC
    String topic = TransactionalMessageUtil.buildOpTopic();
    // 获取这个 topic 的订阅信息
    SubscriptionData sub = new SubscriptionData(topic, "*");
    return getMessage(group, topic, queueId, offset, nums, sub);
}

/**
 * 获取消息
 * @param group     CID_RMQ_SYS_TRANS
 * @param topic     RMQ_SYS_TRANS_OP_HALF_TOPIC
 * @param queueId   队列 ID
 * @param offset    起始偏移量
 * @param nums      消息上限
 * @param sub       订阅信息
 * @return
 */
private PullResult getMessage(String group, String topic, int queueId, long offset, int nums,
    SubscriptionData sub) {
    // 拉取 32 条 op 消息
    GetMessageResult getMessageResult = store.getMessage(group, topic, queueId, offset, nums, null);

    if (getMessageResult != null) {
        PullStatus pullStatus = PullStatus.NO_NEW_MSG;
        List<MessageExt> foundList = null;
        switch (getMessageResult.getStatus()) {
            case FOUND:
                // 如果查询到消息, 统计消息拉取的一些指标
                pullStatus = PullStatus.FOUND;
                foundList = decodeMsgList(getMessageResult);
                this.brokerController.getBrokerStatsManager().incGroupGetNums(group, topic,
                    getMessageResult.getMessageCount());
                this.brokerController.getBrokerStatsManager().incGroupGetSize(group, topic,
                    getMessageResult.getBufferTotalSize());
                this.brokerController.getBrokerStatsManager().incBrokerGetNums(getMessageResult.getMessageCount());
                if (foundList == null || foundList.size() == 0) {
                    break;
                }
                this.brokerController.getBrokerStatsManager().recordDiskFallBehindTime(group, topic, queueId,
                    this.brokerController.getMessageStore().now() - foundList.get(foundList.size() - 1)
                        .getStoreTimestamp());
                break;
            case NO_MATCHED_MESSAGE:
                pullStatus = PullStatus.NO_MATCHED_MSG;
                LOGGER.warn("No matched message. GetMessageStatus={}, topic={}, groupId={}, requestOffset={}",
                    getMessageResult.getStatus(), topic, group, offset);
                break;
            case NO_MESSAGE_IN_QUEUE:
            case OFFSET_OVERFLOW_ONE:
                pullStatus = PullStatus.NO_NEW_MSG;
                LOGGER.warn("No new message. GetMessageStatus={}, topic={}, groupId={}, requestOffset={}",
                    getMessageResult.getStatus(), topic, group, offset);
                break;
            case MESSAGE_WAS_REMOVING:
            case NO_MATCHED_LOGIC_QUEUE:
            case OFFSET_FOUND_NULL:
            case OFFSET_OVERFLOW_BADLY:
            case OFFSET_TOO_SMALL:
                pullStatus = PullStatus.OFFSET_ILLEGAL;
                LOGGER.warn("Offset illegal. GetMessageStatus={}, topic={}, groupId={}, requestOffset={}",
                    getMessageResult.getStatus(), topic, group, offset);
                break;
            default:
                assert false;
                break;
        }

        return new PullResult(pullStatus, getMessageResult.getNextBeginOffset(), getMessageResult.getMinOffset(),
            getMessageResult.getMaxOffset(), foundList);

    } else {
        LOGGER.error("Get message from store return null. topic={}, groupId={}, requestOffset={}", topic, group,
            offset);
        return null;
    }
}

可以看到这里最终调用 getMessage 方法拉取 32 条 op 消息,拉取的 topic 是 RMQ_SYS_TRANS_OP_HALF_TOPIC,至于说 getMessage,我在这篇文章也有详细分析:【RocketMQ 生产者和消费者】- broker 处理消息拉取请求,如果感兴趣可以去看下,里面涉及到消息拉取、消息过滤等逻辑。

3.5 getHalfMsg 从 offset 开始获取一条 half 消息

java 复制代码
/**
 * 从消息队列里面获取 offset 位置的 half 消息
 * @param messageQueue 消息队列
 * @param offset       偏移量, 实际上可以理解成消息下标
 * @return
 */
private GetResult getHalfMsg(MessageQueue messageQueue, long offset) {
    GetResult getResult = new GetResult();

    // 从 offset 位置开始拉取一条消息
    PullResult result = pullHalfMsg(messageQueue, offset, PULL_MSG_RETRY_NUMBER);
    getResult.setPullResult(result);
    List<MessageExt> messageExts = result.getMsgFoundList();
    if (messageExts == null) {
        // 没找到
        return getResult;
    }
    // 找到了, 将这条消息设置到 msg 中
    getResult.setMsg(messageExts.get(0));
    return getResult;
}

这里的 PULL_MSG_RETRY_NUMBER = 1,然后后面的流程跟上面 3.4 拉取 op 的差不都,最终核心的逻辑就是调用 DefaultMessageStore#getMessage 去拉取消息。

3.6 needDiscard 判断是否需要丢掉这条 half 消息

java 复制代码
/**
 * 是否需要丢掉这条 half 消息
 * @param msgExt
 * @param transactionCheckMax
 * @return
 */
private boolean needDiscard(MessageExt msgExt, int transactionCheckMax) {
    // 从 PROPERTY_TRANSACTION_CHECK_TIMES 中获取这条 half 消息已经回查过多少次了
    String checkTimes = msgExt.getProperty(MessageConst.PROPERTY_TRANSACTION_CHECK_TIMES);
    int checkTime = 1;
    // 第一次都是 null
    if (null != checkTimes) {
        checkTime = getInt(checkTimes);
        if (checkTime >= transactionCheckMax) {
            // 如果超过了回查最大次数, 默认 15 次, 直接丢掉这条 half 消息
            return true;
        } else {
            // 没超过就回查次数 + 1
            checkTime++;
        }
    }
    // 将回查次数设置到 PROPERTY_TRANSACTION_CHECK_TIMES 中
    msgExt.putUserProperty(MessageConst.PROPERTY_TRANSACTION_CHECK_TIMES, String.valueOf(checkTime));
    return false;
}

这里面是根据消息回查次数来判断是否需要丢掉,如果消息回查次数达到了上限,默认是 15 次,可以通过 broker.conf 里面的 transactionCheckMax 来配置,就会返回 true,丢掉这条消息。

3.7 needSkip 判断是否需要跳过这条消息

java 复制代码
/**
 * 是否需要跳过这条 half 消息
 * @param msgExt
 * @return
 */
private boolean needSkip(MessageExt msgExt) {
    // 当前距离这条 half 消息生成的时间有多久
    long valueOfCurrentMinusBorn = System.currentTimeMillis() - msgExt.getBornTimestamp();
    // 如果超过 72 小时了, 因为 CommitLog 保留日期是 3 天, 所以如果一条 half 消息都超过 3 天了还没有处理, 直接跳过
    if (valueOfCurrentMinusBorn
        > transactionalMessageBridge.getBrokerController().getMessageStoreConfig().getFileReservedTime()
        * 3600L * 1000) {
        log.info("Half message exceed file reserved time ,so skip it.messageId {},bornTime {}",
            msgExt.getMsgId(), msgExt.getBornTimestamp());
        return true;
    }
    return false;
}

CommitLog 过期清除的时间就是 72 小时,如果一个文件 72 小时内没有修改,就会被删掉,具体可以看这篇文章:【RocketMQ 存储】- CommitLog 过期清除服务 CleanCommitLogService,所以这里的 half 消息也会判断如果一条 half 消息都超过 3 天了还没有处理,直接跳过。

3.8 checkPrepareQueueOffset 将 half 消息重新写回 CommitLog

当用户属性 PROPERTY_CHECK_IMMUNITY_TIME_IN_SECONDS 配置了消息超时时间,同时当前 half 消息本地事务执行没有超时,就会调用这个方法将 half 消息重新返回 half 队列,然后继续处理下一条消息。

java 复制代码
/**
 * If return true, skip this msg
 *
 * @param removeMap     已经 commit 或者 rollback 的消息
 * @param doneOpOffset  已经处理过的 op 消息
 * @param msgExt        要检查的 half 消息
 * @return Return true if put success, otherwise return false.
 */
private boolean checkPrepareQueueOffset(HashMap<Long, Long> removeMap, List<Long> doneOpOffset,
    MessageExt msgExt) {
    // 首先获取下 PROPERTY_TRANSACTION_PREPARED_QUEUE_OFFSET 属性值
    String prepareQueueOffsetStr = msgExt.getUserProperty(MessageConst.PROPERTY_TRANSACTION_PREPARED_QUEUE_OFFSET);
    if (null == prepareQueueOffsetStr) {
        // 第一次将消息重新写回 half 队列, 不需要检查, 写回成功就能返回 true
        return putImmunityMsgBackToHalfQueue(msgExt);
    } else {
        // 不是第一次写入, 这里要注意每一次重新写回 half 消息都会将这条 half 消息的最初的偏移量存到 PROPERTY_TRANSACTION_PREPARED_QUEUE_OFFSET 中
        long prepareQueueOffset = getLong(prepareQueueOffsetStr);
        if (-1 == prepareQueueOffset) {
            return false;
        } else {
            // 如果说到这里这条 half 消息已经 commit 或者 rollback 了
            if (removeMap.containsKey(prepareQueueOffset)) {
                // 删掉这条 half 消息
                long tmpOpOffset = removeMap.remove(prepareQueueOffset);
                // 添加到 doneOpOffset 集合中
                doneOpOffset.add(tmpOpOffset);
                return true;
            } else {
                // 再将 half 消息添加回 half 队列中, 添加成功就返回 true
                return putImmunityMsgBackToHalfQueue(msgExt);
            }
        }
    }
}

可以看到这里首先获取下 PROPERTY_TRANSACTION_PREPARED_QUEUE_OFFSET 属性值,这个属性里面记录了最初的 half 消息的偏移量,每一次重新添加回队列前都会判断这条 half 消息的偏移量是否已经完成本地事务的执行,如果完成了就没必要重新添加回队列,直接返回 true,如果没有就添加回 half 队列。

注意下,这里 removeMap 判断的 永远是最初的一条 half 消息 ,后面那些重新写回的会把最初这条 half 的偏移量写入 PROPERTY_TRANSACTION_PREPARED_QUEUE_OFFSET

下面看下 putImmunityMsgBackToHalfQueue,这个方法主要就是将 half 消息重新返回队列中。

java 复制代码
/**
 * 将 half 消息重新返回队列中
 * @param messageExt
 * @return
 */
private boolean putImmunityMsgBackToHalfQueue(MessageExt messageExt) {
    // 更新 half 消息, 将这条 half 消息第一次添加到 half 队列的偏移量设置到 TRAN_PREPARED_QUEUE_OFFSET 属性中
    MessageExtBrokerInner msgInner = transactionalMessageBridge.renewImmunityHalfMessageInner(messageExt);
    // 调用 DefaultMessageStore 的方法将消息添加到 CommitLog 中
    return transactionalMessageBridge.putMessage(msgInner);
}

/**
 * 更新 half 消息, 保存最初的 half 消息的 ConsumeQueue 偏移量, 无论回写多少次都用一开始的偏移量判断是否 rollback 或者 commit 了,
 * 因为 RocketMQ 没办法删除消息, 所以一条 half 消息会多次回写到 CommitLog 中, 但是我们判断只需要判断最开始那一条是否已经完成本地事务
 * 了就行
 * @param msgExt
 * @return
 */
public MessageExtBrokerInner renewImmunityHalfMessageInner(MessageExt msgExt) {
    // 首先重新刷新下 half 消息
    MessageExtBrokerInner msgInner = renewHalfMessageInner(msgExt);
    // 然后获取 TRAN_PREPARED_QUEUE_OFFSET 属性
    String queueOffsetFromPrepare = msgExt.getUserProperty(MessageConst.PROPERTY_TRANSACTION_PREPARED_QUEUE_OFFSET);
    if (null != queueOffsetFromPrepare) {
        // 如果不为空, 说明这条 half 消息不是第一次回写到 CommitLog 了, 保留这个偏移量
        MessageAccessor.putProperty(msgInner, MessageConst.PROPERTY_TRANSACTION_PREPARED_QUEUE_OFFSET,
            String.valueOf(queueOffsetFromPrepare));
    } else {
        // 为空就说明是第一次回写, 将 half 消息的偏移量保存在 TRAN_PREPARED_QUEUE_OFFSET 中
        MessageAccessor.putProperty(msgInner, MessageConst.PROPERTY_TRANSACTION_PREPARED_QUEUE_OFFSET,
            String.valueOf(msgExt.getQueueOffset()));
    }

    msgInner.setPropertiesString(MessageDecoder.messageProperties2String(msgInner.getProperties()));

    return msgInner;
}

renewHalfMessageInner 就是创建一条新的消息,然后将原来这条 half 消息的属性(topic、queueId ...)都设置到新的消息上面,接再将 TRAN_PREPARED_QUEUE_OFFSET 这个属性值设置到新消息上,这里也能证实上面说的,不管重新添加多少次,最终都会根据最初的 half 偏移量来判断本地事务是否提交或者回滚了。

3.9 事务超时时间的不同逻辑

接着上面的方法,这里说下判断是从用户属性中获取超时时间还是从配置文件中获取超时时间这块的不同。在 3.2 的整体流程中可以看到如果设置了用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS,同时这条 half 消息没有超时,就会将 half 消息放回队列,处理下一条,但是如果没有设置,用的 broker 配置,half 消息在没有超时的情况下会直接退出本次回查。

为什么这里有两种策略,这里我的猜测就是对于用户属性设置的超时时间,可能每一条超时时间都不一样,halfOffset 小的超时时间可能比较大,halfOffset 大的超时时间就比较小都有可能,这种情况下如果当前 halfOffset 对应的消息没有超时,不代表后面那些 half 消息没有超时,因此这里需要将消息重新设置回 half 队列,然后继续处理后面的消息,那为了避免重复回查就将初始 half 消息的偏移量配置到属性 TRAN_PREPARED_QUEUE_OFFSET 中,这样只要一开始的 half 消息提交了,后续就不需要再回查。

而对于 broker 配置文件设置的超时时间,由于都是统一的,正常线上这个配置多个集群下也都是相同的,或者直接用 RocketMQ 默认的 6s,所以这里当前 half 消息没有过期可以代表后面的 half 消息也没有过期,直接退出本轮 half 消息检测,不需要更新 halfOffset,下一次还是从这条消息开始检查。

当然上面都是个人观点,如果有不同看法可以评论交流下。

3.10 putBackHalfMsgQueue 将 half 消息重写写回 half 队列

这里就是确定要回查之后将 half 消息重写写回 half 队列,里面的一些方法在 3.8 都说过了,所以就把这个方法的主流程贴一下,直接看注释就行。

java 复制代码
/**
 * 将 half 消息写回 CommitLog 中
 * @param msgExt
 * @param offset
 * @return
 */
private boolean putBackHalfMsgQueue(MessageExt msgExt, long offset) {
    // 将 half 消息重新写回 CommitLog 中
    PutMessageResult putMessageResult = putBackToHalfQueueReturnResult(msgExt);
    if (putMessageResult != null
        && putMessageResult.getPutMessageStatus() == PutMessageStatus.PUT_OK) {
        // 写入成功, 更新消息的 ConsumeQueue 偏移量
        msgExt.setQueueOffset(
            putMessageResult.getAppendMessageResult().getLogicsOffset());
        // 更新消息的 CommitLog 偏移量
        msgExt.setCommitLogOffset(
            putMessageResult.getAppendMessageResult().getWroteOffset());
        // 更新消息 id
        msgExt.setMsgId(putMessageResult.getAppendMessageResult().getMsgId());
        log.debug(
            "Send check message, the offset={} restored in queueOffset={} "
                + "commitLogOffset={} "
                + "newMsgId={} realMsgId={} topic={}",
            offset, msgExt.getQueueOffset(), msgExt.getCommitLogOffset(), msgExt.getMsgId(),
            msgExt.getUserProperty(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX),
            msgExt.getTopic());
        return true;
    } else {
        log.error(
            "PutBackToHalfQueueReturnResult write failed, topic: {}, queueId: {}, "
                + "msgId: {}",
            msgExt.getTopic(), msgExt.getQueueId(), msgExt.getMsgId());
        return false;
    }
}

这里通过 putBackToHalfQueueReturnResult 将 half 消息重写写回 CommitLog 中,注意这里面写回就没有将原始 half 消息的偏移量存到属性里面了,而是直接重新写回,写回之后 newOffset + 1,继续处理下一条,跟上面 3.8 不一样,那种情况属于 half 消息还没过期的,不会发送事务回查请求,生产者本地事务执行完成之后写入的 op 消息的内容还是最初的 half 消息偏移量。而这种情况属于是过期了,通过 resolveHalfMsg 发送事务回查请求之后,生产者执行完本地事务写入的 op 消息的内容是这条新的 half 消息,所以不需要额外再设置属性。

4. resolveHalfMsg 发送生产者回查请求

java 复制代码
/**
 * 给生产者发送事务回查消息
 * @param msgExt
 */
public void resolveHalfMsg(final MessageExt msgExt) {
    executorService.execute(new Runnable() {
        @Override
        public void run() {
            try {
                sendCheckMessage(msgExt);
            } catch (Exception e) {
                LOGGER.error("Send check message error!", e);
            }
        }
    });
}

这里发送事务回查是通过线程池发送的,线程池核心线程 2、最大线程 5、队列大小 2000,下面看下 sendCheckMessage 方法。

java 复制代码
/**
 * 发送事务回查消息
 * @param msgExt half 消息
 * @throws Exception
 */
public void sendCheckMessage(MessageExt msgExt) throws Exception {
    // 构建请求头
    CheckTransactionStateRequestHeader checkTransactionStateRequestHeader = new CheckTransactionStateRequestHeader();
    // 设置 CommitLog 偏移量
    checkTransactionStateRequestHeader.setCommitLogOffset(msgExt.getCommitLogOffset());
    // 设置消息 ID
    checkTransactionStateRequestHeader.setOffsetMsgId(msgExt.getMsgId());
    // 设置客户端生成的唯一 ID, 也就是 UNIQ_KEY
    checkTransactionStateRequestHeader.setMsgId(msgExt.getUserProperty(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX));
    // 事务 ID 跟上面的 msgId 是同一个
    checkTransactionStateRequestHeader.setTransactionId(checkTransactionStateRequestHeader.getMsgId());
    // 设置 ConsumeQueue 偏移量
    checkTransactionStateRequestHeader.setTranStateTableOffset(msgExt.getQueueOffset());
    // half 消息设置真实的 topic
    msgExt.setTopic(msgExt.getUserProperty(MessageConst.PROPERTY_REAL_TOPIC));
    // 设置真实的队列 id
    msgExt.setQueueId(Integer.parseInt(msgExt.getUserProperty(MessageConst.PROPERTY_REAL_QUEUE_ID)));
    msgExt.setStoreSize(0);
    // 通过生产者组获取出生产者连接
    String groupId = msgExt.getProperty(MessageConst.PROPERTY_PRODUCER_GROUP);
    Channel channel = brokerController.getProducerManager().getAvailableChannel(groupId);
    if (channel != null) {
        // 发送事务回查消息
        brokerController.getBroker2Client().checkProducerTransactionState(groupId, channel, checkTransactionStateRequestHeader, msgExt);
    } else {
        LOGGER.warn("Check transaction failed, channel is null. groupId={}", groupId);
    }
}

/**
 * 发送事务回查请求
 * @param group
 * @param channel
 * @param requestHeader
 * @param messageExt
 * @throws Exception
 */
public void checkProducerTransactionState(
    final String group,
    final Channel channel,
    final CheckTransactionStateRequestHeader requestHeader,
    final MessageExt messageExt) throws Exception {
    // 构建请求, 请求 code 是 CHECK_TRANSACTION_STATE
    RemotingCommand request =
        RemotingCommand.createRequestCommand(RequestCode.CHECK_TRANSACTION_STATE, requestHeader);
    // 设置消息体
    request.setBody(MessageDecoder.encode(messageExt, false));
    try {
        // 发送 oneWay 请求, 也就是不需要等待返回结果的
        this.brokerController.getRemotingServer().invokeOneway(channel, request, 10);
    } catch (Exception e) {
        log.error("Check transaction failed because invoke producer exception. group={}, msgId={}, error={}",
                group, messageExt.getMsgId(), e.toString());
    }
}

事务回查请求 Code 是 CHECK_TRANSACTION_STATE,发送的是 oneWay 消息,也就是不需要返回值,发送不成功也无所谓,下一次事务回查再发送就好了,请求头里面包括下面的核心属性:

  • CommitLog 偏移量
  • 消息 ID
  • 客户端生成的唯一 ID, 也就是 UNIQ_KEY
  • 事务 ID
  • ConsumeQueue 偏移量
  • 真实的 topic
  • 真实的队列 id
  • storeSize = 0

最后来看下生产者如何处理回查请求的。

5. 生产者处理事务回查请求 - checkTransactionState

java 复制代码
/**
 * 处理 broker 的事务回查请求, 检查本地事务执行状态
 * @param ctx
 * @param request
 * @return
 * @throws RemotingCommandException
 */
public RemotingCommand checkTransactionState(ChannelHandlerContext ctx,
    RemotingCommand request) throws RemotingCommandException {
    // 请求头
    final CheckTransactionStateRequestHeader requestHeader =
        (CheckTransactionStateRequestHeader) request.decodeCommandCustomHeader(CheckTransactionStateRequestHeader.class);
    final ByteBuffer byteBuffer = ByteBuffer.wrap(request.getBody());
    // 回查的消息, 注意这时候的消息的 topic 和 queueId 已经是真实的了
    final MessageExt messageExt = MessageDecoder.decode(byteBuffer);
    if (messageExt != null) {
        // 用命名空间封转消息 topic
        if (StringUtils.isNotEmpty(this.mqClientFactory.getClientConfig().getNamespace())) {
            messageExt.setTopic(NamespaceUtil
                .withoutNamespace(messageExt.getTopic(), this.mqClientFactory.getClientConfig().getNamespace()));
        }
        // 事务 ID, 就是消息 ID, 也就是客户端(生产者)生成的唯一 ID
        String transactionId = messageExt.getProperty(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX);
        if (null != transactionId && !"".equals(transactionId)) {
            messageExt.setTransactionId(transactionId);
        }
        // 生产者组
        final String group = messageExt.getProperty(MessageConst.PROPERTY_PRODUCER_GROUP);
        if (group != null) {
            // 获取这个生产者组下面的生产者, 这里本地启动的服务一个生产者组下面只会有一个生产者, 前面生产者启动的源码也有校验
            MQProducerInner producer = this.mqClientFactory.selectProducer(group);
            if (producer != null) {
                // 获取 broker 的地址
                final String addr = RemotingHelper.parseChannelRemoteAddr(ctx.channel());
                // 检查事务状态
                producer.checkTransactionState(addr, messageExt, requestHeader);
            } else {
                log.debug("checkTransactionState, pick producer by group[{}] failed", group);
            }
        } else {
            log.warn("checkTransactionState, pick producer group failed");
        }
    } else {
        log.warn("checkTransactionState, decode message failed");
    }

    return null;
}

checkTransactionState 的逻辑就是先获取这个生产者组下面的生产者,这里本地启动的服务一个生产者组下面只会有一个生产者,前面生产者启动的源码文章也有校验,接下来通过 checkTransactionState 去检查本地事务。

5.1 checkTransactionState 检查事务状态

一个生产者发送的事务消息肯定是不止一条,如果 broker 同时向 producer 发送多条回查请求,生产者肯定不能单线程去处理的,所以我们创建生产者的时候设置的回查线程池就派上用场了。checkTransactionState 把回事务查请求封转成 Runnable 任务交给线程池去处理,避免阻塞业务,下面是整体逻辑。

java 复制代码
/**
 * 检查事务状态
 * @param addr
 * @param msg
 * @param header
 */
@Override
public void checkTransactionState(final String addr, final MessageExt msg,
    final CheckTransactionStateRequestHeader header) {
    // 构建事务回查任务
    Runnable request = new Runnable() {
        private final String brokerAddr = addr;
        private final MessageExt message = msg;
        private final CheckTransactionStateRequestHeader checkRequestHeader = header;
        private final String group = DefaultMQProducerImpl.this.defaultMQProducer.getProducerGroup();

        @Override
        public void run() {
            // 事务检查监听器, 现在已经弃用了
            TransactionCheckListener transactionCheckListener = DefaultMQProducerImpl.this.checkListener();
            // 获取事务监听器, 这个是现在用的
            TransactionListener transactionListener = getCheckListener();
            if (transactionCheckListener != null || transactionListener != null) {
                // 首先默认就是 UNKNOW 状态
                LocalTransactionState localTransactionState = LocalTransactionState.UNKNOW;
                Throwable exception = null;
                try {
                    if (transactionCheckListener != null) {
                        // 如果 transactionCheckListener 不为空, 调用 checkLocalTransactionState 方法检查本地事务执行结果
                        localTransactionState = transactionCheckListener.checkLocalTransactionState(message);
                    } else if (transactionListener != null) {
                        // 如果 transactionCheckListener 为空, 但是 transactionListener 不为空, 调用 checkLocalTransaction 方法检查本地事务执行结果
                        log.debug("Used new check API in transaction message");
                        localTransactionState = transactionListener.checkLocalTransaction(message);
                    } else {
                        // 这里就是没有设置事务监听器
                        log.warn("CheckTransactionState, pick transactionListener by group[{}] failed", group);
                    }
                } catch (Throwable e) {
                    log.error("Broker call checkTransactionState, but checkLocalTransactionState exception", e);
                    exception = e;
                }

                // 处理事务状态
                this.processTransactionState(
                    localTransactionState,
                    group,
                    exception);
            } else {
                log.warn("CheckTransactionState, pick transactionCheckListener by group[{}] failed", group);
            }
        }
    };

    // 事务校验是交给线程池去执行的, 这个线程池就是在示例中我们自己设置的线程池
    this.checkExecutor.submit(request);
}

TransactionCheckListener 是 RocketMQ 提供的事务回查监听器,现在已经不用了,5.0.0 之后就被移除掉,现在用的是 TransactionListener,就是我们自己定义的监听器,可以看到最终也是调用 checkLocalTransaction 去检查本地事务的执行状态。默认的回查状态就是 LocalTransactionState.UNKNOW,如果 checkLocalTransaction 没有返回结果就默认 UNKNOW,broker 就不会提交 op 消息,后面会继续回查。

5.2 processTransactionState 处理事务状态

上面执行完事务回查之后,通过 processTransactionState 处理事务回查的结果。

java 复制代码
/**
 * 处理事务状态
 * @param localTransactionState
 * @param producerGroup
 * @param exception
 */
private void processTransactionState(
    final LocalTransactionState localTransactionState,
    final String producerGroup,
    final Throwable exception) {
    // 设置事务结束请求头
    final EndTransactionRequestHeader thisHeader = new EndTransactionRequestHeader();
    thisHeader.setCommitLogOffset(checkRequestHeader.getCommitLogOffset());
    thisHeader.setProducerGroup(producerGroup);
    thisHeader.setTranStateTableOffset(checkRequestHeader.getTranStateTableOffset());
    thisHeader.setFromTransactionCheck(true);

    // 从属性中获取消息 ID
    String uniqueKey = message.getProperties().get(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX);
    if (uniqueKey == null) {
        uniqueKey = message.getMsgId();
    }
    // 设置消息 ID
    thisHeader.setMsgId(uniqueKey);
    // 设置事务 ID
    thisHeader.setTransactionId(checkRequestHeader.getTransactionId());
    switch (localTransactionState) {
        // 如果本地结果事务执行结果是 COMMIT_MESSAGE
        case COMMIT_MESSAGE:
            // 设置事务状态为 Commit
            thisHeader.setCommitOrRollback(MessageSysFlag.TRANSACTION_COMMIT_TYPE);
            break;
        // 如果本地结果事务执行结果是 ROLLBACK_MESSAGE
        case ROLLBACK_MESSAGE:
            // 设置事务状态为 Rollback
            thisHeader.setCommitOrRollback(MessageSysFlag.TRANSACTION_ROLLBACK_TYPE);
            log.warn("when broker check, client rollback this transaction, {}", thisHeader);
            break;
        // 如果本地结果事务执行结果是 UNKNOW
        case UNKNOW:
            // 设置事务状态为 NOT_TYPE
            thisHeader.setCommitOrRollback(MessageSysFlag.TRANSACTION_NOT_TYPE);
            log.warn("when broker check, client does not know this transaction state, {}", thisHeader);
            break;
        default:
            break;
    }

    String remark = null;
    if (exception != null) {
        remark = "checkLocalTransactionState Exception: " + RemotingHelper.exceptionSimpleDesc(exception);
    }
    // 事务回查后置钩子
    doExecuteEndTransactionHook(msg, uniqueKey, brokerAddr, localTransactionState, true);

    try {
        // 发送事务回查的结果给 broker
        DefaultMQProducerImpl.this.mQClientFactory.getMQClientAPIImpl().endTransactionOneway(brokerAddr, thisHeader, remark,
            3000);
    } catch (Exception e) {
        log.error("endTransactionOneway exception", e);
    }
}

这里就是封装执行结果返回给 broker 了,这里返回时设置的 ConsumeQueue 偏移量就是请求头带过来的,fromTransactionCheck 也设置成了 true,代表是 broker 发送的回查请求的返回结果。endTransactionOneway 这个方法在上一篇文章也说过,可以去看上一篇文章的具体分析,大致就是判断如果是 TRANSACTION_COMMIT_TYPE 或者 TRANSACTION_ROLLBACK_TYPE 就写入 op 消息,如果是 TRANSACTION_NOT_TYPE 就直接退出。

6. 小结

好了,到这里整个事务消息的源码就分析完了,虽然整体流程不复杂,但是里面的一些细节还是比较多的,特别是 op 消息和 half 消息那里,最后再把整体流程贴一下。

如有错误,还原指出!!!!

相关推荐
手握风云-4 小时前
Spring AI:让大模型住进 Spring 生态(四)
java·后端·spring
南滑散修4 小时前
红黑树-非黑即红
java·开发语言
Java面试题总结4 小时前
Spring Boot:别再重复造轮子,这些内置功能香麻了
java·spring boot·后端
迷糊小白告4 小时前
Java微服务——SpringCloud
java·spring cloud·微服务
qq_269870434 小时前
java rabbitmq 队列在Springboot的设计
java·rabbitmq·java-rabbitmq
abcnull4 小时前
Springboot+Vue2的Web项目小白入门Demo快速学习!
java·elementui·vue·maven·springboot·web·小白
2501_932750264 小时前
Java IO流基础全面详解:字节流、字符流
java·开发语言
逸Y 仙X4 小时前
文章二十二:ElasticSearch EQL事件查询语言
java·大数据·elasticsearch·搜索引擎·全文检索
liulilittle4 小时前
LLAMA-CLI 运行千问3.6(R9-7945HX+64G+RTX40608G)
java·前端·llama