详解MMKV

什么是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;
}
  1. mmap系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必再调用read(),write()等操作。
  2. mmap并不分配空间, 只是将文件映射到调用进程的地址空间里(但是会占掉你的 virutal memory), 然后你就可以用memcpy等操作写文件, 而不用write()了。
  3. 写完后,内存中的内容并不会立即更新到文件中,而是有一段时间的延迟,你可以调用msync()来显式同步一下, 这样你所写的内容就能立即保存到文件里了。不过通过mmap来写文件这种方式没办法增加文件的长度, 因为要映射的长度在调用mmap()的时候就决定了。
  4. 如果想取消内存映射,可以调用munmap()来取消内存映射。

数据组织

数据序列化方面MMKV选用 protobuf 协议(下文简称PB),pb 在性能和空间占用上都有不错的表现。PB是一种轻便的高效的结构化数据存储的格式。它适用于数据传输和数据存储的场景。

  • Tag - Length - Value 的数据存储方式

    每一个数据都是以 Tag-Length-Value 方式表示,然后把所有数据拼接成一个字节流,最终实现数据存储和传输的功能。

    这种存储方式的优点是:

    1. 不需要分隔符就能分隔开字段,减少了分隔符的使用;
    2. 各字段存储的非常紧凑,存储空间利用率高;
    3. 若该字段没有值,则该字段在序列化后的二进制数据流中是完全不存在的,即该字段不会被编码进二进制串中。
  • 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的做法:

  1. 将增量 kv 对象序列化后,直接 append 到内存末尾,放弃对之前数据的修改。
  2. 这样同一个 key 会有新旧若干份数据,最新的数据在最后。
  3. 同时内存中维护了一个Dict,会根据Key去重,此时读取Key则会获取到最新的value。
  4. 在程序下次冷启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证内存中Dict数据是最新有效的。

空间增长

为了解决PB增量更新的问题,我们引入 append 。这带来了新的问题,就是不断 append 的话,文件大小会增长得不可控。例如同一个 key 不断更新的话,是可能耗尽几百 M 甚至上 G 空间,而事实上整个 kv 文件就这一个 key,不到 1k 空间就存得下。这明显是不可取的。需要在性能和空间上做个折中,MMKV的做法:

  1. 以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式。
  2. 当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重后的全量结果。
  3. 排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。

伪代码实现如下:

-(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 算法的基本思想是将传输的数据当做一个位数很长的数。将这个数模二除以另一个数。得到的余数作为校验数据附加到原数据后面。

实际应用时,发送方和接收方按以下方式通信:

  1. 发送方和接收方在通信前,约定好一个预设整数作为除数。
  2. 发送方在发送前根据原始数据和约定好的除数进行模二除法运算生成余数(即CRC码),然后将其附加到原始数据后面一起发送给接收方。
  3. 接收方收到后将其模二除以约定好的除数,当且仅当余数为0时接收方认为没有差错。

详细关于CRC校验原理,可参考笔者的另一篇文章用人话讲CRC校验原理

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342