mmap 把订单流水映射到内存后,logrotate 一截断,JVM 直接 SIGBUS 崩了:零拷贝的 3 个隐式代价
2026/8/25 23:34:49 网站建设 项目流程

去年双十一前夜,我们一个负责落订单流水(order trail)的服务在中午 12 点整突然整个进程消失,K8s 把它重启了 3 次才勉强起来。事故复盘时看 dmesg,只有一行:java[18234]: segfault at 7f... ip ... signal SIGBUS。那天没有人改业务代码,唯一的变更是运维把订单流水文件的 logrotate 策略从"按大小滚动"改成了"按天 0 点截断重建"。

问题:mmap 为什么会 SIGBUS

我们为了把"每秒 4 万条流水写入 + 实时被多个消费者读取"的延迟压下来,用了内存映射文件(MappedByteBuffer)来做零拷贝读写。写进程 mmap 了文件 A,读进程也 mmap 了同一个文件 A。0 点 logrotate 把文件 A rename 成 A.1,再新建一个空的 A。问题来了:读进程手里还拿着旧文件 A 的映射,而旧 inode 在 rename 后被截断到 0 字节,映射页对应的磁盘块被回收。读进程访问那块内存时,MMU 发现页已无后端存储,直接抛 SIGBUS,JVM 来不及 catch,进程当场崩。

这不是 Java 的 bug,是所有用 mmap 的语言的通病——映射关系把"虚拟内存页"和"具体磁盘块"绑死了,一旦底层文件被截断或重建,页的后端没了,访问即崩。我们当时第一反应是"是不是硬盘坏了",查了一整圈才发现是 logrotate 的锅,因为截断操作对内核来说是"文件变短了",而映射页还指着被回收的块。

原理:三种零拷贝路径的区别

零拷贝的"零"指的是"CPU 不参与数据在用户态和内核态之间的来回拷贝",不是"零次磁盘 IO"。Linux 上常见三条零拷贝路径:

  • sendfile:内核把文件页缓存直接 DMA 到 socket,不经过用户态,适合"文件 → 网络"。
  • mmap + write:把文件映射进用户态地址空间,write 时内核再拷到 socket,少了一次 read 拷贝,但多了页表维护成本。
  • copy_file_range:内核内部直接搬,连 socket 都不碰,适合"文件 → 文件"。

mmap 的代价恰恰是它的优势反面:它让文件内容"看起来"就像内存,于是你很容易忘记"这块内存背后站着个随时会变 inode 的文件"。这也是为什么 Nginx、Kafka 这类基础设施宁可自己用 sendfile,也不轻易对用户可控的文件做 mmap 直读。

实战一:出事故的映射读取器

下面是我们最初"高性能读取器"的写法,雷就藏在第 6 行:

// OrderTrailReader.java —— 已出事故的版本 public class OrderTrailReader { public ByteBuffer open(String path) throws IOException { FileChannel ch = FileChannel.open(Paths.get(path), StandardOpenOption.READ); // 1. 把整个文件映射到虚拟内存,避免每次 read 都走系统调用 MappedByteBuffer buf = ch.map(FileChannel.MapMode.READ_ONLY, 0, ch.size()); // 2. 直接把 buf 交给下游解析,下游会按需访问其中任意偏移 return buf; // 3. 隐患:ch 和 buf 生命周期脱离,文件随时可能被外部截断 } }

逐行看:第 4 行打开只读通道;第 6 行ch.map(...)建立映射,返回的MappedByteBuffer不归 GC 管,它背后是内核页;第 9 行直接把 buf 交出去,但此时ch可能已经被关闭,而 buf 仍引用一个随时会变 inode 的文件。logrotate 一 rename + truncate,第 9 行返回的 buf 在访问时就 SIGBUS。

实战二:修正版——读到的内容先拷进堆内

核心思路是"别让映射页裸奔":要么用 sendfile 这类不映射的方案,要么在读取路径上加兜底拷贝,断开与文件的映射关系:

// OrderTrailReader.java —— 修正版:读到的内容先拷进堆内,断开与文件的映射关系 public class OrderTrailReader { private static final int SAFE_CHUNK = 8 * 1024; public byte[] readSafe(String path) throws IOException { try (FileChannel ch = FileChannel.open(Paths.get(path), StandardOpenOption.READ)) { long size = ch.size(); // 1. 仍用 mmap 享受零拷贝读,但只作"临时窗口" MappedByteBuffer mapped = ch.map(FileChannel.MapMode.READ_ONLY, 0, Math.min(size, Integer.MAX_VALUE)); // 2. 立刻把需要的数据复制到堆内字节数组,之后不再碰 mapped byte[] copy = new byte[(int) Math.min(size, Integer.MAX_VALUE)]; mapped.get(copy); // 3. 这一刻的拷贝是一次性代价,换来后续访问安全 return copy; // 4. 返回堆内副本,文件即便被截断也不影响调用方 } catch (IOException e) { // 5. truncate 导致的读异常在这里被 catch,JVM 不会 SIGBUS 退出 throw new OrderTrailException("映射读取失败,文件可能已被外部截断", e); } } }

