一、RocketMQ基于Netty的高性能网络通信框架

1、Reactor主线程与长短连接

首先Broker会有一个Reactor主线程,这个线程负责监听一个网络端口的。

如果Producer想跟Broker建立一个TCP长连接,此时Broker上的Reactor主线程会在这个端口上监听到这个Producer建立连接的请求。然后Broker和Producer两者中都有一个SocketChannel用来建立他们的长连接。然后Producer就会通过这个SocketChannel通道来发送消息。

java rocketmq创建连接 rocketmq连接池_服务器

建立后连接后,Broker会有一个Reactor线程池(默认三个线程,注意Reactor主线程是用于建立SocketChannel连接的,而后续的请求监听是交给Reactor线程池的线程的)专门用来监听SocketChannel中是否有请求到达。

当Producer有消息发送过来,此时Reactor线程池的线程会监听到这个请求,然后将这个消息交给worker线程池进行一些预处理(例如ssl加密验证、解码编码、连接空闲检查、网络连接管理等)

java rocketmq创建连接 rocketmq连接池_发送消息_02

预处理后接下来就是就是进行正式的处理了,此时将预处理后的请求交给SendMessage线程池(之前集群的时候就讲过这个SendMessage线程是可以配置的,你配的越多处理消息的吞吐量就越高),然后线程池的线程就会将消息写入CommitLog文件和ConsumeQueue文件等。

java rocketmq创建连接 rocketmq连接池_java rocketmq创建连接_03

这样各自的线程负责各自的任务,此时类似那种流水线技术(某个人只做一件事,只要你有我可以做的活,我就接过来直接做,此时就类似于我们在工厂中看到的流水线工人一样,生产速度是大于一个人全包的生产流程的),可以快速且充分的利用各个线程。

java rocketmq创建连接 rocketmq连接池_服务器_04

二、mmap内存映射实现磁盘文件的高性能读写

Broker中就是使用大量的mmap技术去实现CommitLog这种大磁盘文件的高性能读写优化的。

前面讲过Broker对于磁盘文件的读写都是通过os cache来实现性能优化的,然后让os内核的线程再异步把其中的数据输入磁盘文件。

1、传统文件IO操作的多次数据拷贝问题

假如RocketMq没有mmap技术,此时使用最传统的普通文件IO操作区进行磁盘文件的读写,此时就会出现多次数据拷贝的问题。

2、线程读取数据的流程

首先会将磁盘的数据读到内核IO缓冲区中区,然后再从缓冲区读到用户进程的私有空间里去。

java rocketmq创建连接 rocketmq连接池_网络_05

此时为了读取磁盘文件的数据,此时是不是要经过两次拷贝。如果此时再将数据刷到磁盘就再会造成两次数据拷贝。

3、RocketMq基于mmap技术+page cache进行优化

RocketMq对于CommitLog、ConsumeQueue之类的磁盘文件读写都会采用mmap技术实现,mmap技术就是一种内存映射技术,就是对物理磁盘的文件地址和用户进程私有空间的一些虚拟内存地址做一个映射(也就是使用了JDK NIO包下的MaooedByteBuffer.map()函数,这个函数底层就是基于mmap技术实现的),mmap技术进行文件映射时,一般限制在1.5G-2G之间。

PageCache(磁盘高速缓存,速度大于磁盘,小于内存):可以存储常被使用的数据。这里的虚拟内存(内核缓存区)就是PageCache

java rocketmq创建连接 rocketmq连接池_java_06

此时我们对磁盘文件进行写入,例如写入到CommitLog文件中,此时会先把一个CommitLog文件通过MaooedByteBuffer.map()函数映射其地址到你的虚拟内存地址,然后对这个MaooedByteBuffer执行写入操作,写入的时候它会直接进入Page cache,然后过段时间会由os 的线程异步刷入磁盘中。

java rocketmq创建连接 rocketmq连接池_发送消息_07

此时这个过程只要PageCache拷贝到磁盘文件这一次数据拷贝,减少了一次数据拷贝,从而提高了IO的性能。加载数据的时候也类似,只是会先判断pagecache有没有数据,没有再从磁盘读到page cache中(Page cache加载数据是会将临近的其他数据块也一起加载到Page cache中去)

java rocketmq创建连接 rocketmq连接池_发送消息_08

4、预映射机制+文件预热机制

内存预映射:Broker会针对磁盘上的各种CommitLog、ConsumeQueue文件预先分配好MappedFile,就是提前对可能接下来要读写的磁盘文件先通过MappedByteBuffer执行map函数完成映射,这样后续进行读写文件时就可以直接进行数据拷贝了。

文件预热:就是在上面调用了map函数后进行madvise还是的调用,尽量提前的将尽可能多的磁盘文件加载到内存种去。

