0 背景現狀
APP 當中有這樣的場景:記錄錯誤、警告錯誤日志至本地文本,如用戶支付失敗、H5 內容加載失敗、請求超時、登錄失敗、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
測試查看測試結果,可以發現寫操作耗時為 2294ms。重新分析上面的寫文件操作。一次寫文件過程如下:
- 進程調用庫函數向內核發起讀文件請求;
- 內核通過檢查進程的文件描述符定位到虛擬文件系統的已打開文件列表表項;
- 調用該文件可用的系統調用函數
read()
-
read()
函數通過文件表項鏈接到目錄項模塊,根據傳入的文件路徑,在目錄項模塊中檢索,找到該文件的inode; - 在 inode 中,通過文件內容偏移量計算出要讀取的頁;
- 通過 inode 找到文件對應的 address_space,在 address_space 中查詢對應頁的頁緩存是否存在;
- 如果頁緩存命中,直接把文件內容修改更新在頁緩存的頁中。寫文件就結束了。這時候文件修改位于頁緩存,并沒有寫回到磁盤文件中去。
- 如果頁緩存缺失,那么產生一個頁缺失異常,創建一個頁緩存頁,同時通過 inode 找到文件該頁的磁盤地址,讀取相應的頁填充該緩存頁。此時緩存頁命中,進行第 7 步;
- 一個頁緩存中的頁如果被修改,那么會被標記成臟頁。臟頁需要寫回到磁盤中的文件塊。有兩種方式可以把臟頁寫回磁盤:
- 手動調用
sync()
或者fsync()
系統調用把臟頁寫回 - pdflush進程會定時把臟頁寫回到磁盤
- 手動調用
以上
寫過程
摘自 從內核文件系統看文件讀寫過程
總結以上可以發現有 2 處性能消耗的地方:
- 以上一次寫文件操作,會發生 2 次拷貝操作,用戶內存數據拷貝至內核頁緩存,內核頁緩存寫入磁盤;
- 由于寫日志是非常頻繁的操作,同時每次寫的內容都是很小的,可以理解程序會非常頻繁的對同一個文件執行上述的 1 ~ 8 步驟。
而寫性能低下導致的問題除了 cpu 占用,更容易導致寫日志的線程隊列過長,甚至溢出導致丟棄最老的任務,也容易發生進程被殺時,日志的丟失。
1 優化
針對性能消耗 1,如何減少 2 次拷貝操作,我們能想到使用 mmap
,通過文件內存映射的方式將 2 次拷貝操作減少至 1 次。
mmap是一種內存映射文件的方法,即將一個文件或者其它對象映射到進程的地址空間,實現文件磁盤地址和進程虛擬地址空間中一段虛擬地址的一一對映關系。
實現這樣的映射關系后,進程就可以采用指針的方式讀寫操作這一段內存,而系統會自動回寫臟頁面到對應的文件磁盤上,即完成了對文件的操作
然而根據現在的使用場景,數據寫操作雖然頻繁,然而每次寫的數據量都比較小,同時也不知道下次寫是何時會發生,可能 1ms
后就立即觸發,也可能 1 分鐘后。而 mmap
并不適合小數據的操作,即每次寫的時候都創建一次,length
是每次寫的小數據長度。
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
查看測試結果:
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
由上我們可以發現,執行 1000 次字符串寫,mmap
執行一次大數據寫性能最好,普通文件寫操作要慢 4 倍多,而 mmap
執行多次小數據寫則性能會差很多。
查看文件可以發現,一次性 mmap
1M
的文件,但僅寫入 40K
左右的數據,最后文件大小還是 1M
寫入生成的文件
1M 文件的內容
針對以上情況,一種解決方法是使用內存緩存,業務層寫日志時并不是立馬寫入文件,而是寫入內存緩存中,等內存緩存到達一定大小,或者定期輪訓觸發寫操作。雖然能很好的優化寫性能問題,同時可以根據內存緩存的大小,可以按需寫入文件,不會出現文件有無效數據的情況。然而內存緩存的延遲寫入,在進程被殺的情況下,極大的增加了丟日志的情況。
所以這里采用另外的思路,構建寫消息隊列,并且發送 10ms 延遲關閉 buff 消息,保證在頻繁寫操作下,文件描述符不會被頻繁關閉和打開,同時 10ms 內無寫操作時,能及時關閉文件描述符,避免內存泄露。另外為準確確認文件大小,在文件頭 4 個字節寫入文件內容的真實大小。
日志文件
寫日志流程
同最初的測試代碼,寫日志 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
最后能發現日志寫提升 80%,至于相比測試代碼 1-0
的性能提升 4 倍,為何會差這么多,是因為除了純粹的寫之外,真實日志代碼中需要獲取額外的日志信息,如時間、當前頁面信息等,同時優化的日志文件嚴格控制了應用層設置的文件上限,而老的日志寫并沒有嚴格控制上限。