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。重新分析上面的寫文件操作植影。一次寫文件過程如下:
- 進(jìn)程調(diào)用庫函數(shù)向內(nèi)核發(fā)起讀文件請求裳擎;
- 內(nèi)核通過檢查進(jìn)程的文件描述符定位到虛擬文件系統(tǒng)的已打開文件列表表項;
- 調(diào)用該文件可用的系統(tǒng)調(diào)用函數(shù)
read()
-
read()
函數(shù)通過文件表項鏈接到目錄項模塊思币,根據(jù)傳入的文件路徑鹿响,在目錄項模塊中檢索羡微,找到該文件的inode; - 在 inode 中惶我,通過文件內(nèi)容偏移量計算出要讀取的頁妈倔;
- 通過 inode 找到文件對應(yīng)的 address_space,在 address_space 中查詢對應(yīng)頁的頁緩存是否存在绸贡;
- 如果頁緩存命中盯蝴,直接把文件內(nèi)容修改更新在頁緩存的頁中。寫文件就結(jié)束了听怕。這時候文件修改位于頁緩存捧挺,并沒有寫回到磁盤文件中去。
- 如果頁緩存缺失尿瞭,那么產(chǎn)生一個頁缺失異常闽烙,創(chuàng)建一個頁緩存頁,同時通過 inode 找到文件該頁的磁盤地址声搁,讀取相應(yīng)的頁填充該緩存頁黑竞。此時緩存頁命中,進(jìn)行第 7 步疏旨;
- 一個頁緩存中的頁如果被修改很魂,那么會被標(biāo)記成臟頁。臟頁需要寫回到磁盤中的文件塊檐涝。有兩種方式可以把臟頁寫回磁盤:
- 手動調(diào)用
sync()
或者fsync()
系統(tǒng)調(diào)用把臟頁寫回 - pdflush進(jìn)程會定時把臟頁寫回到磁盤
- 手動調(diào)用
以上
寫過程
摘自 從內(nèi)核文件系統(tǒng)看文件讀寫過程
總結(jié)以上可以發(fā)現(xiàn)有 2 處性能消耗的地方:
- 以上一次寫文件操作遏匆,會發(fā)生 2 次拷貝操作,用戶內(nèi)存數(shù)據(jù)拷貝至內(nèi)核頁緩存骤铃,內(nèi)核頁緩存寫入磁盤拉岁;
- 由于寫日志是非常頻繁的操作,同時每次寫的內(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)的文件磁盤上燃箭,即完成了對文件的操作
然而根據(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
寫入生成的文件
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)容的真實大小群凶。
日志文件
寫日志流程
同最初的測試代碼,寫日志 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)格控制上限。