java rocketmq创建连接 rocketmq连接池_发送消息_09

mmap指的是内存映射:

        整个过程读数据是从用户态切换到内核态,内核态从磁盘进行DMA拷贝数据到IO内核缓冲区中,在从内核态切换到用户态让用户进程缓存区和内核缓冲区建立映射(即共享物理内存读数据不需要在拷贝),再次切换到内核态数据进行cpu拷贝发送到socket缓冲区中,socket缓冲区进行DMA拷贝数据到网络引擎中最后切换回用户态,整个过程是3次拷贝4次切换

零拷贝:

        1.用户态切换到内核态

        2.内核态下从磁盘进行DMA拷贝数据到内核缓冲区

        3.从内核态直接拷贝offset和length到socket缓存区中,在从socket缓冲区拷贝数据到网络引擎中

        4.真正的数据直接通过内核缓冲区DMA拷贝到网络引擎中

        5.最后切换回用户态 整个过程中从内核态内核缓冲区拷贝offset和length到socket缓冲区和到网络引擎这部分数据较少可直接忽略

        这样就是2次DMA拷贝(没有了cpu拷贝) 2次切换(第一次和最后一次) 对比mmap减少了一次拷贝2次切换。

提出一些问题:

java rocketmq创建连接 rocketmq连接池_java rocketmq创建连接_10

java rocketmq创建连接 rocketmq连接池_发送消息_11

零拷贝:原来 8 张图,就可以搞懂「零拷贝」了 - 知乎

三、全链路分析为什么用户支付后没收到红包?(消息丢失问题)

在接入MQ后,最近商城活动,支付完成订单后会拿到一个现金红包,但现在出现一个问题,就是用户在支付完成后没有收到这个现金红包。

最后经过一段日志的排查发现一个奇怪的现象。

按照正常逻辑来说,订单支付成功后是会想MQ推送一条消息的,然后红包系统再从mq中消费这条消息去给用户发送红包。

java rocketmq创建连接 rocketmq连接池_发送消息_12

最后通过日志得知,订单系统有推送消息的mq的日志,但是没有看到红包系统发送红包的日志。

这个时候就可以猜测,消息没有到达红包系统,即消息丢失了没消费到。这个时候就要讲讲RocketMq的消息丢失问题了。

那么此时整个过程中哪些环节会出现消息丢失的可能?
1、订单到Broker的过程可能因为网络问题导致消息没到Broker中。

2、Broker接收到了消息,由于种种原因此时没持久化起来,例如MQ的网络通信模块出异常了,leader broker自身故障,消息还在os pagecache中还没刷到磁盘中就发生故障等等原因。

java rocketmq创建连接 rocketmq连接池_服务器_13

3、磁盘出现故障。

4、红包系统拿到消息后,还没成功发送红包给用户就提交消息的offset到broker去了,此时broker接收到后则表示消费者成功处理了这个消息。

但是在告诉broker我成功拿到消息后因为某种原因此时这个消息没消费成功,最后导致红包没发出去。

java rocketmq创建连接 rocketmq连接池_网络_14

四、解决消息丢失问题(事务消息)(生产者到broker)

RocketMq为了保证消息不丢失,提供了一个事务消息的机制

我们先说一个成功的流程:
生产者发送half消息到MQ,MQ接收到后对half消息进行存储,然后在多于半数broker接收到half消息后会给生产者发送响应消息,生产者接收到后根据自己的需要给broker发生commit/rollback消息,告诉broker进行提交()还是回滚(删除half消息)操作。(如果broker没有接收到commit/rollback消息,此时broker回去回调生产者的方法,看看这个half是要做commit还是rollback)

java rocketmq创建连接 rocketmq连接池_java rocketmq创建连接_15

此时会出现一些信息,且我们看看MQ是如何解决的:
1)、此时broker存储的half消息对于消费者是不可见的,即这个消息还不能被消费

2)、如果half消息写入失败怎么办?  此时订单系统就要进行一系列的回滚。

3)、如果此时订单系统本地事务执行失败,此时则给broker发送rollback操作,让broker删除掉这个half消息。

4)、如果一切照常,此时就会给broker发生commit操作,此时broker则对该half消息进行commit操作,此时消费者才能对这个消息进行消费。

5)、此时如果half消息发送成功,却没收到broker的响应?此时生产者不知道响应信息所以无法确定要给broker发送commit还是rollback操作,这里mq就有一个补偿机制,当broker中的half消息长时间没接收到commit或者rollback命令时,此时就会回调生产的的接口(这个接口事先写好,例如broker回调时,看看这个订单是完成还是关闭,完成就给这个half消息发送commit指令等。)

6)、如果rollback或者commit指令发送失败怎么办?

此时half长时间没接收到rollback或者commit操作,所以会触发5)中的补偿机制,此时会去回调生产者接口。

