MMKV 高性能的数据存取框架解读

目标

了解MMKV

MMKV的基本应用

MMKV的原理概念

多进程设计思想

性能对比

源码解读

简介

MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。

官方文档:https://github.com/Tencent/MMKV/blob/master/README_CN.md

项目地址:https://github.com/Tencent/MMKV

mmap

简单解释(仅供参考)

把文件描述符fd(部分硬件资源外存统一描述符)映射到虚拟空间中,所以能够实现进程间的通信、数据存取。

映射流程(仅供参考)

1、用户进程调用内存映射函数库mmap,当前进程在虚拟地址空间中,寻找一段空闲的满足要求的虚拟地址

2、此时内核收到相关请求后会调用内核的mmap函数,注意,不同于用户空间库函数。内核mmap函数通过虚拟文件系统定位到文件磁盘物理地址,既实现了文件地址和虚拟地址区域的映射关系。 此时,这片虚拟地址并没有任何数据关联到主存中。

注意,前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

3、进程的读或写操作访问虚拟地址空间这一段映射地址,现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页中断。

4、由于引发了缺页中断,内核则调用nopage函数把所缺的页从磁盘装入到主存中

5、之后用户进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。

应用

Linux进程的创建

Android Binder

微信MMKV组件

美团Logan

参考文章

ProtoBuf

简介

protocol buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等。

更多内容、实际应用可参考官方文档。

官方文档:https://developers.google.com/protocol-buffers/docs/overview

特性

语言无关、平台无关:即 ProtoBuf 支持 Java、C++、Python 等多种语言,支持多个平台

高效:即比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单

扩展性、兼容性好:你可以更新数据结构,而不影响和破坏原有的旧程序

数据结构

时间效率对比:

数据格式 1000条数据 5000条数据
Protobuf 195ms 647ms
Json 515ms 2293ms

空间效率对比:

数据格式 5000条数据
Protobuf 22MB
Json 29MB

参考文章

简单使用

MMKV 的使用非常简单,所有变更立马生效,无需调用 syncapply

依赖

初始化

配置 MMKV 根目录

在 App 启动时初始化 MMKV,设定 MMKV 的根目录(files/mmkv/),例如在 Application 里:

其他初始化的方法

CRUD 操作

MMKV 提供一个全局的实例,可以直接使用:

删除 & 查询

区分存储

使用MMKV.mmkvWithID即可创建不同的存储区域的MMKV实例。

支持的数据类型
  • 支持以下 Java 语言基础类型:

    • boolean、int、long、float、double、byte[]
  • 支持以下 Java 类和容器:

    • String、Set<String>
    • 任何实现了Parcelable的类型
SharedPreferences 迁移
  • MMKV 提供了 importFromSharedPreferences() 函数,可以比较方便地迁移数据过来

  • MKV 还额外实现了一遍 SharedPreferencesSharedPreferences.Editor 这两个 interface,在迁移的时候只需两三行代码即可,其他 CRUD 操作代码都不用改。

进阶使用

日志

日志切面AOP思想

MMKV 默认将日志打印到 logcat,不便于对线上问题进行定位和解决。你可以在 App 启动时接收转发 MMKV 的日志。实现MMKVHandler接口,添加类似下面的代码:

如果你不希望 MMKV 打印日志,你可以关掉它(虽然我们强烈不建议你这么做)。
注意:除非有非常强烈的证据表明MMKV的日志拖慢了App的速度,你不应该关掉日志。没有日志,日后万一用户有问题,将无法跟进。

加密

MMKV 默认明文存储所有 key-value,依赖 Android 系统的沙盒机制保证文件加密。如果你担心信息泄露,你可以选择加密 MMKV。

你可以更改密钥,也可以将一个加密 MMKV 改成明文,或者反过来。

自定义 library loader

一些 Android 设备(API level 19)在安装/更新 APK 时可能出错, 导致 libmmkv.so 找不到。然后就会遇到 java.lang.UnsatisfiedLinkError 之类的 crash。有个开源库 ReLinker 专门解决这个问题,你可以用它来加载 MMKV:

Relinker简介:

本地库加载框架,github1000+的star

原理:

尝试使用系统原生方式去加载so,如果加载失败,Relinker会尝试从apk中拷贝so到App沙箱目录下,然后再去尝试加载so。最终,我们可以使用 ReLinker.loadLibrary(context, “mylibrary”) 来加载本地库。

Native Buffer

当从 MMKV 取一个 String or byte[]的时候,会有一次从 native 到 JVM 的内存拷贝。如果这个值立即传递到另一个 native 库(JNI),又会有一次从 JVM 到 native 的内存拷贝。当这个值比较大的时候,整个过程会非常浪费。Native Buffer 就是为了解决这个问题。
Native Buffer 是由 native 创建的内存缓冲区,在 Java 里封装成 NativeBuffer 类型,可以透明传递到另一个 native 库进行访问处理。整个过程避免了先拷内存到 JVM 又从 JVM 拷回来导致的浪费。示例代码:

跨进程通信的实现

本质:共享MMKV实例化信息完成对象的伪复制

  • 通信的数据对象

    该类MMKV内部已经实现,传递进程A的mmkv信息给B进程,B进程新建MMKV实例,B就可以通过MMKV实例来完成数据的操作

  • Aidl文件,需要手动创建该文件

    Aidl定义了跨进程通信的方法细则,这里只需要一个get方法,返回ParcelableMMKV通信实体。

  • 服务端

    服务端Service

  • 客户端

    onServiceConnected连接之后

原理

内存准备

通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。

数据组织

数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。考虑到我们要提供的是通用 kv 组件,key 可以限定是 string 字符串类型,value 则多种多样(int/bool/double 等)。要做到通用的话,考虑将 value 通过 protobuf 协议序列化成统一的内存块(buffer),然后就可以将这些 KV 对象序列化到内存中。

写入优化

标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力:将增量 kv 对象序列化后,直接 append 到内存末尾;这样同一个 key 会有新旧若干份数据,最新的数据在最后;那么只需在程序启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证数据是最新有效的。

空间增长

使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。例如同一个 key 不断更新的话,是可能耗尽几百 M 甚至上 G 空间,而事实上整个 kv 文件就这一个 key,不到 1k 空间就存得下。这明显是不可取的。我们需要在性能和空间上做个折中:以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重结果;排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。

数据有效性

考虑到文件系统、操作系统都有一定的不稳定性,我们另外增加了 crc 校验,对无效数据进行甄别。在 iOS 微信现网环境上,我们观察到有平均约 70万日次的数据校验不通过。

多进程设计思想

官网地址:https://github.com/Tencent/MMKV/wiki/android_ipc

官网有详细的说明,这里主要分享思想:

CS架构:

IPC CS架构有Binder、Socket等,特点是一个单独进程管理数据,数据同步不易出错,简单好用易上手,缺点是慢。

去中心化:

只需要将文件 mmap 到每个访问进程的内存空间,加上合适的进程锁,再处理好数据的同步,就能够实现多进程并发访问。

性能对比

单进程

读写效率

  mmkv SharedPreferences sqlite
write int 1000 6.5 693.1 774.4
write String 1000 18.9 1003.9 857.3
read int 1000 4.3 1.5 302.9
read String 1000 8.3 1.3 320.7

单进程性能
可见,MMKV 在写入性能上远远超越 SharedPreferences & SQLite,在读取性能上也有相近或超越的表现。

(测试机器是 华为 Mate 20 Pro 128G,Android 10,每组操作重复 1k 次,时间单位是 ms。)

多进程性能

可见,MMKV 无论是在写入性能还是在读取性能,都远远超越 MultiProcessSharedPreferences & SQLite & SQLite。

性能对比: https://github.com/Tencent/MMKV/wiki/android_benchmark_cn

原理上和SharedPreference区别

SharedPreference原理

本质是在本地磁盘记录了一个xml文件,在构造方法中开启一个子线程加载磁盘中的xml文件

SharedPreferencesImpl内部维护Map缓存,所以SharedPreference读的效率很高,但是写得时候都是通过FileOutputStreame文件IO得方式完成数据更新操作。

MMKV

利用mmap完成数据的读写,读写高效。

  SharedPreference MMKV
