作者:向阳
经过第一期的了解和学习之后,本期针对RocketMq的部分核心能力进行单独讲解。
贡献成员:
卡西 徐四 行舟 玉琢 麦芽
RocketMq offset
拆解&分析
作者: 卡西
关键词
offset
- 用来管理每个消费队列不同消费组的消费进度。
- 远程消费-集群模式下采用 RemoteBrokerOffsetStore,broker 控制 offset 的值。
- 本地消费-广播模式下采用 LocalFileOffsetStore,消费端存储。
- 注意,默认集群模式,RocketMQ 自动维护 OffsetStore,如果用另外一种 pullConsumer 需要自己进行维护 OffsetStore。
consumerQueue
- 逻辑队列consumeQueue存储的是指向物理存储的地址Topic下的每个messageQueue 都有对应的consumeQueue 文件,内容也会被持久化到磁盘,默认地址:store/consumequeue/{topicName}/{queueid}/fileName。每个consumeQueue对应一个(概念模型图中的)messageQueue,存储了这个Queue在CommitLog中的起始offset,log大小和MessageTag的hashCode。
- consumeQueue是无限长的数组,一条消息进来下标就会涨1,下标就是 offset,消息在某个 MessageQueue 里的位置,通过 offset 的值可以定位到这条消息,或者指示 Consumer 从这条消息开始向后处理。
- consumeQueue中的 maxOffset 表示消息的最大 offset,maxOffset 并不是最新的那条消息的 offset,而是最新消息的 offset+1,minOffset 则是现存在的最小 offset。
- 默认消息存储48小时后,消费会被物理地从磁盘删除,consumeQueue的 minOffset 也就对应增长。所以比 minOffset 还要小的那些消息已经不在 broker上了,所以无法消费。
commitLog
- 存储消息真正内容的文件。
- 生成规则:
- 每个文件的默认1G =1024 * 1024 * 1024,commitlog 的文件名 fileName,名字长度为20位,左边补零,剩余为起始偏移量;比如 00000000000000000000 代表了第一个文件,起始偏移量为0,文件大小为1G=1 073 741 824 Byte;当这个文件满了,第二个文件名字为00000000001073741824,起始偏移量为1073741824,消息存储的时候会顺序写入文件,当文件满了则写入下一个文件。
- 判断消息存储在哪个 CommitLog 上
- 例如 1073742827 为物理偏移量,则其对应的相对偏移量为 1003 = 1073742827 - 1073741824,并且该偏移量位于第二个 CommitLog。
- 生成规则:
producer
- producer发送消息到broker之后,会将消息具体内容持久化到commitLog文件中,再分发到topic下的消费队列consume Queue,消费者提交消费请求时,broker从该consumer负责的消费队列中根据请求参数起始offset获取待消费的消息索引信息,再从commitLog中获取具体的消息内容返回给consumer。在这个过程中,consumer提交的offset为本次请求的起始消费位置,即beginOffset;consume Queue中的offset定位了commitLog中具体消息的位置。
broke
- 如图所示
- broker端启动后,会调用BrokerController.initialize()方法,方法中会对offset进行加载,consumerOffsetManager.load()。获取文件内容后,序列化为ConsumerOffsetManager对象,实质是其属性ConcurrentMap<String,ConcurrentMap<Integer, Long>> **offsetTable,**offsetTable的数据结构为ConcurrentMap,是一个线程安全的容器,key的形式为topic@group(每个topic下不同消费组的消费进度),value也是一个ConcurrentMap,key为queueId,value为消费位移(这里不是offset而是位移)。通过对全局ConsumerOffsetManager对象就可以对各个topic下不同消费组的消费位移进行获取与管理。
client
- 如图所示
- offset初始化
- consumer启动过程中(Consumer主函数默认调用DefaultMQPushConsumer.start()方法)根据MessageModel选择对应的offsetStore,然后调用offsetStore.load()对offset进行加载,LocalFileOffsetStore是对本地文件的加载,而RemotebrokerOffsetStore是没有本地文件的,因此load()方法没有实现。在rebalance完成对messageQueue的分配之后会对messageQueue对应的消费位置offset进行更新。
- CONSUME_FROM_LAST_OFFSET
- 从最新的offset开始消费。 获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset>=0,从lastOffset开始消费;如果lastOffset小于0说明是first start,没有offset信息,topic为重试topic时从0开始消费,否则请求获取该消息队列对应的消费队列consumeQueue的最大offset(maxOffset),从maxOffset开始消费
- CONSUME_FROM_FIRST_OFFSET
- 从第一个offset开始消费。 获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset>=0,从lastOffset开始消费; 否则从0开始消费。
- CONSUME_FROM_TIMESTAMP
- 获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset>=0,从lastOffset开始消费; 当lastOffset<0,如果为重试topic,获取consumeQueue的最大offset;否则获取ConsumeTimestamp(consumer启动时间),根据时间戳请求查找offset。
- 上述三种消费位置的设置流程有一个共同点,都请求获取consumer对当前消息队列messageQueue的消费进度lastOffset,如果lastOffset不小于0,则从lastOffset开始消费。这也是有时候设置了CONSUME_FROM_FIRST_OFFSET却不是从0开始重新消费的原因,rocketMQ减少了由于配置原因造成的重复消费。
- offset提交更新
- consumer从broker拉取消息后,会将消息的扩展信息MessageExt存放到ProcessQueue的属性TreeMap<Long, MessageExt> msgTreeMap中,key值为消息对应的queueOffset,value为扩展信息(包括queueID等)。并发消费模式下(Concurrently),获取的待消费消息会分批提交给消费线程进行消费,默认批次为1,即每个消费线程消费一条消息。消费完成后调用ConsumerMessageConcurrentlyService.processConsumeResult()方法对结果进行处理:消费成功确认ack,消费失败发回broker进行重试。之后便是对offset的更新操作。 首先是调用ProcessQueue.removeMessage()方法,将已经消费完成的消息从msgTreeMap中根据queueOffset移除,然后判断当前msgTreeMap是否为空,不为空则返回当前msgTreeMap第一个元素,即offset最小的元素,否则返回-1。 如果removeMessage()返回的offset大于0,则更新到offsetTable中。offsetTable的结构为ConcurrentMap<MessageQueue, AtomicLong> offsetTable,是一个线程安全的Map,key为MessageQueue,value为AtomicLong对象,值为offset,记录当前messageQueue的消费位移。
RocketMQ 延迟队列
拆解&分析
作者: 玉琢
- 消息到达rocketmq之后,comitLog#putMssage方法中mq会做判断,是否是延迟消息,如果是延迟消息怎会将topic替换为SCHEDULE_TOPIC_XXXX,其他的逻辑都是一样的。
- 如图所示
- 处理延迟消息的是ScheduleMessageService 。 scheduleMessageService是在DefaultMessageStore被初始化,初始化包括构造对象,调用load方法,最后执行ScheduleMessageService的start()方法。18个级别是在一个concurrentHashMap中有对应的键值对,key就是级别数,value就是秒数。start()方法会遍历这个map,遍历的时候回从offsetTable中获取一个偏移量,然后为每一个延迟队列的级别创建了一个task。第一次启动是延迟1s。第二次及以后的启动任务延迟才是延迟级别相应的延迟时间。
- 如图所示
- 这里是每10s执行一次持久化,创建了一个定时任务,用于持久化每个队列消费的偏移量。持久化的频率由flushDelayOffsetInterval属性进行配置,默认为10秒。
- 定时任务具体实现在DeliverDelayedMessageTimerTask中,它是ScheduleMessageService的内部类。核心方法是executeOnTimeup(),首选获取topic为SCHEDULE_TOPIC_XXXX的queue,如果为空,则在100毫秒后在执行一次任务。
- 如果不为空然后通过偏移量从ConsumeQueue中获取消息,这里应该是获取所有有效的消息,就是所有目前为止没处理的延迟消息。
- 如果不为空,会获取物理偏移量,物理长度等信息,这里应该是去消息的磁盘文件中找到消息信息。然后在计算一下距离需要消费的时间,下一条消息的偏移量。如果距离需要设计发送的时间的毫秒数(countdown)是负数,这个需要单独处理一下。如果是正数,则创建一个偏移量是下条消息的偏移量,执行时间是countdown的任务,也就是说创建了一个countdown毫秒后执行的任务。然后更新刚才上面没看懂的那个offersetTable的偏移量的值。注意这里return了,for循环不会继续执行了。
- 如果countDown<=0,那就是代表这个消息要发送了,投入到真正的topic中。
总结: 1.在comitLog#putMssage如果消息的延迟级别大于0,则表示该消息为延迟消息,修改该消息的主题为SCHEDULE_TOPIC_XXXX,队列Id为延迟级别减1。 2.消息进入SCHEDULE_TOPIC_XXXX的队列中。 3.定时任务根据上次拉取的偏移量不断从队列中取出所有消息。这个上次拉取的偏移量就是offsetTable中的值。 4.根据消息的物理偏移量和大小再次获取消息。 5.根据消息属性重新创建消息,清除延迟级别,恢复原主题和队列Id。 6.重新发送消息到原主题的队列中,供消费者进行消费。
RocketMQ消息存储结构
拆解&分析
作者: 徐四
-
CommitLog、MappedFileQueue和MappedFile 关系
-
commitLog记录结构
-
文件的消息单元存储结构 | 字段简称 | 字段大小(字节) | 字段含义 | | --- | --- | --- | | msgSize | 4 | 代表这个消息的大小 | | MAGICCODE | 4 | 1. MagicCode是一个特殊的字段,它可以标志ByteBuffer中的某个CommitLog是一个正常的CommitLog,还是因为ByteBuffer没有多余的空间存放该CommitLog,导致该CommitLog是一个空的CommitLog。 2. MESSAGE_MAGIC_CODE表明该CommitLog记录是一条正常的记录,BLANK_MAGIC_CODE表明该CommitLog记录是一个空的CommitLog记录。3.如果存储CommitLog发现空间不够,会马上开辟第二个文件重新存储CommitLog记录,但是之前的空的CommitLog也一样会保存下来。在Broker正常退出或者异常退出,重启之后需要恢复Broker的时候,就会根据这个MagicCode判断该条CommitLog是否是正常的。 | | BODY CRC | 4 | CRC即循环冗余校验码,是数据通信领域中最常用的一种查错校验码,通过CRC就可以知道数据的正确性和完整性。RocketMQ通过CRC来校验消息部分BodyCRC消息体BODY CRC 当broker重启recover时会校验 | | queueId | 4 | queueId很熟悉的,就是消息发往哪个队列。queueId在producer发送消息时会选择出来Topic下会有一堆消息队列(ConsumerQueue),RocketMQ在保存完消息后,会随后构建ConsumerQueue,里面存放着Topic下消息的在CommitLog文件中的偏移量,方便根据Topic查询消费消息 | | flag | 4 | 暂时不知道有什么用,默认值是0 | | QUEUEOFFSET | 8 | 这里的QueueOffset存放了消息记录应该在ConsumerQueue中的位置,这样构建ConsumerQueue的时候,就知道该条记录在ConsummerQueue的位置顺序,在消费消息的时候很有用处。QueueOffset一般是是累加1的这个值是个自增值不是真正的consume queue的偏移量,可以代表这个consumeQueue队列或者tranStateTable队列中消息的个数,若是非事务消息或者commit事务消息,可以通过这个值查找到consumeQueue中数据,QUEUEOFFSET * 20才是偏移地址;若是PREPARED或者Rollback事务,则可以通过该值从tranStateTable中查找数据 | | PHYSICALOFFSET | 8 | 代表消息在commitLog中的物理起始地址偏移量 | | SYSFLAG | 4 | SysFlag是RocketMQ内部使用的标记位,通过位运算进行标记。例如是否对消息进行了压缩、是否属于事务消息。SysFlag初始值为0指明消息是事物事物状态等消息特征,二进制为四个字节从右往左数:当4个字节均为0(值为0)时表示非事务消息;当第1个字节为1(值为1)时表示表示消息是压缩的(Compressed);当第2个字节为1(值为2)表示多消息(MultiTags);当第3个字节为1(值为4)时表示prepared消息;当第4个字节为1(值为8)时表示commit消息;当第3/4个字节均为1时(值为12)时表示rollback消息;当第3/4个字节均为0时表示非事务消息; | | BORNTIMESTAMP | 8 | 消息产生端(producer)的时间戳 | | BORNHOST | 8 | 消息产生端(producer)地址(address:port) | | STORETIMESTAMP | 8 | 消息在broker存储时间 | | STOREHOSTADDRESS | 8 | 消息存储到broker的地址(address:port) | | RECONSUMETIMES | 8 | 消息被某个订阅组重新消费了几次(订阅组之间独立计数),因为重试消息发送到了topic名字为%retry%groupName的队列queueId=0的队列中去了,成功消费一次记录为0; | | PreparedTransaction Offset | 8 | 表示是prepared状态的事物消息 | | messagebodyLength | 4 | 消息体大小值 | | messagebody | bodyLength | 消息体内容 | | topicLength | 1 | topic名称内容大小 | | topic | topicLength | topic的内容值 | | propertiesLength | 2 | 属性值大小 | | properties | propertiesLength | propertiesLength大小的属性数据 |
-
RMQ存储架构
- 上图即为RocketMQ的消息存储整体架构,RocketMQ采用的是混合型的存储结构,即为Broker单个实例下所有的队列共用一个日志数据文件(即为CommitLog,1G)来存储。Consume Queue相当于kafka中的partition,是一个逻辑队列,存储了这个Queue在CommiLog中的起始offset,log大小和MessageTag的hashCode。每次读取消息队列先读取consumerQueue,然后再通过consumerQueue去commitLog中拿到消息主体。
-
Kafka存储架构
- rocketMQ的设计理念很大程度借鉴了kafka,所以有必要介绍下kafka的存储结构设计:
- 存储特点: 和RocketMQ类似,每个Topic有多个partition(queue),kafka的每个partition都是一个独立的物理文件,消息直接从里面读写。根据之前阿里中间件团队的测试,一旦kafka中Topic的partitoin数量过多,队列文件会过多,会给磁盘的IO读写造成很大的压力,造成tps迅速下降。所以RocketMQ进行了上述这样设计,consumerQueue中只存储很少的数据,消息主体都是通过CommitLog来进行读写。ps:上一行加粗理解:consumerQueue存储少量数据,即使数量很多,但是数据量不大,文件可以控制得非常小,绝大部分的访问还是Page Cache的访问,而不是磁盘访问。正式部署也可以将CommitLog和consumerQueue放在不同的物理SSD,避免多类文件进行IO竞争。
-
RMQ存储设计优缺点
- 优点:队列轻量化,单个队列数据量非常少。对磁盘的访问串行化,避免磁盘竟争,不会因为队列增加导致IOWAIT增高。
- 缺点:写虽然完全是顺序写,但是读却变成了完全的随机读。读一条消息,会先读ConsumeQueue,再读CommitLog,增加了开销。要保证CommitLog与ConsumeQueue完全的一致,增加了编程的复杂度。缺点克服:随机读,尽可能让读命中page cache,减少IO读操作,所以内存越大越好。如果系统中堆积的消息过多,读数据要访问磁盘会不会由于随机读导致系统性能急剧下降,答案是否定的。 访问page cache 时,即使只访问1k的消息,系统也会提前预读出更多数据,在下次读时,就可能命中内存。 随机访问Commit Log磁盘数据,系统IO调度算法设置为NOOP方式,会在一定程度上将完全的随机读变成顺序跳跃方式,而顺序跳跃方式读较完全的随机读性能会高5倍以上。 另外4k的消息在完全随机访问情况下,仍然可以达到8K次每秒以上的读性能。 由于Consume Queue存储数据量极少,而且是顺序读,在PAGECACHE预读作用下,Consume Queue的读性能几乎与内存一致,即使堆积情况下。所以可认为Consume Queue完全不会阻碍读性能。 Commit Log中存储了所有的元信息,包含消息体,类似于Mysql、Oracle的redolog,所以只要有Commit Log在,Consume Queue即使数据丢失,仍然可以恢复出来。
-
RMQ存储底层实现
- MappedByteBuffer
- RocketMQ中的文件读写主要就是通过MappedByteBuffer进行操作,来进行文件映射。利用了nio中的FileChannel模型,可以直接将物理文件映射到缓冲区,提高读写速度。这种Mmap的方式减少了传统IO将磁盘文件数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间来回进行拷贝的性能开销。这里需要注意的是,采用MappedByteBuffer这种内存映射的方式有几个限制,其中之一是一次只能映射1.5~2G 的文件至用户态的虚拟内存,这也是为何RocketMQ默认设置单个CommitLog日志数据文件为1G的原因了。
- page cache
- 刚刚提到的缓冲区,也就是之前说到的page cache。通俗的说:pageCache是系统读写磁盘时为了提高性能将部分文件缓存到内存中,下面是详细解释:page cache:这里所提及到的page cache,在我看来是linux中vfs虚拟文件系统层的cache层,一般pageCache默认是4K大小,它被操作系统的内存管理模块所管理,文件被映射到内存,一般都是被mmap()函数映射上去的。mmap()函数会返回一个指针,指向逻辑地址空间中的逻辑地址,逻辑地址通过MMU映射到page cache上。pageCache缺点:内核把可用的内存分配给Page Cache后,free的内存相对就会变少,如果程序有新的内存分配需求或者缺页中断,恰好free的内存不够,内核还需要花费一点时间将热度低的Page Cache的内存回收掉,对性能非常苛刻的系统会产生毛刺。
- MappedByteBuffer
Rocketmq事务消息及状态回查
拆解&分析
作者: 行舟
- rocketmq事务消息流程图
- 1)应用程序发送方先发送prepare消息给MQ;
- 2)prepare消息发送成功后,应用模块执行本地事务;
- 3)本地执行事务执行结果,返回Commit或者Rollback给MQ;
- 4)根据事务执行结果,如果是Commit,MQ把消息发送给Consumer端,如果是Rollabck,直接删掉prepare消息;
- 如果本地事务执行结果没有返回,或者超时。MQ会启动定时任务(一分钟一次)回查事务状态。
- rocketmq事务消息实现流程图
- //org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl#sendMessageInTransaction 发送事务消息源码阅读详见
1)RMQ_SYS_TRANS_HALF_TOPIC prepare消息主题(即消息备份)
2)RMQ_SYS_TRANS_OP_HALF_TOPIC
3)真实队列
//org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl#endTransaction
发送结束事务命令
//org.apache.rocketmq.broker.processor.EndTransactionProcessor#processRequest
Broker服务端结束事务处理器
- 事务消息回查事务状态
- org.apache.rocketmq.broker.transaction.queue.TransactionMessageServiceImpl#check
定时代码详解
@Override
public void check(long transactionTimeout, int transactionCheckMax,
AbstractTransactionalMessageCheckListener listener) {
try {
String topic = TopicValidator.RMQ_SYS_TRANS_HALF_TOPIC;
//获取Topic 为 RMQ_SYS_TRANS_HALF_TOPIC 的所有消息队列 prepare消息的主题,事务消息首先进入改主题
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);
for (MessageQueue messageQueue : msgQueues) {
long startTime = System.currentTimeMillis();
//获取已处理消息的消息队列 RMQ_SYS_TRANS_OP_HALF_TOPIC 当消息服务器收到事务消息的提交或者回滚请求后,会将消息存储在该主题下
MessageQueue opQueue = getOpQueue(messageQueue);
long halfOffset = transactionalMessageBridge.fetchConsumeOffset(messageQueue);
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;
}
List<Long> doneOpOffset = new ArrayList<>();
HashMap<Long, Long> removeMap = new HashMap<>();
// Read op message, parse op message, and fill removeMap
// 根据当前的处理进度依次从已处理队列拉取32条消息,方便判断当前处理的消息是否已经处理过,如果处理过则无须再次发送事务状态回查请求,避免重复发送事务回查请求
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;
}
// single thread
//获取空消息的次数
int getMessageNullCount = 1;
//当前处理 RMQ_SYS_TRANS_OP_HALF_TOPIC #queueId的最新进度
long newOffset = halfOffset;
// 当前处理消息的队列偏移量 RMQ_SYS_TRANS_OP_HALF_TOPIC
long i = halfOffset;
while (true) {
//60秒 每个任务分配固定时长,超过该时长则需等待下次任务调动
if (System.currentTimeMillis() - startTime > MAX_PROCESS_TIME_LIMIT) {
log.info("Queue={} process time reach max={}", messageQueue, MAX_PROCESS_TIME_LIMIT);
break;
}
//如果任务已经被处理,则继续处理下一条消息
if (removeMap.containsKey(i)) {
log.debug("Half offset {} has been committed/rolled back", i);
Long removedOpOffset = removeMap.remove(i);
doneOpOffset.add(removedOpOffset);
} else {
//根据偏移量从消费队列中获取消息
GetResult getResult = getHalfMsg(messageQueue, i);
MessageExt msgExt = getResult.getMsg();
//判断拉取的消息是否为空
if (msgExt == null) {
//如果重试的次数过多,直接跳出,结束该消息队列的事务状态回查
if (getMessageNullCount++ > MAX_RETRY_COUNT_WHEN_HALF_NULL) {
break;
}
//没有新的消息,也结束该消息队列的事务状态回查
if (getResult.getPullResult().getPullStatus() == PullStatus.NO_NEW_MSG) {
log.debug("No new msg, the miss offset={} in={}, continue check={}, pull result={}", i,
messageQueue, getMessageNullCount, getResult.getPullResult());
break;
} else {
//其他原因,重置i,重新拉取
log.info("Illegal offset, the miss offset={} in={}, continue check={}, pull result={}",
i, messageQueue, getMessageNullCount, getResult.getPullResult());
i = getResult.getPullResult().getNextBeginOffset();
newOffset = i;
continue;
}
}
//判断discard 或者 needSkip
if (needDiscard(msgExt, transactionCheckMax) || needSkip(msgExt)) {
listener.resolveDiscardMsg(msgExt);
newOffset = i + 1;
i++;
continue;
}
if (msgExt.getStoreTimestamp() >= startTime) {
log.debug("Fresh stored. the miss offset={}, check it later, store={}", i,
new Date(msgExt.getStoreTimestamp()));
break;
}
//消息已存储时间
long valueOfCurrentMinusBorn = System.currentTimeMillis() - msgExt.getBornTimestamp();
//transactionTimeout 事务消息的超时时间
long checkImmunityTime = transactionTimeout;
//消息事务消息回查请求的最晚时间
String checkImmunityTimeStr = msgExt.getUserProperty(MessageConst.PROPERTY_CHECK_IMMUNITY_TIME_IN_SECONDS);
if (null != checkImmunityTimeStr) {
checkImmunityTime = getImmunityTime(checkImmunityTimeStr, transactionTimeout);
if (valueOfCurrentMinusBorn < checkImmunityTime) {
if (checkPrepareQueueOffset(removeMap, doneOpOffset, msgExt)) {
newOffset = i + 1;
i++;
continue;
}
}
} else {
if ((0 <= valueOfCurrentMinusBorn) && (valueOfCurrentMinusBorn < checkImmunityTime)) {
log.debug("New arrived, the miss offset={}, check it later checkImmunity={}, born={}", i,
checkImmunityTime, new Date(msgExt.getBornTimestamp()));
break;
}
}
List<MessageExt> opMsg = pullResult.getMsgFoundList();
//判断是否需要发送事务回查消息
boolean isNeedCheck = (opMsg == null && valueOfCurrentMinusBorn > checkImmunityTime)
|| (opMsg != null && (opMsg.get(opMsg.size() - 1).getBornTimestamp() - startTime > transactionTimeout))
|| (valueOfCurrentMinusBorn <= -1);
if (isNeedCheck) {
//消息发送到 RMQ_SYS_TRANS_OP_HALF_TOPIC 主题 重点关注******
if (!putBackHalfMsgQueue(msgExt, i)) {
continue;
}
listener.resolveHalfMsg(msgExt);
} else {
pullResult = fillOpRemoveMap(removeMap, opQueue, pullResult.getNextBeginOffset(), halfOffset, doneOpOffset);
log.debug("The miss offset:{} in messageQueue:{} need to get more opMsg, result is:{}", i,
messageQueue, pullResult);
continue;
}
}
newOffset = i + 1;
i++;
}
if (newOffset != halfOffset) {
transactionalMessageBridge.updateConsumeOffset(messageQueue, newOffset);
}
long newOpOffset = calculateOpOffset(doneOpOffset, opOffset);
if (newOpOffset != opOffset) {
transactionalMessageBridge.updateConsumeOffset(opQueue, newOpOffset);
}
}
} catch (Throwable e) {
log.error("Check error", e);
}
}
RocketMq 增减机器的影响
拆解&分析
作者: 麦芽
- 动态增减Namesrv机器
NameServer是RocketMQ集群的协调者,集群的各个组件是通过NameServer获取各种属性和地址信息的。 主要功能包括两部分:
一个各个Broker定期上报自己的状态信息到NameServer; 另一个是各个客户端,包括Producer、Consumer,以及命令行工具,通过NameServer获取最新的状态信息。 所以,在启动Broker、生产者和消费者之前,必须告诉它们NameServer的地址,为了提高可靠性,建议启动多个NameServer。NameServer占用资源不多,可以和Broker部署在同一台机器。有多个NameServer后,减少某个NameServer不会对其他组件产生影响。
有四种种方式可设置NameServer的地址,下面按优先级由高到低依次介绍
1)通过代码设置,比如在Producer中,通过Producer.setNamesrvAddr(“name-server1-ip:port;name-server2-ip:port”)来设置。 在mqadmin命令行工具中,是通过-n name-server-ip1:port;name-server-ip2:port参数来设置的,如果自定义了命令行工具,也可以通过defaultMQAdminExt.setNamesrvAddr(“nameserver1-ip:port;ame-server2-ip:port”)来设置。
2)使用Java启动参数设置,对应的option是rocketmq.namesrv.addr。 3)通过Linux环境变量设置,在启动前设置变量:NAMESRV_ADDR。 4)通过HTTP服务来设置,当上述方法都没有使用,程序会向一个HTTP地址发送请求来获取NameServer地址,默认的URL是jmenv.tbsite.net:8080/rocketmq/ns… (淘宝的测试地址),通过rocketmq.namesrv.domain参数来覆盖jmenv.tbsite.net;通过rocketmq.namesrv.domain.subgroup参数来覆盖nsaddr。
第4种方式看似繁琐,但它是唯一支持动态增加NameServer,无须重启其他组件的方式。使用这种方式后其他组件会每隔2分钟请求一次该URL,获取最新的NameServer地址
动态增减Broker机器 由于业务增长,需要对集群进行扩容的时候,可以动态增加Broker角色的机器。只增加Broker不会对原有的Topic产生影响,原来创建好的Topic中数据的读写依然在原来的那些Broker上进行。 集群扩容后,一是可以把新建的Topic指定到新的Broker机器上,均衡利用资源;另一种方式是通过updateTopic命令更改现有的Topic配置,在新加的Broker上创建新的队列。比如TestTopic是现有的一个Topic,因为数据量增大需要扩容,新增的一个Broker机器地址是192.168.0.1:10911,这个时候执行下面的命令:sh./bin/mqadmin updateTopic-b 192.168.0.1:10911-t TestTopic-n192.168.0.100:9876,结果是在新增的Broker机器上,为TestTopic新创建了8个读写队列。
如果因为业务变动或者置换机器需要减少Broker,此时该如何操作呢?减少Broker要看是否有持续运行的Producer,当一个Topic只有一个Master Broker,停掉这个Broker后,消息的发送肯定会受到影响,需要在停止这个Broker前,停止发送消息。
当某个Topic有多个Master Broker,停了其中一个,这时候是否会丢失消息呢?答案和Producer使用的发送消息的方式有关,如果使用同步方式send(msg)发送,在DefaultMQProducer内部有个自动重试逻辑,其中一个Broker停了,会自动向另一个Broker发消息,不会发生丢消息现象。如果使用异步方式发送send(msg,callback),或者用sendOneWay方式,会丢失切换过程中的消息。因为在异步和sendOneWay这两种发送方式下,Producer.setRetryTimesWhenSendFailed设置不起作用,发送失败不会重试。DefaultMQProducer默认每30秒到NameServer请求最新的路由消息,Producer如果获取不到已停止的Broker下的队列信息,后续就自动不再向这些队列发送消息。
如果Producer程序能够暂停,在有一个Master和一个Slave的情况下也可以顺利切换。可以关闭Producer后关闭Master Broker,这个时候所有的读取都会被定向到Slave机器,消费消息不受影响。把Master Broker机器置换完后,基于原来的数据启动这个Master Broker,然后再启动Producer程序正常发送消息
用Linux的kill pid命令就可以正确地关闭Broker,BrokerController下有个shutdown函数,这个函数被加到了ShutdownHook里,当用Linux的kill命令时(不能用kill-9),shutdown函数会先被执行。也可以通过RocketMQ提供的工具(mqshutdown broker)来关闭Broker,它们的原理是一样的。
各种故障对消息的影响 期望消息队列集群一直可靠稳定地运行,但有时候故障是难免的,可能的故障情况,如何处理
1)Broker正常关闭,启动; 2)Broker异常Crash,然后启动; 3)OS Crash,重启; 4)机器断电,但能马上恢复供电; 5)磁盘损坏; 6)CPU、主板、内存等关键设备损坏。 假设现有的RocketMQ集群,每个Topic都配有多Master角色的Broker供写入,并且每个Master都至少有一个Slave机器(用两台物理机就可以实现上述配置),我们来看看在上述情况下消息的可靠性情况
第1种情况属于可控的软件问题,内存中的数据不会丢失。如果重启过程中有持续运行的Consumer,Master机器出故障后,Consumer会自动重连到对应的Slave机器,不会有消息丢失和偏差。当Master角色的机器重启以后,Consumer又会重新连接到Master机器(注意在启动Master机器的时候,如果onsumer正在从Slave消费消息,不要停止Consumer。假如此时先停止Consumer后再启动Master机器,然后再启动Consumer,这个时候Consumer就会去读Master机器上已经滞后的offset值,造成消息大量重复)
如果第1种情况出现时有持续运行的Producer,一台Master出故障后,Producer只能向Topic下其他的Master机器发送消息,如果Producer采用同步发送方式,不会有消息丢失。
第2、3、4种情况属于软件故障,内存的数据可能丢失,所以刷盘策略不同,造成的影响也不同,如果Master、Slave都配置成SYNC_FLUSH,可以达到和第1种情况相同的效果。 第5、6种情况属于硬件故障,发生第5、6种情况的故障,原有机器的磁盘数据可能会丢失。如果 Master和Slave机器间配置成同步复制方式,某一台机器发生5或6的故障,也可以达到消息不丢失的效 果。如果Master和Slave机器间是异步复制,两次Sync间的消息会丢失
总的来说,当设置成: 1)多Master,每个Master带有Slave; 2)主从之间设置成SYNC_MASTER; 3)Producer用同步方式写; 4)刷盘策略设置成SYNC_FLUSH。 就可以消除单点依赖,即使某台机器出现极端故障也不会丢消息