
去年雙十一前夜我們一個負責落訂單流水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 不在你掌控內的地方那一行很可能就是下個故障夜的源頭。