再見 MMKV,自己擼一個FastKV,快的一批

語言: CN / TW / HK

 BATcoder技術 群,讓一部分人先進大廠

大家好,我是劉望舒,騰訊最具價值專家,著有三本業內知名暢銷書,連續五年蟬聯電子工業出版社年度優秀作者, 百度百科收錄的資深技術專家。

前華為面試官、獨角獸公司技術總監。

想要 加入  BATcoder技術群,公號回覆 BAT  即可。

作者:呼嘯長風

https://juejin.cn/post/7018522454171582500

1 前言

KV儲存無論對於客戶端還是服務端都是重要的構件。


對於Android客戶端而言,最常見的莫過於SDK提供的SharePreferences(以下簡稱SP),但其低效率和ANR問題飽受詬病。


18年年末微信開源了MMKV,寫入速度比前者高不少。


後來官方又推出了基於Kotlin的DataStore,測試下來,發現寫入效率很低。


我之前寫過一個叫LightKV的儲存元件,不過當時認知不足,設計不夠成熟。

1.1 SP的不足

關於SP的缺點網上有不少討論,這裡主要提兩個點:

  • 儲存速度較慢

SP用記憶體層用HashMap儲存,磁碟層則是用的XML檔案儲存。


每次更改,都需要將整個HashMap序列化為XML格式的報文然後整個寫入檔案。


歸結其較慢的原因:


1、不能增量寫入;


2、序列化比較耗時。

  • 可以能會導致ANR

public void apply() {
    // ...省略無關程式碼...
    QueuedWork.addFinisher(awaitCommit);
    Runnable postWriteRunnable = new Runnable() {
        @Override
        public void run() {
            awaitCommit.run();
            QueuedWork.removeFinisher(awaitCommit);
        }
    };
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
}
public void handleStopActivity(IBinder token, boolean show, int configChanges,
                               PendingTransactionActions pendingActions, boolean finalStateRequest, String reason) {
    // ...省略無關程式碼...
    // Make sure any pending writes are now committed.
    if (!r.isPreHoneycomb()) {
        QueuedWork.waitToFinish();
    }
}

Activity stop時會等待SP的寫入任務,如果SP的寫入任務多且執行慢的話,可能會阻塞主執行緒較長時間,輕則卡頓,重則ANR。

1.2 MMKV的不足

沒有型別資訊,不支援getAll:


MMKV的儲存用類似於Protobuf的編碼方式,只儲存key和value本身,沒有存型別資訊(Protobuf用tag標記欄位,資訊更少)。


由於沒有記錄型別資訊,MMKV無法自動反序列化,也就無法實現getAll介面。

讀取相對較慢:


SP在載入的時候已經將value反序列化存在HashMap中了,讀取的時候索引到之後就能直接引用了。


而MMKV每次讀取時都需要重新解碼,除了時間上的消耗之外,還需要每次都建立新的物件。


不過這不是大問題,相對SP沒有差很多。

需要引入so, 增加包體積:


引入MMKV需要增加的體積還是不少的,且不說jar包和aidl檔案,光是一個arm64-v8a的so就有四百多K。

雖然說現在APP體積都不小,但畢竟增加體積對打包、分發和安裝時間都多少有些影響。

檔案只增不減:


MMKV的擴容策略還是比較激進的,而且擴容之後不會主動trim size。


比方說,假如有一個大value,讓其擴容至1M,後面刪除該value,哪怕有效內容只剩幾K,檔案大小還是保持在1M。

可能會丟失資料:


前面的問題總的來說都不是什麼“要緊”的問題,但是這個丟失資料確實是硬傷。


MMKV官方有這麼一段表述:

通過 mmap 記憶體對映檔案,提供一段可供隨時寫入的記憶體塊,App 只管往裡面寫資料,由作業系統負責將記憶體回寫到檔案,不必擔心 crash 導致資料丟失。

這個表述對一半不對一半。


如果資料完成寫入到記憶體塊,如果系統不崩潰,即使程序崩潰,系統也會將buffer刷入磁碟。


但是如果在刷入磁碟之前發生系統崩潰或者斷電等,資料就丟失了,不過這種情況發生的概率不大。


另一種情況是資料寫一半的時候程序崩潰或者被殺死,然後系統會將已寫入的部分刷入磁碟,再次開啟時檔案可能就不完整了。


例如,MMKV在剩餘空間不足時會回收無效的空間,如果這期間程序中斷,資料可能會不完整。MMKV官方的說明可以佐證:

