檔案 IO 操作的一些最佳實踐

Kirito的技術分享發表於2019-03-04

背景

已經過去的中介軟體效能挑戰賽,和正在進行中的 第一屆 PolarDB 資料效能大賽 都涉及到了檔案操作,合理地設計架構以及正確地壓榨機器的讀寫效能成了比賽中獲取較好成績的關鍵。正在參賽的我收到了幾位公眾號讀者朋友的反饋,他們大多表達出了這樣的煩惱:“對比賽很感興趣,但不知道怎麼入門”,“能跑出成績,但相比前排的選手,成績相差10倍有餘”…為了能讓更多的讀者參與到之後相類似的比賽中來,我簡單整理一些檔案IO操作的最佳實踐,而不涉及整體系統的架構設計,希望通過這篇文章的介紹,讓你能夠歡快地參與到之後類似的效能挑戰賽之中來。

知識點梳理

本文主要關注的 Java 相關的檔案操作,理解它們需要一些前置條件,比如 PageCache,Mmap(記憶體對映),DirectByteBuffer(堆外快取),順序讀寫,隨機讀寫...不一定需要完全理解,但至少知道它們是個啥,因為本文將會主要圍繞這些知識點來展開描述。

初識 FileChannel 和 MMAP

首先,檔案IO型別的比賽最重要的一點,就是選擇好讀寫檔案的方式,那 JAVA 中檔案IO有多少種呢?原生的讀寫方式大概可以被分為三種:普通IO,FileChannel(檔案通道),MMAP(記憶體對映)。區分他們也很簡單,例如 FileWriter,FileReader 存在於 java.io 包中,他們屬於普通IO;FileChannel 存在於 java.nio 包中,屬於 NIO 的一種,但是注意 NIO 並不一定意味著非阻塞,這裡的 FileChannel 就是阻塞的;較為特殊的是後者 MMAP,它是由 FileChannel 呼叫 map 方法衍生出來的一種特殊讀寫檔案的方式,被稱之為記憶體對映。

使用 FIleChannel 的方式:

FileChannel fileChannel = new RandomAccessFile(new File("db.data"), "rw").getChannel();
複製程式碼

獲取 MMAP 的方式:

MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, filechannel.size();
複製程式碼

MappedByteBuffer 便是 JAVA 中 MMAP 的操作類。

面向於位元組傳輸的傳統 IO 方式遭到了我們的唾棄,我們重點探討 FileChannel 和 MMAP 這兩種讀寫方式的區別。

FileChannel 讀寫

// 寫
byte[] data = new byte[4096];
long position = 1024L;
//指定 position 寫入 4kb 的資料
fileChannel.write(ByteBuffer.wrap(data), position);
//從當前檔案指標的位置寫入 4kb 的資料
fileChannel.write(ByteBuffer.wrap(data));

// 讀
ByteBuffer buffer = ByteBuffer.allocate(4096);
long position = 1024L;
//指定 position 讀取 4kb 的資料
fileChannel.read(buffer,position);
//從當前檔案指標的位置讀取 4kb 的資料
fileChannel.read(buffer);
複製程式碼

FileChannel 大多數時候是和 ByteBuffer 這個類打交道,你可以將它理解為一個 byte[] 的封裝類,提供了豐富的 API 去操作位元組,不瞭解的同學可以去熟悉下它的 API。值得一提的是,write 和 read 方法均是執行緒安全的,FileChannel 內部通過一把 private final Object positionLock = new Object(); 鎖來控制併發。

FileChannel 為什麼比普通 IO 要快呢?這麼說可能不嚴謹,因為你要用對它,FileChannel 只有在一次寫入 4kb 的整數倍時,才能發揮出實際的效能,這得益於 FileChannel 採用了 ByteBuffer 這樣的記憶體緩衝區,讓我們可以非常精準的控制寫盤的大小,這是普通 IO 無法實現的。4kb 一定快嗎?也不嚴謹,這主要取決你機器的磁碟結構,並且受到作業系統,檔案系統,CPU 的影響,例如中介軟體效能挑戰賽時的那塊盤,一次至少寫入 64kb 才能發揮出最高的 IOPS。

中介軟體效能挑戰複賽的盤

然而 PolarDB 這塊盤就完全不一樣了,可謂是異常彪悍,具體是如何的表現由於比賽仍在進行中,不予深究,但憑藉著 benchmark everyting 的技巧,我們完全可以測出來。

