什么是MMKV
MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。目前已移植到 Android / macOS / Win32 / POSIX 平台并开源。
MMKV 原理
内存准备
通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。这里最重要的是mmap函数:
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offsize);
start:指向欲映射的内存起始地址,通常设为 NULL,代表让系统自动选定地址,映射成功后返回该地址。
参数length:代表将文件中多大的部分映射到内存。必须是分页的整数倍。
-
参数prot:映射区域的保护方式。可以为以下几种方式的组合:
PROT_EXEC 映射区域可被执行
PROT_WRITE 映射区域可被写入
PROT_NONE 映射区域不能存取
PROT_READ 映射区域可被读取
-
参数flags:影响映射区域的各种特性。在调用mmap()时必须要指定MAP_SHARED 或MAP_PRIVATE。
- MAP_FIXED 如果参数start所指的地址无法成功建立映射时,则放弃映射,不对地址做修正。通常不鼓励用此flag。
- MAP_SHARED对映射区域的写入数据会复制回文件内,而且允许其他映射该文件的进程共享。
- MAP_PRIVATE 对映射区域的写入操作会产生一个映射文件的复制,即私人的“写入时复制”(copy on write)对此区域作的任何修改都不会写回原来的文件内容。
- MAP_ANONYMOUS建立匿名映射。此时会忽略参数fd,不涉及文件,而且映射区域无法和其他进程共享。
- MAP_DENYWRITE只允许对映射区域的写入操作,其他对文件直接写入的操作将会被拒绝。
- MAP_LOCKED 将映射区域锁定住,这表示该区域不会被置换(swap)。
参数fd:要映射到内存中的文件描述符。如果使用匿名内存映射时,即flags中设置了MAP_ANONYMOUS,fd设为-1。
参数offset:文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍。
MMKV中的源码如下:
bool MemoryFile::mmap() {
auto oldPtr = m_ptr;
m_ptr = (char *) ::mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_diskFile.m_fd, 0);
if (m_ptr == MAP_FAILED) {
MMKVError("fail to mmap [%s], %s", m_diskFile.m_path.c_str(), strerror(errno));
m_ptr = nullptr;
return false;
}
MMKVInfo("mmap to address [%p], oldPtr [%p], [%s]", m_ptr, oldPtr, m_diskFile.m_path.c_str());
return true;
}
- mmap系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必再调用read(),write()等操作。
- mmap并不分配空间, 只是将文件映射到调用进程的地址空间里(但是会占掉你的 virutal memory), 然后你就可以用memcpy等操作写文件, 而不用write()了。
- 写完后,内存中的内容并不会立即更新到文件中,而是有一段时间的延迟,你可以调用msync()来显式同步一下, 这样你所写的内容就能立即保存到文件里了。不过通过mmap来写文件这种方式没办法增加文件的长度, 因为要映射的长度在调用mmap()的时候就决定了。
- 如果想取消内存映射,可以调用munmap()来取消内存映射。
数据组织
数据序列化方面MMKV选用 protobuf 协议(下文简称PB),pb 在性能和空间占用上都有不错的表现。PB是一种轻便的高效的结构化数据存储的格式。它适用于数据传输和数据存储的场景。
-
Tag - Length - Value 的数据存储方式
每一个数据都是以 Tag-Length-Value 方式表示,然后把所有数据拼接成一个字节流,最终实现数据存储和传输的功能。
这种存储方式的优点是:
- 不需要分隔符就能分隔开字段,减少了分隔符的使用;
- 各字段存储的非常紧凑,存储空间利用率高;
- 若该字段没有值,则该字段在序列化后的二进制数据流中是完全不存在的,即该字段不会被编码进二进制串中。
-
Length - Value 的数据存储方式
在MMKV中,由于所有的存储都是KV形式,不需要Tag标识字段。每一条键值对,存储的形式如下:
MMKV提供的是通用 kv 组件,key 可以限定是 string 字符串类型,value 则多种多样(int/bool/double 等)。要做到通用的话,需要将 value 通过 protobuf 协议序列化成统一的内存块(buffer),然后就可以将这些 KV 对象序列化到内存中。
伪代码实现如下:
message KV {
string key = 1;
buffer value = 2;
}
-(BOOL)setInt32:(int32_t)value forKey:(NSString*)key {
auto data = PBEncode(value);
return [self setData:data forKey:key];
}
-(BOOL)setData:(NSData*)data forKey:(NSString*)key {
auto kv = KV { key, data };
auto buf = PBEncode(kv);
return [self write:buf];
}
写入优化
上文讲过PB的优点:各字段存储的非常紧凑,没有分隔符;这反过来导致了PB没有增量更新的能力,因为如果数据段中的长度变化,会导致后续的字节流无法解码。
标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。考虑到主要使用场景是频繁地进行写入更新,需要有增量更新的能力,这里MMKV的做法:
- 将增量 kv 对象序列化后,直接 append 到内存末尾,放弃对之前数据的修改。
- 这样同一个 key 会有新旧若干份数据,最新的数据在最后。
- 同时内存中维护了一个Dict,会根据Key去重,此时读取Key则会获取到最新的value。
- 在程序下次冷启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证内存中Dict数据是最新有效的。
空间增长
为了解决PB增量更新的问题,我们引入 append 。这带来了新的问题,就是不断 append 的话,文件大小会增长得不可控。例如同一个 key 不断更新的话,是可能耗尽几百 M 甚至上 G 空间,而事实上整个 kv 文件就这一个 key,不到 1k 空间就存得下。这明显是不可取的。需要在性能和空间上做个折中,MMKV的做法:
- 以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式。
- 当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重后的全量结果。
- 排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。
伪代码实现如下:
-(BOOL)append:(NSData*)data {
if (space >= data.length) { //1.判断要写入的数据大小和剩余空间大小
append(fd, data);
} else {
newData = unique(m_allKV); //2.根据字典,对相同的Key去重,获取去重后的数据
if (total_space >= newData.length) { //3.判断去重后新的大小和总空间大小
write(fd, newData);
} else {
while (total_space < newData.length) { //4.将文件扩大一倍,直到空间足够。
total_space *= 2;
}
ftruncate(fd, total_space);
write(fd, newData); //5. 全量写入文件
}
}
}
MMKV中的源码实现:
bool MMKV::ensureMemorySize(size_t newSize) {
if (!isFileValid()) {
MMKVWarning("[%s] file not valid", m_mmapID.c_str());
return false;
}
if (newSize >= m_output->spaceLeft() || (m_crypter ? m_dicCrypt->empty() : m_dic->empty())) { //判断要写入的数据大小和剩余空间大小,相当于上文伪代码的第1步。
// remove expired keys
if (m_enableKeyExpire) {
filterExpiredKeys();
}
// try a full rewrite to make space
auto preparedData = m_crypter ? prepareEncode(*m_dicCrypt) : prepareEncode(*m_dic); //根据字典去重并编码后,获取去重后的数据,相当于上文伪代码的第2步
// dic.empty() means inserting key-value for the first time, no need to call msync()
return expandAndWriteBack(newSize, std::move(preparedData), m_crypter ? !m_dicCrypt->empty() : !m_dic->empty());
}
return true;
}
bool MMKV::expandAndWriteBack(size_t newSize, std::pair<mmkv::MMBuffer, size_t> preparedData, bool needSync) {
auto fileSize = m_file->getFileSize();
auto sizeOfDic = preparedData.second; //去重后的数据长度
size_t lenNeeded = sizeOfDic + Fixed32Size + newSize; // 新增后的总长度 =Fixed32Size(内容长度的编码) + 去重后的数据长度 + 新增的长度
size_t nowDicCount = m_crypter ? m_dicCrypt->size() : m_dic->size();
size_t laterDicCount = std::max<size_t>(1, nowDicCount + 1);
// or use <cmath> ceil()
size_t avgItemSize = (lenNeeded + laterDicCount - 1) / laterDicCount;
size_t futureUsage = avgItemSize * std::max<size_t>(8, laterDicCount / 2);
// 1. no space for a full rewrite, double it
// 2. or space is not large enough for future usage, double it to avoid frequently full rewrite
if (lenNeeded >= fileSize || (needSync && (lenNeeded + futureUsage) >= fileSize)) { //判断新增后的总大小和总文件大小,相当于上文伪代码的第3步
size_t oldSize = fileSize;
do {
fileSize *= 2;
} while (lenNeeded + futureUsage >= fileSize); //扩大一倍后再次比较,直到空间大小足够。相当于上文伪代码的第4步
MMKVInfo("extending [%s] file size from %zu to %zu, incoming size:%zu, future usage:%zu", m_mmapID.c_str(),
oldSize, fileSize, newSize, futureUsage);
// if we can't extend size, rollback to old state
// this is a good place to mock enlarging file failure
if (!m_file->truncate(fileSize)) {
return false;
}
// check if we fail to make more space
if (!isFileValid()) {
MMKVWarning("[%s] file not valid", m_mmapID.c_str());
return false;
}
}
return doFullWriteBack(std::move(preparedData), nullptr, needSync); //重新写入文件。相当于上文伪代码的第5步
}
数据校验
考虑到文件系统、操作系统都有一定的不稳定性,MMKV另外增加了 crc 校验,对无效数据进行甄别。
循环冗余校验(Cyclic Redundancy Check, CRC)是一种根据网络数据包或电脑文件等数据产生简短固定位数校验码的一种散列函数,主要用来检测或校验数据传输或者保存后可能出现的错误。
CRC 算法的基本思想是将传输的数据当做一个位数很长的数。将这个数模二除以另一个数。得到的余数作为校验数据附加到原数据后面。
实际应用时,发送方和接收方按以下方式通信:
- 发送方和接收方在通信前,约定好一个预设整数作为除数。
- 发送方在发送前根据原始数据和约定好的除数进行模二除法运算生成余数(即CRC码),然后将其附加到原始数据后面一起发送给接收方。
- 接收方收到后将其模二除以约定好的除数,当且仅当余数为0时接收方认为没有差错。
详细关于CRC校验原理,可参考笔者的另一篇文章用人话讲CRC校验原理。