FastKV:一個真的很快的KV存儲庫

一、前言

KV存儲無論對于客戶端還是服務端都是重要的構件。
對于Android客戶端而言,最常見的莫過于SDK提供的SharePreferences(以下簡稱SP),但其低效率和ANR問題飽受詬病。
官方后來又推出了基于Kotlin的DataStore,不過測試下來發現寫入效率很低。
微信開源了MMKV,寫入速度比前者高不少,但是讀取相對較慢,同時也存在其他一些缺點。

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雖然低效,但至少有相應的機制確保數據完整性,頂多可能會丟失最新的update;
而MMKV則有可能會丟失整個文件的數據。

二、FastKV

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

2.1 特性

FastKV有以下特性:

  1. 讀寫速度快
    • FastKV采用二進制編碼,編碼后的體積相對XML等文本編碼要小很多。
    • 增量編碼:FastKV記錄了各個key-value相對文件的偏移量,更新數據時,可以直接在對應的位置寫入數據。
    • 默認用mmap的方式記錄數據,更新數據時直接寫入到內存即可,沒有IO阻塞。
  2. 支持多種寫入模式
    • 除了mmap這種非阻塞的寫入方式,FastKV也支持常規的阻塞式寫入方式,
      并且支持同步阻塞和異步阻塞(分別類似于SharePreferences的commit和apply)。
  3. 支持多種類型
    • 支持常用的boolean/int/float/long/double/String等基礎類型。
    • 支持ByteArray (byte[])。
    • 支持存儲自定義對象。
    • 內置Set<String>的編碼器 (為了方便兼容SharePreferences)。
  4. 支持多進程
    • 項目提供了支持多進程的存儲類(MPFastKV)。
    • 支持監聽文件內容變化,其中一個進程修改文件,所有進程皆可感知。
  5. 方便易用
    • FastKV提供了了豐富的API接口,開箱即用。
    • 提供的接口其中包括getAll()和putAll()方法,
      所以遷移SharePreferences等框架的數據到FastKV很方便,當然,遷移FastKV的數據到其他框架也很方便。
  6. 穩定可靠
    • 通過double-write等方法確保數據的完整性。
    • 在API拋IO異常時自動降級處理。
  7. 代碼精簡
    • FastKV由純Java實現,編譯成jar包后體積只有幾十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兩個文件(如果寫入A過程中崩潰,B仍是完整的,如果A完整寫入了,則B寫入時崩潰也不要緊);
    加載數據時,通過checksum、標記、數據合法性檢驗等方法驗證文件是否完整,若其中一個文件是損壞的,則用完整的文件覆蓋之。
    double-write可以防止進程崩潰后數據不完整,但由于mmap是系統定時刷盤,若在刷盤前系統崩潰或者斷電,仍會丟失未落盤的更新(之前的數據還在);對于非常重要的key-value,在寫入后,可接著調用force()強制將臟頁刷盤。
  • 更新策略(增/刪/改)
    新增:寫入到數據的尾部。
    刪除: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(縮小文件大小)。
  • 多進程支持
    FileLock實現進程互斥,FileObserver實現文件變更監聽。
    A文件mmap寫入,內存共享;B文件FileChannel寫入,觸發FileObserver回調(寫入mmap不會出觸發FileObserver回調)。

2.3 使用方法

2.3.1 導入

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

2.3.2 初始化

    FastKVConfig.setLogger(FastKVLogger)
    FastKVConfig.setExecutor(Dispatchers.Default.asExecutor())

初始化可以按需設置日志接口和Executor。

2.3.3 基本用法

    // FastKV kv = new FastKV.Builder(path, name).build();
    FastKV kv = new FastKV.Builder(context, name).build();
    
    if(!kv.getBoolean("flag")){
        kv.putBoolean("flag" , true);
    }
    
    int count = kv.getInt("count");
    if(count < 10){
        kv.putInt("count" , count + 1);
    }

Builder的構造可傳Context或者path。
如果傳Context的話,會在內部目錄的'files'目錄下創建'fastkv'目錄來作為文件的保存路徑。

