LightKV-高效能key-value儲存元件

Horizon757發表於2018-06-24

LightKV是基於Java NIO的輕量級,高效能,高可靠的key-value儲存元件。

一、起源

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

二、LightKV原理

2.1、儲存格式

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

value

關於value型別,我們需要支援一些常用的基礎型別:boolean, int, long, float, double, 以及String 和 陣列(byte[])。 尤其是後者,更多的複合型別(比如物件)都可以通過String和陣列轉化。 作為底層的元件,支援最基本的型別可以簡化複雜度。 對於String和byte[], 儲存為二進位制時先存長度,再存內容。

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;
}
複製程式碼

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

記憶體快取

SharePreference相對於ACache,DiskLruCache等多了一層記憶體的儲存,於是他們的定位也就涇渭分明瞭: 後者通常用於儲存大物件或者檔案等,他們只負責提供磁碟儲存,至於讀到記憶體之後如果使用和管理,則不是他們的職責了。 太大的物件會佔用太多的記憶體,而SharePreference是長期持有引用,沒有空間限制和淘汰機制的,因此SharePreference適用於“輕量級儲存”, 而由此所帶來的收益就是讀##取速度很快。 LightKV定位也是“輕量級儲存”,所以也會在記憶體中儲存key-value,只不過這裡用SparseArray來儲存。

2.2、 儲存操作

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

方案一、增量&非同步

增量操作

新增:在尾部追加key|value即可; 刪除:為了避免位元組移動,可以用標記的方法——將key的最高位標記為1; 修改:如果value長度不變,定址到對應的位置,寫入value即可;否則,先“刪除”,再“新增”; GC: 解析檔案內容時記錄刪除的內容的長度,大於設定閾值則清空檔案,做一次全量寫入。

mmap

要想增量修改檔案,需要具備隨機寫入的能力: Java NIO會是不錯的選擇,甚至,可以用mmap(記憶體對映檔案)。 mmap還有一些優點: 1、直接操作核心空間:避免核心空間和使用者空間之前的資料拷貝; 2、自動定時重新整理:避免頻繁的磁碟操作; 3、程式退出時重新整理:系統層面的呼叫,不用擔心程式退出導致資料丟失。

如果要說不足,就是在對映檔案階段比常規的IO的開啟檔案消耗更多。 所以API中建議大檔案時採用mmap,小檔案的讀寫用建議用常規IO;而網上介紹mmap也多時舉例大檔案的拷貝。 事實上如果小檔案是高頻寫入的話,也是值得一試的, 比如騰訊的日誌元件 xlog 和 儲存元件 MMKV, 都用了mmap。

mmap的寫入方式其實類似於非同步寫入,只是不需要自己開執行緒去刷資料到磁碟,而是由作業系統去排程。 這樣的方式有利有弊,好處是寫入快,減少磁碟損耗; 不足之處就是,和SharePreference的apply一樣,不具備原子性,沒有入原子性,一致性就得不到保障。 比如,資料寫入記憶體後,在資料重新整理到磁碟之前,發生系統級錯誤(如系統崩潰)或裝置異常(如斷電,磁碟損壞等),此時會丟失資料; 如果寫入記憶體後,刷入磁碟前,有別的程式碼讀取了剛才寫入的記憶體,就有可能導致資料不一致。 不過,通常情況下,發生系統級錯誤和裝置異常的概率較低,所以還是比較可靠的。

方案二、全量&同步

對於一些核心資料,我們希望用更可靠的方式儲存。 怎麼定義可靠呢? 首先原子性是要有的,所以只能同步寫入了; 然後是可用性和完整性: 程式異常,系統異常,或者硬體故障等都肯能導致資料丟失或者錯誤。

檢視SharedPreference原始碼,其容錯策略是, 寫入前重新命名主檔案為備份檔案的名字,成功寫入則刪除備份檔案, 而開啟檔案階段,如果發現有備份檔案,將備份檔案重新命名為主檔案的名字。 從而,假如寫入資料時發生故障,再次重啟APP時可以從備份檔案中恢復資料。 這樣的容錯策略,總體來說是不錯的方案,能保證大多資料情況下的資料可用性。 我們沒有采用該方案,主要是考慮該方案操作相對複雜,以及其他一些顧慮。

