ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

把 8GB 文件 mmap 进内存那天,启动慢了 40 秒:零拷贝的 5 个真实边界

把 8GB 文件 mmap 进内存那天,启动慢了 40 秒:零拷贝的 5 个真实边界 title: 把 8GB 文件 mmap 进内存那天启动慢了 40 秒零拷贝的 5 个真实边界topic: 零拷贝原理sendfile 与 mmapbatch: 6round: 3我们做日志检索服务早年把大日志文件用InputStream一行行读进内存建索引GC 飙、CPU 也飙。后来听人安利「零拷贝」兴冲冲把 8GB 的索引文件用MappedByteBuffer一次性 mmap 进来想着从此不拷贝。结果服务启动慢了 40 秒第一次查询还卡了 3 秒——零拷贝省的是「CPU 在用户态和内核态之间来回搬数据的拷贝」不是「磁盘数据进内存」这件事本身。这篇文章用两个真实事故把sendfile和mmap到底省了什么、边界在哪讲清楚。事故一mmap 一次性映射 8GB启动被 page fault 卡死最早我写的「零拷贝」版本长这样public class LogIndexLoader { public MappedByteBuffer load(String path) throws IOException { try (RandomAccessFile raf new RandomAccessFile(path, r)) { FileChannel ch raf.getChannel(); // 把整个 8GB 文件一次性映射到虚拟内存 return ch.map(FileChannel.MapMode.READ_ONLY, 0, ch.size()); } } }逐行解释为什么这版会出事- 第 5 行ch.map(...)调用mmap系统调用它做的是「把文件映射进进程虚拟地址空间」并不是「把 8GB 内容立刻读进物理内存」。这一步很快返回。- 真正吃时间的是后续「第一次访问每一页」CPU 访问到还没载入物理内存的虚拟页会触发 page fault内核这时才去磁盘把那一页读上来。8GB 文件第一次全量访问等于把 8GB 从磁盘搬进物理内存几十万个 page fault 累加启动慢 40 秒就是这么来的。- 我原先以为 mmap「不拷贝所以快」但「不拷贝」指的是没有「内核缓冲区 → 用户缓冲区」那次 CPU 拷贝磁盘到内存的 DMA 搬运该发生还是发生只是被推迟到你真正读的时候并且分散在每次访问里变成不可控的卡顿。正确的打开方式mmap 适合随机读别拿它当「预加载缓存」我们后来把「启动即全量映射」改成「按需访问 预热错峰」public class LogIndexLoader { private MappedByteBuffer buffer; public void warmUp(long totalBytes, int stride) { // 错峰预热每隔 stride 字节访问一页避免一次性触发全部 page fault for (long off 0; off totalBytes; off stride) { buffer.get((int) off); // 主动触碰让内核后台把页换入 } } }逐行解释- 第 5 行for (long off 0; off totalBytes; off stride)每隔一页或几页主动读一个字节把 page fault 摊到后台异步换入而不是堆在「第一次查询」那一刻爆发。我们把 stride 设成 4KB一页预热放到服务启动后的低峰期慢慢跑。- 这版的本质是承认「数据进物理内存不可避免」只是把不可控的卡顿变成可排期的预热。mmap 真正的价值在「随机读」你要读文件第 5GB 处的 100 字节传统read要先把这 100 字节所在块整块读进内核缓冲再拷到用户空间mmap 直接按虚拟地址取那 100 字节省掉一次拷贝且多进程能共享同一份页缓存。我们的检索服务正是吃这个红利单查延迟从 4ms 降到 1.2ms。事故二用 transferTo 做文件下载2GB 文件被静默截断另一个事故在文件服务我们给下载接口上「零拷贝」public class FileDownloader { public void copyTo(InputStream in, OutputStream out) throws IOException { ReadableByteChannel src Channels.newChannel(in); WritableByteChannel dst Channels.newChannel(out); // 一次性把源通道的数据「零拷贝」搬到目的通道 ((FileChannel) src).transferTo(0, Long.MAX_VALUE, dst); } }逐行解释- 第 6 行transferTo(0, Long.MAX_VALUE, dst)本意是「从 0 搬到末尾全量传输」。但transferTo内部单次传输的 count 参数是int在绝大多数 JDK 实现里一次最多搬Integer.MAX_VALUE约 2GB字节超过的部分不会被自动续传。- 我们的视频文件有 3.6GB结果用户下载到 2GB 处文件就「结束」了服务端没报错客户端拿到的是截断的文件。这是最危险的 bug 类型不抛异常、不报错只是数据少了一半。- 根因是FileChannel.transferTo的语义是「尝试搬运最多 count 字节」返回值才是「实际搬了多少」我没检查返回值。修正按返回值循环搬运别信一次性public class FileDownloader { public void copyTo(FileChannel src, WritableByteChannel dst) throws IOException { long pos 0; long total src.size(); while (pos total) { // transferTo 返回实际搬运的字节数可能小于 count long n src.transferTo(pos, total - pos, dst); if (n 0) break; // 防死循环 pos n; } } }逐行解释- 第 5 行while (pos total)用返回值驱动循环而不是指望一次搬完。每次最多请求total - pos实际搬多少推进多少这样 2GB 以上的文件也能完整传完。- 第 6 行if (n 0) break是兜底某些平台在管道满时会返回 0不是 -1不 break 就死循环。我们线上踩过这个加这行后稳定。-transferTo在这里是真的「零拷贝」数据走sendfile系统调用从磁盘文件直接进网卡缓冲区不经过用户态CPU 占用比readwrite低一个量级。我们压测 1GB 文件下载CPU 从 70% 降到 5%这才是零拷贝该用的场景——大文件网络传输。sendfile 和 mmap 省的不是一回事很多人把两者混为一谈其实它们省的东西不同方式省掉的拷贝适合的 IO 模式边界transferTo(sendfile)内核→用户→内核 两次 CPU 拷贝文件→网络 的单向大块传输单次 ≤2GB 要循环不支持「读进来再改」mmap内核→用户 一次 CPU 拷贝文件随机读、多进程共享page fault 延迟不可控映射体积受虚拟内存限制我的取舍transferTo/sendfile用在「文件原样发出去」的场景下载、静态资源、消息落盘转发它最纯粹mmap用在「要按偏移随机读、且读多写少」的场景索引、大文件解析它的代价是 page fault 抖动别拿它当启动预加载。需要「读进来改了再写」的两者都不合适老老实实用带缓冲的InputStream/OutputStream零拷贝帮不了你——它只对「数据原样搬运」有效。一个被忽略的真相零拷贝转移的是 CPU 拷贝不是 DMA理解零拷贝最容易踩的坑是以为「零拷贝 不读磁盘」。错。sendfile把文件发到网络磁盘到内存仍由 DMA 控制器搬这是硬件搬不占 CPU零拷贝省掉的是之后「内核缓冲区 → 用户缓冲区 → 套接字缓冲区」那两次 CPU 参与的拷贝。所以零拷贝的收益在「CPU 密集型转发」对「磁盘本身很慢」无能为力——如果你的瓶颈是磁盘吞吐零拷贝救不了得换更快的盘或加缓存。我们有一台机器磁盘是 HDD上sendfile后 CPU 是降了但整体下载吞吐没变因为瓶颈在磁盘 DMA 那一步。后来把热文件迁到 SSD CDN吞吐才起来。这个结论很反直觉但重要先定位瓶颈在「CPU 拷贝」还是「磁盘/网络」再决定上不上零拷贝。零拷贝在消息队列里Kafka 走 sendfile、RocketMQ 走 mmap我们自己的日志管道和消息队列也吃零拷贝的红利但两者用的方式不同值得点破// Kafka 消费端把日志段文件发到网络底层就是 FileChannel.transferTosendfile // 源码简化零拷贝把磁盘日志段直接送到 socket不经过 broker 用户态 fileChannel.transferTo(0, size, socketChannel); // RocketMQ 的 CommitLog 用 mmap 映射顺序写时直接往映射区 append读时按偏移 random access MappedByteBuffer commitLog channel.map(READ_WRITE, 0, fileSize);逐行解释- 第 3 行 Kafka 用transferTosendfile做「文件→网络」的零拷贝broker 不碰数据内容、只搬字节所以 Kafka 吞吐能堆到很高——这正好对应我们文件下载那个事故的正确用法。- 第 6 行 RocketMQ 用mmap映射 CommitLog写消息是直接往映射区put省一次拷贝读消息按物理偏移随机访问——吃的是 mmap「随机读省拷贝」的红利对应我们日志检索那个场景。- 两种选型都不是随便的Kafka 重「转发」用 sendfileRocketMQ 重「读写随机」用 mmap。你自己的服务要照搬哪个看 IO 模式——纯转发文件/日志原样出去学 Kafka 用 sendfile要按偏移读写索引、队列学 RocketMQ 用 mmap别混。复盘真实数字8GB 索引全量 mmap启动 40 秒卡顿第一次查询 P99 从 1.2ms 飙到 3 秒改成错峰预热后启动仍慢但查询首字节稳定在 1.5ms 内卡顿消失。文件下载换成transferTo并修掉 2GB 截断单 1GB 下载 CPU 占用 70% → 5%吞吐不变之前 3.6GB 视频被截断的客诉当月归零。HDD 机器上零拷贝对吞吐无改善迁 SSD 后下载带宽从 120MB/s 提到 480MB/s——再一次说明瓶颈在 DMA 不在 CPU 拷贝。我的取舍先量瓶颈再谈零拷贝我不建议一上来就「为了零拷贝而零拷贝」。先拿火焰图看 CPU 是不是真耗在「内核↔用户拷贝」上是再上transferTo或mmap瓶颈在磁盘换硬件/CDN 比改代码划算。另外mmap 的 page fault 抖动在延迟敏感服务里是隐性炸弹要么错峰预热要么用MappedByteBuffer.load()显式预热但接受启动变慢。零拷贝是「优化手段」不是「银弹」它只解决「数据原样搬运时的 CPU 拷贝」超出这个范围它帮不上忙还可能因为 page fault 或 2GB 截断给你挖新坑。思考题如果你的服务要「读一个大文件、按行解析、再聚合写回另一个文件」mmap 和 transferTo 哪个更合适为什么两者其实都不如「带缓冲的流式读写 分块处理」
返回列表