Android 日志記錄小優(yōu)化

0 背景現(xiàn)狀

APP 當(dāng)中有這樣的場景:記錄錯誤、警告錯誤日志至本地文本蛛枚,如用戶支付失敗谅海、H5 內(nèi)容加載失敗、請求超時蹦浦、登錄失敗扭吁、json 解析異常等。目前我們的做法盲镶,每次寫入一個文件侥袜,寫滿 1M,新啟一個文件寫溉贿,最多 3 個文件枫吧,3個文件都寫滿,將最老的文件清空宇色。

為避免多線程競爭文件操作九杂,將寫文件、文件上傳等都放在一個單線程池中處理宣蠕。

public void write(final File file, @NonNull final String msg) {
    ThreadUtil.runOnSingleThread(new Runnable() {
        @Override
        public void run() {
            syncWrite(file, msg);
        }
    });
}

private void syncWrite(File file, @NonNull String msg) {
    if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
        FileOutputStream fos = null;

        long timestamp = SystemUtil.getCurrentTimeMillis();
        StringBuilder sb = new StringBuilder(4 * 1024);
        String currentTime = StringUtil.formatTime("yyyy-MM-dd HH:mm:ss", timestamp);
        sb.append(StringUtil.add("\n[", currentTime, " utc0000]"));
        sb.append(" ");
        appendTopPageInfo(sb);
        sb.append("  ");
        sb.append(msg);
        sb.append("\n\n");

        try {
            fos = new FileOutputStream(file.getPath(), true);
            fos.write(sb.toString().getBytes());
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            Disposable.dispose(fos);
        }
    }
}

寫測試代碼查看性能:

private void test() {
    String msg = "test aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";

    start = System.currentTimeMillis();
    for (int i=0; i<5000; i++) {
        FeedbackUtil.syncSaveFeedbackLogInfo2File(msg);
    }
    Log.i("JOURNAL", "old cost " + (System.currentTimeMillis() - start));
}

測試代碼 0-0

09-14 16:52:43.310 14421-14421/com.netease.yanxuan I/JOURNAL: old cost 2294

測試查看測試結(jié)果例隆,可以發(fā)現(xiàn)寫操作耗時為 2294ms。重新分析上面的寫文件操作植影。一次寫文件過程如下:

  1. 進(jìn)程調(diào)用庫函數(shù)向內(nèi)核發(fā)起讀文件請求裳擎;
  2. 內(nèi)核通過檢查進(jìn)程的文件描述符定位到虛擬文件系統(tǒng)的已打開文件列表表項;
  3. 調(diào)用該文件可用的系統(tǒng)調(diào)用函數(shù) read()
  4. read() 函數(shù)通過文件表項鏈接到目錄項模塊思币,根據(jù)傳入的文件路徑鹿响,在目錄項模塊中檢索羡微,找到該文件的inode;
  5. 在 inode 中惶我,通過文件內(nèi)容偏移量計算出要讀取的頁妈倔;
  6. 通過 inode 找到文件對應(yīng)的 address_space,在 address_space 中查詢對應(yīng)頁的頁緩存是否存在绸贡;
  7. 如果頁緩存命中盯蝴,直接把文件內(nèi)容修改更新在頁緩存的頁中。寫文件就結(jié)束了听怕。這時候文件修改位于頁緩存捧挺,并沒有寫回到磁盤文件中去。
  8. 如果頁緩存缺失尿瞭,那么產(chǎn)生一個頁缺失異常闽烙,創(chuàng)建一個頁緩存頁,同時通過 inode 找到文件該頁的磁盤地址声搁,讀取相應(yīng)的頁填充該緩存頁黑竞。此時緩存頁命中,進(jìn)行第 7 步疏旨;
  9. 一個頁緩存中的頁如果被修改很魂,那么會被標(biāo)記成臟頁。臟頁需要寫回到磁盤中的文件塊檐涝。有兩種方式可以把臟頁寫回磁盤:
    • 手動調(diào)用 sync() 或者 fsync() 系統(tǒng)調(diào)用把臟頁寫回
    • pdflush進(jìn)程會定時把臟頁寫回到磁盤

以上 寫過程 摘自 從內(nèi)核文件系統(tǒng)看文件讀寫過程

總結(jié)以上可以發(fā)現(xiàn)有 2 處性能消耗的地方:

  1. 以上一次寫文件操作遏匆,會發(fā)生 2 次拷貝操作,用戶內(nèi)存數(shù)據(jù)拷貝至內(nèi)核頁緩存骤铃,內(nèi)核頁緩存寫入磁盤拉岁;
  2. 由于寫日志是非常頻繁的操作,同時每次寫的內(nèi)容都是很小的惰爬,可以理解程序會非常頻繁的對同一個文件執(zhí)行上述的 1 ~ 8 步驟喊暖。

