RocketMQ的消息发送

背景:学习RocketMQ


前置知识

1. 内核态与用户态
在计算机里,硬件(如磁盘、网卡)是非常敏感且危险的。如果允许任何一个普通的 Java 应用程序(用户态)直接去读写磁盘、操控网卡,万一代码写了个死循环或者有恶意病毒,整个操作系统当场就会崩溃。
所以,Linux 建立了一道高高的围墙:
用户态:Java 应用程序运行的地方,是“平民区”,没特权。
内核态:操作系统内核(Kernel)运行的地方,是“军管区”,只有它能直接操控硬件。

2. 传统IO的发送过程:
在这里插入图片描述

  1. 第一次复制(磁盘 → \rightarrow 内核态内存)
    动作:Java 程序调用了 read() 方法。
    幕后:Java 自己没资格碰磁盘,于是向操作系统发出申请(系统调用)。操作系统内核派出了 DMA 芯片(一种硬件助手),把数据从磁盘读取出来,放到了内核态的 PageCache(缓冲区)里。
    快递比喻:公司平民(Java)想寄一个存放在公司机密仓库(磁盘)里的货物。平民没钥匙,只能向大楼保安(内核)申请。保安跑进仓库,把货物抱到了保安室登记台(内核内存)。

  2. 第二次复制(内核态内存 → \rightarrow 用户态内存)
    动作:数据到达内核缓冲区后,操作系统把它拷贝到 Java 程序能掌控的内存中。
    幕后:Java 程序的变量、对象都在用户态内存(JVM 堆)里。不拷贝过来,Java 代码里的 byte[] 变量就拿不到数据,没法做解析或修改。
    快递比喻:保安把货物从保安室(内核)拿出来,递到了平民的办公桌(用户内存)上。平民看了一眼,确认货物没问题(数据读取完成)。

  3. 第三次复制(用户态内存 → \rightarrow 网络驱动内核内存)
    动作:Java 程序拿到数据后,紧接着调用 write() 方法准备发送。
    幕后:发送数据同样要经过网卡,Java 依然没权限。它只能再次发起系统调用,把数据从用户态内存,重新拷贝回内核态的 Socket 缓冲区(网络驱动内核内存)。
    快递比喻:平民在货物上贴好快递单,再次抱到保安室的待寄送包裹堆(Socket 缓冲区),拜托保安帮他寄出去。

  4. 第四次复制(网络驱动内核内存 → \rightarrow 网卡)
    动作:进入 Socket 缓冲区后,数据最后一次被拷贝到网卡硬件中,通过网线发射出去。
    幕后:驱动程序通知网卡,网卡利用 DMA 引擎把内核 Socket 缓冲区里的数据拷贝到网卡自身的内存中,转换成电信号发走。
    快递比喻:快递员(网卡硬件)来到保安室,把货物装上快递车,彻底运走。

为什么 RocketMQ 极其讨厌这个过程?你看,为了发一个文件,数据在内存里来回横跳。发生了 4 次数据复制。发生了4 次上下文切换 (Java 切换到内核 → \rightarrow 内核切换回 Java → \rightarrow Java 再次切换到内核 → \rightarrow 内核最后切换回 Java)。当高并发秒杀来临时,RocketMQ 每秒要发送成千上万条消息。如果每条消息都要经历这 4 次折腾,CPU 光是在内存里“搬运数据”和“切换状态”就要彻底累瘫了。


以下是本篇文章正文内容

什么是零拷贝

所谓零拷贝,并不是说真的不复制数据(毕竟数据从磁盘到网卡必须经过内存),而是指“没有数据从内核态内存复制到用户态内存(Java)的过程”,对用户态来说,复制次数为 0。

在 Linux 操作系统中,实现零拷贝主要有两种硬核玩法,而 RocketMQ 把这两位特种兵全都收编了。

具体见下文:

玩法一:mmap + write(内存映射:适合小文件、写操作多的场景)

传统 I/O 的痛点是:为了让 Java 能读到数据,必须把数据从内核态拷贝到用户态(第 2 次复制)。
mmap 的解法是:我不拷贝了,直接共享!

  1. 它是怎么玩的?
    内存映射:Java 申请 mmap 系统调用,操作系统把内核态 PageCache 的某一段物理内存,直接映射(绑定)到用户态虚拟内存中。
    效果:此时,Java 应用程序和操作系统内核,共享同一块物理内存空间。Java 读写这块虚拟内存,就相当于直接在读写内核态的 PageCache。
  2. 复制过程变成了几次?(见蓝色线)
    第一次复制:DMA 芯片把数据从磁盘抱到内核态 PageCache(和以前一样)。
    第二次复制(直接变没了):因为共享了内存,Java 直接能看到 PageCache 里的数据,无需任何内存拷贝!
    第三次复制:Java 调用 write(),CPU 直接把数据从 PageCache 拷贝到 Socket 缓冲区(内核)。
    第四次复制:网卡利用 DMA 把数据从 Socket 缓冲区 拷贝到 网卡 运走。
    在这里插入图片描述