另外一點,成就了 FileChannel 的高效,介紹這點之前,我想做一個提問:FileChannel 是直接把 ByteBuffer 中的資料寫入到磁碟嗎?思考幾秒…答案是:NO。ByteBuffer 中的資料和磁碟中的資料還隔了一層,這一層便是 PageCache,是使用者記憶體和磁碟之間的一層快取。我們都知道磁碟 IO 和記憶體 IO 的速度可是相差了好幾個數量級。我們可以認為 filechannel.write 寫入 PageCache 便是完成了落盤操作,但實際上,作業系統最終幫我們完成了 PageCache 到磁碟的最終寫入,理解了這個概念,你就應該能夠理解 FileChannel 為什麼提供了一個 force() 方法,用於通知作業系統進行及時的刷盤。

同理,當我們使用 FileChannel 進行讀操作時,同樣經歷了:磁碟->PageCache->使用者記憶體這三個階段,對於日常使用者而言,你可以忽略掉 PageCache,但作為挑戰者參賽,PageCache 在調優過程中是萬萬不能忽視的,關於讀操作這裡不做過多的介紹,我們再下面的小結中還會再次提及,這裡當做是引出 PageCache 的概念。

MMAP 讀寫

// 寫
byte[] data = new byte[4];
int position = 8;
//從當前 mmap 指標的位置寫入 4b 的資料
mappedByteBuffer.put(data);
//指定 position 寫入 4b 的資料
MappedByteBuffer subBuffer = mappedByteBuffer.slice();
subBuffer.position(position);
subBuffer.put(data);

// 讀
byte[] data = new byte[4];
int position = 8;
//從當前 mmap 指標的位置讀取 4b 的資料
mappedByteBuffer.get(data);
//指定 position 讀取 4b 的資料
MappedByteBuffer subBuffer = mappedByteBuffer.slice();
subBuffer.position(position);
subBuffer.get(data);
複製程式碼

FileChannel 已經足夠強大了,MappedByteBuffer 還能玩出什麼花來呢?請容許我賣個關子先,先介紹一下 MappedByteBuffer 的使用注意點。

當我們執行 fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1.5 * 1024 * 1024 * 1024); 之後,觀察一下磁碟上的變化,會立刻獲得一個 1.5G 的檔案,但此時檔案的內容全部是 0(位元組 0)。這符合 MMAP 的中文描述:記憶體對映檔案,我們之後對記憶體中 MappedByteBuffer 做的任何操作,都會被最終對映到檔案之中,

mmap 把檔案對映到使用者空間裡的虛擬記憶體,省去了從核心緩衝區複製到使用者空間的過程,檔案中的位置在虛擬記憶體中有了對應的地址,可以像操作記憶體一樣操作這個檔案,相當於已經把整個檔案放入記憶體,但在真正使用到這些資料前卻不會消耗實體記憶體,也不會有讀寫磁碟的操作,只有真正使用這些資料時,也就是影象準備渲染在螢幕上時,虛擬記憶體管理系統 VMS 才根據缺頁載入的機制從磁碟載入對應的資料塊到實體記憶體進行渲染。這樣的檔案讀寫檔案方式少了資料從核心快取到使用者空間的拷貝,效率很高

看了稍微官方一點的描述,你可能對 MMAP 有了些許的好奇,有這麼厲害的黑科技存在的話,還有 FileChannel 存在的意義嗎!並且網上很多文章都在說,MMAP 操作大檔案效能比 FileChannel 搞出一個數量級!然而,通過我比賽的認識,MMAP 並非是檔案 IO 的銀彈,它只有在一次寫入很小量資料的場景下才能表現出比 FileChannel 稍微優異的效能。緊接著我還要告訴你一些令你沮喪的事,至少在 JAVA 中使用 MappedByteBuffer 是一件非常麻煩並且痛苦的事,主要表現為三點:

  1. MMAP 使用時必須實現指定好記憶體對映的大小,並且一次 map 的大小限制在 1.5G 左右,重複 map 又會帶來虛擬記憶體的回收、重新分配的問題,對於檔案不確定大小的情形實在是太不友好了。
  2. MMAP 使用的是虛擬記憶體,和 PageCache 一樣是由作業系統來控制刷盤的,雖然可以通過 force() 來手動控制,但這個時間把握不好,在小記憶體場景下會很令人頭疼。
  3. MMAP 的回收問題,當 MappedByteBuffer 不再需要時,可以手動釋放佔用的虛擬記憶體,但…方式非常的詭異。
public static void clean(MappedByteBuffer mappedByteBuffer) {
    ByteBuffer buffer = mappedByteBuffer;
    if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0)
        return;
    invoke(invoke(viewed(buffer), "cleaner"), "clean");
}

