Android KV存储之MMKV解析

485 阅读9分钟

春节假期快结束了,随机找个库看看代码收收心,之前一直没有看过MMKV,那么就随便看一下吧。

对于这种出了n年,被人解析烂了的库,我还是直接带着问题去看,这样能更准确的抓到核心。

  • 为什么腾讯要自己再实现一个kv库?
  • MMKV的核心设计思路是什么?
  • MMKV的核心设计思路带来哪些问题,如何设计解决?
  • 我们一定需要MMKV吗?

基于上面几个问题,结合MMKV自己在github上写的几个设计文档,我们来看看他的实现。

缘由

都到了2025了,不看官方文档我们也能知道用 SharedPreference 做kv存储有什么缺陷,SharedPreference因为有加锁/写等待等机制,会导致大量读写的时候出现性能问题。

而MMKV在官网介绍的缘起则是微信内部处理显示异常的技术方案需要一个大量写kv存储的埋点功能,所以开发出了MMKV。

核心设计思路

MMKV核心设计思路有下面几个:

  • 高性能:MMKV通过mmap写入数据,既高效IO,也不丢数据。并且使用protobuf协议写入,性能和存储空间占用上比较优秀。
  • 多平台支持:MMKV主体实现是C++实现,即Core目录下的代码。其他平台对应的都是api实现和native bridge。从文档上也能看出来这个方案先支持的iOS,后支持了Android。因为Android还具备多进程功能,所以专门为多进程场景做了适配。并且他对各个平台其实也都做了支持。

解决的问题

MMKV核心思路带来的问题主要体现在2个方面

  1. MMKV初衷是需要支持频繁写入的场景,所以MMKV需要实现增量更新kv数据。这里的增量更新是什么意思呢?在SharedPreference里面,当我们重新写入数据调用commit或者apply之后,对应的内存数据会更新,然后把内容重新覆盖写入到对应的XML文件里面去,这种覆盖写入所有内容的,就是全量更新。而我们希望具备的增量更新,就是只写入我们这次更新的key+value的内容。实现增量更新有2个可能:
    1. pb协议支持增量更新,我虽然调用的是写入,但是此协议自己是增量的更新的。此路并不通,pb不支持这个操作,实际上似乎也没有什么协议是支持这个操作的。
    2. 自己通过某个方案去实现增量更新,MMKV就是这个路子。
  2. 读写竞争问题。这里也包括两个方面
    1. 多线程读写,这个其实比较好解决,读写的时候加个线程锁即可。但是要记得处理这回事情。
    2. 多进程读写,这个是MMKV比较厉害的地方,也是SharedPreference不支持的功能,我们来思考下多进程需要解决哪些问题,这样能更好的帮助自己理解MMKV:
      1. 寻找合适的进程锁。
      2. 多进程状态同步,当前进程写入数据的时候,需要知道其他进程有没有对文件进行过写入。如果文件变化了,当前进程肯定要以变化后的内容作为基础去写入。

写入优化与空间增长

写入优化

针对上面提到的增量更新,MMKV优化了mmap文件里写入的内容。每次更新kv的时候,把内容追加在mmap文件的末尾,最后读取内容的时候,用后面的key+value覆盖前面读取到的key+value,这样读取到的value就是最新的了。

我们可以查看MMKV写入的代码,写入字符串使用的 MMKV.cpp 代码里的 set 方法:

bool MMKV::set(const string &value, MMKVKey_t key) {
    return set(value, key, m_expiredInSeconds);
}

bool MMKV::set(const string &value, MMKVKey_t key, uint32_t expireDuration) {
    if (isKeyEmpty(key)) {
        return false;
    }
    return setDataForKey(MMBuffer((void *) value.data(), value.length(), MMBufferNoCopy), key, expireDuration);
}

这里会把需要写入的数据包装成一个 MMBuffer 对象,接下来看 MMKV_IO.cpp 的 setDataForKey函数:

bool MMKV::setDataForKey(MMBuffer &&data, MMKVKey_t key, bool isDataHolder) {
  //...省略和写入优化无关的代码
  // m_dic是mmap里内容读取出来的map
  auto itr = m_dic->find(key);
  if (itr != m_dic->end()) {
    // 找到了这个key
    bool onlyOneKey = !isMultiProcess() && m_dic->size() == 1;
    if (onlyOneKey) {
        // 只有一个key,覆盖即可
        ret = overrideDataWithKey(data, itr->second, isDataHolder);
    } else {
        // 不止一个key,把这个key继续追加到后面
        ret = appendDataWithKey(data, itr->second, isDataHolder);
    }
    if (!ret.first) {
      return false;
    }
    itr->second = std::move(ret.second); // 更新对应key的内容
  } else {
    // 没有找到key
    bool needOverride = !isMultiProcess() && m_dic->empty() && m_actualSize > 0;
    KVHolderRet_t ret;
    if (needOverride) {
        // 没有其他key,覆盖写入
        ret = overrideDataWithKey(data, key, isDataHolder);
    } else {
        // 其他情况,追加写入
        ret = appendDataWithKey(data, key, isDataHolder);
    }
    if (!ret.first) {
        return false;
    }
    m_dic->emplace(key, std::move(ret.second)); // kv内容写入m_dic map
    mmkv_retain_key(key);
  }
}