战果:数据复制从 4 次减少到了 3 次!更关键的是,用户态和内核态之间的数据拷贝彻底变成了 0。

玩法二:sendfile(真正的一步到位:适合大文件、纯转发场景)

在 mmap 中,Java 依然要调用 write() 才能把数据送给 Socket 缓冲区。
Linux 觉得这还不够极致:既然你 Java 只是把数据当工具人转发一下,连看都不看,那你干脆别插手了,我(内核)自己在里面搞定!

  1. 它是怎么玩的?
    Java 直接调用 sendfile(out_fd, in_fd, …) 函数。

效果:Java 只下达一个命令:“把 A 文件的内容发给 B 网络套接字”,然后 Java 就可以去喝茶了。所有的搬运工作全在内核态内部闭环完成。

  1. 复制过程变成了几次?
    第一次复制:DMA 把数据从磁盘拷贝到内核态 PageCache。
    第二次复制:CPU 直接在内核里面,把数据从 PageCache 拷贝到 Socket 缓冲区。
    第三次复制:DMA 把数据从 Socket 缓冲区 拷贝到 网卡 运走。

战果:数据复制从 4 次减少到了 3 次,更绝的是,上下文切换直接从 4 次缩减到了 2 次(Java 发起命令 → \rightarrow 内核干完活返回),性能逆天。注意此处与mmap的区别,mmap仍为4次上下文切换

🚀 sendfile 的终极形态(利用网卡的 SG-DMA 技术)
如果你的服务器网卡足够高端(支持 Scatter-Gatter),在执行 sendfile 时:
CPU 连“从 PageCache 拷贝到 Socket 缓冲区”这一步都省了!它只把数据的内存地址和长度(面单信息)传给 Socket 缓冲区,然后网卡直接根据这个地址,去 PageCache 里抓取数据发走。
终极战果:数据全程只在磁盘到内存、内存到网卡时拷贝了 2 次,完全没有 CPU 参与拷贝,这就是最完美的零拷贝。


注意事项

mmap 适合小文件、频繁读写修改的场景: 因为文件小(如 RocketMQ 的 1G 限制),它触发的缺页中断次数在可控范围内,而且一旦全部加载到内存后,后续的读写就全是纯内存操作,快到极致。

sendfile 适合大文件、纯网络转发的场景:它彻底斩断了用户态的物理枷锁,没有虚拟内存的负担,全程在内核态如同流水般倾泄数据,零缺页中断、零内存颠簸,是把大文件送上网络网卡的“绝对王者”。

问题1:当使用第一个机制mmap的时候,如果文件特别大,是不是会让性能下降的很多?

答:正确。教科书上或者网上博客里天天吹的“mmap 只有 4 次上下文切换和 3 次数据复制”,那完全是理想状态下的“PPT理论”(即假设文件已经全部在物理内存中了)。
但在实际的高并发、大文件生产环境中,如果文件特别大,mmap 产生的上下文切换次数和性能损耗,会远远超过普通 I/O! 我们把这个残酷的真相看得更具体一点:

当 Java 开始疯狂读写这块内存时,每当碰到一个不在物理内存中的数据块(4KB 页面),CPU 就会触发一次缺页中断(Page Fault):

  1. CPU 强行把正在运行的用户态 Java 线程挂起,硬生生切换到内核态
  2. 内核去驱动磁盘把这 4KB 读进来
  3. 读完之后,内核再把状态切换回用户态,让 Java 继续跑

如果一个文件有 10G,按 4KB 一个页来算,就是 260 多万个页面!如果内存不够,发生了频繁的缺页和置换,CPU 就得在这 260 万次“用户态 ↔ \leftrightarrow 内核态”之间来回疯狂拉扯、反复横跳。所以,在高并发大文件场景下,mmap 真实的上下文切换次数是 4次 + 2 × 2 \times 2× 缺页中断次数,直接变成了一个天文数字!

但是,只要文件大小控制在内存能容纳的范围内(比如 RocketMQ 严格限制的 1G),缺页中断就不会频繁发生,这时候它就展现出了近乎无敌的纯内存读写性能。这就是为什么 RocketMQ 的核心架构师在设计存储时,必须要小心翼翼地把 CommitLog 文件死死卡在 1G。多一分,mmap 就会从“性能神器”变成“缺页中断”的“性能泥潭”。

问题2:为什么 sendfile 能闭着眼睛免疫这一切?

对比之下,sendfile 背后采用的是“内核级管道流(Streaming)”的实现逻辑。它从一开始就没打算在用户态建立虚拟内存映射,所以它压根不需要去维护那张肥胖的页表。

  1. 不经过虚拟内存页表,没有缺页异常
    因为整个搬运过程完全是在内核态内部闭环发生的(直接从 PageCache 复制到 Socket 缓冲区),它不涉及到 Java 进程的虚拟地址空间,CPU 根本不会去查 Java 的页表。既然不查页表,就绝对不会触发任何“缺页中断”异常。
  2. 内存流水线管理
    无论文件是 10G 还是 100G,内核都是读一点、塞给网卡一点、释放一点,网卡立刻发走,然后释放这一部分内存,接着读下一部分。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