LightKV-高性能key-value存儲組件

注:此組件不夠成熟,筆者最近寫了一個更好的KV存儲組件:
https://juejin.cn/post/7018522454171582500

LightKV是基于Java NIO的輕量級,高性能,高可靠的key-value存儲組件。

一、起源

Android平臺常見的本地存儲方式, SDK內(nèi)置的有SQLite,SharedPreference等,開源組件有ACache, DiskLruCahce等,有各自的特點和適用性。
SharedPreference以其天然的 key-value API,二級存儲(內(nèi)存HashMap, 磁盤xml文件)等特點,為廣大開發(fā)者所青睞。
然而,任何工具都是有適用性的,參見文章《不要濫用SharedPreference》。
當然,其中一些缺點是其定位決定的,比如說不適合存儲大的key-value, 這個無可厚非;
不過有一些地方可以改進,比如存儲格式:xml解析速度慢,空間占用大,特殊字符需要轉(zhuǎn)義等特點,對于高頻變化的存儲,實非良策。
故此,有必要寫一個改良版的key-value存儲組件。

二、LightKV原理

2.1 存儲格式

我們希望文件可以流式解析,對于簡單key-value形式,完全可以自定義格式。
例如,簡單地依次保存key-value就好:
key|value|key|value|key|value……

value

關于value類型,我們需要支持一些常用的基礎類型:boolean, int, long, float, double, 以及String 和 數(shù)組(byte[])。
尤其是后者,更多的復合類型(比如對象)都可以通過String和數(shù)組轉(zhuǎn)化。
作為底層的組件,支持最基本的類型可以簡化復雜度。
對于String和byte[], 存儲時先存長度,再存內(nèi)容。

key

我們觀察到,在實際使用中key通常是預先定義好的;
故此,我們可以舍棄一定的通用性,用int來作為key, 而非用String。
有舍必有得,用int作為key,可以用更少的空間承載更多的信息。

public interface DataType {
    int OFFSET = 16;
    int MASK = 0xF0000;
    int ENCODE = 1 << 20;

    int BOOLEAN = 1 << OFFSET;
    int INT = 2 << OFFSET;
    int FLOAT = 3 << OFFSET;
    int LONG = 4 << OFFSET;
    int DOUBLE = 5 << OFFSET;
    int STRING = 6 << OFFSET;
    int ARRAY = 7 << OFFSET;
}

int的低16位用來定義key,
17-19位用來定義類型,
20位預留,
21位標記是否編碼(后面會講到),
32位(最高位)標記是否有效:為1時為無效,讀取時會跳過。

內(nèi)存緩存

SharePreference相對于ACache,DiskLruCache等多了一層內(nèi)存的存儲,于是他們的定位也就涇渭分明了:
后者通常用于存儲大對象或者文件等,他們只負責提供磁盤存儲,至于讀到內(nèi)存之后如果使用和管理,則不是他們的職責了。
太大的對象會占用太多的內(nèi)存,而SharePreference是長期持有引用,沒有空間限制和淘汰機制的,因此SharePreference適用于“輕量級存儲”, 而由此所帶來的收益就是讀取速度很快。
LightKV定位也是“輕量級存儲”,所以也會在內(nèi)存中存儲key-value,只不過這里用SparseArray來存儲。

2.2 存儲操作

上面提到, 存儲格式是簡單地key-value依次排列:
key|value|key|value|key|value……
這樣存放,讀取時可以流式地解析,甚至,寫入時可以增量寫入。

方案一、增量&異步

增量操作
  • 新增:在尾部追加key|value即可;
  • 刪除:為了避免字節(jié)移動,可以用標記的方法——將key的最高位標記為1;
  • 修改:如果value長度不變,尋址到對應的位置,寫入value即可;否則,先“刪除”,再“新增”;
  • GC: 解析文件內(nèi)容時(加載數(shù)據(jù)時進行解析),記錄被刪除的內(nèi)容的長度,大于設定閾值則清空文件,做一次全量寫入。 使用過程中,有“刪除”操作時,“刪除”之后,累加刪除的內(nèi)容的長度,若超過設定閾值則GC。
mmap