继续看 appendDataWithKey:

KVHolderRet_t MMKV::appendDataWithKey(const MMBuffer &data, const KeyValueHolder &kvHolder, bool isDataHolder) {
    uint32_t keyLength = kvHolder.keySize;
    size_t rawKeySize = keyLength + pbRawVarint32Size(keyLength);
    {
        auto valueLength = static_cast<uint32_t>(data.length());
        if (isDataHolder) {
            valueLength += pbRawVarint32Size(valueLength);
        }
        auto size = rawKeySize + valueLength + pbRawVarint32Size(valueLength);
        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);
}

这里会组装 keyData,包括mmap文件内存起始位置、实际key占用的字节数等。继续调用doAppendDataWithKey:

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_t size = isKeyEncoded ? keyLength : (keyLength + pbRawVarint32Size(keyLength));
    size += valueLength + pbRawVarint32Size(valueLength);
    SCOPED_LOCK(m_exclusiveProcessLock); //锁
    bool hasEnoughSize = ensureMemorySize(size);
    if (!hasEnoughSize || !isFileValid()) {
        return make_pair(false, KeyValueHolder());
    }
    try {
        if (isKeyEncoded) {
            m_output->writeRawData(keyData);
        } else {
            // 写入key
            m_output->writeData(keyData);
        }
        if (isDataHolder) {
            m_output->writeRawVarint32((int32_t) valueLength);
        }
        // 写入value
        m_output->writeData(data); // note: write size of data
    } catch (//...) {
    }
    auto offset = static_cast<uint32_t>(m_actualSize);
    auto ptr = (uint8_t *) m_file->getMemory() + Fixed32Size + m_actualSize;
    return make_pair(true, KeyValueHolder(originKeyLength, valueLength, offset));
}

这里代码细节不少,但主要就是把key和value数据写入了mmap文件,这里可以看出写入等数据格式其实简化来看就是这样,分别存储了kv的长度和内容,存储长度的作用主要有2个,快速定位内存位置和数据校验。

内存增长

增量更新解决了,还有一个内存增长的问题,当写入的kv太多,内存不够用的时候,会调用ensureMemorySize(expandAndWriteBack)函数:

bool MMKV::expandAndWriteBack(size_t newSize, std::pair<mmkv::MMBuffer, size_t> preparedData, bool needSync) {
    if (lenNeeded >= fileSize || (needSync && (lenNeeded + futureUsage) >= fileSize)) {
        size_t oldSize = fileSize;
        do {
            fileSize *= 2;
        } while (lenNeeded + futureUsage >= fileSize);
        if (!m_file->truncate(fileSize)) {
            // 文件扩容
            return false;
        }
        // ... 异常返回false
    }
    return doFullWriteBack(std::move(preparedData), nullptr, needSync);
}

这里当没有足够空间或者即将内存不足的时候,会把mmap文件大小扩大2倍。然后调用 doFullWriteBack,doFullWriteBack会调用 recalculateCRCDigestWithIV:

void MMKV::recalculateCRCDigestWithIV(const void *iv) {
    auto ptr = (const uint8_t *) m_file->getMemory();
    if (ptr) {
        m_crcDigest = 0;
        m_crcDigest = (uint32_t) CRC32(0, ptr + Fixed32Size, (uint32_t) m_actualSize);
        writeActualSize(m_actualSize, m_crcDigest, iv, IncreaseSequence);
    }
}

writeActualSize里面会更新mmap的字段:

m_metaInfo->m_actualSize = static_cast<uint32_t>(size); //更新 actualSize
if (mmkv_unlikely(increaseSequence)) {
    m_metaInfo->m_sequence++; // 序列+1
    //...
}

当内存不够的时候,内存会做增长处理。每次处理之后,都会把mmap文件存储的序列+1,来代表完成了文件内存的增长。

多进程和进程锁

