ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

mmap 把订单流水映射到内存后,logrotate 一截断,JVM 直接 SIGBUS 崩了:零拷贝的 3 个隐式代价

mmap 把订单流水映射到内存后,logrotate 一截断,JVM 直接 SIGBUS 崩了:零拷贝的 3 个隐式代价 去年双十一前夜我们一个负责落订单流水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 发现页已无后端存储直接抛 SIGBUSJVM 来不及 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 导致的读异常在这里被 catchJVM 不会 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 行强转DirectBuffer拿Cleaner第 7 行clean()立即回收映射避免频繁 map 小文件时地址空间只涨不跌。注意强转DirectBuffer依赖内部 APIJDK 模块化后需要--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 不在你掌控内的地方那一行很可能就是下个故障夜的源头。
返回列表