RocketMQ 源码学习--Message-01 消息进度存储

67 阅读8分钟

消息消费进度:

  • 消费者订阅消息消费队列(MessageQueue), 当生产者将消息负载发送到 MessageQueue 中时,消费订阅者开始消费消息,消息消费过程中,为了避免重复消费,或者说是增量消费,需要一个地方存储消费进度(消费偏移量)。
  • 消息模式主要分为

    • 集群模式:

      • 集群模式:一条消息被集群中任何一个消费者消费。由于集群模式下,一条消息只能被集群内的一个消费者消费,进度不能保存在消费端,只能集中保存在一个地方,比较合适的是在 Broker 端。
    • 广播模式:

      • 每条消息都被每一个消费者消费。既然每条消息要被每一个消费者消费,则消费进度可以与消费者保存在一起,也就是本地保存

既然消息进度存储可以分为本地或者远程,可想而知,一个接口,两个实现类

存储接口

OffsetStore

public interface OffsetStore {
  /**
  * Load
  */
  void load() throws MQClientException;
​
  /**
  * Update the offset,store it in memory
  */
  void updateOffset(final MessageQueue mq, final long offset, final boolean increaseOnly);
​
  /**
  * Get offset from local storage
  *
  * @return The fetched offset
  */
  long readOffset(final MessageQueue mq, final ReadOffsetType type);
​
  /**
  * Persist all offsets,may be in local storage or remote name server
  */
  void persistAll(final Set<MessageQueue> mqs);
​
  /**
  * Persist the offset,may be in local storage or remote name server
  */
  void persist(final MessageQueue mq);
​
  /**
  * Remove offset
  */
  void removeOffset(MessageQueue mq);
​
  /**
  * @return The cloned offset table of given topic
  */
  Map<MessageQueue, Long> cloneOffsetTable(String topic);
​
  void updateConsumeOffsetToBroker(MessageQueue mq, long offset, boolean isOneway) throws RemotingException,
  MQBrokerException, InterruptedException, MQClientException;
}

创建

  • 这些进度属于消费者的,那么肯定是在消费者的启动的时候,初始化这些服务类,DefautlMQPushConsumerImpl#start

    if (this.defaultMQPushConsumer.getOffsetStore() != null) {
      this.offsetStore = this.defaultMQPushConsumer.getOffsetStore();
    } else {
      //根据不用的消费模式选择不同的OffsetStore实现
      switch (this.defaultMQPushConsumer.getMessageModel()) {
        //广播模式  
        case BROADCASTING:
          //如果是广播消费模式,则是LocalFileOffsetStore,消息消费进度即offset存储在本地磁盘中。
          this.offsetStore = new LocalFileOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
          break;
        //集群模式  
        case CLUSTERING:
          //如果是集群消费模式,则是RemoteBrokerOffsetStore,消息消费进度即offset存储在远程broker中。
          this.offsetStore = new RemoteBrokerOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
          break;
        default:
          break;
      }
      this.defaultMQPushConsumer.setOffsetStore(this.offsetStore);
    }
    

广播模式

问题

广播模式消息,如果返回 CONSUME_LATER,竟然不会重试,而是直接丢弃,为什么呢?

关键属性

//offset 存储根目录,默认为用户主目录,例如 /home/dingw,可以在消费者启动的JVM参数中,通过 - Drocketmq.client.localOffsetStoreDir=路径
public final static String LOCAL_OFFSET_STORE_DIR = System.getProperty(
  "rocketmq.client.localOffsetStoreDir",
  System.getProperty("user.home") + File.separator + ".rocketmq_offsets");
​
​
private final MQClientInstance mQClientFactory;
​
//消费组名称
private final String groupName;
​
//具体的消费进度保存文件名(全路径)
private final String storePath;
​
//内存中的 offfset 进度保持,以 MessageQueue 为键,偏移量为值
private ConcurrentMap<MessageQueue, AtomicLong> offsetTable = new ConcurrentHashMap<MessageQueue, AtomicLong>();

关键方法

load()
  • 从 offset.json 或者 offset.json.bak 加载进度到内存中
