导语
在之前的分析中分析了关于SendMessageProcessor,并且提供了对应的源码分析分析对于消息持久化的问题,下面来看另外一个PullMessageProcessor,在RocketMQ中比较重要的一个概念就是消息拉取,这个类就是表示消息的拉取操作。也是继承了NettyRequestProcessor 接口并且实现了其中的RemotingCommand processRequest(ChannelHandlerContext ctx, RemotingCommand request) throws Exception; 这篇分享就来看看这个类的主要操作。
通过之前的分享可以知道,对于消息的发送以及消息的收取其实,最后交给底层Netty之前都是进行的是封装的操作,现在网上很多的分析都是没有分析到核心的部分,而是将这些封装的操作进行了剖析,但是实际上结合之前的分析,所有的操作都是由一个Pair来进行操作,这Pair是一个处理器和执行器的封装,也就是说最后关于PullMessage的操作也会被封装成这样一个对象。在这个对象中真正负责操作的是处理器对象,也就是PullMessageProcessor对象,下面就来详细的了解一下这个对象
类继承关系

通过上面的类继承关系可以看到这个对象PullMessageProcessor其实是继承了NettyRequestProcessor接口既然继承了这个接口,那么就要实现这个接口中的一个processRequest()方法,对于这个方法应该是不陌生的,在之前的分析中提到过。这个就是实际处理逻辑的方法,看看在PullMessageProcessor类中是怎么实现的?
处理请求方法
processRequest() 方法法分析
从上面的分析中可以知道processRequest()方法传入的参数,其中一个表示Channel处理器的上下文对象,但是会看到下面代码中内部调用的方法传入的参数其实是一个Channel,并且这个内部方法传入了三个参数,按照之前的分析,就需要先对这个方法的参数进行分析
@Override
public RemotingCommand processRequest(final ChannelHandlerContext ctx,
RemotingCommand request) throws RemotingCommandException {
return this.processRequest(ctx.channel(), request, true);
}
首先会看到这个内部方法有三个参数
- Channel 结合NIO中的Channel概念,进行理解
- RemotingCommand 传入的请求参数,这个在之前的分析中提到过
- brokerAllowSuspend 是否允许被挂起,也就是是否允许在未找到消息的时候,暂时挂起处理线程,第一次传入的参数默认为true。
private RemotingCommand processRequest(final Channel channel, RemotingCommand request, boolean brokerAllowSuspend)
对于RemotingCommand参数自然是不用多说了,这里主要来分析一下其中另外两个参数
Channel
分析改方法完成之后发现对于Channel的使用只有两种情况channel.remoteAddress()和下面截图中的使用,而对于第一中使用更多的是出现与日志的输出中。也就是说下面截图代码才是最关键的地方。这里给大家分享一个思路,会发现在日志中使用了channel.remoteAddress()的方法,那么这里就应该对Channel对象有所怀疑,这对象是不是Netty提供的对象,RocketMQ在Netty的基础上进行了封装,查看源码会知道这里获取到的就是Netty的Channel对象,但是具体使用什么样的实现,这个就要看具体调用的方法了。毕竟Channel是接口。

分析方法可以知道在上面截图中有一个对象FileRegion,这个接口是Netty的接口,它由一个默认实现类DefaultFileRegion,当然这个实现类是有Netty提供的,在RocketMQ中有如下的三个实现类