要想增量修改文件,需要具備隨機寫入的能力:
Java NIO會是不錯的選擇,甚至,可以用mmap(內(nèi)存映射文件)。
mmap還有一些優(yōu)點:
1、直接操作內(nèi)核空間:避免內(nèi)核空間和用戶空間之間的數(shù)據(jù)拷貝;
2、自動定時刷新:避免頻繁的磁盤操作;
3、進程退出時刷新:系統(tǒng)層面的調(diào)用,不用擔心進程退出導致數(shù)據(jù)丟失。

如果要說不足,就是在映射文件階段比常規(guī)的IO的打開文件消耗更多。
所以API中建議大文件時采用mmap,小文件的讀寫用建議用常規(guī)IO;而網(wǎng)上介紹mmap也多是舉例大文件的拷貝。
事實上如果小文件是高頻寫入的話,也是值得一試的,
比如騰訊的日志組件 xlog 和 存儲組件 MMKV, 都用了mmap。

mmap的寫入方式其實類似于異步寫入,只是不需要自己開線程去刷數(shù)據(jù)到磁盤,而是由操作系統(tǒng)去調(diào)度。
這樣的方式有利有弊:好處是寫入快,減少磁盤損耗;
缺點就是,和SharePreference的apply一樣,不具備原子性,沒有入原子性,一致性就得不到保障。
比如,數(shù)據(jù)寫入內(nèi)存后,在數(shù)據(jù)刷新到磁盤之前,發(fā)生系統(tǒng)級錯誤(如系統(tǒng)崩潰)或設備異常(如斷電,磁盤損壞等),此時會丟失數(shù)據(jù);
如果寫入內(nèi)存后,刷入磁盤前,有別的代碼讀取了剛才寫入的內(nèi)存,就有可能導致數(shù)據(jù)不一致。

不過,通常情況下,發(fā)生系統(tǒng)級錯誤和設備異常的概率較低,所以還是比較可靠的。

方案二、全量&同步

對于一些核心數(shù)據(jù),我們希望用更可靠的方式存儲。
怎么定義可靠呢?
首先原子性是要有的,所以只能同步寫入了;
然后是可用性和完整性:
程序異常,系統(tǒng)異常,或者硬件故障等都可能導致數(shù)據(jù)丟失或者錯誤;
需添加一些機制確保異常和故障發(fā)生時數(shù)據(jù)仍然完整可用。

查看SharedPreference源碼,其容錯策略是,
寫入前重命名主文件為備份文件的名字,成功寫入則刪除備份文件,
而打開文件階段,如果發(fā)現(xiàn)有備份文件,將備份文件重命名為主文件的名字。
從而,假如寫入數(shù)據(jù)時發(fā)生故障,再次重啟APP時可以從備份文件中恢復數(shù)據(jù)。
這樣的容錯策略,總體來說是不錯的方案,能保證大多數(shù)據(jù)情況下的數(shù)據(jù)可用性。
我們沒有采用該方案,主要是考慮該方案操作相對復雜,以及其他一些顧慮。

我們采用的策略是:冗余備份+數(shù)據(jù)校驗。

冗余備份

冗余備份來提高數(shù)據(jù)數(shù)據(jù)可用性的思想在很多地方有體現(xiàn),比如 RAID 1 磁盤陣列。
同樣,我們可以通過一份內(nèi)存寫兩個文件,這樣當一個文件失效,還有另外一個文件可用。
比方說一個文件失效的概率時十萬分之一,則兩個文件同時失效的概率是百億分之一。
總之,冗余備份可以大大減少數(shù)據(jù)丟失的概率。
有得必有失,其代價就是雙倍磁盤空間和寫入時間。

不過我們的定位是“輕量級存儲”,如果只存“核心數(shù)據(jù)”,數(shù)據(jù)量不會很大,所以總的來說收益大于代價。
就寫入時間方面,相比SharedPreference而言,重命名和刪除文件也是一種IO,其本質(zhì)是更新文件的“元數(shù)據(jù)”。
寫磁盤以頁(page)為單位,一頁通常為4K。

向文件寫入1個字節(jié)和2497字節(jié),在磁盤寫入階段是等價的(都需要占用4K的字節(jié))。
數(shù)據(jù)量較少時,寫入兩份文件,相比于“重命名->寫數(shù)據(jù)->刪除文件”的操作,區(qū)別不大。

數(shù)據(jù)校驗