我們採用的策略是:冗餘備份+資料校驗。

冗餘備份

冗餘備份來提高資料資料可用性的思想在很多地方有體現,比如 RAID 1 磁碟陣列。 同樣,我們可以通過一份記憶體寫兩個檔案,這樣當一個檔案失效,還有另外一個檔案可用。 比方說一個檔案失效的概率時十萬分之一,則兩個檔案同時失效的概率是百億分之一。 總之,冗餘備份可以大大減少資料丟失的概率。 有得必有失,其代價就是雙倍磁碟空間和寫入時間。

不過我們的定位是“輕量級儲存”,如果只存“核心資料”,資料量不會很大,所以總的來說收益大於代價。 就寫入時間方面,相比SharedPreference而言,重新命名和刪除檔案也是一種IO,其本質是更新檔案的“後設資料”。 寫磁碟以頁(page)為單位,一頁通常為4K。 向檔案寫入一個位元組和1K位元組,在磁碟寫入階段是等價的(除非這1K跨頁-_-)。 資料量較少時,寫入兩份檔案,相比於“重新命名->寫資料->刪除檔案”的操作,區別不大。

資料校驗

資料校驗的方法通常是對資料進行一些的運算,將運算結果放在資料後;讀取時做同樣運算,然後和之前的結果對比。 常見的方法有奇偶校驗,CRC, MD5, SHA等。 奇偶校驗多被應用於計算機硬體的錯誤檢測中; 軟體層面,通常是計算雜湊。 眾多Hash演算法中,我們選擇 64bit 的 MurmurHash, 關於MurmurHash可檢視筆者的另一篇文章《漫談雜湊函式》。

在考慮分組寫入還全量寫入,分組校驗還是全量校驗時, 分組的話,細節多,程式碼複雜,還是選擇全量的方式吧。 也就是,收集所有key|value到buffer, 然後計算hash, 放到資料後,一併寫入次磁碟。

魚和熊掌

不同的應用場景有不同的需求。 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即可; 兩者的特點在前面“方案一”和“方案二”中有所闡述了,根絕具體需求靈活使用即可。

2.3 內容混淆

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

LightKV由於採用流式儲存,而且key是用int型別,所以不容易看出其檔案內容; 但是如果value是明文字串,還是可以看到部分內容的,如下圖:

LightKV-高效能key-value儲存元件

LightKV提供了混淆value(String和Array型別)的介面:

    public interface Encoder {
        byte[] encode(byte[] src);
        byte[] decode(byte[] des);
    }
複製程式碼

開發者可以按照自己的規則實現編碼和解碼。 LightKV專案中有編碼和解碼的Demo, 感興趣的讀者可以Download下來參考一下。 經過混淆後的結果,開啟檔案看到的都是無關的亂碼:

LightKV-高效能key-value儲存元件

肯能有人會說,這樣的做法殺敵一千,自損八百啊! 沒事,LightKV的toString方法可以檢視檔案的內容:

LightKV-高效能key-value儲存元件

而且,這個“後門”通常在DEBUG時才有效,除非釋出RELEASE時不混淆…… 具體原理,我們後面細細道來,先說下使用方法。

三、使用方法

前面我們看到,SyncKV和AsyncKV都繼承於LightKV, 二者在記憶體中的儲存格式是一致的,都是SparseArray, 所以get方法封裝在LightKV中,然後各自實現put方法。 方法列表如下圖:

LightKV-高效能key-value儲存元件

和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 putInt(String key, int value) {
        editor.putInt(key, value);
        editor.commit();
    }

    private static int getInt(String key) {
        return sp.getInt(key, 0);
    }
}
複製程式碼
public class AppData {
    private static final SyncKV DATA =
            new LightKV.Builder(GlobalConfig.getAppContext(), "app_data")
                    .logger(AppLogger.getInstance())
                    .executor(AsyncTask.THREAD_POOL_EXECUTOR)
                    .keys(Keys.class)
                    .encoder(new ConfuseEncoder())
                    .sync();

    public interface Keys {
        int SHOW_COUNT = 1 | DataType.INT;

        int ACCOUNT = 1 | DataType.STRING | DataType.ENCODE;
        int TOKEN = 2 | DataType.STRING | DataType.ENCODE;
    }

    public static SyncKV data() {
        return DATA;
    }
    
    // ......
    
    public static int getInt(int key) {
        return DATA.getInt(key);
    }

    public static void putInt(int key, int value) {
        DATA.putInt(key, value);
        DATA.commit();
    }
}
複製程式碼

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

對於LightKV而言,key的定義方法如下:

  • 1.最好一個檔案對應一個統一定義key的類,如上面的“Keys”;
    1. key的賦值,按型別從1到65534都可以定義,然後和對應的DataType做“|”運算(解析的時候需要據此判斷型別);
    1. String和byte[]型別,如果需要混淆的話,再與DataType.ENCODE做“|”運算。

相對於SharePreferences,LightKV有更多的初始化選項,故而用構造者模式來構建物件。 下面逐一分析各個引數和對應的特性:

3.1 錯誤日誌

public interface Logger {
    void e(String tag, Throwable e);
}
複製程式碼

大多陣列件都不能保證執行期不發生異常,發生異常時,開發者通常會把異常資訊列印到日誌檔案(有的還會上傳雲端)。 故此,LightKV提供了列印日誌介面,傳入實現類即可。

3.2 非同步載入

SharePreferences的載入是在新建立的的執行緒中進行的, 在完成載入之前,阻塞讀和寫: LightKV也實現了非同步載入和阻塞讀寫,但是實現相對簡單(至少筆者是這麼認為的-_-), 還有就是,使用者可以使用自己的執行緒池,也可以選擇不非同步載入(不傳Executor即可)。

private final Object mWaiter = new Object();

LightKV(Executor executor,  /*省略其他引數*/) {
    if (executor == null) {
        getData(path, keyDefineClass);
    } else {
        synchronized (mWaiter) {
            executor.execute(new Runnable() {
                @Override
                public void run() {
                    getData(path, keyDefineClass);
                }
            });
            try {
                // wait util loadData() get the object lock(lock of LightKV)
                mWaiter.wait();
            } catch (InterruptedException ignore) {
            }
        }
    }
}

private synchronized void getData(String path, Class keyDefineClass) {
    // we got the object lock, notify waiter to continue the procedure on that thread
    synchronized (mWaiter) {
        mWaiter.notify();
    }
    // loading data
}

public synchronized String getString(int key) {
    // return value
}
複製程式碼

值得提醒的是,雖然提供了非同步載入,但是有時候沒有非同步載入的效果, 比如物件初始化的同時立即呼叫get或者put方法(會阻塞當前執行緒直到載入完成)。

建議寫法:

    fun inti(context: Context) {
        // 僅初始化物件,以觸發載入,不做get和put
        AppData.data()

        // 其他初始化工作
    }
複製程式碼

3.3 混淆配置

上一章最後一節講到內容混淆,最後提到DEBUG時可以通過toString方法檢視檔案內容。 其實漏講了一些,比如說,構建LightKV時如果不傳入 Keys.class , 則無法檢視。 其原理為,構建LightKV時,如果傳入Keys.class, 可以通過反射獲取各個key的定義值以及名字, 這樣toString時就可以據此列印檔案內容了。

private SparseArray<String> mKeyArray;

private void getKeyArray(Class keyDefineClass) {
	if (keyDefineClass == null) {
		return;
	}
	Field[] fields = keyDefineClass.getDeclaredFields();
	mKeyArray = new SparseArray<>(fields.length);
	for (Field field : fields) {
		if (field.getType() == int.class
				&& Modifier.isPublic(field.getModifiers())
				&& Modifier.isFinal(field.getModifiers())) {
			mKeyArray.put(field.getInt(keyDefineClass), field.getName());
		}
	}
}
複製程式碼

