KV-存储之mmkv
在平时的业务中,需要用到轻量级存储业务中的数据(例如设置数据存储),绝大多数时候 Anroid 管法提供的 SharedPreferences 组件就能实现,但针对一些需要高效的场景它就不那么使用了,不适合存储大量数据、多线程操作的不安全性、数据明文不安全性,以及不支持多进程之间的调用等各种问题。
MMKV的诞生就是为了解决以上的问题,本文主要对 MMKV 源码的学习知识点进行一些总结。
核心设计与原理
在官方的开源工程中可以看到如下的一些介绍
MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。从 2015 年中至今在微信上使用,其性能和稳定性经过了时间的验证。
传统I/O与 mmap
mmap 这个是 mmkv 实现的核心,没有 mmap 那么就没有 mmkv。对于 SharedPreferences
的实现来说,每次的数据更新都将操作本地文件,而本地文件的写入是通过传统的I/O实现。要理解两者的实现差异,需要先理解 Linux 用户空间与内核空间设计。
用户空间与内核空间
Linux的进程是相互独立的,一个进程是不能直接操作或者访问别一个进程空间的。每个进程空间还分为用户空间和内核(Kernel)空间,相当于把Kernel和上层的应用程序抽像的隔离开。
用户空间和内核空间,用户空间是用户程序代码运行的地方,内核空间是内核代码运行的地方。为了安全,它们是隔离的,即使用户的程序崩溃了,内核也不受影响。
这里有两个隔离,一个进程间是相互隔离的,二是进程内有用户空间和内核空间的隔离。
进程间,用户空间的数据不可共享,所以用户空间 = 不可共享空间
进程间,内核空间的数据可共享,所以内核空间 = 可共享空间,所以Linux系统的内存通常是MemFree+Cache
所有进程共用1个内核空间。
传统I/O读写流程
常规文件读写操作(调用read/fread等函数)过程如下:
进程发起读写文件请求。
内核通过查找进程文件符表,定位到内核已打开文件集上的文件信息,从而找到此文件的
inode
。inode
在address_space
上查找要请求的文件页是否已经缓存在页缓存中。如果存在,则直接返回这片文件页的内容。如果不存在,则通过
inode
定位到文件磁盘地址,将数据从磁盘复制到页缓存。之后再次发起读页面过程,进而将页缓存中的数据发给用户进程。什么是 inode ?
全称为 index node,既存储文件元信息的区域,中文译名“索引节点”。
包含:文件权限、文件拥有者的UID、文件的大小等等。
总结来说,常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址访问,所以还需要将页缓存中的数据页再次拷贝到用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的访问。
mmap基本概念和原理
内存映射(mmap),就是将文件的磁盘扇区映射到进程的虚拟内存空间的过程,即将一个文件映射到进程的虚拟空间,实现文件磁盘地址和进程虚拟空间中一段虚拟地址的一一对应关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。
由上图可知,进程的虚拟地址空间,由多个虚拟内存区域构成。每个虚拟内存区域都是进程在虚拟地址空间中的一个同质区间,即具有同样特性的连续地址范围。上图中所示的text数据段(代码段)、初始数据段、BSS数据段、堆、栈和内存映射,都是一个独立的虚拟内存区域。内存映射的地址空间处在堆栈之间的空余部分。
linux内核使用 vm_area_struc
t 结构来表示一个独立的虚拟内存区域,由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个 vm_area_struct
结构来分别表示不同类型的虚拟内存区域。各个 vm_area_struct
结构使用链表或者树形结构链接,方便进程快速访问,如下图所示:
vm_area_struct
结构中包含区域起始和终止地址以及其他相关信息。这样,进程对某一虚拟内存区域的任何操作需要用要的信息,都可以从 vm_area_struct
中获得。mmap函数就是要创建一个新的 vm_area_struct
结构,并将其与文件的物理磁盘地址相连。
mmap内存映射的实现过程,总的来说可以分为三个阶段:
阶段一:进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
- 进程在用户空间调用mmap库函数
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); |
addr
:指定映射的虚拟内存地址,可以设置为 NULL,让内核自动选择合适的虚拟内存地址
length
:映射的长度。
prot
:映射内存的保护模式,可选值如下:
flags
:指定映射的类型
fd
:进行映射的文件句柄。
offset
:文件偏移量(从文件的何处开始映射)
在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
为此虚拟区分配一个
vm_area_struct
结构,接着对这个结构的各个域进行了初始化将新创建的虚拟区结构
vm_area_struct
对象插入到进程的虚拟地址区域链表/树中
阶段二:调用内核空间的mmap函数(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
通过该文件的文件结构体,链接到
file_operations
模块,调用内核mmap函数,其原型为:
int mmap(struct file *filp, struct vm_area_struct *vma) //不同于用户空间mmap库函数 |
内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
通过
remap_pfn_range
函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到物理内存(主存)中。
主存
主存储器(Main memory),简称主存。是计算机硬件的一个重要部件,其作用是存放指令和数据,并能由中央处理器(CPU)直接随机存取
阶段三:进程发起对这片映射地址空间的访问,引发缺页异常,实现文件内容到主存(物理内存)的拷贝
前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时
进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。
之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。
修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用
msync()
来强制同步, 这样所写的内容就能立即保存到文件里了
常规文件操作需要从磁盘到内核空间页缓存再到用户空间主存的两次数据拷贝。而mmap文件映射,只需要从磁盘到用户空间主存的一次数据拷贝过程。mmap的关键点是实现了用户空间和内核空间的数据直接交互而省去了空间不同数据不通的繁琐过程,因此 mmap 效率更高。
以上是 mmap 的基本概念和原理,搞明白了这些才能看明白整个 mmkv 里面的逻辑处理
mmkv 一次 put 的流程
mmkv初始化比较简单,主要涉及到一些配置的初始化,文件夹创建等,其中最重要的逻辑 mmap 调用被封装到一个 MemoryFile
到对象里面
bool MemoryFile::mmap() { |
主要记录一下一次 put 任务的流程,以 mmkv.putInt("int", 1)
为例,进过 JNI 的调用到了
native-birdge.cpp
MMKV_JNI jboolean encodeInt(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jint value) { |
进入了MMVK.cpp的
bool MMKV::set(int32_t value, MMKVKey_t key, uint32_t expireDuration) { |
这一步主要是准备一下数据,并使用 MMBuffer
CodedOutputData
将写入的数据进行一次包装(不仅仅是 key-value,还有数据size等等),实际调用在setDataForKey
bool MMKV::setDataForKey(MMBuffer &&data, MMKVKey_t key, bool isDataHolder) { |
这里面的代码逻辑很长,做了很多 if-else 的逻辑,最终走向两个大分支:
key 是新增的走 appendDataWithKey
key 将会覆盖原来的将会走 overrideDataWithKey
有这两个分支,主要是因为 mmkv 存储采用的 protobuf 协议,另外有一个很重要的方法也在这里执行了:checkLoadData();
安卓里面的多进程实现,将需要这里的一些逻辑,在 mmkv多进程原理篇进行讲解。
appendDataWithKey
转换为 MMBuffer
并继续向下执行
KVHolderRet_t MMKV::appendDataWithKey(const MMBuffer &data, MMKVKey_t key, bool isDataHolder) { |
doAppendDataWithKey
里面的代码也很长,不过也就只做一件事:将k-v值写入到文件里面做准备,真正的写入逻辑在 m_output->writeData(keyData);
,这里先后调用了两次 writeData
,是先写入key再写入了 value。
KVHolderRet_t |
writeData
进行了两步先写入数据的 长度信息,再写入真实的数据,这里还是因为 protobuf 协议设计相关
void CodedOutputData::writeData(const MMBuffer &value) { |
最终走到了writeRawData
关键代码
void CodedOutputData::writeRawData(const MMBuffer &data) { |
核心逻辑使用 memcpy
将数据直接通过 memcpy 直接在内存层面进行拷贝,而这里的 m_ptr
就是最开始通过mmap
创建出来的指针!!到这里一次写入基本上就结束了。
mmkv 一次 get 的流程
依然先通过 JNI走到
MMKV_JNI jint decodeInt(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jint defaultValue) { |
再到 mmkv getInt32
int32_t MMKV::getInt32(MMKVKey_t key, int32_t defaultValue, bool *hasValue) { |
getRawDataForKey
方法,主要有两个分支,一种是加密逻辑,另一种是非加密逻辑,但他们流程都差不多从一个 map 里面根据 key 获取一个对象(这个对象暂时并不是 get 最终的返回值),那这个 map 是从哪里来的呢?
MMBuffer MMKV::getRawDataForKey(MMKVKey_t key) { |
从源码里面溯源m_dicCrypt
和 m_dic
是在 MMKV 初始化的时候生成的,主要逻辑在 MMKV_IO .cpp
里面的 loadFromFile
方法内:
void MMKV::loadFromFile() { |
总统来说就是在初始化的时候就会将基于protobuf
协议的本地文件里面的数据加载到内存,并将其放在一个 map 内,方便后续使用。
回到 int32_t MMKV::getInt32()
通过 getDataForKey(key)
获取到一个MMBuffer
对象,并通过 CodedInputData进行反序列化操作,读取 Varint32 的 valueSize 值,随后不断循环通过 CodedInputData 读取到value 值。
int32_t MMKV::getInt32(MMKVKey_t key, int32_t defaultValue, bool *hasValue) { |
mmkv 与 SharedPreferences
以下是 MMKV 与 SharedPreferences 的优劣势对比总结,结合性能、安全性、功能支持等核心维度进行分析:
性能对比
维度 | SharedPreferences | MMKV |
---|---|---|
读写速度 | 慢(同步 I/O,多次数据拷贝) | 快(mmap 零拷贝,内存直接操作) |
线程安全 | 需自行加锁(apply() 异步写入仍有风险) |
内置多线程锁(文件锁 + 内存锁) |
大数据量支持 | 性能急剧下降(全量 XML 解析/序列化) | 高效(增量更新,Protobuf 编码) |
安全性与稳定性
维度 | SharedPreferences | MMKV |
---|---|---|
数据加密 | 无(明文存储) | 支持 AES-128/AES-256 加密 |
崩溃恢复 | 可能因异常导致 XML 损坏 | 通过 CRC 校验 + 备份文件保障完整性 |
系统版本适配 | 部分版本有 ANR 问题(如 apply() ) |
无系统级兼容性问题 |
功能支持
维度 | SharedPreferences | MMKV |
---|---|---|
多进程 | 不支持(跨进程数据不同步) | 支持(通过文件锁 + mmap 共享内存) |
数据类型 | 仅支持基本类型(int/String 等) | 支持基本类型、二进制数据(MMBuffer) |
加密存储 | 明文存储(XML) | 支持 AES 加密(可选) |
增量更新 | 全量写入(即使只改一个键值) | 仅追加新数据,定期整理 |
从上面的对比看看,mmkv 在很多层面都是领先 SharedPreferences 的,那么 mmkv 是否有缺陷呢?答案是有的。
任何的操作系统、任何的软件,在往磁盘写数据的过程中如果发生了意外——例如程序崩溃,或者断电关机——磁盘里的文件就会以这种写了一半的、不完整的形式被保留。写了一半的数据怎么用啊?没法用,这就是文件的损坏。这种问题是不可能避免的,MMKV 虽然由于底层机制的原因,在程序崩溃的时候不会影响数据往磁盘的写入,但断电关机之类的操作系统级别的崩溃,MMKV 就没办法了,文件照样会损坏。对于这种文件损坏,SharedPreferences 和 DataStore 的应对方式是在每次写入新数据之前都对现有文件做一次自动备份,这样在发生了意外出现了文件损坏之后,它们就会把备份的数据恢复过来;而 MMKV,没有这种自动的备份和恢复,那么当文件发生了损坏,数据就丢了,之前保存的各种信息只能被重置。也就是说,MMKV 是唯一会丢数据的方案。
在 mmkv 里面有 CRC 校验,如果不通过的话,将会废弃掉之前所有的数据。在 mmkv 里面也有人反馈:https://github.com/Tencent/MMKV/issues/729 在写入的过程中因为一些特殊情况写入失败,会导致本地的文件损坏且不可recovery。
那有什么办法避免这个问题呢?有大佬开源另一个 KV 框架 FastKV对这个问题进行了处理,采用通过double-write等方法确保数据的完整性,原理是数据依次写入A/B两个文件,如果写入A过程中崩溃,B仍是完整的,如果A完整写入了,则B写入时崩溃也不要紧。这种实现方式理论上是不错的,不太清楚 mmkv 为什么没有采取这样的逻辑。不过这个库并没有经过大量业务进行验证,只能作为一个学习的方案先看看。
另外谷歌已经开发了新的KV存储框架DataStore,SharedPreferences
也将渐渐地退出历史的舞台了。不过 DataStore 的性能目前仍然没有 mmkv 的好。关于这三者的比较可以查看: 《Android 的键值对存储有没有最优解?》
总结
这篇文章深入剖析了 MMKV(腾讯开源的高性能键值存储组件)的核心设计与实现原理,重点对比了传统 I/O 与 mmap
内存映射的差异,并详细分析了 MMKV 的读写流程以及和 SharedPreferences 的各方面对比。
参考