而寫性能低下導(dǎo)致的問題除了 cpu 占用,更容易導(dǎo)致寫日志的線程隊列過長撕瞧,甚至溢出導(dǎo)致丟棄最老的任務(wù)陵叽,也容易發(fā)生進(jìn)程被殺時,日志的丟失丛版。

1 優(yōu)化

針對性能消耗 1巩掺,如何減少 2 次拷貝操作,我們能想到使用 mmap页畦,通過文件內(nèi)存映射的方式將 2 次拷貝操作減少至 1 次胖替。

mmap是一種內(nèi)存映射文件的方法,即將一個文件或者其它對象映射到進(jìn)程的地址空間,實現(xiàn)文件磁盤地址和進(jìn)程虛擬地址空間中一段虛擬地址的一一對映關(guān)系独令。
實現(xiàn)這樣的映射關(guān)系后端朵,進(jìn)程就可以采用指針的方式讀寫操作這一段內(nèi)存,而系統(tǒng)會自動回寫臟頁面到對應(yīng)的文件磁盤上燃箭,即完成了對文件的操作

摘自 認(rèn)真分析mmap:是什么 為什么 怎么用

然而根據(jù)現(xiàn)在的使用場景冲呢,數(shù)據(jù)寫操作雖然頻繁,然而每次寫的數(shù)據(jù)量都比較小招狸,同時也不知道下次寫是何時會發(fā)生敬拓,可能 1ms 后就立即觸發(fā),也可能 1 分鐘后裙戏。而 mmap 并不適合小數(shù)據(jù)的操作乘凸,即每次寫的時候都創(chuàng)建一次,length 是每次寫的小數(shù)據(jù)長度累榜。

c 方法原型
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

java 方法原型
public abstract MappedByteBuffer map(MapMode mode, long position, long size)

查看測試代碼:

protected void test() {

    String content = "aaaaaaaaaaaassssdfsfasfsdsdfsegwegegwgs";

    long start = System.currentTimeMillis();
    oldWrite(content);
    Log.i("TEST", "old: " + (System.currentTimeMillis() - start));

    start = System.currentTimeMillis();
    newWrite1(content);
    Log.i("TEST", "new1: " + (System.currentTimeMillis() - start));
    
    start = System.currentTimeMillis();
    newWrite(content);
    Log.i("TEST", "new: " + (System.currentTimeMillis() - start));
}

private void oldWrite(String content) {
    String path = StorageUtil.getWritePath("oldwrite.txt", StorageType.TYPE_FILE);

    for (int i = 0; i < 1000; i++) {
        File file = new File(path);
        FileOutputStream os = null;
        try {
            os = new FileOutputStream(file, true);
            os.write(content.getBytes());
        } catch (IOException e) {
            Log.e("TEST", "old: " + e.toString());
        } finally {
            Disposable.dispose(os);
        }
    }
}

private void newWrite(String content) {
    String path = StorageUtil.getWritePath("newwrite.txt", StorageType.TYPE_FILE);
    File file = new File(path);

    RandomAccessFile raf = null;
    try {
        raf = new RandomAccessFile(file, "rw");
        MappedByteBuffer buff = raf.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, 1024 * 1024);
        for (int i = 0; i < 1000; i++) {
            buff.put(content.getBytes());
        }
        buff.force();
        buff.flip();
    } catch (Exception e) {
        Log.e("TEST", "new: " + e.toString());
    } finally {
        Disposable.dispose(raf);
    }
}

private void newWrite1(String content) {
    String path = StorageUtil.getWritePath("newwrite1.txt", StorageType.TYPE_FILE);
    File file = new File(path);

    for (int i = 0; i < 1000; i++) {
        RandomAccessFile raf = null;
        try {
            raf = new RandomAccessFile(file, "rw");
            byte[] bytes = content.getBytes();
            MappedByteBuffer buff = raf.getChannel().map(FileChannel.MapMode.READ_WRITE, raf.length(), bytes.length);
            buff.put(content.getBytes());

            buff.force();
            buff.flip();
        } catch (Exception e) {
            Log.e("TEST", "new: " + e.toString());
        } finally {
            Disposable.dispose(raf);
        }
    }
}

測試代碼 1-0

查看測試結(jié)果:

09-14 18:06:47.205 7311-7311/com.netease.yanxuan I/TEST: old: 129
09-14 18:06:50.899 7311-7311/com.netease.yanxuan I/TEST: new1: 3694
09-14 18:06:47.076 7311-7311/com.netease.yanxuan I/TEST: new: 27

由上我們可以發(fā)現(xiàn)翰意,執(zhí)行 1000 次字符串寫,mmap 執(zhí)行一次大數(shù)據(jù)寫性能最好信柿,普通文件寫操作要慢 4 倍多甲献,而 mmap 執(zhí)行多次小數(shù)據(jù)寫則性能會差很多宏胯。