此时我们在提几个疑问

half消息是怎么存放的?

每当broker接收到half消息后都会将他存放在名为“RMQ_SYS_TRANS_HALF_TOPIC”这个Topic对应的ConsumerQueue中去。而不是你自己指定的Topic。

half消息长时间没收到rollback或者commit请求,那么此时MQ会做什么?
其实这个时候MQ后台会有一个定时任务,定时的去扫描RMQ_SYS_TRANS_HALF_TOPIC中的half消息,如果超过一段时间还是half消息,它就会回调生产者的接口(让你判断这个half消息是要rollback还是commit)。

java rocketmq创建连接 rocketmq连接池_发送消息_16

如果half消息接到rollback操作后MQ如何运行?
如果half消息需要进行rollback回滚操作,此时MQ就会将这个half消息删除,此时并不是直接去磁盘文件中删除,MQ是顺序的将消息写入磁盘文件并标记该消息的offset。而对于half消息此时会有一个OP操作来标记half的状态,此时又有一个Topic-----“OP_TOPIC”,OP操作会标记某个half消息是rollback了。(如果是commit也会在Topic中标记该half消息为commit状态,注意这只是标记不是将消息移到这个Topic),然后就会将RMQ_SYS_TRANS_HALF_TOPIC中的half消息写到自己对应绑定的Topic的ConsumeQueue中去,然后再被消费者消费。

(注意:此时如果回调15次还是没有指定rollback还是commit操作,此时这个half消息就会被标记为rollback状态)

java rocketmq创建连接 rocketmq连接池_java_17

解决消息零丢失一定要用事务消息?

事务消息为我们保证了消息从生产者到broker是不会丢失的,而相应的是要写一些的回调接口等等。且整个事务消息机制流程是比较复杂的,这样相比于普通消息性能可能稍低。

那此时我们是否可以基于重试机制去保证消息不会丢失?即如果broker将消息持久化到磁盘就响应消息给生产者

如果消息发送到MQ后持久化完消息同步等待MQ响应给我们,如果正常则表示消息已经被MQ持久化了,如果响应的是异常消息(连接超时、MQ内部异常等)此时就认为MQ发生消息失败,此时就会进行消息重发,依次重复。

这就是使用了同步发送消息+反复重试的方案来确保消息能投递到MQ中。(例如Kakfa就是基于这个方案去设计的)

我们该先执行本地事务还是先发送消息到MQ?
此时假如我们采用了上面的同步发送消息+反复重试的方案去实现消息的不丢失。那么此时存在本地事务和发送消息的前后顺序。

假如此时先执行本地事务再发送消息,此时如果本地事务执行失败了就不会发送消息,如果本地事务执行成功MQ发送失败还可以进行重试。

java rocketmq创建连接 rocketmq连接池_服务器_18

但这里存在一个问题,如果此时本地事务执行成功,还没等执行发送消息到MQ此时整个系统就崩溃了,这就会导致本地事务成功而消息没有发送出去。

那么此时我们可以控制本地事务和发送消息到同一个事务中,这样就可以解决上面的问题

java rocketmq创建连接 rocketmq连接池_发送消息_19

同一个事务就不会导致本地事务执行了而消息没发送出去。

但是这个机制存在的问题就是同步发送消息的阻塞+多次重试而导致的长时间等待问题,而且如果此时重试代码还没执行就发生宕机,此时就会使得没法发送消息出去(要是事务消息就会来回调看看本地事务是否成功来判断是回滚还是提交,超过15次则默认为回滚)。再说了此时还会存在一个问题就是如果我此时这个事务中使用了第三方的存储缓存等例如redis、es等。此时这个事务是无法控制redis、es的回滚的(两种机制都会存在的情况)。

所以这种机制相比于事务消息机制还是存在一定的误差的,事务消息可以等你本地事务执行成功后再将half消息进行commit(还会回调看你本地事务是否执行成功)。而重试机制是存在长时间的阻塞和重试的情况的。

(同步阻塞+多次重试会导致性能比事务消息的性能还低)

所以零丢失方案还是比较推荐采用事务消息机制。

(事务消息需要回滚全过程的是half消息发送失败的情况)

结合案例看看mq的事务消息机制怎么写?(使用官方提供的java API和伪代码演示)

1、发送half事务消息到broker

java rocketmq创建连接 rocketmq连接池_服务器_20

2、如果此时half消息发送失败怎么办?

如果此时half发送失败,此时senResult会接收到发送异常,此时我们就可以catch这个异常做相应的回滚处理

java rocketmq创建连接 rocketmq连接池_服务器_21

如果一直没收到half发送成功的消息?
此时我们将每个half消息存到内存或者磁盘文件,然后开启一个线程定时去检查,如果half消息超过10分钟都没收到响应,那就触发回滚逻辑。