多进程这个问题比较复杂,我们先看进程互斥的判断和处理。当前进程在写入数据的时候,需要考虑的场景有如下几个:

  • mmap文件写指针增长。也就是别的进程往里写入了其他的kv数据,那当前进程就需要把kv都读取一遍,然后写入的时候正常插入或者替换kv,同步写指针的位置即可。如果不读取一遍,那么不同进程写入一样的key可能m_dic的内容就错乱了,会导致读取的内容不正确。
  • mmap文件发生了内存增长,这时候直接读取一遍mmap文件最简单
多进程同步检查逻辑

多进程的互斥检查实现在 setDataForKey 开头的 checkLoadData 里面:

void MMKV::checkLoadData() {
    // ...省略一些其他代码
    SCOPED_LOCK(m_sharedProcessLock);
    MMKVMetaInfo metaInfo;
    metaInfo.read(m_metaFile->getMemory());

    if (m_metaInfo->m_sequence != metaInfo.m_sequence) {
        // 序列不一样,发生了内存增长,重新读取
        SCOPED_LOCK(m_sharedProcessLock);
        clearMemoryCache();
        loadFromFile();
        notifyContentChanged();
    } else if (m_metaInfo->m_crcDigest != metaInfo.m_crcDigest) {
        // crc校验不一样,内容发生变化
        SCOPED_LOCK(m_sharedProcessLock);
        size_t fileSize = m_file->getActualFileSize();
        if (m_file->getFileSize() != fileSize) {
            // 文件大小不一样(例如删除key,文件大小又做了截断),重新读取
            clearMemoryCache();
            loadFromFile();
        } else {
            partialLoadFromFile();
        }
        notifyContentChanged();
    }
}

这里如果文件大小不一致,直接采取从本地文件读取kv内容覆盖的策略。如果只是写指针增长,那么调用partialLoadFromFile:

void MMKV::partialLoadFromFile() {
    if (m_actualSize > 0) {
        if (m_actualSize < fileSize && m_actualSize + Fixed32Size <= fileSize) {
            if (m_actualSize > oldActualSize) {
                auto position = oldActualSize;
                size_t addedSize = m_actualSize - position; // 计算新增大小
                auto basePtr = (uint8_t *) m_file->getMemory() + Fixed32Size;
                m_crcDigest = (uint32_t) CRC32(m_crcDigest, basePtr + position, (z_size_t) addedSize);
                if (m_crcDigest == m_metaInfo->m_crcDigest) {
                    // 通过crc校验
                    MMBuffer inputBuffer(basePtr, m_actualSize, MMBufferNoCopy);
                    if (m_crypter) {
                        MiniPBCoder::greedyDecodeMap(*m_dicCrypt, inputBuffer, m_crypter, position);
                    } else {
                        MiniPBCoder::greedyDecodeMap(*m_dic, inputBuffer, position);
                    }
                    // 写指针位置修改
                    m_output->seek(addedSize);
                    m_hasFullWriteback = false;
                    [[maybe_unused]] auto count = m_crypter ? m_dicCrypt->size() : m_dic->size();
                }
            }
        }
    }
}

greedyDecodeMap就是读取内容填充 m_dic 这个map的过程。

进程锁

MMKV多进程同步的时候进程锁选择基于文件锁封装来实现。理由如下:

  • ContentProvider:启动和访问较慢,不可用。
  • socket:两次内存拷贝,效率低,不可用。
  • linux C层里使用共享内存+pthread_mutex的方式在Android上不太可行,因为持有锁的进程被kill了锁不会释放,会导致等待锁的进程饿死,所以不可用。
  • 文件锁:可用,但是不支持递归锁/锁升级降级,但是可以自己实现。

在 MMKV_IO.cpp 代码中,可以看到下面几个锁的定义:

mmkv::ThreadLock *m_lock;
mmkv::FileLock *m_fileLock;
mmkv::InterProcessLock *m_sharedProcessLock;
mmkv::InterProcessLock *m_exclusiveProcessLock;

其中:

  • m_lock是线程锁,不是多进程的时候读写也需要加锁。通过pthread_mutex实现。
  • m_fileLock是文件锁,通过fcntl/flock实现。
  • 文件锁最后使用通过 shared或者exclusive来使用,分别表示共享锁和独占锁两种类型。
class InterProcessLock {
    FileLock *m_fileLock;
    LockType m_lockType;
}

MMKV在写入的时候,会使用独占锁,读取的时候,会使用共享锁。这样来提高此文件锁的性能。

不过目前看InterProcessLock.cpp里的代码,MMKV只实现了锁的升级,没有实现锁的可重入。可能是我没找到//

小结

画板

以上我们就摸清了MMKV的基本原理,以及他重点解决的几个问题。通过对这几个问题的理解,我们可以更好的理解MMKV的特点,在使用的时候帮助我们决定是否需要使用 MMKV。