查看文件可以發(fā)現(xiàn),一次性 mmap 1M 的文件媳握,但僅寫入 40K 左右的數(shù)據(jù)稠曼,最后文件大小還是 1M

Alt pic

寫入生成的文件

Alt pic

1M 文件的內(nèi)容

針對以上情況形病,一種解決方法是使用內(nèi)存緩存,業(yè)務(wù)層寫日志時并不是立馬寫入文件霞幅,而是寫入內(nèi)存緩存中漠吻,等內(nèi)存緩存到達(dá)一定大小,或者定期輪訓(xùn)觸發(fā)寫操作司恳。雖然能很好的優(yōu)化寫性能問題途乃,同時可以根據(jù)內(nèi)存緩存的大小,可以按需寫入文件扔傅,不會出現(xiàn)文件有無效數(shù)據(jù)的情況耍共。然而內(nèi)存緩存的延遲寫入,在進(jìn)程被殺的情況下猎塞,極大的增加了丟日志的情況试读。

所以這里采用另外的思路,構(gòu)建寫消息隊列荠耽,并且發(fā)送 10ms 延遲關(guān)閉 buff 消息钩骇,保證在頻繁寫操作下,文件描述符不會被頻繁關(guān)閉和打開,同時 10ms 內(nèi)無寫操作時倘屹,能及時關(guān)閉文件描述符银亲,避免內(nèi)存泄露。另外為準(zhǔn)確確認(rèn)文件大小唐瀑,在文件頭 4 個字節(jié)寫入文件內(nèi)容的真實大小群凶。

Alt pic

日志文件

Alt pic

寫日志流程

同最初的測試代碼,寫日志 5000 次到日志文件

private void test() {
    String msg = "test aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";

    start = System.currentTimeMillis();
    for (int i=0; i<5000; i++) {
        FeedbackUtil.syncSaveLogInfo2File(msg);
    }
    Log.i("JOURNAL", "old cost " + (System.currentTimeMillis() - start));
    
    start = System.currentTimeMillis();
    for (int i = 0; i < 5000; i++) {
        NewFeedbackUtil.syncSaveLogInfo2File(msg);
    }
    Log.i("JOURNAL", "new cost " + (System.currentTimeMillis() - start));
}

測試代碼 1-1

09-15 09:09:46.744 4450-4450/com.netease.yanxuan I/JOURNAL: old cost 2319
09-15 09:09:44.425 4450-4450/com.netease.yanxuan I/JOURNAL: new cost 1285

最后能發(fā)現(xiàn)日志寫提升 80%哄辣,至于相比測試代碼 1-0 的性能提升 4 倍请梢,為何會差這么多,是因為除了純粹的寫之外力穗,真實日志代碼中需要獲取額外的日志信息毅弧,如時間、當(dāng)前頁面信息等当窗,同時優(yōu)化的日志文件嚴(yán)格控制了應(yīng)用層設(shè)置的文件上限够坐,而老的日志寫并沒有嚴(yán)格控制上限。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末崖面,一起剝皮案震驚了整個濱河市元咙,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌巫员,老刑警劉巖庶香,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異简识,居然都是意外死亡赶掖,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進(jìn)店門七扰,熙熙樓的掌柜王于貴愁眉苦臉地迎上來奢赂,“玉大人,你說我怎么就攤上這事颈走∩旁睿” “怎么了?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵立由,是天一觀的道長袖瞻。 經(jīng)常有香客問我,道長拆吆,這世上最難降的妖魔是什么聋迎? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮枣耀,結(jié)果婚禮上霉晕,老公的妹妹穿的比我還像新娘庭再。我一直安慰自己,他們只是感情好牺堰,可當(dāng)我...
    茶點故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布拄轻。 她就那樣靜靜地躺著,像睡著了一般伟葫。 火紅的嫁衣襯著肌膚如雪恨搓。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天筏养,我揣著相機(jī)與錄音斧抱,去河邊找鬼。 笑死渐溶,一個胖子當(dāng)著我的面吹牛辉浦,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播茎辐,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼宪郊,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了拖陆?” 一聲冷哼從身側(cè)響起弛槐,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎依啰,沒想到半個月后丐黄,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡孔飒,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了艰争。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片坏瞄。...
    茶點故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖甩卓,靈堂內(nèi)的尸體忽然破棺而出鸠匀,到底是詐尸還是另有隱情,我是刑警寧澤逾柿,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布缀棍,位于F島的核電站,受9級特大地震影響机错,放射性物質(zhì)發(fā)生泄漏爬范。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一弱匪、第九天 我趴在偏房一處隱蔽的房頂上張望青瀑。 院中可真熱鬧,春花似錦、人聲如沸斥难。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽哑诊。三九已至群扶,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間镀裤,已是汗流浹背竞阐。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留淹禾,地道東北人馁菜。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像铃岔,于是被迫代替她去往敵國和親汪疮。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,592評論 2 353

推薦閱讀更多精彩內(nèi)容