FastKV:一個真的很快的KV存儲組件

一、前言

KV存儲無論對於客戶端還是服務端都是很常用且重要的。
對於Android客戶端而言,最常見的莫過於SDK提供的SharePreferences(以下簡稱SP),但其低效率和ANR問題飽受詬病。
後來官方又推出了基於Kotlin的DataStore, 其中的Preferences DataStore,換湯不換藥,底層的存儲策略還是一樣的,目測該有的問題還是有。
18年年末微信開源了MMKV, 有較高熱度。
我之前寫過一個叫LightKV的Android客戶端的KV存儲組件,開源時間比MMKV要早一點,但沒什麼熱度……不過話說回來,由於當時認知不足,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,後面即使觸發GC,哪怕有效內容有幾K,文件大小還是保持在1M。

  • 可能會丟失數據
    前面的問題總的來說都不是什麼“要緊”的問題,但是這個丟失數據確實是硬傷。
    MMKV官方有這麼一段表述:

    通過 mmap 內存映射文件,提供一段可供隨時寫入的內存塊,App 只管往裏面寫數據,由操作系統負責將內存回寫到文件,不必擔心 crash 導致數據丟失。

這個表述對一半不對一半。
如果數據完成寫入到內存塊,如果系統不崩潰,即使進程崩潰,系統也會將buffer刷入磁盤;
但是如果在刷入磁盤之前發生系統崩潰或者斷電等,數據就丟失了,不過這種情況發生的概率不大;
另一種情況是數據寫一半的時候進程崩潰或者被殺死,然後系統會將已寫入的部分刷入磁盤,再次打開時文件可能就不完整了。
例如,MMKV在剩餘空間不足時會回收無效的空間,如果這期間進程中斷,數據可能會不完整。
MMKV官方的說明可以佐證:

CRC校驗失敗之後,MMKV有兩種應對策略:直接丟棄所有數據,或者嘗試讀取數據(用戶可以在初始化時設定)。
嘗試讀取數據不一定能恢復數據,甚至可能會讀到一些錯誤的數據,得看運氣。

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

  1. 新增和刪除若干key-value
    得到數據如下:
  1. 插入一個大字符串,觸發擴容,擴容前會觸發垃圾回收

  2. 斷點打在執行memmove的循環中,執行一部分memmove, 然後在手機上殺死進程


  3. 再次打開APP,數據丟失

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

二、FastKV

在總結了之前的經驗和感悟之後,筆者琢磨出了一個高效且可靠的版本,且將其命名爲: FastKV

2.1 特性

FastKV有以下特性:

  1. 讀寫速度快
    • FastKV,二進制編碼,編碼後的體積相對XML等文本編碼要小很多;
    • 增量編碼:FastKV記錄了各個key-value相對文件的偏移量(包括失效的key-value),
      從而在更新數據時可以直接在指定的位置寫入數據。
    • 默認用mmap的方式記錄數據,更新數據時直接寫入到內存即可,沒有IO阻塞。
  2. 支持多種寫入模式
    • 除了mmap這種非阻塞的寫入方式,FastKV也支持常規的阻塞式寫入方式,
      並且支持同步阻塞和異步阻塞(分別類似於SharePreferences的commit和apply)。
  3. 支持多種類型
    • 支持常用的boolean/int/float/long/double/String等基礎類型;
    • 支持ByteArray (byte[]);
    • 支持存儲對象。
    • 內置Set<String>的編碼器 (爲了方便兼容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 存儲

  • mmap
    爲了提高寫入性能,FastKV默認採用mmap的方式寫入。
  • 降級
    當mmap API發生IO異常時,降級到常規的blocking I/O,同時爲了不影響當前線程,會將寫入放到異步線程中執行。
  • 數據完整性
    如果在寫入一部分的過程中發生中斷(進程或系統),則文件可能會不完整。
    故此,需要用一些方法確保數據的完整性。
    當用mmap的方式打開時,FastKV採用double-write的方式:數據依次寫入A/B兩個文件,確保任何時刻總有一個文件完整的;
    加載數據時,通過checksum, 標記,數據合法性檢驗等方法驗證數據的正確性。
    double-write可以防止進程崩潰後數據不完整,但mmap是系統定時刷盤,若在刷盤系統崩潰或者斷電,仍會丟失更新(之前的數據還在,僅丟失更新)。可以通過調用force()強制刷盤,但這就不能發揮mmap的優點了,總不能每次update都調用force()吧,這樣的話還不如用blocking I/O。
    基於此,FastKV也支持用blocking I/O的方式寫文件 。
    當用blocking I/O的寫入時,先寫臨時文件,完整寫入後再刪除主文件,然後重命名臨時文件爲主文件。
    FastKV支持同步的和異步的blocking I/O,寫入方式類似於SP的commit和apply,但是序列化key-value的部分是增量的,比SP的序列化整個HashMap的方式要快許多。
  • 更新策略(增/刪/改)
    新增:寫入到數據的尾部。
    刪除:delete_flag設置爲1。
    修改:如果value部分的長度和原來一樣,則直接寫入原來的位置;
    否則,先寫入key-value到數據尾部,再標記原來位置的delete_flag爲1(刪除),最後再更新文件的data_len和checksum。
  • 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

  • blocking I/O
    要使用blocking I/O,在構造FastKV時調用blocking()或者asyncBlocking()函數即可。
    用法如下:
    FastKV kv = new FastKV.Builder(TestHelper.DIR, "test").blocking().build();
    // 自動提交
    kv.putLong("time", System.currentTimeMillis());

    // 批量提交
    kv.disableAutoCommit();
    kv.putLong("time", System.currentTimeMillis());
    kv.putString("str", "hello");
    kv.putInt("int", 100);
    boolean success = kv.commit();
    if (success) {
        // handle success
    }else {
        // handle failed
    }

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

三、 性能測試

  • 測試數據:蒐集APP中的SharePreferenses彙總的部份key-value數據(經過隨機混淆)得到總共四百多個key-value。由於日常使用過程中部分key-value訪問多,部分訪問少,所以構造了一個正態分佈的訪問序列。
  • 比較對象: SharePreferences 和 MMKV
  • 測試機型:榮耀20S

分別讀寫10次,耗時如下:

寫入(ms) 讀取(ms)
SharePreferences 1490 6
MMKV 34 9
FastKV 14 1
  • SharePreferences提交用的是apply, 耗時依然不少,用commit的方式在該機器上需要五秒多。
  • MMKV的讀取比SharePreferences要慢一些,寫入則比之快許多;
  • FastKV無論讀取還是寫入都比另外兩種方式要快。

四、結語

本文探討了當下Android平臺的各類KV存儲方式,提出並實現了一種新的存儲組件,着重解決了KV存儲的效率和數據可靠性問題。
目前代碼以上傳Github: https://github.com/BillyWei01/FastKV

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章