博客 / 詳情

返回

度加剪輯App的MMKV應用優化實踐

作者 | 我愛吃海米

導讀 

移動端開發中,IO密集問題在很多時候沒有得到充足的重視和解決,貿然的把IO導致的卡頓放到異步線程,可能會導致真正的問題被掩蓋,前人挖坑後人踩。其實首先要想的是,數據存儲方式是否合理,數據的使用方式是否合理。本文介紹度加剪輯對MMKV的使用和優化。

全文14813字,預計閲讀時間38分鐘。

01 一切皆文件-移動端IO介紹

移動端的App程序很多情況是IO密集型,比如説聊天信息的讀取和發送、短視頻的下載和緩存、信息流應用的圖文緩存等。

相對於計算密集,IO密集場景更加多樣,比如系統SharedPreferences和NSUserDefault自帶的一些問題、Android中繁忙的binder通信、文件磁盤讀取和寫入、文件句柄泄露、主線程操作Sqlite導致的卡頓等,處理起來相當燙手。

IO不繁忙的情況下,主線程低頻次的調用IO函數是沒什麼問題的。然而在IO繁忙時,IO性能急劇退化,任何IO操作都可能是壓死駱駝的最後一根稻草。在平常開發測試中很難遇到IO卡頓,到了線上後才會暴露出來,iOS/Android雙端基本都是如此:常用的open系統調用,線下測試只需要4ms,線上大把用户執行時間超過10秒;就連獲取文件長度、檢查文件是否存在這種常規操作,竟然也能卡頓。

以Android線上抓到的卡頓為例(>5秒):

at libcore.io.Linux.access(Native Method)
at libcore.io.ForwardingOs.access(ForwardingOs.java:128)
at libcore.io.BlockGuardOs.access(BlockGuardOs.java:76)
at libcore.io.ForwardingOs.access(ForwardingOs.java:128)
at android.app.ActivityThread$AndroidOs.access(ActivityThread.java:8121)
at java.io.UnixFileSystem.checkAccess(UnixFileSystem.java:281)
at java.io.File.exists(File.java:813)
at com.a.b.getDownloaded(SourceFile:2)
at libcore.io.Linux.stat(Native Method)
at libcore.io.ForwardingOs.stat(ForwardingOs.java:853)
at libcore.io.BlockGuardOs.stat(BlockGuardOs.java:420)
at libcore.io.ForwardingOs.stat(ForwardingOs.java:853)
at android.app.ActivityThread$AndroidOs.stat(ActivityThread.java:8897)
at java.io.UnixFileSystem.getLength(UnixFileSystem.java:298)
at java.io.File.length(File.java:968)

具體源碼可以參考 :

https://android.googlesource.com/platform/libcore/+/master/lu...\_io\_Linux.cpp

最終是在C++中發起了系統調用access()和stat()。

IO問題在很多時候被輕視,貿然的把IO導致的卡頓放到異步線程,可能會導致真正的問題被掩蓋,前人挖坑後人踩。其實首先要想的是,數據存儲方式是否合理,數據的使用方式是否合理。

作為一款視頻剪輯工具,度加剪輯在內存、磁盤、卡頓方面有大量的技術挑戰,同時也積累了大量的技術債。我從隔壁做圖片美化工具的團隊那得到了雙端的IO卡頓數據,可以説是難兄難弟,不分伯仲:有卧龍的地方,十步以內必有鳳雛。

下面簡單介紹度加剪輯App中對文件磁盤IO這部分的使用和優化,本文是有關MMKV。

(廣告時間:度加剪輯是一款音視頻剪輯軟件,針對口播用户開發了很多貼心功能,比如説快速剪輯,各類素材也比較豐富,比如貼紙、文字模板等,歡迎下載使用。)

02 高性能kv神器-MMKV

MMKV是基於mmap的高性能通用key-value組件,性能極佳,讓我們在主線程使用kv成為了可能,堪稱移動端的Redis,實際上這兩者在設計上也能找到相似的影子。