3、half消息发送成功了,如何执行本地事务?
先执行本地事务、再提交commit或者rollback操作

java rocketmq创建连接 rocketmq连接池_服务器_22

4、如果broker没有接收到commit或者rollbaack,此时怎么办?
此时就会回来回调生产者写好的函数

java rocketmq创建连接 rocketmq连接池_java rocketmq创建连接_23

这个是对应生产者----broker的事务消息机制的伪代码实现。

五、broker保证消息零丢失方案:同步刷盘+raft协议主从同步

上面基于事务消息的机制保证了生产者------broker过程的消息零丢失。

这里我们再来看看broker本身持久化消息是如何做到消息零丢失的?
我们将half消息进行commit操作时,是将half消息从RMQ_SYS_TRANS_HALF_TOPIC移动到相应的Topic的ConSumerQueue中,此时消费者就可以进行消费了,但是该消息移到相应的ConSumerQueue时,此时是先存在os cache中的,此时如果机器宕机了,不就出现消息丢失的问题???

java rocketmq创建连接 rocketmq连接池_服务器_24

(到磁盘后要是磁盘坏了还是会存在数据丢失的)

(无论前面是用事务消息还是重试机制,都会存在消息在os cache中,然后宕机导致数据丢失的情况,所以这里还需要在broker中进行消息零丢失的方案)

在broker中对消息进行刷盘时,有两种方式:异步刷盘和同步刷盘

异步就是我们之前说了那种模式:先写到os cache中,过段时间再进行刷盘操作。

同步刷盘则只要响应消息写入成功,则代表这个消息已经被持久化磁盘中了。

所以一定要保证数据零丢失的话,可以调整MQ的刷盘策略,我们可以调整broker的配置文件,将其中的flushDiskType设置为SYNC_FLUSH(默认是ASYNC_FLUSH:异步模式)

上面同步刷盘解决了异步刷盘导致的数据丢失问题,而对于磁盘故障导致的丢失,此时我们可以通过主从模式来解决。

六、Consumer(消费者)消息零丢失方案:手动提交offset+自动故障转移

第四、五点中对于生产者----broker、broker本身这两个过程的消息零丢失方案进行讲解,接下来就对于最后一个会丢失消息的环节(broker---消费者)分析消息零丢失方案。

假如现在消费者拿到消息,此时还没进行消息的消费就提交了这条消息的offset给broker,broker收到后就认为消费者已经进行处理过这条消息了(此时没有立即删除,而是标记这个消费者的offset),之后消费者再来消费时就不会将这个消息发给消费者了。

这个时候就出现了消息丢失的情况。我们来看看消费者消费消息的主要代码:

java rocketmq创建连接 rocketmq连接池_发送消息_25

图中可以看到MQ会注册一个监听器,就是上面的MessageListenerConcurrently。

当你的消费者获取到一批消息后就会回调你这个监听器函数consumeMessage来处理这些函数,当你处理完后就会返回Consume Concurrently Status.CONSUME_SUCCESS作为消费成功的标志告诉MQ(此时MQ就会更新这些消费者的消费进度offset)(如果消费者宕机后导致CONSUME_SUCCESS无法发送给MQ,此时MQ就不会更新对应的offset,从而broker会认为你没有消费这个消息,所以下次请求还是可以消费这个消息的)

消费者消费消息的时候,需要注意一个点:不能异步消费消息

就是我们不能在消费消息的代码里进行异步处理,例如我们开启一个子线程去处理这批消息,然后启动线程之后就直接返回Consume Concurrently Status.CONSUME_SUCCESS

java rocketmq创建连接 rocketmq连接池_发送消息_26

此时有可能开启的线程还没处理完消息就发送CONSUME_SUCCESS,从而导致消息的丢失。

七、总结一下MQ如何保证全链路的消息零丢失方案(可靠性)

1、生产者到broker

两种方案:事务消息机制(推荐)、同步发送消息+反复多次重试

2、MQ本身持久化消息

同步刷盘+主从架构同步机制

3、broker到消费者

处理完消息后在提交消息的offset到broker去(注意不要使用多线程异步处理)

上面的这套消息零丢失方案虽然可以满足消息零丢失,但是这套全链路的方案会使得整体性能有所下降,多阶段信息通知、同步刷盘等都是比普通方案性能略低的。所以一般建议在支付系统、金融系统等严谨性、数据一致性要求比较高的地方使用这套方案。而在其他大部分场景,其实丢失一些数据是可以接收的,此时这些要求不是很高的场景就可以用同步发送+反复重试(降低重试次数)、异步刷盘这种机制来提高性能吞吐量。

这篇讲了一些MQ的特性和消息可靠性,下一篇会对MQ的幂等消费、有序性、延迟机制、常见问题进行分析。