@Override
public void load() throws MQClientException {
  
  // 通过路径获取到 MessageQueue 和 offset 的映射关系,封装此对象
  OffsetSerializeWrapper offsetSerializeWrapper = this.readLocalOffset();
  if (offsetSerializeWrapper != null && offsetSerializeWrapper.getOffsetTable() != null) {
    
    //写入内存中
    offsetTable.putAll(offsetSerializeWrapper.getOffsetTable());
​
    for (Entry<MessageQueue, AtomicLong> mqEntry : offsetSerializeWrapper.getOffsetTable().entrySet()) {
      AtomicLong offset = mqEntry.getValue();
      log.info("load consumer's offset, {} {} {}",
               this.groupName,
               mqEntry.getKey(),
               offset.get());
    }
  }
}
public class OffsetSerializeWrapper extends RemotingSerializable {
  private ConcurrentMap<MessageQueue, AtomicLong> offsetTable =
    new ConcurrentHashMap<MessageQueue, AtomicLong>();
​
  public ConcurrentMap<MessageQueue, AtomicLong> getOffsetTable() {
    return offsetTable;
  }
​
  public void setOffsetTable(ConcurrentMap<MessageQueue, AtomicLong> offsetTable) {
    this.offsetTable = offsetTable;
  }
}
private OffsetSerializeWrapper readLocalOffset() throws MQClientException {
  String content = null;
​
  //1. 从 offset.json 中读取
  try {
    content = MixAll.file2String(this.storePath);
  } catch (IOException e) {
    log.warn("Load local offset store file exception", e);
  }
​
  //2. offset.json 中没有,从 offset.json.bak 中读取
  if (null == content || content.length() == 0) {
    return this.readLocalOffsetBak();
  } else {
    OffsetSerializeWrapper offsetSerializeWrapper = null;
    try {
      offsetSerializeWrapper =
        OffsetSerializeWrapper.fromJson(content, OffsetSerializeWrapper.class);
    } catch (Exception e) {
      log.warn("readLocalOffset Exception, and try to correct", e);
      return this.readLocalOffsetBak();
    }
​
    return offsetSerializeWrapper;
  }
}
  • offset.json 内容

    image-20231204170105663

persistAll
  • 持久化消息进度,可以学习学习 MixAll 的文件操作流程

    @Override
    public void persistAll(Set<MessageQueue> mqs) {
      if (null == mqs || mqs.isEmpty())
        return;
    ​
      OffsetSerializeWrapper offsetSerializeWrapper = new OffsetSerializeWrapper();
      for (Map.Entry<MessageQueue, AtomicLong> entry : this.offsetTable.entrySet()) {
        if (mqs.contains(entry.getKey())) {
          AtomicLong offset = entry.getValue();
          offsetSerializeWrapper.getOffsetTable().put(entry.getKey(), offset);
        }
      }
    ​
      //写入到封装对象
      String jsonString = offsetSerializeWrapper.toJson(true);
      if (jsonString != null) {
        try {
    ​
          // 持久化到路径中,这个地方会涉及三个文件 ,源文件,bak 文件,tmp 文件
          // bak 文件作为备份
          // tmp 文件作为零时文件,创建成功之后,删除源文件,tmp 文件变成源文件
          MixAll.string2File(jsonString, this.storePath);
        } catch (IOException e) {
          log.error("persistAll consumer offset Exception, " + this.storePath, e);
        }
      }
    }
    
  • 持久化的地方在哪里呢???查看一下调用流程 MQClientInstance#startScheduledTask

    /**
    * K2 3 每隔30S尝试清除无效的broker信息,以及发送心跳信息给所有broker
    */
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
    ​
      @Override
      public void run() {
        try {
          MQClientInstance.this.cleanOfflineBroker();
          MQClientInstance.this.sendHeartbeatToAllBrokerWithLock();
        } catch (Exception e) {
          log.error("ScheduledTask sendHeartbeatToAllBroker exception", e);
        }
      }
    }, 1000, this.clientConfig.getHeartbeatBrokerInterval(), TimeUnit.MILLISECONDS);
    
    • 原来是一个定时任务,默认消费端启动10秒后,每隔5s的频率持久化一次。
    • 广播模式消费进度存储容易,但其实还是 没有解决 为什么RocketMQ广播模式,如果消费失败,则丢弃,因为广播模式有时候也必须确保每个消费者都成功消费,通常的场景为,通过MQ刷新本地缓存等