从图中不难发现,其实这里使用的显然不是由Netty实现的的默认类而是RocketMQ中实现的三个类其中的一个,这里先来看一个东西,看这个三个类的实现包PageCache。并且后面有一个ManyMessageTransfer 类的存在,这个在上篇分享的时候提到过叫页缓存与内存映射,当然提到页缓存技术不得不提的就是零拷贝。
页缓存与内存映射
页缓存(PageCache)是OS对文件的缓存,用于加速对文件的读写。一般来说,程序对文件进行顺序读写的速度几乎接近于内存的读写速度,主要原因就是由于OS使用PageCache机制对读写访问操作进行了性能优化,将一部分的内存用作PageCache。对于数据的写入,OS会先写入至Cache内,随后通过异步的方式由pdflush内核线程将Cache内的数据刷盘至物理磁盘上。对于数据的读取,如果一次读取文件时出现未命中PageCache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取。
在RocketMQ中,ConsumeQueue逻辑消费队列存储的数据较少,并且是顺序读取,在page cache机制的预读取作用下,Consume Queue文件的读性能几乎接近读内存,即使在有消息堆积情况下也不会影响性能。而对于CommitLog消息存储的日志数据文件来说,读取消息内容时候会产生较多的随机访问读取,严重影响性能。如果选择合适的系统IO调度算法,比如设置调度算法为“Deadline”(此时块存储采用SSD的话),随机读的性能也会有所提升。
另外,RocketMQ主要通过MappedByteBuffer对文件进行读写操作。其中,利用了NIO中的FileChannel模型将磁盘上的物理文件直接映射到用户态的内存地址中(这种Mmap的方式减少了传统IO将磁盘文件数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间来回进行拷贝的性能开销),将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率(正因为需要使用内存映射机制,故RocketMQ的文件存储都使用定长结构来存储,方便一次将整个文件映射至内存)。
brokerAllowSuspend
对于这个参数在该方法中有如下的几处被使用了,从下面代码截图中可看到其实,第一次和第三次使用都不是太重要,其中最重要的一次应该是第二次的调用
第一次

第二次

第三次

PullRequestHoldService 服务
在第二次调用的时候有如下的一些注意
- hasSuspendFlag 构建拉取消息是的拉取标记,默认是true
- suspendTimeoutMillisLong 是从DefaultMQPullConsumer 的brokerSuspendMaxTimeMillis属性值
- pullRequest 对象是被PullRequestHoldService 线程调度,触发拉取消息
- response = null; 这个设置表示 此次调用不会向客户端输出任何字节,客户端网络请求客户端的读事件不会被触发,不会有响应结果就表示,会一直处于等待状态
也就是在第二次触发事件的时候,调用了PullRequestHoldService服务,并调用了其中的suspendPullRequest()方法。这个方法其实就是将ManyPullRequest进行了封装。

也会注意到PullRequestHoldService服务的run()方法

如果代码开启了长轮询模式,每次挂起五秒钟,然后就再次尝试拉取,如果不开启长轮询,则值挂起一次,并且时间也是有ShortPollTimeMill进行设置,然后去进行查找消息。在其中的checkHoldRequest()方法中遍历pullRequestTable 如果拉取任务的待拉取偏移量小于当前队列的最大偏移量是执行拉取,否则没有超过最大等待时间则进行等待,否则返回未拉取消息,返回给消息客户端

在notifyMessageArriving()方法中调用了与给唤醒方法executeRequestWhenWakeup(),这个方法,是在PullMessageProcessor中提供的

executeRequestWhenWakeup()方法说明