CRC校驗失敗之後,MMKV有兩種應對策略:直接丟棄所有資料,或者嘗試讀取資料(使用者可以在初始化時設定)。


嘗試讀取資料不一定能恢復資料,甚至可能會讀到一些錯誤的資料,得看運氣。

這個過程是比較容易復現的,下面是其中一種復現路徑:

1、新增和刪除若干key-value 得到資料如下:

2、插入一個大字串,觸發擴容,擴容前會觸發垃圾回收。

3、斷點打在執行 memmove 的迴圈中,執行一部分 memmove , 然後在手機上殺死程序。

4、再次開啟APP,資料丟失。

相比之下,SP雖然低效,但至少不會丟失資料。

2.FastKV

在總結了之前的經驗和感悟之後,筆者實現了一個高效且可靠的版本,且將其命名為: FastKV。

2.1 特性

FastKV有以下特性:

1、讀寫速度快

FastKV採用二進位制編碼,編碼後的體積相對XML等文字編碼要小很多。

增量編碼:FastKV記錄了各個key-value相對檔案的偏移量,更新資料時,可以直接在對應的位置寫入資料。

預設用mmap的方式記錄資料,更新資料時直接寫入到記憶體即可,沒有IO阻塞。

2、支援多種寫入模式

除了mmap這種非阻塞的寫入方式,FastKV也支援常規的阻塞式寫入方式, 並且支援同步阻塞和非同步阻塞(分別類似於 SharePreferencescommitapply )。

3、支援多種型別

支援常用的 boolean/int/float/long/double/String 等基礎型別。

支援 ByteArray ( byte[] )。

支援儲存自定義物件。

內建StringSet編碼器 (為了相容 SharePreferences )。

4、方便易用

FastKV提供了了豐富的API介面,開箱即用。

提供的介面其中包括 getAll()putAll() 方法, 所以遷移 SharePreferences 等框架的資料到FastKV很方便,當然,遷移FastKV的資料到其他框架也很方便。

5、穩定可靠

通過 double-write 等方法確保資料的完整性。

在API拋IO異常時提供降級處理。

6、程式碼精簡

FastKV由純Java實現,編譯成jar包後體積僅30多K。

2.2 實現原理

2.2.1 編碼

檔案的佈局:

[data_len | checksum | key-value | key-value|....]

data_len : 佔4位元組, 記錄所有key-value所佔位元組數。

checksum: 佔8位元組,記錄key-value部分的checksum。

key-value的資料佈局:

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| delete_flag | external_flag | type  | key_len | key_content |  value  |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     1bit    |      1bit     | 6bits |  1 byte |             |         |

delete_flag  :標記當前key-value是否刪除。

external_flag : 標記value部分是否寫到額外的檔案。


注:對於資料量比較大的value,放在主檔案會影響其他key-value的訪問效能,因此,單獨用一個檔案來儲存該value, 並在主檔案中記錄其檔名。

type : value型別,目前支援 boolean/int/float/long/double/String/ByteArray 以及自定義物件。

key_len : 記錄key的長度,key_len本身佔1位元組,所以支援key的最大長度為255。

key_content : key的內容本身,utf8編碼。

value : 基礎型別的value, 直接編碼(little-end);


其他型別,先記錄長度(用varint編碼),再記錄內容。

String採用UTF-8編碼,ByteArray無需編碼,自定義物件實現Encoder介面,分別在Encoder的 encode/decode 方法中序列化和反序列化。

2.2.2 儲存

1、mmap

為了提高寫入效能,FastKV預設採用 mmap 的方式寫入。

2、降級


當mmap API發生IO異常時,降級到常規的blocking I/O,同時為了不影響當前執行緒,會將寫入放到非同步執行緒中執行。

3、資料完整性


如果在寫入一部分的過程中發生中斷(程序或系統),則檔案可能會不完整。


故此,需要用一些方法確保資料的完整性。

當用mmap的方式開啟時,FastKV採用 double-write 的方式:資料依次寫入A/B兩個檔案,確保任何時刻總有一個檔案完整的;


載入資料時,通過checksum、標記、資料合法性檢驗等方法驗證資料的正確性。

double-write 可以防止程序崩潰後資料不完整,但由於 mmap 是系統定時刷盤,若在刷盤前系統崩潰或者斷電,仍會丟失未落盤的更新(之前的資料還在);對於非常重要的key-value,在寫入後,可接著呼叫 force() 強制將髒頁刷盤。

4、更新策略(增/刪/改)


新增:寫入到資料的尾部。