集群模式

问题

  • 在集群模式下,多个消费者会负载到不同的消费队列上,因为消息消费进度是基于消息队列进行保存的,也就是不同的消费者之间的消费进度保存是不会存在并发的,但是在同一个消费者,非顺序消息消费时,一个消费者(多个线程)并发消费消息,比如m1 < m2,,但m2先消费完,此时是如何保存的消费进度呢?举个例子,如果m2的offset为5,而m1的offset为4,如果m2先消费完,保存进度为5,那m1消息消费完,保存进度为4,这样岂不乱来了,该如何处理呢?

RemoteBrokerOffsetStore

关键属性

private final static Logger log = ClientLogger.getLog();
// MQ客户端实例,该实例被同一个客户端的消费者、生产者共用
private final MQClientInstance mQClientFactory;  
// MQ消费组    
private final String groupName;
// 消费进度存储(内存中)                                 
private ConcurrentMap<MessageQueue, AtomicLong> offsetTable =
        new ConcurrentHashMap<MessageQueue, AtomicLong>();    
// 构造方法
public RemoteBrokerOffsetStore(MQClientInstance mQClientFactory, String groupName) {   
    this.mQClientFactory = mQClientFactory;
    this.groupName = groupName;
}

关键方法

updateOffset
@Override
public void updateOffset(MessageQueue mq, long offset, boolean increaseOnly) {
  if (mq != null) {
    //获取已存在的offset
    AtomicLong offsetOld = this.offsetTable.get(mq);
​
    //如果没有老的offset,那么将新的offset存进去
    if (null == offsetOld) {
      offsetOld = this.offsetTable.putIfAbsent(mq, new AtomicLong(offset));
    }
​
    //如果有老的offset,那么尝试更新offset
    if (null != offsetOld) {
      //如果仅单调增加offset,顺序消费为false,并发消费为true
      if (increaseOnly) {
        //如果新的offset大于已存在offset,则尝试在循环中CAS的更新为新offset
        MixAll.compareAndIncreaseOnly(offsetOld, offset);
      } else {
        //直接设置为新offset,可能导致offset变小
        offsetOld.set(offset);
      }
    }
  }
}

针对上面的问题,此处发现是处于最底层的方法,那么 offset 是哪里使用的呢,去查看一下,最终到了 ConsumeMessageConcurrentlyService#processConsumeResult,不管是集群消息还是集群模式,都会走到这个地方,那么关键点就是 offset 的生成了

long offset = consumeRequest.getProcessQueue().removeMessage(consumeRequest.getMsgs());
//如果偏移量大于等于0并且处理队列没有被丢弃
if (offset >= 0 && !consumeRequest.getProcessQueue().isDropped()) {
  //尝试更新内存中的offsetTable中的最新偏移量信息,第三个参数是否仅单调增加offset为true
  this.defaultMQPushConsumerImpl.getOffsetStore().updateOffset(consumeRequest.getMessageQueue(), offset, true);
}

还记得之前的 msgTree 吗,此方法就是使用了 MsgTreeMap 的特性,来获取偏移量,好嘛,终于,全跑通了,源码搂一下

public long removeMessage(final List<MessageExt> msgs) {
  long result = -1;
  final long now = System.currentTimeMillis();
  try {
    //获取锁
    this.treeMapLock.writeLock().lockInterruptibly();
    //更新时间戳
    this.lastConsumeTimestamp = now;
    try {
      //如果msgTreeMap存在数据
      if (!msgTreeMap.isEmpty()) {
        //首先将result设置为该队列最大的消息偏移量+1
        result = this.queueOffsetMax + 1;
        int removedCnt = 0;
        //遍历每一条消息尝试异常
        for (MessageExt msg : msgs) {
          MessageExt prev = msgTreeMap.remove(msg.getQueueOffset());
          if (prev != null) {
            removedCnt--;
            msgSize.addAndGet(0 - msg.getBody().length);
          }
        }
        msgCount.addAndGet(removedCnt);
​
        //如果移除消息之后msgTreeMap不为空集合,那么result设置为msgTreeMap当前最小的消息偏移量
        if (!msgTreeMap.isEmpty()) {
          result = msgTreeMap.firstKey();
        }
      }
    } finally {
      this.treeMapLock.writeLock().unlock();
    }
  } catch (Throwable t) {
    log.error("removeMessage exception", t);
  }
​
  return result;
}