private static Object invoke(final Object target, final String methodName, final Class<?>... args) {
    return AccessController.doPrivileged(new PrivilegedAction<Object>() {
        public Object run() {
            try {
                Method method = method(target, methodName, args);
                method.setAccessible(true);
                return method.invoke(target);
            } catch (Exception e) {
                throw new IllegalStateException(e);
            }
        }
    });
}

private static Method method(Object target, String methodName, Class<?>[] args)
        throws NoSuchMethodException {
    try {
        return target.getClass().getMethod(methodName, args);
    } catch (NoSuchMethodException e) {
        return target.getClass().getDeclaredMethod(methodName, args);
    }
}

private static ByteBuffer viewed(ByteBuffer buffer) {
    String methodName = "viewedBuffer";
    Method[] methods = buffer.getClass().getMethods();
    for (int i = 0; i < methods.length; i++) {
        if (methods[i].getName().equals("attachment")) {
            methodName = "attachment";
            break;
        }
    }
    ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);
    if (viewedBuffer == null)
        return buffer;
    else
        return viewed(viewedBuffer);
}
複製程式碼

對的,你沒看錯,這麼長的程式碼僅僅是為了幹回收 MappedByteBuffer 這一件事。

所以我建議,優先使用 FileChannel 去完成初始程式碼的提交,在必須使用小資料量(例如幾個位元組)刷盤的場景下,再換成 MMAP 的實現,其他場景 FileChannel 完全可以 cover(前提是你理解怎麼合理使用 FileChannel)。至於 MMAP 為什麼在一次寫入少量資料的場景下表現的比 FileChannel 優異,我還沒有查到理論根據,如果你有相關的線索,歡迎留言。理論分析下,FileChannel 同樣是寫入記憶體,但比 MMAP 多了一次核心緩衝區與使用者空間互相複製的過程,所以在極端場景下,MMAP 表現的更加優秀。至於 MMAP 分配的虛擬記憶體是否就是真正的 PageCache 這一點,我覺得可以近似理解成 PageCache。

順序讀比隨機讀快,順序寫比隨機寫快

無論你是機械硬碟還是 SSD,這個結論都是一定成立的,雖然背後的原因不太一樣,我們今天不討論機械硬碟這種古老的儲存介質,重點 foucs 在 SSD 上,來看看在它之上進行的隨機讀寫為什麼比順序讀寫要慢。即使各個 SSD 和檔案系統的構成具有差異性,但我們今天的分析同樣具備參考價值。

首先,什麼是順序讀,什麼是隨機讀,什麼是順序寫,什麼是隨機寫?可能我們剛接觸檔案 IO 操作時並不會有這樣的疑惑,但寫著寫著,自己都開始懷疑自己的理解了,不知道你有沒有經歷過這樣類似的階段,反正我有一段時間的確懷疑過。那麼,先來看看兩段程式碼:

寫入方式一:64個執行緒,使用者自己使用一個 atomic 變數記錄寫入指標的位置,併發寫入

ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
    final int index = i;
    executor.execute(()->{
        fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
    })
}
複製程式碼

寫入方式二:給 write 加了鎖,保證了同步。

ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
    final int index = i;
    executor.execute(()->{
        write(new byte[4*1024]);
    })
}

public synchronized void write(byte[] data){
    fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
}
複製程式碼

答案是方式二才算順序寫,順序讀也是同理。對於檔案操作,加鎖並不是一件非常可怕的事,不敢同步 write/read 才可怕!有人會問:FileChannel 內部不是已經有 positionLock 保證寫入的執行緒安全了嗎,為什麼還要自己加同步?為什麼這樣會快?我用大白話來回答的話就是多執行緒併發 write 並且不加同步,會導致檔案空洞,它的執行次序可能是

時序1:thread1 write position[0~4096)

時序2:thread3 write position[8194~12288)

時序2:thread2 write position[4096~8194)

所以並不是完全的“順序寫”。不過你也別擔心加鎖會導致效能下降,我們會在下面的小結介紹一個優化:通過檔案分片來減少多執行緒讀寫時鎖的衝突。

在來分析原理,順序讀為什麼會比隨機讀要快?順序寫為什麼比隨機寫要快?這兩個對比其實都是一個東西在起作用:PageCache,前面我們已經提到了,它是位於 application buffer(使用者記憶體)和 disk file(磁碟)之間的一層快取。

PageCache

以順序讀為例,當使用者發起一個 fileChannel.read(4kb) 之後,實際發生了兩件事

  1. 作業系統從磁碟載入了 16kb 進入 PageCache,這被稱為預讀
  2. 操作通從 PageCache 拷貝 4kb 進入使用者記憶體

