Android 高效能日誌寫入方案

網易考拉移動端團隊發表於2018-08-10

Android 高效能日誌寫入方案

前言

公司目前在做一款企業級智慧客服系統,對於系統穩定性要求很高,不過難保使用者在使用中不會出現問題,而 Android SDK 整合在客戶的 APP 中,同時由於 Android 碎片化的問題,對於 SDK 的問題排查就顯得尤為困難,因此記錄下使用者的操作日誌就顯得極為重要。

初始方案

一開始,SDK 記錄日誌的方式是直接通過寫檔案,當有一條日誌要寫入的時候,首先,開啟檔案,然後寫入日誌,最後關閉檔案。這樣做的問題就在於頻繁的IO操作,影響程式的效能,而且 SDK 為了保證訊息的及時性,還維護了一個後臺程式,當其中一個程式進行日誌寫入時,另一個就會被鎖在門外等著,問題就愈發嚴重。使用這種方案雖然當前看上去對程式的影響不大,但是隨著日誌量的增加,更多的IO操作,一定會造成效能瓶頸。

下面我們來分析下直接寫入檔案的流程:

  1. 使用者發起 write 操作
  2. 作業系統查詢頁快取 a.若未命中,則產生缺頁異常,然後建立頁快取,將使用者傳入的內容寫入頁快取 b.若命中,則直接將使用者傳入的內容寫入頁快取
  3. 使用者 write 呼叫完成
  4. 頁被修改後成為髒頁,作業系統有兩種機制將髒頁寫回磁碟 a.使用者手動呼叫 fsync() b.由 pdflush 程式定時將髒頁寫回磁碟

可以看出,資料從程式寫入到磁碟的過程中,其實牽涉到兩次資料拷貝:一次是使用者空間記憶體拷貝到核心空間的快取,一次是回寫時核心空間的快取到硬碟的拷貝。當發生回寫時也涉及到了核心空間和使用者空間頻繁切換。

而且相對於機械硬碟,SSD 儲存還有一個“寫入放大”的問題。這個問題主要和 SSD 儲存的物理結構有關。當 SSD 被全部寫過一遍之後,再寫入的資料是不可以直接更新,只可以通過覆蓋重寫,在覆蓋之前需要先擦除資料。但寫入的最小單位是 Page,擦除的最小單位是 Block,而 Block 遠大於 Page,所以在寫入新資料時就需要先把 Block 上的資料讀出來和要寫入的資料合併在一起,再把 Block 擦除,最後把讀出來的資料重新寫入到儲存上,這樣導致實際寫入的資料可能遠遠大於最開始需要寫入的資料。

沒想到簡單的寫檔案竟然涉及了這麼多操作,只是對於應用層透明而已。

既然每寫一次檔案會執行這麼多次操作,那麼我們能不能將日誌快取起來,當達到一定的數量後再一次性的寫入磁碟中呢?

這樣確實能夠大量減少 IO 次數,但是卻會引發另一個更嚴重的問題——丟日誌

把日誌快取在記憶體中,當程式發生 Crash 或程式被殺後就無法保證日誌的完整性,而且由於 SDK 存在多程式,也無法保證多程式下日誌的順序。

一個完善的日誌方案,需要滿足

  • 高效,不能影響系統效能,不能因為引入了日誌模組而造成應用卡頓
  • 保證日誌的完整性,如果不能保證日誌完整,那麼日誌收集就沒有意義了
  • 對於多程式應用,要保證最終看到的日誌順序的準確性

高效能方案

既然無法減少寫入次數,那麼我們能不能在寫檔案的過程中去優化呢?

答案是可以的,使用 mmap

mmap是一種記憶體對映檔案的方法,即將一個檔案或者其它物件對映到程式的地址空間,實現檔案磁碟地址和程式虛擬地址空間中一段虛擬地址的一一對映關係,函式原型如下

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
複製程式碼

Android 高效能日誌寫入方案

mmap操作提供了一種機制,讓使用者程式直接訪問裝置記憶體,這種機制,相比較在使用者空間和核心空間互相拷貝資料,效率更高。在要求高效能的應用中比較常用。

同時 mmap 能夠保證日誌的完整性,mmap 的回寫時機:

  • 記憶體不足
  • 程式退出
  • 呼叫 msync 或者 munmap
  • 不設定 MAP_NOSYNC 情況下 30s-60s(僅限FreeBSD)

當對映一個檔案後,程式就會在 native 記憶體中申請一塊相同大小的空間,因此建議每次對映一小段內容,如 64k,寫滿後再重新對映檔案後面的內容。

日誌寫入效能和完整性的問題解決了,那麼如何保證多程式下日誌的順序呢?

由於 mmap 是採用共享記憶體的方式寫入資料,如果兩個程式同時對映一個檔案,那麼一定會造成日誌覆蓋的問題。

既然不能直接保證順序,那我們只能退而求其次,兩個程式分別對映不同的檔案,每天合併一次,合併時對日誌進行排序。

繼續優化

根據上述方案,設計 jni 介面,打包 so,引入 SDK,看似沒什麼問題了,但是作為一款 SDK,總覺得包含 so 不太友好,在一定程度上會增加接入的難度。

那麼能不能不用 so 呢?

其實 Java 中已經提供了記憶體對映的實現——MappedByteBuffer

MappedByteBuffer 位於 Java NIO 包下,用於將檔案內容對映到緩衝區,使用的即是 mmap 技術。通過 FileChannel 的 map 方法可以建立緩衝區

RandomAccessFileraf = new RandomAccessFile(file, "rw");
MappedByteBuffer buffer = raf.getChannel().map(FileChannel.MapMode.READ_WRITE, position, size);
複製程式碼

為了測試 MappedByteBuffer 的效率,我們把 64byte 的資料分別寫入記憶體、MappedByteBuffer 和磁碟檔案 50 萬次,並統計耗時

方法 耗時
記憶體 384ms
MappedByteBuffer 700ms
磁碟檔案 16805ms

可以看出 MappedByteBuffer 雖然不及寫入記憶體的效能,但是相比較寫入磁碟檔案,已經有了質的提升。

總結

本文主要分析了直接寫檔案記錄日誌方式存在的問題,並引申出高效能檔案寫入方案 mmap,兼顧了寫入效能和完整性,並通過補償方案確保多程式下日誌的順序。最後發現了記憶體對映在 Java 層的實現,避免了引入 so。

遷移自我的簡書 2018.01.28

相關文章