一、 消息的诞生
消息,可以理解为异构系统数据交流的载体。它可以简单到只是一个单词:“Hello”,或是复杂的对象。
每个生命的诞生都是一场考验,消息也不例外(这里的诞生是指消息成功发送并安全存储的过程)。
-
准备工作 - 序列化
由于RocketMQ是分布式的,其消息存储组件-broker承担着核心的存储工作。
也就是说消息需要跨网络传输,才能到达broker。
那么这就涉及到序列化工作了,何谓序列化?
序列化就是把对象转化为字节数组的过程。
为什么需要转换成字节数组呢?因为计算机存储和网络传输都只认字节,一个字节=8个位,一个位由0或1组成。。。,好了 不再啰嗦了。
那么如何序列化?这里就不再展开来说了,常见的工具有jdk自带的,kryo,各种json库,protobuf等等。
这里需要注意的是,由于使用消息通信的一般是两个系统,如果生产者选择了一种序列化工具,消费者必须也使用同样的序列化工具进行反序列化。
所以,假如生产者是java语言,消费者是go语言,那么消息体最好只使用字符串,否则光是序列化这块也会让消费者感到棘手。
如果决定了消息使用字符串,那么就涉及到该怎么转换为字节数组的问题。这里又涉及到了字符和字节的关系,字符编码。好吧,这又是一个非常长的故事,这里就不再展开了。总之,如果有一天消费者说,怎么回事,接到的消息都是乱码呀!那么就需要问下生产者是使用何种字符编码发送消息的了。
-
发送 - 网络
发送消息直接调用api不就得了吗,还有啥好说的?就像下面这样:
producer.send(msg)
确实是这样的,但是要保证消息真正发送成功并不容易。思考一下如下的问题:
- 发送消息过大怎么办?
- 发送时网络异常怎么办?
- 发送超时如何处理?
- 发送后,返回结果为失败怎么办?
其实除了第一种情况之外,只要是涉及到网络通信的,都会存在这些问题,尤其是现在微服务流行,各系统间通过网络接口调用的趋势越来越甚。
下面来针对上面的几个问题分析一下:
-
发送消息过大怎么办?
RocketMQ默认支持的消息大小为4M。其实如果发送的消息超过1M大小,就该从业务层面考虑是否能够拆分。为什么呢?虽然RocketMQ号称能支持几十万的TPS,消息量的存储限制也只在于硬盘。但是,消息量大并不等于消息大!因为具有这么大消息体的消息对于任何系统来说都可以称得上是大对象,而大对象极易造成网络阻塞和GC问题。
所以,解决消息过大的办法就是拆分成多条发送。
-
发送时网络异常怎么办?
这里分两种情况:
第一:发送的时候网络异常(消息未发送出去)。
这样的确定发送失败了。如果业务要求必须发送成功,最简单的策略就是进行重试。如果达到重试次数仍未成功怎么办?其实办法是通用的,就是降级。至于降级怎么做,要根据业务不同来定制不同的降级策略,比如存储到redis,mysql等,通过补偿程序完成数据发送。
第二:发送后网络异常(消息发出去了),但是等待响应时网络抖动了。
这种情况和第3种情况一样,可以直接看3。
-
发送超时如何处理?
发送超时其实预示着结果是未知的,可能成功,也可能失败。如果业务要求必须发送成功,依然是重试+降级策略。
-
发送后,返回结果为失败怎么办?
这里其实涉及到broker如何存储消息,将在后面说明。不过如果生产者业务要求发送出去的消息不丢,并且返回结果告知失败,就需要根据返回结果做重试和降级。
-
持久化-broker
经过千辛万苦,消息终于到达broker了,但是,这仅仅是复杂的持久化过程的开始。
为什么持久化会复杂?对于任何需要持久化数据的系统(mysql,hbase等)来说,这块的设计都是核心且复杂的,因为要满足如下需求本身就是一个挑战:
- 海量的数据
- 高效的性能
- 持久性
其实说白了就是需要快速持久化大量数据,那么这个问题就转化为了IO问题。
消息最终要持久化到磁盘中,衡量磁盘的性能指标主要有以下两点:
- iops:每秒读写次数。
- 吞吐量:单位时间传输的数据量。
传统磁盘iops基本低于200,无法满足性能要求;而SSD硬盘iops一般能达到几万,性能有大大的提升,但是作为一款消息中间件,RocketMQ的性能不能依赖于硬件来实现。
其实自从操作系统诞生的那天起,关于IO的优化就从未停止过。这里以Linux为例,虚拟文件系统,Page Cache,Buffer Cache,IO调用等等,其都是为了突破IO瓶颈做的设计和优化,而RocketMQ可谓站在了巨人的肩膀上。
下面看一下,普通的写入过程:
- 用户态,Application发起系统调用write,进行写数据。
- 内核态,将用户态的数据复制到kernel buffer(Page Cache)中。
- 由操作系统调度flush,将脏页回写到磁盘。
这其中存在着3个问题,如下:
- write是系统调用,每调用一次将产生一次用户态到内核态的上下文切换。
- write需要将用户态数据拷贝到Page Cache中。
- 磁盘回写无法保证是顺序的,随机写严重影响IO性能。
RocketMQ采用mmap机制来解决这些问题:
fileChannel = new RandomAccessFile(this.file, "rw").getChannel(); // mmap mappedByteBuffer = fileChannel.map(MapMode.READ_WRITE, 0, fileSize); // 可以理解为page cache
看下mmap的写入过程:
- mmap可以使直接操作内核态的数据成为可能,直接写到Page Cache,无需每次上下文切换。
- 减少了一次拷贝过程,也称为零拷贝技术。
- RocketMQ通过只追加的模式写文件,保障了顺序写的实现。
当然,任何事情有利必有弊。mmap初始化时映射的内存并未真正分配,只有当发生真正读写的时候才会触发page fault,进行内存分配,频繁的page fault将会影响系统的性能。
RocketMQ提供了预热机制,即在mmap初始化时,通过预写内存的方式主动触发page fault,让OS提前分配内存,并锁定该内存,防止OS内存换出,从而避免频繁的page fault。
下面来看一下RocketMQ的Commit Log设计:
它采用mmap映射的文件组成队列,每个mmap映射为1G大小,并且每次都会预分配好下一个文件,保障当前文件数据写满的时候,下一个文件直接可以写入。
mmap保障了RocketMQ消息写入时的性能,但是,并不是任何时候都是这样。
mmap高性能的原因是让用户态的程序可以直接操作Page Cache,但是内存分配是一项昂贵的操作。
尤其是分配大量内存(开启内存预热机制)的时候,遇上脏页回写,内存回收等情况,会出现锁竞争,导致Page Cache写入卡主的情况。
解决这个问题很难,涉及到OS的内存管理机制了,那么能不能绕过这个问题呢?
如果直接从OS申请一部分内存,用于消息写入,不使用OS的Page Cache机制不就行了。
所以,为了性能权衡,RocketMQ使用了java的堆外内存,实现内存池机制,循环利用,来保障写入无延迟:
for (int i = 0; i < poolSize; i++) { ByteBuffer byteBuffer = ByteBuffer.allocateDirect(fileSize); // malloc直接分配内存 Pointer pointer = new Pointer(byteBuffer.address); LibC.INSTANCE.mlock(pointer, new NativeLong(fileSize)); // 内存锁定 availableBuffers.offer(byteBuffer); // 放入内存池 }
同时,采用异步刷新的方式,将数据从堆外内存刷入mmap文件中。
当然,还是那句话,有利必有弊。性能保障的同时带来的问题就是可靠性的下降。
因为消息需要从堆外内存异步刷入page cache中,假设消息已经写入了堆外内存,程序退出、宕机等导致消息未及时刷入page cache,会造成消息丢失。
鱼与熊掌不可兼得也! 可靠性和高性能也是如此!
RocketMQ通过如下方式来保障高可靠:
- 用户写入消息到master。
- master通过同步刷盘机制,保障消息写入硬盘,在发生可恢复性故障(broker或OS crash等),保证可靠性。
- 通过同步双写机制,将数据写入slave节点,在发生不可恢复性故障(磁盘损坏等),保证可靠性。
所以,RocketMQ发送消息api返回的结果,会存在如下几种状态:
public enum SendStatus { SEND_OK, // 发送成功 FLUSH_DISK_TIMEOUT, // 同步刷盘超时 FLUSH_SLAVE_TIMEOUT, // 同步双写超时 SLAVE_NOT_AVAILABLE, // slave不存在 }
这里跟之前讲的发送后,返回结果为失败怎么办?关联上了。
好了,这里从消息的高性能写入和高可靠性保障两个方面讲述了broker的持久化机制,消息终于诞生了。
二、 消息的消费
RocketMQ支持两种消息获取方式:推和拉。
拉适用于消费者想自行控制消费进度,而一般情况下都会使用推的方式进行消费:
consumer.registerMessageListener(new MessageListenerConcurrently() { // 注册消费监听
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.println(msgs); // 消费消息
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; // 返回消费成功标识
}
});
从表面看来,客户端并没有主动拉取消息,消息就像从RocketMQ源源不断的推到了客户端。其实RocketMQ内部采用拉的方式,定时将消息拉取到客户端缓存起来,看起来就像从broker端实时推过来一样。
这里有几个常见的问题,如下:
-
消息会重复消费吗?
会。RocketMQ的push方式消费能保障消息至少被消费一次,所以消费者需要根据业务做幂等性处理。
啥是幂等性处理?无论执行多少次,结果都一样!
就拿常见的CURD来说,R(查询)和D(删除)天然就是幂等的,而C(创建)和U(更新)就不是幂等的。
那么如何设计幂等性接口呢?
可以针对业务唯一键去重的方式来保障消费过的消息不再处理,去重可以依赖redis或数据库等。
-
由于某些原因,某条消息无法消费,能不能删掉?
不能!这里以删除一条消息为例,进行说明:
假设共有5条消息已经持久化到文件中,删除msg3。后面的消息都需要往前移动,这将带来大量的IO操作。
-
消息能不能重新消费或者跳过呢?
可以。RocketMQ可以通过控制下标(偏移量)的移动,来重新消费或跳过消息。
这里就涉及到了消息是如何定位的,可以看下下面的例子。
那么如何来定位消息并获取呢?
我们知道RocketMQ将消息存储在commitlog里了,如下:
这里假设一条消息,内容是Hello
,长度就是5个字节(因为每个英文字母都有对应的ASCII码,每个ASCII码占一个字节),它被写在了第二个mmap文件中,起始偏移量是1012。
看一下消息的定位过程:
-
定位到第二个mmap文件:
-
定位到第二个mmap文件的1012位置:
-
从1012位置读取5个字节:
好了,通过这个例子,我们如果知道如下信息,就可以定位到数据了:
- 在哪个mmap文件中
- 在mmap文件中的起始位置
- 消息的长度
RocketMQ将定位信息设计为叫做consume queue的文件队列,与commit log类似,也是通过mmap实现:
consume queue只存储消息的定位数据,而定位数据的长度是固定的20个字节:
long physicalOffset;
int msgSize;
long tagsCode;
所以,消费消息其实就变成了遍历consume queue文件,按照每次20个字节的长度,读出定位数据,然后从commit log中获取消息即可。
这里可以看到,consume queue是顺序写,顺序读,但是从commit log获取真实消息数据时,变成了随机读,并且读一条消息,需要两次IO!
RocketMQ如果解决这些问题呢?
- consume queue文件很小,可被OS完全缓存;并且是顺序读,OS的预读机制会cache接下来需要读的数据。
- 关于commit log随机读问题,由于消息有及时性的特点,大部分的消息基本都会命中page cache。
- 如果需要大量消费历史消息,若消息量超过内存一定阈值,RocketMQ会通知客户端到slave去拉取,减轻master压力。
到这里,我们知道RocketMQ是如何定位消息并获取的了。
另外,当消费消息出现如下状况时:
- 因为某些原因抛出异常;
- 消费长时间没有返回;
- 消费返回了失败的状态码(
RECONSUME_LATER
);
将导致产生重试消息,重试消息含义如下:
因为某些原因导致某消息消费失败,该消息将会重新被发送回RocketMQ的延时队列中,变成重试消息。
重试消息会按照重试频率进行衰减,至到达到最大重试次数。参见如下流程:
而达到最大重试次数仍未被成功消费的消息,将变成死信消息,即无法投递成功的消息。
到这里,消息基本完成了它重要的使命:被消费或变成死信消息。
三、 消息的死亡
一般死亡都是平淡无奇的,消息也是如此。
由于消息不能删除,而磁盘空间是有限的,所以RocketMQ会定时根据保留时间删除过期的mmap文件,消息也随之死亡,再也无法获取了。
这里用下图来作为本文的总结吧: