自定义一款高性能的Key-Value存储组件

640?wx_fmt=png


今日科技快讯


近日,京东物流回应将取消快递员的底薪问题,称原来的薪酬结构已经不适应新的模式,无法对绩效优异的员工体现出足够的激励;同时京东物流员工公积金缴存比例仍处于行业中位水准之上。另有网友爆料,京东还将降低快递员的公积金系数,从12%降到7%,下调了5%。


作者简介


本篇文章来自 Horizon757 的投稿,和大家分享了LightKV-高性能key-value存储组件的相关知识,希望对大家有所帮助!

Horizon757的博客地址:

https://www.jianshu.com/u/11d3f06afbcd


起源


LightKV是基于Java NIO的轻量级,高性能,高可靠的key-value存储组件。

Android平台常见的本地存储方式, SDK内置的有SQLite,SharedPreference等,开源组件有ACache, DiskLruCahce等,有各自的特点和适用性。

SharedPreference以其天然的 key-value API,二级存储(内存HashMap, 磁盘xml文件)等特点,为广大开发者所青睐。

然而,任何工具都是有适用性的,参见文章《不要滥用SharedPreference》,地址如下所示:

http://www.cnblogs.com/mingfeng002/p/5970221.html

当然,其中一些缺点是其定位决定的,比如说不适合存储大的key-value, 这个无可厚非;不过有一些地方可以改进,比如存储格式:xml解析速度慢,空间占用大,特殊字符需要转义等特点,对于高频变化的存储,实非良策。

故此,有必要写一个改良版的key-value存储组件。


LightKV原理


存储格式

我们希望文件可以流式解析,对于简单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;
}

int的低16位用来定义key,17-19位用来定义类型,20位预留,21位标记是否编码(后面会讲到),32位(最高位)标记是否有效:为1时为无效,读取时会跳过。

  • 内存缓存

SharePreference相对于ACache,DiskLruCache等多了一层内存的存储,于是他们的定位也就泾渭分明了;后者通常用于存储大对象或者文件等,他们只负责提供磁盘存储,至于读到内存之后如果使用和管理,则不是他们的职责了。太大的对象会占用太多的内存,而SharePreference是长期持有引用,没有空间限制和淘汰机制的,因此SharePreference适用于“轻量级存储”, 而由此所带来的收益就是读取速度很快。

LightKV定位也是“轻量级存储”,所以也会在内存中存储key-value,只不过这里用SparseArray来存储。

存储操作

上面提到, 存储格式是简单地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。

640?wx_fmt=png

640?wx_fmt=png

向文件写入1个字节和2497字节,在磁盘写入阶段是等价的(都需要占用4K的字节)。数据量较少时,写入两份文件,相比于“重命名->写数据->删除文件”的操作,区别不大。

  • 数据校验

数据校验的方法通常是对数据进行一些的运算,将运算结果放在数据后;读取时做同样运算,然后和之前的结果对比。

常见的方法有奇偶校验,CRC, MD5, SHA等。奇偶校验多被应用于计算机硬件的错误检测中; 软件层面,通常是计算散列。众多Hash算法中,我们选择 64bit 的 MurmurHash, 关于MurmurHash可查看笔者的另一篇文章《漫谈散列函数》,文章地址如下所示:

https://www.jianshu.com/p/ad9756fe21c8

在考虑分组写入还全量写入,分组校验还是全量校验时,分组的话,细节多,代码复杂,还是选择全量的方式吧。也就是,收集所有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即可;两者的特点在前面“方案一”和“方案二”中有所阐述了,根绝具体需求灵活使用即可。

混淆操作

对于用XML来存储的SharePreferences来说,打开其文件即可一览所有key-value, 即使开发者对value进行编码,key还是可以看到的。SharePreferences的文件不是存在App下的目录,在沙盒之中吗?无root权限下,对于其他应用(非系统),沙盒确实是不可访问的;但是对于APP逆向者(黑色产业?)来说,SharePreferences文件不过是囊中之物,或可从中一窥APP的关键,以助其破解APP。

故此,混淆内容文件,或可增加一点破解成本。对于APP来说,没有绝对的安全,只是破解成本与收益之间的博弈,这里就不多作展开了。

LightKV由于采用流式存储,而且key是用int类型,所以不容易看出其文件内容;
但是如果value是明文字符串,还是可以看到部分内容的,如下图:

640?wx_fmt=other

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

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

开发者可以按照自己的规则实现编码和解码。通过该接口可以做很多扩展:

  1. 严格的加密

  2. 数据压缩

  3. 内容混淆(事实上前二者都有混淆的功能)

混淆后,打开文件,都是乱码。

640?wx_fmt=other

