前言
網易考拉作為一款超級電商應用,每天都會產生海量日誌資訊,對日誌的寫入效能和完整性都有更高的要求。
常規方案
Android 中記錄日誌通常的方式是通過 Java Api 操作檔案,當有一條日誌要寫入的時候,首先,開啟檔案,然後寫入日誌,最後關閉檔案。使用這種方案雖然當前看上去對程式的影響不大,但是隨著日誌量的增加,在 Java 中頻繁的 IO 操作,容易導致 gc,頻繁開啟檔案,容易引發 CPU 峰值。
下面我們來分析下直接寫入檔案的流程:
- 使用者發起 write 操作
- 作業系統查詢頁快取
a.若未命中,則產生缺頁異常,然後建立頁快取,將使用者傳入的內容寫入頁快取
b.若命中,則直接將使用者傳入的內容寫入頁快取 - 使用者 write 呼叫完成
- 頁被修改後成為髒頁,作業系統有兩種機制將髒頁寫回磁碟
a.使用者手動呼叫 fsync()
b.由 pdflush 程式定時將髒頁寫回磁碟
可以看出,資料從程式寫入到磁碟的過程中,其實牽涉到兩次資料拷貝:一次是使用者空間記憶體拷貝到核心空間的快取,一次是回寫時核心空間的快取到硬碟的拷貝。當發生回寫時也涉及到了核心空間和使用者空間頻繁切換。
而且相對於機械硬碟,SSD 儲存還有一個“寫入放大”的問題。這個問題主要和 SSD 儲存的物理結構有關。當 SSD 被全部寫過一遍之後,再寫入的資料是不可以直接更新,只可以通過覆蓋重寫,在覆蓋之前需要先擦除資料。但寫入的最小單位是 Page,擦除的最小單位是 Block,而 Block 遠大於 Page,所以在寫入新資料時就需要先把 Block 上的資料讀出來和要寫入的資料合併在一起,再把 Block 擦除,最後把讀出來的資料重新寫入到儲存上,這樣導致實際寫入的資料可能遠遠大於最開始需要寫入的資料。
沒想到簡單的寫檔案竟然涉及了這麼多操作,只是對於應用層透明而已。
既然每寫一次檔案會執行這麼多次操作,那麼我們能不能將日誌快取起來,當達到一定的數量後再一次性的寫入磁碟中呢?
這樣確實能夠大量減少 IO 次數,但是卻會引發另一個更嚴重的問題——丟日誌
把日誌快取在記憶體中,當程式發生 Crash 或程式被殺後就無法保證日誌的完整性。
一個完善的日誌方案,需要滿足
- 高效,不能影響系統效能,不能因為引入了日誌模組而造成應用卡頓
- 保證日誌的完整性,如果不能保證日誌完整,那麼日誌收集就沒有意義了
- 如果有必要,對日誌進行壓縮、加密
高效能方案
既然無法減少寫入次數,那麼我們能不能在寫檔案的過程中去優化呢?
答案是可以的,使用 mmap
mmap 是一種記憶體對映檔案的方法,即將一個檔案或者其它物件對映到程式的地址空間,實現檔案磁碟地址和程式虛擬地址空間中一段虛擬地址的一一對映關係,函式原型如下
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
複製程式碼
引數說明
mmap 對映模型
示例程式碼
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
main(){
int fd;
void *start;
struct stat sb;
fd = open("/etc/passwd", O_RDONLY); /*開啟/etc/passwd */
fstat(fd, &sb); /* 取得檔案大小 */
start = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if(start == MAP_FAILED) /* 判斷是否對映成功 */
return;
printf("%s", start); munma(start, sb.st_size); /* 解除對映 */
closed(fd);
}
複製程式碼
mmap 操作提供了一種機制,讓使用者程式直接訪問裝置記憶體,這種機制,相比較在使用者空間和核心空間互相拷貝資料,效率更高。在要求高效能的應用中比較常用。
同時 mmap 能夠保證日誌的完整性,mmap 的回寫時機:
- 記憶體不足
- 程式退出
- 呼叫 msync 或者 munmap
- 不設定 MAP_NOSYNC 情況下 30s-60s(僅限FreeBSD)
unmap 函式原型
#include <sys/mman.h>
int munmap(void *addr, size_t length);
複製程式碼
當對映一個檔案後,程式就會在 native 記憶體中申請一塊相同大小的空間,因此建議每次對映一小段內容,如 64k,寫滿後再重新對映檔案後面的內容。
有一點需要注意,對於多程式操作檔案,使用 Java Api 可以通過 FileLock 同步,而 mmap 不適用於多程式操作同一個檔案。對於多程式應用,需要按需對映多個檔案。
繼續優化
根據上述方案,設計 jni 介面,打包 so,引入專案。
考慮到安裝包大小,能不能不用 so 呢?
其實 Java 中已經提供了記憶體對映的實現——MappedByteBuffer
MappedByteBuffer 位於 Java NIO 包下,用於將檔案內容對映到緩衝區,使用的即是 mmap 技術。通過 FileChannel 的 map 方法可以建立緩衝區
MappedByteBuffer raf = new RandomAccessFile(file, "rw");
MappedByteBuffer buffer = raf.getChannel().map(FileChannel.MapMode.READ_WRITE, position, size);
複製程式碼
有一點比較坑,Java 雖然提供了 map 方法,但是並沒有提供 unmap 方法,通過 Google 得知 unmap 方法是有的,不過是私有的
// FileChannelImpl.class
private static void unmap(MappedByteBuffer var0) {
Cleaner var1 = ((DirectBuffer)var0).cleaner();
if (var1 != null) {
var1.clean();
}
}
// Cleaner.class
public void clean() {
if (remove(this)) {
try {
this.thunk.run();
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}
System.exit(1);
return null;
}
});
}
}
}
複製程式碼
這時我們自然想到了反射呼叫
public static void unmap(MappedByteBuffer buffer) {
if (buffer == null) {
return;
}
try {
Class<?> clazz = Class.forName("sun.nio.ch.FileChannelImpl");
Method m = clazz.getDeclaredMethod("unmap", MappedByteBuffer.class);
m.setAccessible(true);
m.invoke(null, buffer);
} catch (Throwable e) {
e.printStackTrace();
}
}
複製程式碼
由於 Android P 已經限制私有 API 的訪問,這裡仍需要優化以適配 Android P。
為了測試 MappedByteBuffer 的效率,我們把 64byte 的資料分別寫入記憶體、MappedByteBuffer 和磁碟檔案 50 萬次,並統計耗時
方法 | 耗時 |
---|---|
記憶體 | 384ms |
MappedByteBuffer | 700ms |
磁碟檔案 | 16805ms |
可以看出 MappedByteBuffer 雖然不及寫入記憶體的效能,但是相比較寫入磁碟檔案,已經有了質的提升。
展望
目前日誌模組僅對日誌寫入效能和完整性提供了保障,對於日誌的壓縮和加密目前還沒有實現,目前快取的日誌都是脫敏資料,後期如果有業務要求安全儲存,會考慮新增加密功能。
總結
本文主要分析了直接寫檔案記錄日誌方式存在的問題,並引申出高效能檔案寫入方案 mmap,兼顧了寫入效能和完整性。最後介紹了記憶體對映在 Java 層的實現,避免了引入 so。
更多技術文章,可以訪問 考拉移動端團隊技術部落格。