一、前言
KV存储无论对于客户端还是服务端都是重要的构件。
对于Android客户端而言,最常见的莫过于SDK提供的SharePreferences(以下简称SP),但其低效率和ANR问题饱受诟病。
官方后来又推出了基于Kotlin的DataStore,不过测试下来发现写入效率很低。
微信开源了MMKV,写入速度比前者高不少,但是读取相对较慢,同时也存在其他一些缺点。
1.1 SP的不足
关于SP的缺点网上有不少讨论,这里主要提两个点:
- 保存速度较慢
SP用内存层用HashMap保存,磁盘层则是用的XML文件保存。
每次更改,都需要将整个HashMap序列化为XML格式的报文,然后整个写入文件。
这也是SP在数据量较大时保存速度慢的原因之一。
- 可能会导致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。
SP这样设计的原因,是想尽量确保在会话结束(Activity destroy)前保存数据,但由此引出的问题也不容忽视。
值得注意的是,QueuedWork中,负责执行任务的是一个HandlerThread
单例;
也就是说,整个进程的SP,包括第三方库的SP和自身的SP,都由一个线程串行执行保存任务。
具体代码见:QueuedWork.java#106
1.2 MMKV的不足
- 没有类型信息,不支持getAll
MMKV的存储用类似于Protobuf的编码方式,只存储key和value本身,没有存类型信息(Protobuf用tag标记字段,信息更少)。
由于没有记录类型信息,MMKV无法自动反序列化,也就无法实现getAll接口。
没有getAll,在需要遍历所有key-value的时候(比如迁移数据)就比较棘手了。 - 读取相对较慢
SP在加载的时候已经将value反序列化存在HashMap中了,读取的时候索引到之后就能直接引用了。
而MMKV每次读取时都需要重新解码,除了解码时间上的消耗之外,还需要每次都创建新的对象,增加GC负担。 - 需要引入so, 增加包体积
引入MMKV需要增加的体积还是不少的,且不说jar包和aidl文件,光是一个arm64-v8a的so就有四百多K。
虽然说现在APP体积都不小,但毕竟增加体积对打包、分发和安装时间都多少有些影响。
- 文件只增不减
MMKV的扩容策略还是比较激进的,而且扩容之后不会主动trim size。
比方说,假如有一个大value,让其扩容至1M,后面删除该value,哪怕有效内容只剩几K,文件大小还是保持在1M。 - 可能会丢失数据
前面的问题总的来说都不是什么“要紧”的问题,但是这个丢失数据确实是硬伤。
MMKV官方有这么一段表述:通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。
这个表述容易让人误以为用mmap写入就不会丢数据。
如果数据完整地写入到内存块,即使进程崩溃,系统也会将buffer刷入磁盘(如果不发生系统崩溃和设备断电等情况的话);
但需要注意,是在“完整写入”的前提下,才有“不必担心crash导致数据丢失“。
如果数据写一半的时候进程崩溃或者被杀死,然后系统会将已写入的部分刷入磁盘,再次打开时文件可能就不完整了。
例如,MMKV在剩余空间不足时会重整文件,如果这期间进程中断,数据可能会不完整。
关于这个情况,在githun上有讨论:github.com/Tencent/MMK…
这个过程是比较容易复现的,下面是其中一种复现路径:
- 新增和删除若干key-value。
- 比方新增[string, double, float, long, int ...] 。
- 然后删除若干key-value, 比方说删除其中的 double 和 long。
得到数据如下:
-
插入一个大字符串,触发扩容,扩容前会触发文件整理。
-
断点打在执行整理数据的循环中,执行一部分memmove, 然后在手机上杀死进程。
比如上面增删的case, 删除了double和long之后,有效的数据不连续,需要依次移动float, int...
比方在刚移动了float之后进程终止,后面的数据都没来得及整理,再次打开后自然就CRC校验不通过了。
- 再次打开APP,数据丢失
注意,这不是丢失更新而已,而是整个文件所有key-value都丢失了。
也就是,且不考虑系统崩溃的情况,进程中止也可能会导致MMKV丢数据。
而进程中止(进程异常崩溃,用户主动杀死进程,系统回收进程)是很普遍的情况。
相比之下,SP虽然低效,但至少有相应的机制确保数据完整性,顶多可能会丢失最新的update。
1.3 更好的方案
总的来说,Android SDK迭代这么多年了,始终没有拿出让人满意的KV存储方案;
而开源项目中关于客户端KV存储的更是凤毛麟角,且差强人意。
目前流行的方案中,要么效率不高、有可能ANR,要么有可能丢失数据。
鱼和熊掌,不可兼得?
我全都要!
在研究了各KV存储方案的原理和代码,以及总结了之前的经验之后,笔者实现了一个高效且可靠的版本,且将其命名为: FastKV。
二、FastKV
2.1 特性
FastKV有以下特点:
- 读写速度快
- 二进制编码,编码后的体积相对XML等文本编码要小很多;
- 增量编码:FastKV记录了各个key-value相对文件的偏移量, 从而在更新数据时可以直接在指定的位置写入数据。
- 默认用mmap的方式记录数据,更新数据时直接写入到内存即可,没有IO阻塞。
- 对超大字符串和大数组做特殊处理,另起文件写入,不影响主文件的加载和更新。
- 支持多种写入模式
- 除了mmap这种非阻塞的写入方式,FastKV也支持常规的阻塞式写入方式, 并且支持同步阻塞和异步阻塞(分别类似于SharePreferences的commit和apply)。
- 支持多种类型
- 支持常用的boolean/int/float/long/double/String等基础类型。
- 支持ByteArray (byte[])。
- 支持存储自定义对象。
- 内置Set的编码器 (兼容SharePreferences)。
- 支持数据加密
- 支持注入加密解密的实现,在数据写入磁盘之前执行加密。
- 解密处理发生在数据解析阶段,解析完成后,数据是缓存的(用HashMap缓存),
所以加解密会稍微增加写入和解析的时间,不会增加索引数据的时间。
- 支持多进程
- 项目提供了支持多进程的存储类(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:
如果是基础类型(boolean/int/float/long/double), 直接编码(little-end);
如果是可变长类型(byte[],String,自定义对象),先记录长度,再记录内容。
“内容”部分:
1. byte[]:无需编码;
2. String:用UTF-8编码,保存时String->byte[],解码时byte[]->String;
3. 自定义对象:实现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
编码对象涉及序列化/反序列化。
这里推荐笔者的另外一个框架:github.com/BillyWei01/…
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' ... 等方法,并在其中做适配处理。
可参考:FooKV
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
KVData中包含了基础类型,对象类型,枚举类型,Map等的封装,具体可参考项目中的Demo。
三、 性能测试
- 测试数据:搜集APP中的SharePreferenses汇总的部份key-value数据(经过随机混淆)得到总共六百多个key-value。
分别截取其中一部分,构造正态分布的输入序列,进行多次测试。 - 测试机型:华为P30 Pro
- 测试代码:Benchmark
测试结果如下,第一行为kv数量,第一列为读写方式。
更新:
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 | 4 | 2 | 1 | 1 | 1 |
DataStore | 25 | 6 | 2 | 1 | 1 | 2 |
SQLiteKV | 93 | 189 | 277 | 407 | 728 | 1032 |
SP-apply | 0 | 0 | 1 | 1 | 2 | 3 |
MMKV | 0 | 1 | 3 | 3 | 8 | 9 |
FastKV | 0 | 1 | 1 | 3 | 3 | 1 |
每次执行Benchmark获取到的结果有所浮动,尤其是APP启动后执行多次,部分KV会变快(JIT优化)。
以上数据是取APP冷启动后第一次Benchmark的数据。
四、结语
本文探讨了当下Android平台的各类KV存储方式,提出并实现了一种新的存储组件,着重解决了KV存储的效率和数据可靠性问题。
目前代码已上传Github: github.com/BillyWei01/…