MMKV源码解读与理解

概述

通过 mmap 技术实现的高性能通用 key-value 组件。同时选用 protobuf 协议,进一步压缩数据存储。

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

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

文件数据结构

一个 MMKV 对象会生成两个文件,一个存储数据的主文件,一个 crc 校验文件,文件名规则为:

C++ 复制代码
// 主文件名为 mmapedKVKey() 返回值, crc 校验文件名为 mmapedKVKey()返回值加上 .crc 后缀
string mmapedKVKey(const string &mmapID, const MMKVPath_t *rootPath) {  
    if (rootPath && g_rootDir != (*rootPath)) {  
        return md5(*rootPath + MMKV_PATH_SLASH + string2MMKVPath_t(mmapID));  
    }  
    return mmapID;  
}

主文件

前四个字节记录了存储数据的总大小,紧接着保存每一个 key-value 对,由于使用了 protobuf 编码,为了便于读取 key、value 的数据,在保存具体数据前都先记录下其占用的字节数。由于 keyLength 和 valueLength 都为 int32 整数,因此直接按照 protobuf 编码规则读取即可,无需像 key、value 需要一个长度来确定值的结束边界。

diff 复制代码
+--------------+------------+------+--------------+--------+------------+------+---------------+-------+
| 存储的数据大小 | keyLength1 | key1 | valueLength1 | value1 | keyLength2 | key2 | valueLength2  | value2 |
+--------------+------------+------+--------------+--------+------------+------+---------------+-------+

CRC文件

CRC文件中保存的内容为以下结构体定义的数据结构,包括 crc32 校验和的值以及一堆辅助数据,用以验证文件的一致性。

C++ 复制代码
struct MMKVMetaInfo {
    uint32_t m_crcDigest = 0;
    uint32_t m_version = MMKVVersionSequence;
    uint32_t m_sequence = 0; // full write-back count
    uint8_t m_vector[AES_KEY_LEN] = {};
    uint32_t m_actualSize = 0;

    // confirmed info: it's been synced to file
    struct {
        uint32_t lastActualSize = 0;
        uint32_t lastCRCDigest = 0;
        uint32_t _reserved[16] = {};
    } m_lastConfirmedMetaInfo;
}

数据初始化

MMKV 对象构造时会调用 loadFromFile 读取数据,将文件中的 key-value 对读取到一个 dict 中保存。dict 是一个 std::unordered_map<std::string, mmkv::KeyValueHolder> 结构,dict 的 key 即为保存的 key-value 对中的 key。并且通过 KeyValueHolder 来保存 key-value 对的内容。

C++ 复制代码
// MiniPBCoder.cpp#decodeOneMap
auto block = [position, this](MMKVMap &dictionary) {
    if (position) {
        m_inputData->seek(position);
    } else {
        m_inputData->readInt32();
    }
    while (!m_inputData->isAtEnd()) {
        KeyValueHolder kvHolder;
        // 读取 key,保存 key 的 起始位置和size信息到 KeyValueHoder 中
        const auto &key = m_inputData->readString(kvHolder);
        if (key.length() > 0) {
            // 读取 value,保存 value 的size信息到 KeyValueHolder,此时并不会将 value 解码出来
            m_inputData->readData(kvHolder);
            if (kvHolder.valueSize > 0) {
                dictionary[key] = move(kvHolder);
            } else {
                auto itr = dictionary.find(key);
                if (itr != dictionary.end()) {
                    dictionary.erase(itr);
                }
            }
        }
    }
};

// CodedInputData.cpp#readString
// 读取 key
string CodedInputData::readString(KeyValueHolder &kvHolder) {  
    kvHolder.offset = static_cast<uint32_t>(m_position);  
  
    int32_t size = this->readRawVarint32();  
    if (size < 0) {  
        throw length_error("InvalidProtocolBuffer negativeSize");  
    }  
  
    auto s_size = static_cast<size_t>(size);  
    if (s_size <= m_size - m_position) {  
        kvHolder.keySize = static_cast<uint16_t>(s_size);  
  
        auto ptr = m_ptr + m_position;  
        string result((char *) (m_ptr + m_position), s_size);  
        m_position += s_size;  
        return result;  
    } else {  
        throw out_of_range("InvalidProtocolBuffer truncatedMessage");  
    }  
}