最終我們在使用者記憶體訪問到了 4kb,為什麼順序讀快?很容量想到,當使用者繼續訪問接下來的[4kb,16kb]的磁碟內容時,便是直接從 PageCache 去訪問了。試想一下,當需要訪問 16kb 的磁碟內容時,是發生4次磁碟 IO 快,還是發生1次磁碟 IO+4 次記憶體 IO 快呢?答案是顯而易見的,這一切都是 PageCache 帶來的優化。

深度思考:當記憶體吃緊時,PageCache 的分配會受影響嗎?PageCache 的大小如何確定,是固定的 16kb 嗎?我可以監控 PageCache 的命中情況嗎? PageCache 會在哪些場景失效,如果失效了,我們又要哪些補救方式呢?

我進行簡單的自問自答,背後的邏輯還需要讀者去推敲:

  • 當記憶體吃緊時,PageCache 的預讀會受到影響,實測,並沒有搜到到文獻支援
  • PageCache 是動態調整的,可以通過 linux 的系統引數進行調整,預設是佔據總記憶體的 20%
  • github.com/brendangreg… github 上一款工具可以監控 PageCache
  • 這是很有意思的一個優化點,如果用 PageCache 做快取不可控,不妨自己做預讀如何呢?

順序寫的原理和順序讀一致,都是收到了 PageCache 的影響,留給讀者自己推敲一下。

直接記憶體(堆外) VS 堆內記憶體

前面 FileChannel 的示例程式碼中已經使用到了堆內記憶體: ByteBuffer.allocate(4 * 1024),ByteBuffer 提供了另外的方式讓我們可以分配堆外記憶體 : ByteBuffer.allocateDirect(4 * 1024)。這就引來的一系列的問題,我什麼時候應該使用堆內記憶體,什麼時候應該使用直接記憶體?

我不花太多筆墨去闡述了,直接上對比:

堆內記憶體 堆外記憶體
底層實現 陣列,JVM 記憶體 unsafe.allocateMemory(size)返回直接記憶體
分配大小限制 -Xms-Xmx 配置的 JVM 記憶體相關,並且陣列的大小有限制,在做測試時發現,當 JVM free memory 大於 1.5G 時,ByteBuffer.allocate(900M) 時會報錯 可以通過 -XX:MaxDirectMemorySize 引數從 JVM 層面去限制,同時受到機器虛擬記憶體(說實體記憶體不太準確)的限制
垃圾回收 不必多說 當 DirectByteBuffer 不再被使用時,會出發內部 cleaner 的鉤子,保險起見,可以考慮手動回收:((DirectBuffer) buffer).cleaner().clean();
拷貝方式 使用者態<->核心態 核心態

關於堆內記憶體和堆外記憶體的一些最佳實踐:

  1. 當需要申請大塊的記憶體時,堆內記憶體會受到限制,只能分配堆外記憶體。
  2. 堆外記憶體適用於生命週期中等或較長的物件。( 如果是生命週期較短的物件,在 YGC 的時候就被回收了,就不存在大記憶體且生命週期較長的物件在 FGC 對應用造成的效能影響 )。
  3. 直接的檔案拷貝操作,或者 I/O 操作。直接使用堆外記憶體就能少去記憶體從使用者記憶體拷貝到系統記憶體的消耗
  4. 同時,還可以使用池+堆外記憶體 的組合方式,來對生命週期較短,但涉及到 I/O 操作的物件進行堆外記憶體的再使用( Netty中就使用了該方式 )。在比賽中,儘量不要出現在頻繁 new byte[] ,建立記憶體區域再回收也是一筆不小的開銷,使用 ThreadLocal<ByteBuffer>ThreadLocal<byte[]> 往往會給你帶來意外的驚喜~
  5. 建立堆外記憶體的消耗要大於建立堆內記憶體的消耗,所以當分配了堆外記憶體之後,儘可能複用它。

黑魔法:UNSAFE

