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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容