數(shù)據(jù)校驗的方法通常是對數(shù)據(jù)進行一些的運算,將運算結(jié)果放在數(shù)據(jù)后;讀取時做同樣運算,然后和之前的結(jié)果對比。
常見的方法有奇偶校驗,CRC, MD5, SHA等。
奇偶校驗多被應用于計算機硬件的錯誤檢測中; 軟件層面,通常是計算散列。
眾多Hash算法中,我們選擇 64bit 的 MurmurHash, 關于MurmurHash可查看筆者的另一篇文章《漫談散列函數(shù)》。

在考慮分組寫入還全量寫入,分組校驗還是全量校驗時,
分組的話,細節(jié)多,代碼復雜,還是選擇全量的方式吧。
也就是,收集所有key|value到buffer, 然后計算hash, 放到數(shù)據(jù)后,一并寫入次磁盤。

魚和熊掌

不同的應用場景有不同的需求。
LightKV同時提供了快速寫入的mmap方式,和更可靠寫入的同步寫入方式。
它們有相同的API,只是存儲機制不一樣。

public abstract class LightKV {
    final SparseArray<Object> mData = new SparseArray<>();
    //......
}

public class AsyncKV extends LightKV {
    private FileChannel mChannel;
    private MappedByteBuffer mBuffer;
    //......
}

public class SyncKV extends LightKV {
    private FileChannel mAChannel;
    private FileChannel mBChannel;
    private ByteBuffer mBuffer;
    //......
}

AsyncKV由于不具備一致性,所以也沒有必要冗余備份了,寫一份就好,以求更高的寫入效率和更少磁盤寫入。
SyncKV由于要做冗余備份,所以需要打開兩個文件,而buffer用同一份即可;
兩者的特點在前面“方案一”和“方案二”中有所闡述了,根據(jù)具體需求靈活使用即可。

2.3 混淆操作

對于用XML來存儲的SharePreferences來說,打開其文件即可一覽所有key-value, 即使開發(fā)者對value進行編碼,key還是可以看到的。
SharePreferences的文件不是存在App下的目錄,在沙盒之中嗎?
無root權限下,對于其他應用(非系統(tǒng)),沙盒確實是不可訪問的;
但是對于APP逆向者(黑色產(chǎn)業(yè)?)來說,SharePreferences文件不過是囊中之物,或可從中一窺APP的關鍵,以助其破解APP。
故此,混淆內(nèi)容文件,或可增加一點破解成本。
對于APP來說,沒有絕對的安全,只是破解成本與收益之間的博弈,這里就不多作展開了。

LightKV由于采用流式存儲,而且key是用int類型,所以不容易看出其文件內(nèi)容;
但是如果value是明文字符串,還是可以看到部分內(nèi)容的,如下圖:


LightKV提供了混淆value(String和byte[]類型)的接口:

    public interface Encoder {
        byte[] encode(byte[] src);
        byte[] decode(byte[] des);
    }

開發(fā)者可以按照自己的規(guī)則實現(xiàn)編碼和解碼。
通過該接口可以做很多擴展:

  • 1、嚴格的加密;
  • 2、數(shù)據(jù)壓縮;
  • 3、內(nèi)容混淆(事實上前二者都有混淆的功能)

混淆后,打開文件,都是亂碼。

值得一提的是,只能對String和byte[]類型的value混淆。
因為基礎類如long, double等,以二進制形式寫入,用文本的形式打開,本就是不好閱讀的,無需再作混淆。

三、使用方法

前面我們看到,SyncKV和AsyncKV都繼承于LightKV, 二者在內(nèi)存中的存儲格式是一致的,都是SparseArray,
所以get方法封裝在LightKV中,然后各自實現(xiàn)put方法。
方法列表如下圖:


和SharePreferences類似,也有contains, remove, clear 和 commit 方法,甚至于,具體用法也很類似:

public class AppData {
    private static final SharedPreferences sp = 
        GlobalConfig.getAppContext().getSharedPreferences("app_data", Context.MODE_PRIVATE);
    
    private static final SharedPreferences.Editor editor = sp.edit();

    private static final String ACCOUNT = "account";
    private static final String TOKEN = "token";

    private static void putString(String key, String value) {
        editor.putString(key, value);
        editor.commit();
    }

    private static String getString(String key) {
        return sp.getString(key, "");
    }
}
public class AppData {
    private static final SyncKV DATA =
            new LightKV.Builder(GlobalConfig.getAppContext(), "app_data")
                    .logger(AppLogger.getInstance())
                    .executor(AsyncTask.THREAD_POOL_EXECUTOR)
                    .encoder(new ConfuseEncoder())
                    .sync();