刪除: delete_flag 設定為1。

修改:如果value部分的長度和原來一樣,則直接寫入原來的位置;否則,先寫入key-value到資料尾部,再標記原來位置的 delete_flag 為1(刪除),最後再更新檔案的data_len和checksum。

5、gc/truncate


刪除key-value時會收集資訊(統計刪除的個數,以及所在位置,佔用空間等)。


GC的觸發點有兩個:


1、新增key-value時剩餘空間不足,且已刪除的空間達到閾值,且騰出刪除空間後足夠寫入當前key-value, 則觸發GC。


2、刪除key-value時,如果刪除空間達到閾值,或者刪除的key-value個數達到閾值,則觸發GC。


GC後如果不用的空間達到設定閾值,則觸發truncate(縮小檔案大小)。

2.3 使用方法

2.3.1 匯入

dependencies {
    implementation 'io.github.billywei01:fastkv:1.0.2'
}

2.3.2 初始化

FastKVConfig.setLogger(FastKVLogger)
FastKVConfig.setExecutor(ChannelExecutorService(4))

初始化可以按需設定日誌回撥和Executor。


建議傳入自己的執行緒池,以複用執行緒。

日誌介面提供三個級別的回撥,按需實現即可。

public interface Logger {
    void i(String name, String message);

    void w(String name, Exception e);

    void e(String name, Exception e);
}

2.3.3 資料讀寫

基本用法

FastKV kv = new FastKV.Builder(path, name).build();
if(!kv.getBoolean("flag")){
    kv.putBoolean("flag" , true);
}

儲存自定義物件

FastKV.Encoder<?>[] encoders = new FastKV.Encoder[]{LongListEncoder.INSTANCE};
FastKV kv = new FastKV.Builder(path, name).encoder(encoders).build();

String objectKey = "long_list";
List<Long> list = new ArrayList<>();
list.add(100L);
list.add(200L);
list.add(300L);
kv.putObject(objectKey, list, LongListEncoder.INSTANCE);

List<Long> list2 = kv.getObject("long_list");

FastKV支援儲存自定義物件,為了載入檔案時能自動反序列化,需在構建FastKV例項時傳入物件的編碼器。

編碼器為實現 FastKV.Encoder 的物件。

比如上面的 LongListEncoder 的實現如下:

public class LongListEncoder implements FastKV.Encoder<List<Long>> {
    public static final LongListEncoder INSTANCE = new LongListEncoder();

    @Override
    public String tag() {
        return "LongList";
    }

    @Override
    public byte[] encode(List<Long> obj) {
        return new PackEncoder().putLongList(0, obj).getBytes();
    }

    @Override
    public List<Long> decode(byte[] bytes, int offset, int length) {
        PackDecoder decoder = PackDecoder.newInstance(bytes, offset, length);
        List<Long> list = decoder.getLongList(0);
        decoder.recycle();
        return (list != null) ? list : new ArrayList<>();
    }
}

編碼物件涉及序列化/反序列化。

這裡推薦筆者的 另外一個框架

https://github.com/BillyWei01/Packable

2.3.4 For Android

Android平臺上的用法和常規用法一致,不過Android平臺多了 SharePreferences  API,以及支援Kotlin。

FastKV的API相容 SharePreferences , 可以很輕鬆地遷移 SharePreferences 的資料到FastKV。

相關用法可 參考

https://github.com/BillyWei01/FastKV/blob/main/android_case_CN.md

3.效能測試

測試資料 :蒐集APP中的 SharePreferenses 彙總的部分key-value資料(經過隨機混淆)得到總共四百多個key-value。由於日常使用過程中部分key-value訪問多,部分訪問少,所以構造了一個正態分佈的訪問序列。

比較物件 SharePreferences/DataStore/MMKV

測試機型 :榮耀20S

測試結果:

寫入(ms) 讀取(ms)
SharePreferences 1258 3
DataStore 16650 3
MMKV 25 9
FastKV 16 1

1、 SharePreferences 提交用的是apply, 耗時依然不少。

2、 DataStore 的寫入很慢。

3、MMKV的讀取比 SharePreferences/DataStore 要慢一些,寫入則比之快許多。

4、FastKV無論讀取還是寫入都比其他方式要快。

結語

本文探討了當下Android平臺的各類KV儲存方式,提出並實現了一種新的儲存元件,著重解決了KV儲存的效率和資料可靠性問題。

目前程式碼已上傳Github, 地址

https://github.com/BillyWei01/FastKV