2.3.4 存儲自定義對象

FastKV.Encoder<?>[] encoders = new FastKV.Encoder[]{LongListEncoder.INSTANCE};  
FastKV kv = new FastKV.Builder(context, name).encoder(encoders).build();  
  
List<Long> list = new ArrayList<>();  
list.add(100L);  
list.add(200L);  
list.add(300L);  
kv.putObject("long_list", list, LongListEncoder.INSTANCE);  
  
List<Long> list2 = kv.getObject("long_list");  

除了支持基本類型外,FastKV還支持寫入對象,只需在構建FastKV實例時傳入對象的編碼器即可。
編碼器為實現FastEncoder接口的對象。
上面LongListEncoder就實現了FastEncoder接口,代碼實現可參考:LongListEncoder

編碼對象涉及序列化/反序列化。
這里推薦筆者的另外一個框架:https://github.com/BillyWei01/Packable

2.3.5 數據加密

如需對數據進行加密,在創建FastKV實例時傳入
FastCipher 的實現即可。

FastKV kv = FastKV.Builder(path, name)  
.cipher(yourCihper)  
.build()  

項目中有舉例Cipher的實現,可參考:AESCipher

2.3.6 遷移 SharePreferences 到 FastKV

FastKV實現了SharedPreferences接口,并且提供了遷移SP數據的方法。
用法如下:

public class SpCase {
   public static final String NAME = "common_store";
   // 原本的獲取SP的方法
   // public static final SharedPreferences preferences = GlobalConfig.appContext.getSharedPreferences(NAME, Context.MODE_PRIVATE);
   
   // 導入原SP數據
   public static final SharedPreferences preferences = FastKV.adapt(AppContext.INSTANCE.getContext(), NAME);
}

2.3.7 遷移 MMKV 到 FastKV

由于MMKV沒有實現 'getAll' 接口,所以無法像SharePreferences一樣一次性遷移。
但是可以封裝一個KV類,創建 'getInt','getString' ... 等方法,并在其中做適配處理。
可參考:MMKV2FastKV

2.3.8 多進程

項目提供了支持多進程的實現:MPFastKV
MPFastKV除了支持多進程讀寫之外,還實現了SharedPreferences的接口,包括支持注冊OnSharedPreferenceChangeListener ;
其中一個進程修改了數據,所有的進程都會感知(通過OnSharedPreferenceChangeListener回調)。
可參考 MultiProcessTestActivityTestService

需要提醒的是,由于支持多進程需要維護更多的狀態,MPFastKV 的寫入要比FastKV慢不少,
所以在不需要多進程訪問的情況下,盡量用FastKV。

2.3.9 Kotlin 委托

Kotlin是兼容Java的,所以Kotlin下也可以直接用FastKV或者SharedPreferences的API。
此外,Kotlin還提供了“委托屬性”這一語法糖,可以用于改進key-value API訪問。
可參考:KVData

三、 性能測試

  • 測試數據:搜集APP中的SharePreferences匯總的部份key-value數據(經過隨機混淆)得到總共六百多個key-value。
    分別截取其中一部分,構造正態分布的輸入序列,進行多次測試。
  • 測試機型:華為P30 Pro
  • 測試代碼:Benchmark

測試結果如下:

更新:

25 50 100 200 400 600
SP-commit 114 172 411 666 2556 5344
DataStore 231 625 1717 4421 7629 13639
SQLiteKV 192 382 1025 1565 4279 5034
SP-apply 3 9 35 118 344 516
MMKV 4 8 5 8 10 9
FastKV 3 6 4 6 8 10

查詢:

25 50 100 200 400 600
SP-commit 1 3 2 1 2 3
DataStore 57 76 115 117 170 216
SQLiteKV 96 161 265 417 767 1038
SP-apply 0 1 0 1 3 3
MMKV 0 1 1 5 8 11
FastKV 0 1 1 3 3 1

每次執行Benchmark獲取到的結果有所浮動,尤其是APP啟動后執行多次,部分KV會變快(JIT優化)。
以上數據是取APP冷啟動后第一次Benchmark的數據。

四、結語

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容