// CodedInputData.cpp#readData
// 读取 value
void CodedInputData::readData(KeyValueHolder &kvHolder) {  
    int32_t size = this->readRawVarint32();  
    if (size < 0) {  
        throw length_error("InvalidProtocolBuffer negativeSize");  
    }  
  
    auto s_size = static_cast<size_t>(size);  
    if (s_size <= m_size - m_position) {  
        kvHolder.computedKVSize = static_cast<uint16_t>(m_position - kvHolder.offset);  
        kvHolder.valueSize = static_cast<uint32_t>(s_size);  
  
        m_position += s_size;  
    } else {  
        throw out_of_range("InvalidProtocolBuffer truncatedMessage");  
    }  
}

数据写入与读取

这里仅分析在 Android 平台的主流程逻辑,因此对于加密功能和在 iOS 设备上的逻辑不去关注。由于 MMKV 对于 value 支持多种类型格式,这里也主要通过类型为 int 和 string 的写入和读取逻辑来进行了解。

MMBuffer

MMKV 中定义的内存单元,用来更方便的进行一些操作而抽象的结构。对于占用内存小的数据,直接保存在栈中,而对于占用内存大的数据则保存在堆中。 判断占用内存的大小取决于 sizeof(MMBuffer) - offsetof(MMBuffer, paddedBuffer) 计算的值,其实也就是 paddedBuffer[10] 的大小。这里应该是考虑到对于基本数值类型进行 protobuf 编码后最多占用10个字节,因此使用这种方式来更高效的进行内存操作。 MMBuffer 中包含一个联合体,其中的两个结构体共用存储空间,在实际使用时只能使用其中的一个。在默认情况下,编译器会对 MMBuffer 进行内存对齐,添加了 7 个填充字节,以保证 size 和 ptr 成员都按照 8 字节对齐。而对于第二个结构体,由于其成员都是 1 字节大小,因此没有进行内存对齐,没有填充字节。其内存布局如下:

scss 复制代码
+--------------------+------------------------+---------------+--------------+
|  isNoCopy(1 byte)  |    padding(7 bytes)    | size(8 bytes) | ptr(8 bytes) |
+--------------------+------------------------+---------------+--------------+
+--------------------+----------------------------+
| paddedSize(1 byte) |   paddedBuffer(10 bytes)   |
+--------------------+----------------------------+
C++ 复制代码
class MMBuffer {
    enum MMBufferType : uint8_t {
        MMBufferType_Small,  // store small buffer in stack memory
        MMBufferType_Normal, // store in heap memory
    };
    MMBufferType type;

    union {
        struct {
            MMBufferCopyFlag isNoCopy;
            size_t size;
            void *ptr;
        };
        struct {
            uint8_t paddedSize;
            // make at least 10 bytes to hold all primitive types (negative int32, int64, double etc) on 32 bit device
            // on 64 bit device it's guaranteed larger than 10 bytes
            uint8_t paddedBuffer[10];
        };
    };

    static constexpr size_t SmallBufferSize() {
        return sizeof(MMBuffer) - offsetof(MMBuffer, paddedBuffer);
    }

public:
    explicit MMBuffer(size_t length = 0);
    MMBuffer(void *source, size_t length, MMBufferCopyFlag flag = MMBufferCopy);

    MMBuffer(MMBuffer &&other) noexcept;

    ~MMBuffer();

    bool isStoredOnStack() const { return (type == MMBufferType_Small); }

    void *getPtr() const { return isStoredOnStack() ? (void *) paddedBuffer : ptr; }

    size_t length() const { return isStoredOnStack() ? paddedSize : size; }
};

int类型数据写入

写入的 value 为 int 类型时,计算 value 通过 protobuf 编码需要占用多少个字节,并将其编码后的结果写入到分配的内存段中。

C++ 复制代码
// MMKV.cpp#set
bool MMKV::set(int32_t value, MMKVKey_t key) {  
    if (isKeyEmpty(key)) {  
        return false;  
    }  
    // 根据 protobuf 编码规则,获取 value 通过 protobuf 编码需要占用几个字节
    size_t size = pbInt32Size(value);  
    // 声明 MMBuffer,其为 MMKV 中定义的内存单元,存储了映射的指针和大小
    MMBuffer data(size);  
    // 将 MMBuffer 的 ptr 与 CodedOutputData 关联在一起,
    // 则 CodedOutputData 写入数据后,通过 MMBuffer 也能获取得到
    CodedOutputData output(data.getPtr(), size); 
    // CodedOutputData 主要负责 protobuf 的编码逻辑,
    output.writeInt32(value);  
  
    return setDataForKey(move(data), key);  
}

setDataForKey