msgTreeMap 的类型,TreeMap, 按消息的 offset 升序排序,返回的 result, 如果 treemap 中不存在任何消息,那就返回该处理队列最大的偏移量+1,如果移除自己本批消息后,处理队列中,还存在消息,则返回该处理队列中最小的偏移量,也就是此时返回的偏移量有可能不是消息本身的偏移量,而是处理队列中最小的偏移量。 对于 1,3,2 处理顺序,等 3 的时候,更新的 offset 也是 2,

readOffset

根据读取来源,读取消费队列的消费进度。

@Override
public long readOffset(final MessageQueue mq, final ReadOffsetType type) {
  if (mq != null) {
    switch (type) {
      case MEMORY_FIRST_THEN_STORE:
        
      // 先从内存中读取,如果内存中不存在,再尝试从磁盘中读取
      case READ_FROM_MEMORY: {
        AtomicLong offset = this.offsetTable.get(mq);
        if (offset != null) {
          return offset.get();
        } else if (ReadOffsetType.READ_FROM_MEMORY == type) {
          return -1;
        }
      }
      case READ_FROM_STORE: {
        try {
          
          //从 broker 中读取进度
          long brokerOffset = this.fetchConsumeOffsetFromBroker(mq);
          AtomicLong offset = new AtomicLong(brokerOffset);
          this.updateOffset(mq, offset.get(), false);
          return brokerOffset;
        }
        // No offset in broker
        catch (MQBrokerException e) {
          return -1;
        }
        //Other exceptions
        catch (Exception e) {
          log.warn("fetchConsumeOffsetFromBroker exception, " + mq, e);
          return -2;
        }
      }
      default:
        break;
    }
  }
​
  return -1;
}
fetchConsumeOffsetFromBroker
private long fetchConsumeOffsetFromBroker(MessageQueue mq) throws RemotingException, MQBrokerException,
InterruptedException, MQClientException {
  FindBrokerResult findBrokerResult = this.mQClientFactory.findBrokerAddressInAdmin(mq.getBrokerName());
  if (null == findBrokerResult) {
​
    this.mQClientFactory.updateTopicRouteInfoFromNameServer(mq.getTopic());
    findBrokerResult = this.mQClientFactory.findBrokerAddressInAdmin(mq.getBrokerName());
  }
​
  if (findBrokerResult != null) {
    QueryConsumerOffsetRequestHeader requestHeader = new QueryConsumerOffsetRequestHeader();
    requestHeader.setTopic(mq.getTopic());
    requestHeader.setConsumerGroup(this.groupName);
    requestHeader.setQueueId(mq.getQueueId());
​
    return this.mQClientFactory.getMQClientAPIImpl().queryConsumerOffset(
      findBrokerResult.getBrokerAddr(), requestHeader, 1000 * 5);
  } else {
    throw new MQClientException("The broker[" + mq.getBrokerName() + "] not exist", null);
  }
}

主要是首先根据 mq 的 broker 名称获取 broker 地址,然后发送请求,我们重点关注一下消费进度是保存在 broker 哪个地方:Broker端的offset管理参照 ConsumerOffsetManager,保存逻辑其实与广播模式差不多,就不深入研究了,重点说一下offset保存的路径:/rocketmq_home/store/config/consumerOffset.json。

广播模式,存放在消费者本地,集群模式,存储在Broker,存储文件,存放的是JSON。

也就是 OffsetStore 提供保存消费进度方法,也就是 {“consumeGroup" : [ {”ConsumeQueue1“:offset} ] }。

引用借鉴原文链接:blog.csdn.net/prestigedin…