mmap是使用極其廣泛的內存映射技術,對內存的讀寫約等於對磁盤的讀寫,內存中的字節與文件中的字節相映成趣,一一對應。像Kafka和RocketMQ等消息中間件都使用了mmap,避免了數據在用户態跟內核態大量的拷貝切換, 所謂零拷貝。

為了提高性能,度加逐漸從SharedPreferences向MMKV遷移,關於Sp的卡頓逐漸消失,性能提升效果十分哇塞。

然而,MMKV依然有不少IO操作發生在主線程,這些函數在用户緩衝區都沒有buffer(對比fread和fwrite等f打頭的帶有緩衝的函數),且磁盤相對是低速設備,同步時效率較低,有時難免會出現性能問題。

度加剪輯作為MMKV的重度甚至變態用户,隨着使用越來越頻繁,陸續發現了線上很多和MMKV相關的有趣問題,下面拋磚引玉簡單介紹。

03 setX/encodeX卡頓-佔度加剪輯總卡頓的1.2%

at com.tencent.mmkv.MMKV.encodeString(Native Method)
at com.tencent.mmkv.MMKV.encode(Proguard:8)

經過分析,卡頓基本都發生IO繁忙時刻。度加App在使用中充滿了大量的磁盤IO,在編輯頁面會讀取大量的視頻文件、貼紙、字體等各種文件,像降噪、語音轉文字等大量場景都需要本地寫入;導出頁面會在短時間內寫入上G的視頻到磁盤中:為了保證輸出視頻的清晰度,度加App設置了極高的視頻和音頻碼率。

不可避免,當磁盤處於大規模寫入狀態,在視頻合成導出、視頻文件讀取和下載、各類素材的下載過程中很容易發現MMKV卡頓的身影;通過增加研發打點數據以及其他輔助手段後,我大體歸納了兩種卡頓發生的典型場景。

1、存儲較長的字符串,例如雲控json