    public interface Keys {
        int SHOW_COUNT = 1 | DataType.INT;
        int ACCOUNT = 2 | DataType.STRING | DataType.ENCODE;
        int TOKEN = 3 | DataType.STRING | DataType.ENCODE;
    }

    public static SyncKV data() {
        return DATA;
    }

    public static String getString(int key) {
        return DATA.getString(key);
    }

    public static void putString(int key, String value) {
        DATA.putString(key, value);
        DATA.commit();
    }
}

當然,以上只是眾多封裝方法中的一種,具體使用中,不同的開發(fā)者有不同的偏好。

對于LightKV而言,key的定義方法如下:
1、最好一個文件對應一個統(tǒng)一定義key的類,如上面的“Keys”;
2、key的賦值,按類型從1到65534都可以定義,然后和對應的DataType做“|”運算(解析的時候需要據(jù)此判斷類型)。

相對于SharePreferences,LightKV有更多的初始化選項,故而用構(gòu)造者模式來構(gòu)建對象。
下面逐一說明各個參數(shù)和對應的特性。

3.1 內(nèi)容混淆

若需要對value混淆,只需在構(gòu)造LightKV時傳入Encoder,
然后聲明key時和DataType.ENCODE做“|”運算即可。
保存和讀取時,LightKV會將key和DataType.ENCODE做“&”運算,若不為0,則調(diào)用Encoder進行編碼(保存)或解碼(讀取)。

3.2 異步加載

SharePreferences的加載在新創(chuàng)建的的線程中加載的, 在完成加載之前阻塞讀和寫:
LightKV同樣實現(xiàn)了異步加載, 而且可以指定 Executor,當然也可以選擇不異步加載(不傳Executor即可)。
需要提醒的是,雖然提供了異步加載,但是有時候沒有異步加載的效果。
比如對象初始化的同時立即調(diào)用get或者put方法,會阻塞當前線程直到加載完成,這樣和同步加載沒什么區(qū)別。

建議寫法,在進程初始化的時候調(diào)用data(), 以觸發(fā)數(shù)據(jù)的加載:

    fun init(context: Context) {
        // 僅初始化對象,不做get和put
        AppData.data()

        // 其他初始化工作
    }

3.3 錯誤日志

    public interface Logger {
        void e(String tag, Throwable e);
    }

大多數(shù)組件都不能保證運行期不發(fā)生異常,發(fā)生異常時,開發(fā)者通常會把異常信息打印到日志文件(有的還會上傳云端)。
故此,LightKV提供了打印日志接口,傳入實現(xiàn)類即可。

3.4 選擇模式

在Builder的最后,調(diào)用 sync() 和 async() 可分辨創(chuàng)建AsyncKV和SyncKV。
各自的特點前面也交代過了,靈活選取即可。
如果不是存一些十分重要的數(shù)據(jù)(比如帳號信息等),用AsyncKV即可。

3.5 訪問數(shù)據(jù)

寫完初始化參數(shù),定義好key, 編寫 get 和 set方法之后,
就可以訪問數(shù)據(jù)了:

String account = AppData.getString(AppData.Keys.ACCOUNT)
if(TextUtils.isEmpty(account)){
      AppData.putString(AppData.Keys.ACCOUNT, "foo@gmail.com")
}

3.6 Kotlin下的用法

借助Kotlin的委托屬性,筆者拓展了LightKV的API, 提供了更方便的用法。

object AppData : KVData() {
    override val data: LightKV by lazy {
        LightKV.Builder(GlobalConfig.appContext, "app_data")
                .logger(AppLogger)
                .executor(AsyncTask.THREAD_POOL_EXECUTOR)
                .encoder(GzipEncoder)
                .async()
    }

    var showCount by int(1)
    var account by string(2)
    var token by string(3)
    var secret by array(4 or DataType.ENCODE)
}
val account = AppData.account
if (TextUtils.isEmpty(account)) {
   AppData.account = "foo@gmail.com"
}

與Java版的API相比,key的聲明更加簡單,而且可以像訪問變量一樣訪問key對應的value。

四、評測

倉促之間,準備的測試用例可能不是很科學,僅供參考-_-

測試用例中,對支持的7種類型各配置5個key, 共35對key|value。

4.1 存儲空間