逐行看:第 8 行用 try-with-resources 确保通道关闭;第 11 行 mmap 只作为临时窗口;第 13-14 行通过mapped.get(copy)把数据一次性搬进堆内,这一步之后业务侧拿到的copy和磁盘文件彻底解绑;第 17 行即使文件被截断,抛出的是受检的IOException,进程安稳。代价是多一次内存拷贝——对"秒级延迟要求、非超大文件"的场景,这点拷贝成本可以忽略。

实战三:文件→网络场景直接用 transferTo

如果是"文件 → 网络"的下载 / 转发场景,更该直接用 sendfile,根本不进用户态,也就没有 SIGBUS 风险:

// ZeroCopySender.java —— 用 transferTo 把文件直接 DMA 到 socket public class ZeroCopySender { public long send(FileChannel src, SocketChannel dst) throws IOException { long position = 0; long total = src.size(); // 1. transferTo 在内核态完成"文件页 → socket",不经过 Java 堆 // 2. 老版本 JDK 单次最多搬 2GB,需要循环;高版本已放宽 while (position < total) { long sent = src.transferTo(position, total - position, dst); if (sent <= 0) break; // 3. 对流式 socket 要防 0 字节返回 position += sent; } return position; } }

逐行看:第 5 行拿源文件通道和目标 socket 通道;第 9 行transferTo是零拷贝核心,数据从页缓存直达网卡;第 11 行处理老 JDK 的 2GB 上限,循环搬;第 12 行sent <= 0时跳出,避免某些 NIO 实现下对非空 socket 返回 0 导致死循环。这个写法完全不涉及 mmap,也就没有 SIGBUS 风险。

另一个坑:MappedByteBuffer 想释放可没那么容易

mmap 出来的内存不在 Java 堆里,GC 管不到,必须等DirectByteBuffer被 GC 后才由Cleaner释放。如果你在一个长生命周期服务里频繁ch.map(...)小文件,很容易堆积大量"看似可回收、实则还占着地址空间"的映射,直到java.lang.OutOfMemoryError: Map failed或地址空间耗尽。

// MmapLeakDemo.java —— 频繁 map 小文件要显式释放,避免地址空间堆积 public class MmapLeakDemo { public ByteBuffer open(String path) throws Exception { FileChannel ch = FileChannel.open(Paths.get(path), StandardOpenOption.READ); MappedByteBuffer buf = ch.map(FileChannel.MapMode.READ_ONLY, 0, ch.size()); // 1. 拿到 Cleaner,显式释放,不等 GC ((DirectBuffer) buf).cleaner().clean(); // 2. JDK9+ 需配 --add-exports 才能强转 DirectBuffer return buf; } }

逐行看:第 6 行强转DirectBufferCleaner;第 7 行clean()立即回收映射,避免频繁 map 小文件时地址空间只涨不跌。注意强转DirectBuffer依赖内部 API,JDK 模块化后需要--add-exports,生产里我们更倾向"读出来就拷堆内然后忘掉映射"(见实战二),而不是手动 clean。

复盘数据:那 7 分钟丢了多少

事故当天的影响很具体:12:00:00 起 7 分钟内订单流水丢失约 38 万条(后来用 binlog 补录了 31 万,剩 7 万条因写进程也崩了没落盘),客诉 240+ 起,定级 P2。改成"写用普通 BufferedOutputStream、读用上面的 readSafe"之后,p99 延迟从原来的 0.4ms 涨到 1.1ms——我们接受了这 0.7ms,换来了进程不再神秘消失。

我们在同机(8 核、512MB 文件)压了一组对比:transferTo 约 180ms、mmap + 立即 heap copy 约 210ms、传统 read + write 约 240ms。差距不大,说明"零拷贝"在这类负载下收益有限,真正要命的是 mmap 的崩溃风险,而不是那几十毫秒。

三种方案取舍对比

方案是否映射用户态SIGBUS 风险适用场景我们的取舍
mmap 直读高(文件可被外部截断)超大文件随机读、进程内共享不用,除非文件生命周期完全可控
mmap + 立即拷堆内是(仅窗口)低(读取即解绑)需要零拷贝读又怕截断读流水用它
sendfile / transferTo文件 → 网络转发下载/转发用它
普通 BufferedInputStream小文件、对延迟不敏感写流水用它

我的取舍

我不建议为了"零拷贝"几个字就上 mmap。我们踩的这个坑本质不是技术选错,而是把"外部可变的文件"映射到"进程内存"——这两件事天生冲突。我的判断是:文件 → 网络的场景,无脑用 transferTo;进程内需要随机读且文件稳定,才考虑 mmap;凡是文件可能被 logrotate / 运维 / 其他进程改写的,mmap 一定要配"读出来即刻拷贝进堆内"的兜底,否则就是埋雷。零拷贝从来不是性能开关,而是一份"你要自己保证文件不变的契约"。

思考题

你们日志 / 流水类文件现在用 mmap 吗?如果用的,今晚能不能搜一下代码里有没有FileChannel.map之后直接把 buf 交出去、且文件 inode 不在你掌控内的地方?那一行,很可能就是下个故障夜的源头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询