Netty FileRegion 实现零拷贝
这就不难理解了,下面就来介绍一下通过 FileRegion 实现零拷贝。Netty 中使用 FileRegion 实现文件传输的零拷贝, 不过在底层 FileRegion 是依赖于 Java NIO FileChannel.transfer 的零拷贝功能.
首先我们从最基础的 Java IO 开始吧. 假设我们希望实现一个文件拷贝的功能, 那么使用传统的方式, 我们有如下实现:
public static void copyFile(String srcFile, String destFile) throws Exception {
byte[] temp = new byte[1024];
FileInputStream in = new FileInputStream(srcFile);
FileOutputStream out = new FileOutputStream(destFile);
int length;
while ((length = in.read(temp)) != -1) {
out.write(temp, 0, length);
}
in.close();
out.close();
}
上面是一个典型的读写二进制文件的代码实现了. 不用我说, 大家肯定都知道, 上面的代码中不断中源文件中读取定长数据到 temp 数组中, 然后再将 temp 中的内容写入目的文件, 这样的拷贝操作对于小文件倒是没有太大的影响, 但是如果我们需要拷贝大文件时, 频繁的内存拷贝操作就消耗大量的系统资源了.
下面我们来看一下使用 Java NIO 的 FileChannel 是如何实现零拷贝的:
public static void copyFileWithFileChannel(String srcFileName, String destFileName) throws Exception {
RandomAccessFile srcFile = new RandomAccessFile(srcFileName, "r");
FileChannel srcFileChannel = srcFile.getChannel();
RandomAccessFile destFile = new RandomAccessFile(destFileName, "rw");
FileChannel destFileChannel = destFile.getChannel();
long position = 0;
long count = srcFileChannel.size();
srcFileChannel.transferTo(position, count, destFileChannel);
}
可以看到, 使用了 FileChannel 后, 我们就可以直接将源文件的内容直接拷贝(transferTo) 到目的文件中, 而不需要额外借助一个临时 buffer, 避免了不必要的内存操作.
有了上面的一些理论知识, 我们来看一下在 Netty 中是怎么使用 FileRegion 来实现零拷贝传输一个文件的:
@Override
public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
RandomAccessFile raf = null;
long length = -1;
try {
// 1. 通过 RandomAccessFile 打开一个文件.
raf = new RandomAccessFile(msg, "r");
length = raf.length();
} catch (Exception e) {
ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n');
return;
} finally {
if (length < 0 && raf != null) {
raf.close();
}
}
ctx.write("OK: " + raf.length() + '\n');
if (ctx.pipeline().get(SslHandler.class) == null) {
// SSL not enabled - can use zero-copy file transfer.
// 2. 调用 raf.getChannel() 获取一个 FileChannel.
// 3. 将 FileChannel 封装成一个 DefaultFileRegion
ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));
} else {
// SSL enabled - cannot use zero-copy file transfer.
ctx.write(new ChunkedFile(raf));
}
ctx.writeAndFlush("\n");
}
上面的代码是 Netty 的一个例子, 其源码在 netty/example/src/main/java/io/netty/example/file/FileServerHandler.java
可以看到, 第一步是通过 RandomAccessFile 打开一个文件, 然后 Netty 使用了 DefaultFileRegion 来封装一个 FileChannel 即: new DefaultFileRegion(raf.getChannel(), 0, length) 当有了 FileRegion 后, 我们就可以直接通过它将文件的内容直接写入 Channel 中, 而不需要像传统的做法: 拷贝文件内容到临时 buffer, 然后再将 buffer 写入 Channel. 通过这样的零拷贝操作, 无疑对传输大文件很有帮助
有了上面这个例子就不难理解使用Netty FileRegion实现文件零拷贝
消息拉取分析
要开启长轮询, 在 broker 配置文件中 longPollingEnable=true, 默认是开启的。
消息拉取为了提高网络性能,在消息服务端根据拉取偏移量去物理文件查找消息时没有找到,并不立即返回消息未找到,而是会将该线程挂起一段时间,然后再次重试,直到重试。挂起分为长轮询或短轮询,在broker 端可以通过 longPollingEnable=true 来开启长轮询。
-
短轮询:longPollingEnable=false,第一次未拉取到消息后等待 shortPollingTimeMills时间后再试。shortPollingTimeMills默认为1S。
-
长轮询:longPollingEnable=true,会根据消费者端设置的挂起超时时间,受DefaultMQPullConsumer 的brokerSuspendMaxTimeMillis控制,默认20s,(brokerSuspendMaxTimeMillis),长轮询有两个线程来相互实现。
PullRequestHoldService:每隔5s重试一次。
DefaultMessageStore#ReputMessageService,每当有消息到达后,转发消息,然后调用PullRequestHoldService 线程中的拉取任务,尝试拉取,每处理一次,Thread.sleep(1), 继续下一次检查。
总结
本次主要是对PullMessageProcessor的逻辑进行了简单的说明,整个的流程按照代码执行流程进行分析,中间也提到了几个关键的概念,在上面已经做了总结,主要注意的是就是长轮询和零拷贝。
本文深入分析RocketMQ中的PullMessageProcessor,详细解释消息拉取过程,包括长轮询机制与零拷贝技术的应用,揭示PullRequestHoldService服务的作用及NettyFileRegion如何实现高效文件传输。
3195

被折叠的 条评论
为什么被折叠?