public class UnsafeUtil {
    public static final Unsafe UNSAFE;
    static {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            UNSAFE = (Unsafe) field.get(null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
複製程式碼

我們可以使用 UNSAFE 這個黑魔法實現很多無法想象的事,我這裡就稍微介紹一兩點吧。

實現直接記憶體與記憶體的拷貝:

ByteBuffer buffer = ByteBuffer.allocateDirect(4 * 1024 * 1024);
long addresses = ((DirectBuffer) buffer).address();
byte[] data = new byte[4 * 1024 * 1024];
UNSAFE.copyMemory(data, 16, null, addresses, 4 * 1024 * 1024);
複製程式碼

copyMemory 方法可以實現記憶體之間的拷貝,無論是堆內和堆外,1~2 個引數是 source 方,3~4 是 target 方,第 5 個引數是 copy 的大小。如果是堆內的位元組陣列,則傳遞陣列的首地址和 16 這個固定的 ARRAY_BYTE_BASE_OFFSET 偏移常量;如果是堆外記憶體,則傳遞 null 和直接記憶體的偏移量,可以通過 ((DirectBuffer) buffer).address() 拿到。為什麼不直接拷貝,而要藉助 UNSAFE?當然是因為它快啊!少年!另外補充:MappedByteBuffer 也可以使用 UNSAFE 來 copy 從而達到寫盤/讀盤的效果哦。

至於 UNSAFE 還有那些黑科技,可以專門去了解下,我這裡就不過多贅述了。

檔案分割槽

前面已經提到了順序讀寫時我們需要對 write,read 加鎖,並且我一再強調的一點是:加鎖並不可怕,檔案 IO 操作並沒有那麼依賴多執行緒。但是加鎖之後的順序讀寫必然無法打滿磁碟 IO,如今系統強勁的 CPU 總不能不壓榨吧?我們可以採用檔案分割槽的方式來達到一舉兩得的效果:既滿足了順序讀寫,又減少了鎖的衝突。

那麼問題又來了,分多少合適呢?檔案多了,鎖衝突變降低了;檔案太多了,碎片化太過嚴重,單個檔案的值太少,快取也就不容易命中,這樣的 trade off 如何平衡?沒有理論答案,benchmark everything~

Direct IO

linux io

最後我們來探討一下之前從沒提到的一種 IO 方式,Direct IO,什麼,Java 還有這東西?博主你騙我?之前怎麼告訴我只有三種 IO 方式!別急著罵我,嚴謹來說,這並不是 JAVA 原生支援的方式,但可以通過 JNA/JNI 呼叫 native 方法做到。從上圖我們可以看到 :Direct IO 繞過了 PageCache,但我們前面說到過,PageCache 可是個好東西啊,幹嘛不用他呢?再仔細推敲一下,還真有一些場景下,Direct IO 可以發揮作用,沒錯,那就是我們前面沒怎麼提到的:隨機讀。當使用 fileChannel.read() 這類會觸發 PageCache 預讀的 IO 方式時,我們其實並不希望作業系統幫我們幹太多事,除非真的踩了狗屎運,隨機讀都能命中 PageCache,但機率可想而知。Direct IO 雖然被 Linus 無腦噴過,但在隨機讀的場景下,依舊存在其價值,減少了 Block IO Layed(近似理解為磁碟) 到 Page Cache 的 overhead。

話說回來,Java 怎麼用 Direct IO 呢?有沒有什麼限制呢?前面說過,Java 目前原生並不支援,但也有好心人封裝好了 Java 的 JNA 庫,實現了 Java 的 Direct IO,github 地址:github.com/smacke/jayd…

int bufferSize = 20 * 1024 * 1024;
DirectRandomAccessFile directFile = new DirectRandomAccessFile(new File("dio.data"), "rw", bufferSize);
for(int i= 0;i< bufferSize / 4096;i++){
    byte[] buffer = new byte[4 * 1024];
    directFile.read(buffer);
    directFile.readFully(buffer);
}
directFile.close();
複製程式碼

但需要注意的是,只有 Linux 系統才支援 DIO! 所以,少年,是時候上手裝一臺 linux 了。值得一提的是,據說在 Jdk10 釋出之後,Direct IO 將會得到原生的支援,讓我們拭目以待吧!

總結

以上均是個人的實踐積累而來的經驗,有部分結論沒有找到文獻的支撐,所以如有錯誤,歡迎指正。關於 PolarDB 資料效能大賽的比賽分析,等複賽結束後我會專門另起一篇文章,分析下具體如何使用這些優化點,當然這些小技巧其實很多人都知道,決定最後成績的還是整體設計的架構,以及對檔案IO,作業系統,檔案系統,CPU 和語言特性的理解。雖然 JAVA 搞這種效能挑戰賽並不吃香,但依舊是樂趣無窮,希望這些檔案 IO 的知識能夠幫助你,等下次比賽時看到你的身影~

歡迎關注我的微信公眾號:「Kirito的技術分享」,關於文章的任何疑問都會得到回覆,帶來更多 Java 相關的技術分享。

關注微信公眾號

相關文章