Android 高性能日誌寫入方案

前言

網易考拉做爲一款超級電商應用,天天都會產生海量日誌信息,對日誌的寫入性能和完整性都有更高的要求。緩存

常規方案

Android 中記錄日誌一般的方式是經過 Java Api 操做文件,當有一條日誌要寫入的時候,首先,打開文件,而後寫入日誌,最後關閉文件。使用這種方案雖然當前看上去對程序的影響不大,可是隨着日誌量的增長,在 Java 中頻繁的 IO 操做,容易致使 gc,頻繁打開文件,容易引起 CPU 峯值。安全

下面咱們來分析下直接寫入文件的流程:bash

  1. 用戶發起 write 操做
  2. 操做系統查找頁緩存
    a.若未命中,則產生缺頁異常,而後建立頁緩存,將用戶傳入的內容寫入頁緩存
    b.若命中,則直接將用戶傳入的內容寫入頁緩存
  3. 用戶 write 調用完成
  4. 頁被修改後成爲髒頁,操做系統有兩種機制將髒頁寫回磁盤
    a.用戶手動調用 fsync()
    b.由 pdflush 進程定時將髒頁寫回磁盤

能夠看出,數據從程序寫入到磁盤的過程當中,其實牽涉到兩次數據拷貝:一次是用戶空間內存拷貝到內核空間的緩存,一次是回寫時內核空間的緩存到硬盤的拷貝。當發生回寫時也涉及到了內核空間和用戶空間頻繁切換。app

並且相對於機械硬盤,SSD 存儲還有一個「寫入放大」的問題。這個問題主要和 SSD 存儲的物理結構有關。當 SSD 被所有寫過一遍以後,再寫入的數據是不能夠直接更新,只能夠經過覆蓋重寫,在覆蓋以前須要先擦除數據。但寫入的最小單位是 Page,擦除的最小單位是 Block,而 Block 遠大於 Page,因此在寫入新數據時就須要先把 Block 上的數據讀出來和要寫入的數據合併在一塊兒,再把 Block 擦除,最後把讀出來的數據從新寫入到存儲上,這樣致使實際寫入的數據可能遠遠大於最開始須要寫入的數據。dom

沒想到簡單的寫文件居然涉及了這麼多操做,只是對於應用層透明而已。函數

既然每寫一次文件會執行這麼屢次操做,那麼咱們能不能將日誌緩存起來,當達到必定的數量後再一次性的寫入磁盤中呢?性能

這樣確實可以大量減小 IO 次數,可是卻會引起另外一個更嚴重的問題——丟日誌測試

把日誌緩存在內存中,當程序發生 Crash 或進程被殺後就沒法保證日誌的完整性。優化

一個完善的日誌方案,須要知足ui

  • 高效,不能影響系統性能,不能由於引入了日誌模塊而形成應用卡頓
  • 保證日誌的完整性,若是不能保證日誌完整,那麼日誌收集就沒有意義了
  • 若是有必要,對日誌進行壓縮、加密

高性能方案

既然沒法減小寫入次數,那麼咱們能不能在寫文件的過程當中去優化呢?

答案是能夠的,使用 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。

更多技術文章,能夠訪問 考拉移動端團隊技術博客

相關文章
相關標籤/搜索