這個卡頓大部分是MMKV的重寫和擴容機制引起,首先簡單介紹MMKV的數據存儲佈局。_(https://github.com/Tencent/MMKV/wiki/design)_

MMKV在創建一個ID時,例如默認的mmkv.default,會為這個ID單獨創建兩個4K大小(操作系統pagesize值)的文件,存放內容的文件和CRC校驗文件。

每次插入新的key-value,以append模式在內容文件的尾部追加,取值以最後插入的數據為準,即使是已有相同的key-value,也直接append在文件末尾;key與value交替存儲,與Redis的AOF十分類似。

便於理解方便,省去了key長度和value長度等其他字段:

此時MMKV的dict中有兩對有效的key=>value數據: {"key1":"val3", "key2", "val2"}

重寫:Append模式有個問題,當一個相同的key不斷被寫入時,整個文件有部分區域是被浪費掉的,因為前面的value會被後面的代替掉,只有最後插入的那組kv對才有效。所以當文件不足以存放新增的kv數據時,MMKV會先嚐試對key去重,重寫文件以重整佈局降低大小,類似Redis的bgrewriteaof。(重寫後實際上是key2在前key1在後。)

擴容:在重寫文件後,如果空間還是不夠,會不斷的以2倍大小擴容文件直到滿足需要:JAVA中ArrayList的擴容係數是1.5,GCC中std::vector擴容係數是2,MMKV的擴容係數也是2。

size_t oldSize = fileSize;
do {
    fileSize *= 2;
} while (lenNeeded + futureUsage >= fileSize);

重寫和擴容都會涉及到IO相關的系統調用,重寫會調用msync函數強制同步數據到磁盤;而擴容時邏輯更為複雜,系統調用次數更多:

1、ftruncate修改文件的名義大小。

2、修改文件的實際大小。Linux上ftruncate會造成“空洞文件”,而不是真正的去申請磁盤block,在磁盤已滿或者沒有權限時會有奇怪的錯誤甚至是崩潰。MMKV不得不使用lseek+write系統調用來保證文件一定擴容成功,測試和確認文件在磁盤中的實際大小,以防止後續MMKV的寫入可能出現SIGBUS等錯誤信號。

3、確認了文件真正的長度滿足要求後,調用munmap+mmap,重新對內存和文件建立映射。在解除綁定時,munmap也會同步內存數據髒頁到磁盤(msync),這也是個耗時操作。

    if (::ftruncate(m_diskFile.m_fd, static_cast<off_t>(m_size)) != 0) {
        MMKVError("fail to truncate [%s] to size %zu, %s", m_diskFile.m_path.c_str(), m_size, strerror(errno));
        m_size = oldSize;
        return false;
    }
    if (m_size > oldSize) {
        // lseek+write 保證文件一定擴容成功
        if (!zeroFillFile(m_diskFile.m_fd, oldSize, m_size - oldSize)) {
            MMKVError("fail to zeroFile [%s] to size %zu, %s", m_diskFile.m_path.c_str(), m_size, strerror(errno));
            m_size = oldSize;
            return false;
        }
    }

    if (m_ptr) {
        if (munmap(m_ptr, oldSize) != 0) {
            MMKVError("fail to munmap [%s], %s", m_diskFile.m_path.c_str(), strerror(errno));
        }
    }
    auto ret = mmap();
    if (!ret) {
        doCleanMemoryCache(true);
    }

由此可見,MMKV在重寫和擴容時,會發生一定次數的系統調用,是個重型操作,在IO繁忙時可能會導致卡頓;而且相比較重寫操作,擴容的成本更高,至少有5個IO系統調用,出現性能問題的概率也更大。

所以解決此問題的核心在於,要儘量減少和抑制MMKV的重寫和擴容次數,尤其是擴容次數。針對度加App的業務特點,我們做了幾點優化。

(1)某些key-value不經常變動(比如雲控參數),在寫入前先比較是否與原值相同,值不相同再插入數據。 上面提過,即使是已有相同的key-value,也直接append在文件末尾,其實這次插入沒有什麼用處。但字符串或者內存的比較(strcmp或者memcmp)也需要消耗點資源,所以業務方可以根據實際情況做比較,增加命中率,提高性能。

我從文心一言隨機要了一首英文詩,測試30萬次的插入性能差異

auto mmkv = [MMKV mmkvWithID:@"test0"];
NSString *key = [NSString stringWithFormat: @"HelloWorld!"];
NSString *value = [NSString stringWithFormat:
@"There are two roads in the forest \
  One is straight and leads to the light \
  The other is crooked and full of darkness \
  Which one will you choose to walk? \
\
  The straight road may be easy to follow \
  But it may lead you to a narrow path \
  The crooked road may be difficult to navigate \
  But it may open up a world of possibilities \
\
  The choice is yours to make \
  Decide wisely and with a open heart \
  Walk the path that leads you to your dreams \
  And leaves you with no regrets at the end of the day"];
double start = [[NSDate date] timeIntervalSince1970] * 1000;
for(int i = 0; i < 300000; i++) {
    /**
     * 判斷值是否相同再寫入
     * 可以利用短路表達式,先執行getValueSizeForKey確定value的長度是否有變化,如果有變化不需要再比較字符串的實質內容:
     *   getValueSizeForKey是極其輕量的操作,getStringForKey和isEqualToString相對較重
     */
    if ([mmkv getValueSizeForKey:key actualSize:true] != [value length] 
      || ![value isEqualToString: [mmkv getStringForKey: key]]) {
        [mmkv setString: value forKey:key];
    }
}
double end = [[NSDate date] timeIntervalSince1970] * 1000;
NSLog(@"funcionalTest, time = %f", (end - start));

運行環境:MacBook Pro (Retina, 15-inch, Mid 2015) 12.6.5

可見此方案對於值沒有任何變化的極端情況,有不小的性能提升。實際在生產環境,尤其是在配置較低的手機設備或磁盤IO繁忙時,這兩者的運行時間差距可能會被無限放大。

如果,這個先判斷再插入的邏輯,由MMKV來自動完成就更好了;但對於頻繁變化的鍵值對,會多出求value長度和比較字符串內容的“多餘操作”,可能小小的影響MMKV的插入性能。目前可以根據自己業務特點和數據變動情況合適選擇策略。

或者,MMKV考慮增加一組方法,可以叫個setWithCompare()之類的的名字,如果開發者認為key-value變動的概率不大,可以調用這個函數來降低擴容重寫文件的概率。就像C++20新增的likely和unlikely關鍵字一樣,提高命中率,均攤複雜度會變低,綜合性價比會變高。

(https://en.cppreference.com/w/cpp/language/attributes/likely)

(2)提前在閒時或者異步時擴容。 這個方案我沒在線上試過,但是個可行方案。假如我們能夠預估MMKV可能存放數據的大小,那麼完全可以在閒時插入一組長度接近的佔位key1-value1數據,先擴容好;當插入真正的數據key1-value2時,理想情況下至多觸發一次重寫,而不會再觸發擴容。

騰籠換鳥。

    MMKV *mmkv = [MMKV mmkvWithID:@"mmkv_id1"];
    
    NSString *s = [NSString stringWithFormat:@""];
    for (int i = 0; i < 7000; i++) {
        s = [s stringByAppendingString: @"a"];
    }
    // 閒時插入佔位數據
    [mmkv setString:s forKey:@"key1"];
    NSLog(@"setString key1 done");
    
    s = [s stringByAppendingString: @"b"];
    // 重寫一次,但不會再擴容
    [mmkv setString:s forKey:@"key1"];

其實説到這,就不難想到,這個思路跟Java中的ArrayList,或者STL中的vector的有參構造函數是一個意思,既然已經知道要存放數據的大體量級了,那麼在初始化的時候不妨直接就一次性的申請好,沒必要再不斷的*2去擴容了。

    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }
 // 3. 構造函數將n個elem拷貝給容器本身
 vector<int> v3(10, 2);
 printV(v3);
 // 2 2 2 2 2 2 2 2 2 2

目前MMKV默認創建時都是先創建4K的文件,就算我們明確知道要插入的是100K的數據,也絲毫沒有辦法,只能忍受一次擴從4K->128K的擴容。如果能支持構造器中直接指定預期文件大小,好像是更好的方案。

mmkv::getFileSize(m_fd, m_size);
// round up to (n * pagesize)
if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
    // 這裏可以通過構造函數直接在初始化時指定文件大小
    size_t roundSize = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
    truncate(roundSize);
} else {
    auto ret = mmap();
    if (!ret) {
        doCleanMemoryCache(true);
    }
}