读写方式 IO mmap
数据格式 XML 总体结构、整型编码、二进制
更新方式 全量更新 增量与全量写入
SharedPreferences注意点
  1. 只要file name相同,拿到的就是同一个SharedPreferencesImpl对象,内部有缓存机制,首次获取才会创建对象。
  2. 在SharedPreferencesImpl构造方法中,会开启子线程把对应的文件key-value全部加载进内存,加载结束后,mLoaded被设置为true。
  3. 调用getXXX方法时,会阻塞等待直到mLoaded为true,也就是getXXX方法是有可能阻塞UI线程的,另外,调用contains和 edit等方法也是。
  4. 写数据时,会先拿到一个EditorImpl对象,然后putXXX,这时只是把数据写入到内存中,最后调用commit或者apply方法,才会真正写入文件。
  5. 不管是commit还是apply方法,第一步都是调用commitToMemory方法生成一个MemoryCommitResult对象,注意这里会先处理clear旧的key-value,再处理新添加的key-value,另外value为this或者null都表示需要被remove掉。
  6. 调用commit方法,就会同步执行写入文件的操作,该方法是耗时操作,不能在主线程中调用,该方法最后会返回成功或失败结果。
  7. 调用apply方法,就会把任务放到QueuedWork的队列中,然后在HandlerThread中执行,然后apply方法会立即返回。但如果是Android8.0之前,这里就是放到QueuedWork的一个单线程中执行了。
  8. 最后是写入文件,会先把原有的文件命名为bak备份文件,然后创建新的文件全量写入,写入成功后,把bak备份文件删除掉。

安全

基于Android的沙盒模式,在内存读写的方式上做了改变,所以不存在应用程序之前的安全问题。

MMKV使用ProtoBuf 编码,另外增加了内部实现的加密模式(AES CFB),相比SharedPrefrence,在文件暴露的情况下MMKV的数据不具有可读性。

在TV中的应用

配置参数较多、需要频繁读写修改参数的场景

可以提高读写耗时,减少SP带来的耗时成本和操作不当引发的ANR

源码解读

初始化

1.当不指定目录的时候,会创建一个app内的/data/data/包名/files/mmkv的目录。所有的文件都保存在里面;

2.加载两个so库,c++_shared以及mmkv, 根据打包配置来选择是否要加载c++_shared

MMKV 的实例化
java层的实例化

defaultMMKV

getDefaultMMKV Native层做好实例化工作返回一个long类型的handle,以这个handler作为Java层MMKV的构造参数

mmkvWithID

与defaultMMKV区别就是多了参数设置

native层实例化

native-bridge.cpp==>getDefaultMMKV

MMKV.cpp==>mmkvWithID 默认的ID为mmkv.default

只要是实例化,最后都是调用mmkvWithID进行实例化。默认的mmkv的id就是mmkv.default

mmkvWithID

将所有的MMKV实例都会保存在之前实例化的g_instanceDic散列表中。其中mmkv每一个id对应一个文件的路径:

  • 相对路径(android中是 data/data/包名/files/mmkv) + / + mmkvID

如果发现对应路径下的mmkv在散列表中已经缓存了,则直接返回。否则就会把相对路径保存下来,传递给MMKV进行实例化,并保存在g_instanceDic散列表中。

MMKV 的构造函数

  • 1.m_mmapID MMKV的ID通过mmapedKVKey创建:

mmkvID就是经过md5后对应缓存文件对应的路径。

  • 2.m_path mmkv 缓存的路径通过mappedKVPathWithID生成

能看到这里是根据当前的mode初始化id,如果不是ashmem匿名共享内存模式进行创建,则会和上面的处理类似。id就是经过md5后对应缓存文件对应的路径。

注意这里mode设置的是MMKV_ASHMEM,也就是ashmem匿名共享内存模式则是如下创建方法:

实际上就是在驱动目录下的一个内存文件地址。

  • 3.m_crcPath 一个.crc文件的路径。这个crc文件实际上用于保存crc数据校验key,避免出现传输异常的数据进行保存了。
  • 4.m_file 一个依据m_path构建的内存文件MemoryFile对象。
  • 5.m_metaFile 一个依据m_crcPath构建的内存文件MemoryFile对象。
  • 6.m_metaInfo 一个MMKVMetaInfo结构体,这个结构体一般是读写的时候,带上的MMKV的版本信息,映射的内存大小,加密crc的key等。
  • 7.m_crypter 默认是一个AESCrypt 对称加密器
  • 8.m_lock ThreadLock线程锁
  • 9.m_fileLock 一个以m_metaFile的fd 文件锁
  • 10.m_sharedProcessLock 类型是InterProcessLock,这是一种文件共享锁
  • 11.m_exclusiveProcessLock 类型是InterProcessLock,这是一种排他锁
  • 12.m_isInterProcess 判断是否打开了多进程模式的标志位,一旦关闭了,所有进程锁都会失效。
Ashmem匿名共享内存

Anonymous Shared Memory-Ashmem

简单理解:

共享内存是Linux自带的一种IPC机制,Android直接使用了该模型,不过做出了自己的改进,进而形成了Android的匿名共享内存(Anonymous Shared Memory-Ashmem)

应用:

APP进程同SurfaceFlinger共用一块内存,如此,就不需要进行数据拷贝,APP端绘制完毕,通知SurfaceFlinger端合成,再输出到硬件进行显示即可

更多文章

多进程MMKV实例化

多进程通信的过程

mmkvWithAshmemFD

encode 写入数据
encodeString

  • 1.encodeDataWithObject 编码压缩内容
  • 2.setDataForKey 保存数据
setDataForKey

保存数据到映射的文件

设置了互斥锁,和线程锁。整个步骤分为两步骤:

  • 1.checkLoadData 保存数据之前,校验已经存储的数据
  • 2.appendDataWithKey 进行数据的保存
appendDataWithKey

判断是否有足够的空间,没有则调用ensureMemorySize进行扩容,实在无法从内存中映射出来,那说明系统没空间了就返回异常。

正常情况下,是往全局缓冲区CodedOutputData 先后在文件内存的末尾写入key和value的数据。并对这部分的数据进行一次加密,最后更新这个存储区域的crc校验码。

这里实际上是调用了CodedOutputData的writeString把数据保存到映射的内存中。

decode MMKV读取数据

MMKV读取数据

大致可以分分为两步:

  • 1.getDataForKey 通过key找缓存的数据
  • 2.decodeString 对获取到的数据进行解码
getDataForKey

由于是一个多进程的组件,因此每一次进行读写之前都需要进行一次checkLoadData的校验。而这个方法从上文可知,通过crc校验码,写回计数,文件长度来判断文件是否发生了变更,是否追加删除数据,从而是否需要重新充内存文件中获取数据缓存到m_dic。

也因此,在getDataForKey方法中,可以直接从m_dic中通过key找value。

decodeString

能看到实际上很简单就是从m_dic找到对应的MMBuffer数据,此时的可以通过CodedInputData对MMBuffer对应的内存块(已经知道内存起始地址,长度)进行解析数据。

总结

MMKV读写是直接读写到mmap文件映射的内存上,绕开了普通读写io需要进入内核,写到磁盘的过程。光是这种级别优化,都可以拉开三个数量级的性能差距。但是也诞生了一个很大的问题,一个进程在32位的机子中有4g的虚拟内存限制,而我们把文件映射到虚拟内存中,如果文件过大虚拟内存就会出现大量的消耗最后出现异常,对于不熟悉Linux的朋友就无法理解这种现象。

有几个关于MMKV使用的注意事项:

  • 1.保证每一个文件存储的数据都比较小,也就说需要把数据根据业务线存储分散。这要就不会把虚拟内存消耗过快。
  • 2.还需要在适当的时候释放一部分内存数据,比如在App中监听onTrimMemory方法,在Java内存吃紧的情况下进行MMKV的trim操作(不准确,我们暂时以此为信号,最好自己监听进程中内存使用情况)。
  • 2.在不需要使用的时候,最好把MMKV给close掉。甚至调用exit方法。

参考文章:https://www.jianshu.com/p/c12290a9a3f7

官方Demo:https://github.com/Tencent/MMKV/tree/master/Android/MMKV

参考链接


发布者

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注