开发中,咱们总会需求存储KV 数据,虽然看上去简略,但考虑因素也是许多的,完成手段也就各有差异。今日,咱们就来看看 Android 现在有哪些 KV 库能够供咱们运用,以及其有哪些优缺点。

SharedPreferences

这个是 Android 很早就内置的 KV 存储库了, 不过由于其缺点多,现在除了拿来做源码剖析和数说外,就没人会推荐运用了。其被诟病的点首要有以下几个:

  1. 首次子线程加载,在加载未完结时,主线程读取会堵塞等候加载完结。
  2. 会整个文件加载到内存,并且是 xml 格局。所以假如存储许多 kv,首次加载会很耗时,并且占内存,占存储。
  3. commit 会堵塞写文件,并且是每次写入整个文件,所以一般用 apply
  4. waitToFinish 会堵塞主线程,或许造成 ANR。

Jetpack DataStore

官方终于不想听开发者对 sp 的吐槽了,然后开发了 DataStore 这个新库,比较于 sp, 它有以下几个改进:

  1. 运用协程、Flow 等东西,在 Dispatchers.IO读写,不会堵塞主线程,当然这也导致必须在协程环境调用。
  2. 能够运用 proto 来序列化数据,因此存储体积会小许多
  3. 不需求调用 commit/apply

但其依旧是初始化时读取整个文件,写入时掩盖整个文件,关于许多 kv 的场景依旧不友好。

MMKV

MMKV 是 Tencent 开源的跨平台 kv 存储,其长处是:

  1. 能够跨平台运用,假如有跨平台需求,这是不二之选
  2. 文件增量写入,运用 proto 序列化数据,并且运用了 mmap,所以写入很快。

问:文件增量是一个怎样的逻辑?

答:每次写入一个 kv, 就直接在文件末尾追加这个 kv 的数据。当然,假如屡次写入同一个 key,那么就会在文件中写入多份数据,这是以空间换时间的思路。在首次加载时,会从文件开端读到文件结束,关于重复 key, 后读的就会掩盖前面读的,所以假如有许多重复,这儿就会存在额外耗时,当然,MMKV 会进行数据的重收拾,便是把内存数据重新落盘,这样就去掉了重复 key

MMKV 依旧是会把一切数据一次性加载到内存,所以依旧不能用于许多 kv 的状况。

LevelDB

LevelDB 是 google 开源的,基于 LSM-Tree(Log Structured Merge Trees)

首先咱们来了解下它的作业原理。

其中心结构为:

  1. MemTable: 一个内存 kv 表, 其key 是有序的。
  2. SSTable:将其 MemTable 落地到磁盘,便是 SSTable 了,其 key 是有序的,所以能够二分查找。
  3. WAL: write ahead log, 在写数据时,先写入 WAL, 再写入 MemTable, 首要用于数据恢复,例如假如 MemTable 还没落地就杀死了进程,那么重启时能够用 WAL 恢复出 MemTable

其具体作业流程为:

写数据

  1. WAL
  2. 写入 MemTable
  3. 假如 MemTable 多到一定程度,则落地成一个 SSTable

读数据

  1. 先看 MemTable 里有没有,假如有,则代表是刚写过的,直接回来,假如没有,则进行下一步。
  2. 查找最新落地的 SSTable,假如有,则回来,假如没有,则寻觅下一个 SSTable,知道最后一个 SSTable

当然,假如落地了十分多的 SSTable, 假如最后一个 SSTable 才存在咱们的数据,那查找就会十分耗时,所以这儿就有许多优化。诸如:SSTable 合并,布隆过滤器、Cache等。

综上,LevelDB 的写入很快,读取反而很复杂的库。很适用于写多于读的场景。当然它读取还是很可观的。并且它不会把一切数据读入内存,所以终于是一个能够用于许多 kv 的场景了。

可是,它是一个纯 c++ 库,并不是面向 Android 而生,当然有人把它接入了 Android,例如:hf/leveldb-android,使得 Android 也能运用它,惋惜的是,这个库并没有继续更新了。

并且 LevelDB 会发生 Lock 文件以防止其它进程运用,但假如我强杀进程,这个文件还在,所以 Open 或许会失败,需求再主动删文件。。。

EmoKV

最后,就要来吹一波自己新造的这个轮子了,具体用法能够前往官网检查 造这个轮子的首要原因是,LevelDB 的的确确不更新了,并且关于 Android, 或许依旧是读大于写多一点。所以我思考了下,搞了这个库,趁便实践了下 c++ (写起来真特么苦楚~)。

EmoKV 首要有以下几个结构:

  1. Key 文件: 存储 key 值的文件,选用mmap + 追加写的办法
  2. Value 文件: 存储 value 值的文件,选用mmap + 追加写的办法
  3. Index 文件: 一个文件 HashMap 的完成, 一切 kv 都记录在这儿,每个 kv 固定 20 字节(存储了 flagkeyKey 文件的方位以及 valueValue 文件的方位)。

其操作流程为:

  • 写数据:经过 key 定位 Index 方位,假如 key 不存在,则写 Key 文件,然后写 Value 文件,这两者都是追加写。写完更新 Index 文件信息。
  • 读数据:经过 key 定位 Index 方位,然后经过 Index 信息读取 Value 文件。

由于直接凭借了 mmap, 所以其读写速度都是很快的,并且确保了数据刷入磁盘(当然这对错强制宕机的状况)。并且 mmap 也是在拜访时才真的读取磁盘页,实际内存占用不大,并且能够由系统办理刷回。

当然,由于运用 mmap,所以也有潜在的问题:

  1. 数据太多导致虚拟内存竭尽。当然这还是有难度的,即使是10w个 kvIndex 用量大约为:10w * 20 / 0.75 大约 2.6M 的空间(0.75 为加载因子),Value 以均匀 500 字节的长度,大约 48M 的长度。关于一般使用,这个是满足的了。当然假如在非紧缩,不使用 CRC32 的状况, Index 能够直接存储8字节以下的 value,空间就更可控了。
  2. 假如 key 算出来 hash 抵触很高,由于选用线性勘探法,也会导致读写功能变差。 Index 文件还存在扩容的重量级操作。
  3. 由于 KeyValue 都选用追加写,在屡次重复 kv 写入后,Value 就会存在许多过期数据,所以 EmoKV 依然有重收拾的过程。

当然,由于是个人开发的这个库,就还能聊更多细节的点:

  1. EmoKV 选用写加锁,读没有加锁,而是选用乐观锁 StampedLock 相似的办法。每次写时增加版本号,读取时,先读取当时版本号,假如读取完之后,发现版本号变了,假如改变版本大于1或许且最近一次修改为当时 kv, 则重新读。由于一般读比写快,并且读写抵触概率低,这样更合理
  2. 由于写分了多个过程,所以要考虑写入过程中进程退出的状况:我这儿选用的办法是写入前先备份索引数据,并先写标记位,假如初始化时发现有标记时,就将备份数据还原回去,上一份数据失败。当然,运用者还能够开启 crc32 校验数据。
  3. 关于数据紧缩和 crc32校验,一般的想法都是在 c++ 上完成,不过我嫌写起来困难,就在 kotlin 层调用内置库办法完成了。
  4. 运用 raii 办理内存,所以内存泄漏的或许性很低。

读写功能比照

最后,我简略比照了 LevelDBMMKVEmoKV 的读写速度。(本来计划用 benchmark 的,不过不知为何,我的 win 机一向跑不起来,报 Activity Missing,各种查找都没处理。只得抛弃,用单元测试时间替代。)

个人测试的是 1w 数据的读写。

写入数据

KV 存储那些事儿

单线程读取

KV 存储那些事儿

多线程读取

KV 存储那些事儿

由于并不是真的 benchmark,所以数据具有偶然性,但大体数量级没差。假如你有爱好,也能够依据不同完成计划,扼要剖析下为何数据是这个姿态?

最后,这个是个人第一次 c++ 实战,或许有诸多不正确不完美的地方,欢迎我们来做 Code Review