资讯中心

深入学习RocketMQ消息发送的底层逻辑

📅 2026/7/7 12:03:50
深入学习RocketMQ消息发送的底层逻辑
RocketMQ的消息发送背景学习RocketMQ文章目录前置知识什么是零拷贝玩法一mmap write内存映射适合小文件、写操作多的场景玩法二sendfile真正的一步到位适合大文件、纯转发场景注意事项前置知识1. 内核态与用户态在计算机里硬件如磁盘、网卡是非常敏感且危险的。如果允许任何一个普通的 Java 应用程序用户态直接去读写磁盘、操控网卡万一代码写了个死循环或者有恶意病毒整个操作系统当场就会崩溃。所以Linux 建立了一道高高的围墙用户态Java 应用程序运行的地方是“平民区”没特权。内核态操作系统内核Kernel运行的地方是“军管区”只有它能直接操控硬件。2. 传统IO的发送过程第一次复制磁盘→ \rightarrow→内核态内存动作Java 程序调用了 read() 方法。幕后Java 自己没资格碰磁盘于是向操作系统发出申请系统调用。操作系统内核派出了 DMA 芯片一种硬件助手把数据从磁盘读取出来放到了内核态的 PageCache缓冲区里。快递比喻公司平民Java想寄一个存放在公司机密仓库磁盘里的货物。平民没钥匙只能向大楼保安内核申请。保安跑进仓库把货物抱到了保安室登记台内核内存。第二次复制内核态内存→ \rightarrow→用户态内存动作数据到达内核缓冲区后操作系统把它拷贝到 Java 程序能掌控的内存中。幕后Java 程序的变量、对象都在用户态内存JVM 堆里。不拷贝过来Java 代码里的 byte[] 变量就拿不到数据没法做解析或修改。快递比喻保安把货物从保安室内核拿出来递到了平民的办公桌用户内存上。平民看了一眼确认货物没问题数据读取完成。第三次复制用户态内存→ \rightarrow→网络驱动内核内存动作Java 程序拿到数据后紧接着调用 write() 方法准备发送。幕后发送数据同样要经过网卡Java 依然没权限。它只能再次发起系统调用把数据从用户态内存重新拷贝回内核态的 Socket 缓冲区网络驱动内核内存。快递比喻平民在货物上贴好快递单再次抱到保安室的待寄送包裹堆Socket 缓冲区拜托保安帮他寄出去。第四次复制网络驱动内核内存→ \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 的解法是我不拷贝了直接共享它是怎么玩的内存映射Java 申请 mmap 系统调用操作系统把内核态 PageCache 的某一段物理内存直接映射绑定到用户态虚拟内存中。效果此时Java 应用程序和操作系统内核共享同一块物理内存空间。Java 读写这块虚拟内存就相当于直接在读写内核态的 PageCache。复制过程变成了几次见蓝色线第一次复制DMA 芯片把数据从磁盘抱到内核态 PageCache和以前一样。第二次复制直接变没了因为共享了内存Java 直接能看到 PageCache 里的数据无需任何内存拷贝第三次复制Java 调用 write()CPU 直接把数据从 PageCache 拷贝到 Socket 缓冲区内核。第四次复制网卡利用 DMA 把数据从 Socket 缓冲区 拷贝到 网卡 运走。战果数据复制从 4 次减少到了 3 次更关键的是用户态和内核态之间的数据拷贝彻底变成了 0。玩法二sendfile真正的一步到位适合大文件、纯转发场景在 mmap 中Java 依然要调用 write() 才能把数据送给 Socket 缓冲区。Linux 觉得这还不够极致既然你 Java 只是把数据当工具人转发一下连看都不看那你干脆别插手了我内核自己在里面搞定它是怎么玩的Java 直接调用 sendfile(out_fd, in_fd, …) 函数。效果Java 只下达一个命令“把 A 文件的内容发给 B 网络套接字”然后 Java 就可以去喝茶了。所有的搬运工作全在内核态内部闭环完成。复制过程变成了几次第一次复制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 FaultCPU 强行把正在运行的用户态 Java 线程挂起硬生生切换到内核态内核去驱动磁盘把这 4KB 读进来读完之后内核再把状态切换回用户态让 Java 继续跑如果一个文件有 10G按 4KB 一个页来算就是 260 多万个页面如果内存不够发生了频繁的缺页和置换CPU 就得在这 260 万次“用户态↔ \leftrightarrow↔内核态”之间来回疯狂拉扯、反复横跳。所以在高并发大文件场景下mmap 真实的上下文切换次数是 4次 2 × 2 \times2×缺页中断次数直接变成了一个天文数字但是只要文件大小控制在内存能容纳的范围内比如 RocketMQ 严格限制的 1G缺页中断就不会频繁发生这时候它就展现出了近乎无敌的纯内存读写性能。这就是为什么 RocketMQ 的核心架构师在设计存储时必须要小心翼翼地把 CommitLog 文件死死卡在 1G。多一分mmap 就会从“性能神器”变成“缺页中断”的“性能泥潭”。问题2为什么 sendfile 能闭着眼睛免疫这一切对比之下sendfile 背后采用的是“内核级管道流Streaming”的实现逻辑。它从一开始就没打算在用户态建立虚拟内存映射所以它压根不需要去维护那张肥胖的页表。不经过虚拟内存页表没有缺页异常因为整个搬运过程完全是在内核态内部闭环发生的直接从 PageCache 复制到 Socket 缓冲区它不涉及到 Java 进程的虚拟地址空间CPU 根本不会去查 Java 的页表。既然不查页表就绝对不会触发任何“缺页中断”异常。内存流水线管理无论文件是 10G 还是 100G内核都是读一点、塞给网卡一点、释放一点网卡立刻发走然后释放这一部分内存接着读下一部分。