值得一提的是,只能对String和byte[]类型的value混淆。因为基础类如long, double等,以二进制形式写入,用文本的形式打开,本就是不好阅读的,无需再作混淆。


使用方法


前面我们看到,SyncKV和AsyncKV都继承于LightKV, 二者在内存中的存储格式是一致的,都是SparseArray,所以get方法封装在LightKV中,然后各自实现put方法。

方法列表如下图:

640?wx_fmt=other

和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();
    }
}

当然,以上只是众多封装方法中的一种,具体使用中,不同的开发者有不同的偏好。

对于LightKV而言,key的定义方法如下:

  1. 最好一个文件对应一个统一定义key的类,如上面的“Keys”

  2. key的赋值,按类型从1到65534都可以定义,然后和对应的DataType做“|”运算(解析的时候需要据此判断类型)

相对于SharePreferences,LightKV有更多的初始化选项,故而用构造者模式来构建对象。下面逐一说明各个参数和对应的特性。

内容混淆

若需要对value混淆,只需在构造LightKV时传入Encoder,然后声明key时和DataType.ENCODE做“|”运算即可。保存和读取时,LightKV会将key和DataType.ENCODE做“&”运算,若不为0,则调用Encoder进行编码(保存)或解码(读取)。

异步加载

SharePreferences的加载在新创建的的线程中加载的, 在完成加载之前阻塞读和写:LightKV同样实现了异步加载, 而且可以指定 Executor,当然也可以选择不异步加载(不传Executor即可)。需要提醒的是,虽然提供了异步加载,但是有时候没有异步加载的效果。比如对象初始化的同时立即调用get或者put方法,会阻塞当前线程直到加载完成,这样和同步加载没什么区别。建议写法,在进程初始化的时候调用data(), 以触发数据的加载:

fun inti(context: Context) {
        // 仅初始化对象,不做get和put
        AppData.data()

        // 其他初始化工作
 }

错误日志

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

大多数组件都不能保证运行期不发生异常,发生异常时,开发者通常会把异常信息打印到日志文件(有的还会上传云端)。故此,LightKV提供了打印日志接口,传入实现类即可。

选择模式

在Builder的最后,调用 sync() 和 async() 可分辨创建AsyncKV和SyncKV。各自的特点前面也交代过了,灵活选取即可。如果不是存一些十分重要的数据(比如帐号信息等),用AsyncKV即可。

访问数据

写完初始化参数,定义好key,编写 get 和 set方法之后,就可以访问数据了:

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

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。

测试机器:小米 note 1, 16G存储

存储空间

存储方式文件大小(kb)
AsyncKV4
SyncKV1.7
SharePreferences3.3

AsyncKV由于采用mmap的打开方式,需要映射一块磁盘空间到内存,为了减少碎片,故而一次映射一页(4K)。SyncKV由于存储格式比较紧凑,所以文件大小相比SharePreferences要小;但是由于SyncKV采用双备份,所以总大小和SharePreferences差不多。数据量都少于4K时,其实三者相差无几;当存储内容变多时,AsyncKV反而会更少占用,因为其存储格式和SyncKV一样,但是只用存一份。

加载性能

存储方式加载耗时(毫秒)
AsyncKV10.46
SyncKV1.56
SharePreferences4.99

前面也提到,mmap在打开文件比常规打开文件消耗更多,故而API文档中建议大文件时才用mmap。测试结果确实显示mmap在读取阶段确实比较耗时,但是,如果打开后频繁写入,那就体现出mmap的优势了。

写入性能

理想中的写入是各组key|value全写到内存,然后统一调用一次commit, 这样写入是最快的。然而实际使用中,各组key|value的写入通常是随机的,所以下面测试结果,都是每次put后立即提交。AsyncKV例外,因为其定位就是减少IO,让系统内核自己去提交更新。

存储方式写入耗时(毫秒)
AsyncKV2.25
SyncKV75.34
SharePreferences-apply6.90
SharePreferences-commit279.14

AsyncKV 和 SharePreferences-apply 这两种方式,提交到内存后立即返回,所以耗时较少;SyncKV 和 SharePreferences-commit,都是在当前线程提交内存和磁盘,故而耗时较长。无论是同步写入还是异步写入,LightKV都要比SharePreferences快。


总结


SharePreferences是Android平台轻量且方便的key-value存储组件,然而不少可以改进的地方。LightKV以SharePreferences为参考,从效率,安全和易用性等方面,提供更好的存储方式。

下载

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

项目地址

https://github.com/No89757/LightKV


推荐阅读:

让你直呼666的仿Excel表格效果

优雅地实现一个高效、异步数据实时刷新的列表

Gradle妙用,统一化自动依赖管理


欢迎关注我的公众号,学习技术或投稿

640.png?

640?wx_fmt=jpeg

长按上图,识别图中二维码即可关注

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值