存儲方式 文件大小(kb)
AsyncKV 4
SyncKV 1.7
SharePreferences 3.3

AsyncKV由于采用mmap的打開方式,需要映射一塊磁盤空間到內(nèi)存,為了減少碎片,故而一次映射一頁(4K)。
SyncKV由于存儲格式比較緊湊,所以文件大小相比SharePreferences要??;
但是由于SyncKV采用雙備份,所以總大小和SharePreferences差不多。

數(shù)據(jù)量都少于4K時,其實三者相差無幾;
當存儲內(nèi)容變多時,AsyncKV反而會更少占用,因為其存儲格式和SyncKV一樣,但是只用存一份。

4.2 寫入性能

理想中的寫入是各組key|value全寫到內(nèi)存,然后統(tǒng)一調(diào)用一次commit, 這樣寫入是最快的。
然而實際使用中,各組key|value的寫入通常是隨機的,所以下面測試結(jié)果,都是每次put后立即提交。
AsyncKV例外,因為其定位就是減少IO,讓系統(tǒng)內(nèi)核自己去提交更新。

測試機器1:小米 note 1 (2018年5月)

存儲方式 寫入耗時(毫秒)
AsyncKV 2.25
SyncKV 75.34
SharePreferences-apply 6.90
SharePreferences-commit 279.14

測試機器2:華為P30 pro (2020年3月)

存儲方式 寫入耗時(毫秒)
AsyncKV 0.31
SyncKV 8.31
SharePreferences-apply 1.9
SharePreferences-commit 30.81

(新機器寫入速度確實快很多-_-)

AsyncKV 和 SharePreferences-apply 這兩種方式,提交到內(nèi)存后立即返回,所以耗時較少;
SyncKV 和 SharePreferences-commit,都是在當前線程提交內(nèi)存和磁盤,故而耗時較長。
無論是同步寫入還是異步寫入,LightKV都要比SharePreferences快:
在同步寫入方面,SharePreferences-commit耗時比SyncKV多3到4倍;
在異步寫入方面,AsyncKV也比SharePreferences-apply也要快很多。

至于加載性能,筆者比較了華為P30pro和小米Note1的機器,發(fā)現(xiàn)AsyncKV的loading時間在小米note上相對較慢,而在華為P30pro上則相對較快,所以就不貼出數(shù)據(jù)了。
文末有github鏈接,讀者可自行run一下benchmark。

然后就是讀取性能,SharePreferences是從HashMap中讀取,LightKV是從SparseArray中讀取,兩種數(shù)據(jù)結(jié)構(gòu)的優(yōu)點和缺點網(wǎng)上已經(jīng)有很多討論了,這次就不多作比較了。

五、總結(jié)

SharePreferences是Android平臺輕量且方便的key-value存儲組件,然而不少可以改進的地方。
LightKV以SharePreferences為參考,從效率,安全和易用性等方面,提供更好的存儲方式。

六、下載

dependencies {
    implementation 'com.horizon.lightkv:lightkv:1.0.7'
}

項目地址:
https://github.com/BillyWei001/LightKV

參考文章:
http://www.cnblogs.com/mingfeng002/p/5970221.html
https://cloud.tencent.com/developer/article/1066229
https://segmentfault.com/r/1250000007474916?shareId=1210000007474917
http://www.itdecent.cn/p/ad9756fe21c8
http://www.itdecent.cn/p/07664dc4c51a

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

  • 一、MySQL架構(gòu)與歷史 A.并發(fā)控制 1.共享鎖(shared lock,讀鎖):共享的,相互不阻塞的 2.排他...
    ZyBlog閱讀 20,024評論 3 177
  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,568評論 19 139
  • feisky云計算、虛擬化與Linux技術筆記posts - 1014, comments - 298, trac...
    不排版閱讀 4,354評論 0 5
  • 曾經(jīng)有段話是這樣說的:女生努力賺錢的意義就是碰到自己心怡的男生時,可以說我只要愛情,面包我自己有。我們之所以努力,...
    鋒雲(yún)星璇閱讀 630評論 2 1
  • 高溫的季節(jié)即將到來,雞皮膚的你難道還要像往年那樣蒸桑拿般的度過夏天嗎?你以為你長袖長褲把雞皮皮膚遮起來就沒事了嗎?...
    你的昵稱啊3閱讀 412評論 0 0

友情鏈接更多精彩內(nèi)容