於是向MMKV提了pr,構造函數支持設置文件初始大小 (https://github.com/Tencent/MMKV/discussions/1135) pr_(https://github.com/Tencent/MMKV/pull/1138/files)_

插一句,MMKV支持的平台很多,包括Android、iOS、Flutter、Windows、POSIX(Linux/UNIX/MacOS)等,哪怕想加一個小小的功能,也得花上不少時間去測試:光湊齊這麼多測試設備,也不是一件很容易的事兒。

説到底,MMKV畢竟不是為大kv設計的方案。不是他不優秀,實在是老鐵的要求太多了。

(3)使用gzip等壓縮數據,大幅降低重寫和擴容概率。

(4)大字符串或者數據從MMKV切換成數據庫,異步處理。

(3)和(4)在下章深入描述。

2、新ID第一次存儲key-value數據

這個問題困擾了我很久。原本以為,只有長字符串才會導致卡頓,但萬萬沒想到,不到50字節的key-value也會頻繁的卡頓,實在是讓人費解。有時候想直接把他丟到異步線程算了,但又有點不甘心。於是我又胡亂添加了幾個研發打點,發版後經過瞎分析,一個有趣的現象引起了我的注意:卡頓基本都發生在某個MMKV\_ID的第一次寫入,也就是文件內容(key-value對)從0到1的過程。

為什麼?

我懷疑是某個IO的系統調用導致的卡頓,藉助frida神器,我在demo中用撞大運式編程法挨個嘗試,有了新發現:這個過程竟然出現了msync系統調用。上面説過,mmap能夠建立文件和內存的映射,由操作系統負責數據同步。但有些時候我們想要磁盤立刻馬上去同步內存的信息,就需要主動調用msync來強制同步,這是個耗時操作,在IO繁忙時會導致卡頓。

在分析MMKV源碼,斷點調試和增加log後,我基本確定這是MMKV的“特性”:MMKV在文件長度不足、或者是clear所有的key時(clearAll())會主動的重寫文件。其中在從0到1時第一次插入key-value時,會誤觸發一次msync。

優化代碼:(https://github.com/Tencent/MMKV/discussions/1136)和pr(https://github.com/Tencent/MMKV/pull/1110/files),這個優化可能在一段時間後隨新版本發出。

msync() flushes changes made to the in-core copy of a file that
was mapped into memory using mmap(2) back to the filesystem.
Without use of this call, there is no guarantee that changes are
written back before munmap(2) is called.

考慮到老版本的升級週期問題,這個bug還可以用較為trick的方式規避: 在MMKV\_ID創建時,趁着IO空閒時不注意,趕緊寫入一組小的佔位數據,提前走通從0到1的過程。這樣在IO繁忙時就不會再執行msync。

// 保證至少有一個key-value
if (!TextUtils.equals(mmkv.decodeString("a")), "b") {
    mmkv.encodeString("a", "b");
}

這段“垃圾代碼”提交後迅速喜迎好幾個code review的 -1,求爹告奶後總算是通過了。好在上線後,這個卡頓幾乎銷聲匿跡:就算是一張衞生紙都有它的用處,更何況是幾行垃圾代碼呢。

另外,繼續追查卡頓時,發現了另外十分有趣的bug:第一次插入500左右字節的數據,會引發一次多餘的擴容。也一併修復

issue_(https://github.com/Tencent/MMKV/issues/1120 )_和pr_(https://github.com/Tencent/MMKV/pull/1121/files)_

而且我還有新的發現:很多同學因為編程習慣問題以及對MMKV不瞭解,度加剪輯有很多MMKV\_ID只包含一組(key=>value),存在巨大浪費。上面説過,每個MMKV\_ID都對應着兩個4K的文件,不僅佔據了8K的磁盤,還消耗了8K的內存,其實裏面就存着幾十字節的內容。更合理的做法是做好統一規範和管理,根據業務場景的劃分來創建對應的MMKV實例,數量不能太多也不能太少,更不是想在哪創建就在哪創建。

度加剪輯存在很多一個ID裏就存放一對key=>value的情況,需要統一治理。

04 getMMKV卡頓—佔度加總卡頓的0.5%

at com.tencent.mmkv.MMKV.getMMKVWithID(Native Method)
at com.tencent.mmkv.MMKV.mmkvWithID(Proguard:2)

此卡頓也大多發生在IO繁忙時。通過上面提到的frida神器,以及查看源碼,MMKV在初始化一個MMKV\_ID文件時,會調用lstat檢測文件夾是否存在,若不存在就執行mkdir(第一次)創建文件夾。然後調用open函數打開文件,依然可能會導致卡頓。

if (rootPath) {
    MMKVPath_t specialPath = (*rootPath) + MMKV_PATH_SLASH + SPECIAL_CHARACTER_DIRECTORY_NAME;
    if (!isFileExist(specialPath)) { // lstat系統調用
        mkPath(specialPath); // stat和mkdir系統調用
    }
    MMKVInfo("prepare to load %s (id %s) from rootPath %s", mmapID.c_str(), mmapKey.c_str(), rootPath->c_str());
}
    m_fd = ::open(m_path.c_str(), OpenFlag2NativeFlag(m_flag), S_IRWXU);
    if (!isFileValid()) {
        MMKVError("fail to open [%s], %d(%s)", m_path.c_str(), errno, strerror(errno));
        return false;
    }

open系統調用在平常測試中基本不怎麼耗時,但內部可能存在分配inode節點等操作,在IO繁忙時也可能卡住。無獨有偶,我在Sqlite的官網上也看到了一篇關於Sqlite和文件讀寫性能對比的文章,這裏面提到,open、close比read、write的操作更加耗時。

於是我又做了一個測試:

char buf[500 * 1024];
void testOpenCloseAndWrite() {
    for (int i = 0; i < sizeof(buf) / sizeof(char); i++) {
        buf[i] = '0' + (i % 10);
    }

    long long startTime = getTimeInUs();
    for (int i = 0; i < 1000; i++) {
        // 可以用snprintf代替,demo測試方便拼接字符串
        string s = "/sdcard/tmp/" + to_string(i);
        s += ".txt";
        int fd = open(s.c_str(), O_CREAT | O_RDWR, "w+");
        // 打開後寫入100K的數據
        //write(fd, buf, sizeof(buf));
        close(fd);
    }

    long long endTime = getTimeInUs();
    LOGE("time %lld (ms)", (endTime - startTime) / 1000);
}

1、當只有open/close調用時,在一加8Pro上只創建1000個"空"文件,需要3920ms(多次取平均)。

2、將第14行代碼取消註釋後,執行write系統調用,寫入500k的數據後,共4150ms,也就是説,多出1000次的寫操作,只增加了230毫秒,每次寫只需要0.23ms,和open比確實是快多了。Sqlite誠不我欺。

3、當文件已經存在,再次執行open系統調用耗時明顯要少一些,這也意味着第一次打開MMKV實例時會相對的慢。

度加線上抓到的open系統調用卡頓(libcore輾轉反側,最終執行了open系統調用)

07-29 06:48:47.316
at libcore.io.Linux.open(Native Method)
at libcore.io.ForwardingOs.open(ForwardingOs.java:563)
at libcore.io.BlockGuardOs.open(BlockGuardOs.java:274)
at libcore.io.ForwardingOs.open(ForwardingOs.java:563)
at android.app.ActivityThread$AndroidOs.open(ActivityThread.java:7980)
at libcore.io.IoBridge.open(IoBridge.java:560)
at java.io.FileInputStream.<init>(FileInputStream.java:160)

此問題可以通過預熱MMKV解決—在IO不繁忙時提前加載好MMKV(得益於MMKV內部的各種鎖,甚至還可以放心大膽的在異步線程初始化,和提前在異步線程加載SharedPreferences一樣)。不過要注意,沒必要過早加載,尤其是在App剛啓動時一股腦的初始化了所有的MMKV\_ID。對於使用頻率不高的ID,畢竟加載MMKV也就意味着內存的浪費,也意味着佔據着一個文件句柄。舉個栗子,某些ID只在度加剪輯的導出視頻後使用,我們不妨就在剛進入導出頁面時去預熱,而不是在進程創建的時候或者MainActivity創建的時候加載,太早了會浪費內存。

來得早不如來得巧。

05 存儲膨脹-MMKV不是數據庫

在排查其他線上問題時,偶然發現了兩個不當使用MMKV的情況:

第一是隻增不刪,key=>value只增不減;這種情況會導致大量垃圾數據產生,對內存消耗和磁盤佔用都是浪費。下一篇會重點説説及時清理空間的問題,這裏不再贅述。

第二是用MMKV存儲大量緩存數據,導致文件很大,通過分析研發打點數據,不少用户的MMKV文件體積最大的有512M了!

此外,MMKV為了避免頻繁的擴容,會根據平均的key-value長度,預留至少8個鍵值對的空間,這也加重了內存和文件的空間冗餘:

    auto sizeOfDic = preparedData.second;
    size_t lenNeeded = sizeOfDic + Fixed32Size + newSize;
    size_t dicCount = m_crypter ? m_dicCrypt->size() : m_dic->size();
    size_t avgItemSize = lenNeeded / std::max<size_t>(1, dicCount);
    // 預留至少8個鍵值對的空間
    size_t futureUsage = avgItemSize * std::max<size_t>(8, (dicCount + 1) / 2);

度加之前是把語音轉字幕的識別結果(ID:QUICK\_EDIT\_AI\_TXT\_CACHE)放到了MMKV裏緩存,但是從來不會主動刪除,只會越來越多;如果不早點處理,積重難返,總有一天App的內存會全部被這些大文件吃掉。

因為MMKV是典型的空間換時間,磁盤大小≈內存大小:磁盤佔據着512M,也意味着虛擬內存同時也會增加512M,大幅增加了OOM的風險。

Redis性能瓶頸揭秘:如何優化大key問題?(https://zhuanlan.zhihu.com/p/622474134)其實這這和Redis的大Key問題如出一轍,解決方案也十分類似:壓縮數據、數據切割分片、設置過期時間、更換其他數據存儲方式。

1、壓縮

如果非要將很多大內容存儲在MMKV裏,對value做壓縮可能也是一個不差的選擇。

MMKV存儲整數等短value採取了類似protobuf的變長整數壓縮,比如,一個int整形可以用1-5個字節表示(其實Redis的RDB也用了類似的變長編碼,不過看起來和UTF8的思路更為接近),本身來講,MMKV對pb並沒有直接的依賴。

MMKV對字符串沒有壓縮,將MMKV的二進制文件用vim(vim作者Bram Moolenaar於2023年8月3號去世,致敬大佬)當做文本格式直接打開,還是能看出來key和value都是以字符串的原始形式保存的。

MMKV *mmkv = [MMKV mmkvWithID: @"mm1"];
[mmkv setString: @"This is value" forKey:@"I am Key"];

看到這不得不説句容易捱打的話:哪怕是把key改短點,也能很有效的降低擴容概率。比如説度加剪輯某個key,從"key\_draft\_crash\_project\_id" 縮短為 "kdcpi"後,就降低了不少卡頓。是的,我試過了確實有效果,但我不建議你這麼做,畢竟代碼可讀性也十分重要。

而Protobuf本身就有對字符串壓縮的支持

GzipOutputStream::Options options;
options.format = GzipOutputStream::GZIP;
options.compression_level = _COMPRESSION_LEVEL;
GzipOutputStream gzipOutputStream(&outputFileStream, options);

Redis在保存RDB文件時,也有對字符串的壓縮支持,採取的是LZF壓縮算法

  /* Try LZF compression - under 20 bytes it's unable to compress even
     * aaaaaaaaaaaaaaaaaa so skip it */
    if (server.rdb_compression && len > 20) {
        n = rdbSaveLzfStringObject(rdb,s,len);
        if (n == -1) return -1;
        if (n > 0) return n;
        /* Return value of 0 means data can't be compressed, save the old way */
    }

自己動手豐衣足食。

既然MMKV對字符串沒有壓縮,那就先自己實現。對部分長字符串進行壓縮解壓後,線上數據顯示,文本json的gzip壓縮率在9-11倍左右,這就意味着文件體積會縮小至十分之一,並且內存消耗也縮小至十分之一。想象一下,50M的文件在主動壓縮後,瞬間變成5M,對磁盤和內存是多麼大的解放。當然,壓縮和解壓縮是消耗CPU資源的操作,一加8Pro上測試,338K的JSON文本使用Java自帶的GZIPOutputStream壓縮需要4ms,壓縮後體積是25K;通過GZIPInputStream解壓(注意buffer要設置成4096以上,太少會增加耗時)的時間也是4ms,有一定耗時,但也能接受;看起來,有時候反其道而行之,用時間換空間好像也是值得的!如果想要壓縮速度更快,可以換lz4、snappy等壓縮算法,但壓縮率也會隨之降低,應當基於自己的業務特點來選擇最合適的壓縮算法,在壓縮率和壓縮速度上找到平衡點。

估計有的同學會有疑問:既然用MMKV是空間換時間,那為什麼還要反過來用時間換空間,豈不是瞎折騰,玩兒呢?

關鍵就在於:

1、IO文件操作作用在磁盤,運行時間不穩定,抽起風來很要命,少則幾毫秒,多則幾十秒,所以我們願意用空間換時間來削掉波峯,提升穩定性。

2、而CPU操作的運行時間大體上比較穩定,一般只在CPU由大核切換小核、手機沒快電、温度太高、CPU降頻等少數情況才會劣化,且劣化趨勢不明顯,為了節約內存,所以用CPU換空間。

2、設置合理的過期時間。

為大key設置過期時間,以便在數據失效後自動清理,避免長時間累積,尾大不掉。MMKV 1.3.0已經支持了過期設置,到期自動清理。

3、更換數據存儲方式,例如Sqlite

用户產生的可無限增長的大規模數據,用數據庫(Sqlite等)更加合理。數據庫的訪問速度相比MMKV雖然要慢,但是內存等資源消耗不大,只要合理運用異步線程並處理好線程衝突的問題,數據庫的性能和穩定性也相當靠譜。

度加剪輯對在編輯頁面和導出頁面對內存的需求較高,我們的內存優化方案也比較激進,大體上遵循了以下4點:

1、不把MMKV當數據庫用,複雜和大規模的緩存數據考慮從MMKV切換到Sqlite或者他數據庫

2、必須要用MMKV存儲字符串等大數據的情況,使用gzip等算法壓縮後存儲,使用時解壓

3、對於只是一次性的讀取和寫入MMKV,操作完之後及時close該MMKV\_ID的實例,以釋放虛擬內存

4、MMKV的數據要做到不用的時候立即刪除,有始有終。

06 總結

自從切換成MMKV後,就再也不想用Sp了,回憶過去的苦難,回想今天的幸福生活,MMKV帶來的這點卡頓,跟Sp導致的卡頓比,就是”小巫見大巫“,直接可以忽略不計。

我分析了應用商店top級別的應用,有不少App在使用MMKV時也多少存在度加剪輯遇到的問題。大家使用MMKV主要存儲的內容是雲控的參數、以及AB實驗的參數(千萬不要小瞧,大廠的線上實驗賊多,且實驗的參數一點也不簡單),文件大小超過1M的場景相當的多。

不過讓我更為好奇的是,部分頭部App既沒有引入MMKV,我也沒發現類似的二進制映射文件,特別想了解這些頂級App是如何來處理key-value的,十分期待大佬們的分享。

MMKV作為極其優秀的存儲組件,最後用這張圖片來描述TA與開發者的關係:

—— END ——

參考資料:

[1]https://www.clear.rice.edu/comp321/html/laboratories/lab10/

[2]https://developer.ibm.com/articles/benefits-compression-kafka...

[3]https://github.com/Tencent/MMKV

[4]《Linux系統編程手冊》

[5]《UNIX環境高級編程》

[6]《Android開發高手課》

推薦閲讀:

百度工程師淺析解碼策略

百度工程師淺析強化學

淺談統一權限管理服務的設計與開發

百度APP iOS端包體積50M優化實踐(五) HEIC圖片和無用類優化實踐

百度知道上雲與架構演進

user avatar shishangdexiaomaju 頭像
1 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.