对 value 进行 protobuf 编码后,将数据写入到文件尾部,同时还需要更新 dic 中的内容,以便为后续快速读取数据服务。 查找 dic 中是否已存在要写入 key 相关的 key-value 对。

  • 当 dic 中存在这个 key,直接使用 dic 中保存的 KeyValueHolder 使用。在 doAppendDataWithKey 流程将 key 写入文件时复制 KeyValueHolder 指向的 key 数据块。这个分支走向决定了 doAppendDataWithKeyisKeyEncoded 为 true。
  • 当 dic 中没有这个 key 时, doAppendDataWithKeyisKeyEncoded 为 false,在写入文件时需要写入 keyLength,再写入 key。
C++ 复制代码
// MMKV_IO.cpp#setDataForKey
auto itr = m_dic->find(key);
// 
if (itr != m_dic->end()) {  
    auto ret = appendDataWithKey(data, itr->second, isDataHolder);  
    if (!ret.first) {  
        return false;  
    }  
    itr->second = std::move(ret.second);  
} else {  
    auto ret = appendDataWithKey(data, key, isDataHolder);  
    if (!ret.first) {  
        return false;  
    }  
    m_dic->emplace(key, std::move(ret.second));  
}

appendDataWithKey

根据 setDataForKey 的逻辑分支,appendDataWithKey 也有两种逻辑,主要区别在于构造 key 的 MMBuffer 方式不一样。

  • 当 dic 中存有相关 key,对应的 MMBuffer 将 protobuf 编码的 keyLength 计算在内
  • 当 dic 中没有相关 key,对应的 MMBuffer 长度即为 key 的长度大小
C++ 复制代码
// MMKV_IO.cpp#appendDataWithKey

// dic 中已有相关 key 的逻辑分支
KVHolderRet_t MMKV::appendDataWithKey(const MMBuffer &data, const KeyValueHolder &kvHolder, bool isDataHolder) {  
    SCOPED_LOCK(m_exclusiveProcessLock);  
  
    uint32_t keyLength = kvHolder.keySize;  
    // size needed to encode the key  
    size_t rawKeySize = keyLength + pbRawVarint32Size(keyLength);  

	// 
    // ensureMemorySize() might change kvHolder.offset, so have to do it early  
    {  
        auto valueLength = static_cast<uint32_t>(data.length());  
        if (isDataHolder) {  
            valueLength += pbRawVarint32Size(valueLength);  
        }  
        auto size = rawKeySize + valueLength + pbRawVarint32Size(valueLength);  
        // ensureMemorySize 确保有足够的空间大小以供这次写入,内部逻辑比较复杂,
        // 这里简单记住当申请的 mmap 空间不够时会尝试扩容
        bool hasEnoughSize = ensureMemorySize(size);  
        if (!hasEnoughSize) {  
            return make_pair(false, KeyValueHolder());  
        }  
    }    
    auto basePtr = (uint8_t *) m_file->getMemory() + Fixed32Size;  
    MMBuffer keyData(basePtr + kvHolder.offset, rawKeySize, MMBufferNoCopy);  
  
    return doAppendDataWithKey(data, keyData, isDataHolder, keyLength);  
}

// dic 中没有相关 key 的逻辑分支
KVHolderRet_t MMKV::appendDataWithKey(const MMBuffer &data, MMKVKey_t key, bool isDataHolder) {
    auto keyData = MMBuffer((void *) key.data(), key.size(), MMBufferNoCopy);
    return doAppendDataWithKey(data, keyData, isDataHolder, static_cast<uint32_t>(keyData.length()));
}

doAppendDataWithKey

实际将 key-value 对进行写入的地方。这里需要先了解两个字段代表的含义,否则对于写入流程可能并不会太过清晰。

isDataHolder

isDataHolder 的取值从 setDataForKey 一路传下来,这里看下其函数定义,对于 isDataHolder 默认取值为 false。

C++ 复制代码
bool setDataForKey(mmkv::MMBuffer &&data, MMKVKey_t key, bool isDataHolder = false);

数据类型为 string/char* 时,才进行了 true 的赋值。而当 isDataHolder 为 true 时,对 value 的写入会再额外写入一个字段,表示 valueLength。在Github Discussion 中的讨论,作者解释是为了在写入 string 列表中使用的,而为了代码的统一性就没有再进行区分了。

isKeyEncoded

通过原始 key 长度和将 key 封装为 MMBuffer 的 length 做比较来判断是否已经包含 keyLength 的 protobuf 编码值。实际上在 MMKV_IO.cpp#setDataForKey 中根据 dic 是否存在写入的 key 就决定了 isKeyEncoded 的值,当 dic 中存在写入的 key 时,isKeyEncoded 为 true,表示写入时不需要再将 keyLength 的 protobuf 编码数据写入。