在RELEASE版本,開發者通常會做程式碼混淆。 通常情況下,如果不做特別的混淆配置,常量會內聯到呼叫處,定義的地方會被“優化”掉。

public class a
{
  // ......
  // 原來的 Keys
  public static abstract interface a {}
}
複製程式碼

因此,RELEASE版本呼叫toString看不到檔案的內容。 不過,如果APP被除錯了,還是可以從SparseArray中看到內容的……

傳入Keys.class還有另一個用途:

LightKV-高效能key-value儲存元件
隨著程式碼的演進,有的key-value或許再也用不到了,但資料還是存在檔案中的。 有沒有什麼辦法在不用的時候從檔案中也一併移除呢? LightKV支援這種操作。 其原理是,在讀取檔案之前獲取Keys的key的定義值(前面有說到),讀取檔案的key|value時,有對應的key定義則存如SparseArray, 否則忽略。 等下一次重新整理資料,沒有定義的資料也就被刷掉了。 不過這個操作的前提是Keys的定義沒有被“優化”掉-_-

如果需要該特性,可以配置“允許混淆”,但是保持成員:

-keepclassmembers,allowobfuscation interface **.Keys {*;}
-keepclassmembers,allowobfuscation interface **$Keys {*;}
複製程式碼

如此,最終生成沒有被“優化”但被混淆的“Keys”:

  public static abstract interface a
  {
    public static final int a = 131073;
    public static final int b = 1441793;
    public static final int c = 1441794;
    public static final int d = 1507329;
  }
複製程式碼

如果你不需要檢視全部內容,也不在意空間佔用,可以不傳Keys.class,也無需額外的混淆配置。

四、評測

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

測試用例中,對支援的7種型別各配置5個key, 共35對key|value。 測試機器:小米 note 1, 16G儲存

儲存空間

儲存方式 檔案大小(kb)
AsyncKV 4
SyncKV 1.7
SharePreferences 3.3

AsyncKV由於採用mmap的開啟方式,需要對映一塊磁碟空間到記憶體,為了減少碎片,故而一次對映一頁(4K)。 SyncKV由於儲存格式比較緊湊,所以檔案大小相比SharePreferences要小; 但是由於SyncKV採用雙備份,所以總大小和SharePreferences差不多。

資料量都少於4K時,其實三者相差無幾; 當儲存內容變多時,AsyncKV反而會更少佔用,因為其儲存格式和SyncKV一樣,但是隻用存一份。

載入效能

儲存方式 載入耗時(毫秒)
AsyncKV 10.46
SyncKV 1.56
SharePreferences 4.99

前面也提到,mmap在開啟檔案比常規開啟檔案消耗更多,故而API文件中建議大檔案時才用mmap。 測試結果確實顯示mmap在讀取階段確實比較耗時,但是,如果開啟後頻繁寫入,那就體現出mmap的優勢了。

寫入效能

理想中的寫入是各組key|value全寫到記憶體,然後統一呼叫一次commit, 這樣寫入是最快的。 然而實際使用中,各組key|value的寫入通常是隨機的,所以下面測試結果,都是每次put後立即提交。 AsyncKV例外,因為其定位就是減少IO,讓系統核心自己去提交更新。

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

AsyncKV 和 SharePreferences-apply 這兩種方式,提交到記憶體後立即返回,所以耗時較少; SyncKV 和 SharePreferences-commit,都是在當前執行緒提交記憶體和磁碟,故而耗時較長。 無論是同步寫入還是非同步寫入,LightKV都要比SharePreferences快。

五、總結

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

六、下載

repositories {
    jcenter()
}

dependencies {
    implementation 'com.horizon.lightkv:lightkv:1.0.1'
}
複製程式碼

專案地址:LightKV

參考文章:

http://www.cnblogs.com/mingfeng002/p/5970221.html https://blog.csdn.net/u010335298/article/details/72884644 https://cloud.tencent.com/developer/article/1066229 https://segmentfault.com/r/1250000007474916?shareId=1210000007474917 https://zhuanlan.zhihu.com/p/26697193 https://sites.google.com/site/murmurhash

相關文章