一、前言
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有兩種應對策略:直接丟棄所有數據,或者嘗試讀取數據(用戶可以在初始化時設定)。
嘗試讀取數據不一定能恢復數據,甚至可能會讀到一些錯誤的數據,得看運氣。
這個過程是比較容易復現的,下面是其中一種復現路徑:
- 新增和刪除若干key-value
得到數據如下:
插入一個大字符串,觸發擴容,擴容前會觸發垃圾回收
-
斷點打在執行memmove的循環中,執行一部分memmove, 然后在手機上殺死進程
再次打開APP,數據丟失
相比之下,SP雖然低效,但至少有相應的機制確保數據完整性,頂多可能會丟失最新的update;
而MMKV則有可能會丟失整個文件的數據。
二、FastKV
在總結了之前的經驗和感悟之后,筆者實現了一個高效且可靠的版本,且將其命名為: FastKV。
2.1 特性
FastKV有以下特性:
- 讀寫速度快
- FastKV采用二進制編碼,編碼后的體積相對XML等文本編碼要小很多。
- 增量編碼:FastKV記錄了各個key-value相對文件的偏移量,更新數據時,可以直接在對應的位置寫入數據。
- 默認用mmap的方式記錄數據,更新數據時直接寫入到內存即可,沒有IO阻塞。
- 支持多種寫入模式
- 除了mmap這種非阻塞的寫入方式,FastKV也支持常規的阻塞式寫入方式,
并且支持同步阻塞和異步阻塞(分別類似于SharePreferences的commit和apply)。
- 除了mmap這種非阻塞的寫入方式,FastKV也支持常規的阻塞式寫入方式,
- 支持多種類型
- 支持常用的boolean/int/float/long/double/String等基礎類型。
- 支持ByteArray (byte[])。
- 支持存儲自定義對象。
- 內置Set<String>的編碼器 (為了方便兼容SharePreferences)。
- 支持多進程
- 項目提供了支持多進程的存儲類(MPFastKV)。
- 支持監聽文件內容變化,其中一個進程修改文件,所有進程皆可感知。
- 方便易用
- FastKV提供了了豐富的API接口,開箱即用。
- 提供的接口其中包括getAll()和putAll()方法,
所以遷移SharePreferences等框架的數據到FastKV很方便,當然,遷移FastKV的數據到其他框架也很方便。
- 穩定可靠
- 通過double-write等方法確保數據的完整性。
- 在API拋IO異常時自動降級處理。
- 代碼精簡
- 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回調)。
可參考 MultiProcessTestActivity 和 TestService
需要提醒的是,由于支持多進程需要維護更多的狀態,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