diff 复制代码
+-----------+-----+
| keyLength | key |
+-----------+-----+

这样做的原因上面其实也提及过,对于 key 的写入其格式如上。当 dic 中存有这个 key,那么说明初始 loadFromFile 或在此之前已经构造了相关的 KeyValueHolder 信息。通过 KeyValueHolder 拿到 offset 数据后,offset 后面的一段内存区数据即为 key 写入所需的格式数据。

C++ 复制代码
// MMKV_IO.cpp#doAppendDataWithKey
KVHolderRet_t
MMKV::doAppendDataWithKey(const MMBuffer &data, const MMBuffer &keyData, bool isDataHolder, uint32_t originKeyLength) {
    auto isKeyEncoded = (originKeyLength < keyData.length());
    auto keyLength = static_cast<uint32_t>(keyData.length());
    auto valueLength = static_cast<uint32_t>(data.length());
    if (isDataHolder) {
        valueLength += pbRawVarint32Size(valueLength);
    }
    // size needed to encode the key
    size_t size = isKeyEncoded ? keyLength : (keyLength + pbRawVarint32Size(keyLength));
    // size needed to encode the value
    size += valueLength + pbRawVarint32Size(valueLength);

    SCOPED_LOCK(m_exclusiveProcessLock);

    bool hasEnoughSize = ensureMemorySize(size);
    if (!hasEnoughSize || !isFileValid()) {
        return make_pair(false, KeyValueHolder());
    }
    try {
	    // 仍然是区分 key 是否已经编码过了
        if (isKeyEncoded) {
            // 直接将 MMBuffer 的数据拷贝写入
            m_output->writeRawData(keyData);
        } else {
	        // 写入 protobuf 编码的 keyLength,再写入 key 的值
            m_output->writeData(keyData);
        }
        if (isDataHolder) {
            m_output->writeRawVarint32((int32_t) valueLength);
        }
        m_output->writeData(data); // note: write size of data
    } catch (std::exception &e) {
        MMKVError("%s", e.what());
        return make_pair(false, KeyValueHolder());
    }

    auto offset = static_cast<uint32_t>(m_actualSize);
    auto ptr = (uint8_t *) m_file->getMemory() + Fixed32Size + m_actualSize;
    m_actualSize += size;
    updateCRCDigest(ptr, size);

    return make_pair(true, KeyValueHolder(originKeyLength, valueLength, offset));
}

int 类型数据读取

数据读取内容相对简单点,根据要获取的数据 key,从 dic 中获取到相应的 KeyValueHolder,并将其转换为 MMBuffer 内存单元,读取出映射的指针地址开始的数据。

C++ 复制代码
int32_t MMKV::getInt32(MMKVKey_t key, int32_t defaultValue, bool *hasValue) {
    if (isKeyEmpty(key)) {
        if (hasValue != nullptr) {
            *hasValue = false;
        }
        return defaultValue;
    }
    SCOPED_LOCK(m_lock);
    SCOPED_LOCK(m_sharedProcessLock);
    // 从 dic 中获取数据
    auto data = getDataForKey(key);
    if (data.length() > 0) {
        try {
            CodedInputData input(data.getPtr(), data.length());
            if (hasValue != nullptr) {
                *hasValue = true;
            }
            return input.readInt32();
        } catch (std::exception &exception) {
            MMKVError("%s", exception.what());
        }
    }
    if (hasValue != nullptr) {
        *hasValue = false;
    }
    return defaultValue;
}

MMBuffer MMKV::getDataForKey(MMKVKey_t key) {
    checkLoadData();
    {
        auto itr = m_dic->find(key);
        if (itr != m_dic->end()) {
            auto basePtr = (uint8_t *) (m_file->getMemory()) + Fixed32Size;
            // 拿到 KeyValueHolder 信息,将其转换为 MMBuffer 数据格式
            return itr->second.toMMBuffer(basePtr);
        }
    }
    MMBuffer nan;
    return nan;
}

缺陷

  • 没有类型信息,不支持 getAll MMKV的存储使用 Protobuf 的编码方式,只存储 key 和 value 本身,没有存类型信息。由于没有记录类型信息,MMKV无法自动反序列化,也就无法实现 getAll 接口,因此在需要遍历所有 key-value 的时候(比如迁移数据)就比较棘手了。
  • 文件大小问题 扩容后如果进行 key-value 的删除不会主动 trim size
相关推荐
雨白7 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹9 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空11 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭11 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日12 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安12 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑12 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟16 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡18 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0018 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体