MMKV分析

基于 mmap 的高性能通用 key-value 组件, 底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。 https://github.com/Tencent/MMKV

官方对比:

IOS 循环写入随机的int 1w 次 Android 循环写入随机的int 1k 次
ios android

运行过程

  1. 通过 mmap 系统调用,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失
  2. 在内存中维护一份 k,v 字典数据
  3. 写入操作:将 value protobuf 序列化更新到字典,然后将字典再 protobuf 序列化追加到文件中(每个 Key 的更新都是直接追加到文件末尾,不是覆盖,只有当文件大小不足时才会进行 key 去重操作,所以文件大小会比通过常规方式(SharedPreferences)储存的 xml 文件大)
  4. 读取操作:将文件中的数据反序列化到内存的字典中,之后的每次 get 操作直接从内存中获取,获取到 value 之后再反序列化

原理

获取 MMKV 实例

private native static long getMMKVWithID(String mmapID, int mode, String cryptKey);

对应的 Native 代码:

extern "C" JNIEXPORT JNICALL jlong Java_com_tencent_mmkv_MMKV_getMMKVWithID(
    JNIEnv *env, jobject obj, jstring mmapID, jint mode, jstring cryptKey) {
    MMKV *kv = nullptr;
    if (!mmapID) {
        return (jlong) kv;
    }
    string str = jstring2string(env, mmapID);

    if (cryptKey != nullptr) {
        string crypt = jstring2string(env, cryptKey);
        if (crypt.length() > 0) {
            kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, &crypt);
        }
    }
    if (!kv) {
        kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, nullptr);
    }

    return (jlong) kv;
}

进一步调用 MMKV 类的静态方法:

// 声明
static MMKV *mmkvWithID(const std::string &mmapID,
                            int size = DEFAULT_MMAP_SIZE,
                            MMKVMode mode = MMKV_SINGLE_PROCESS,
                            std::string *cryptKey = nullptr);

// 实现
MMKV *MMKV::mmkvWithID(const std::string &mmapID, int size, MMKVMode mode, string *cryptKey) {

    if (mmapID.empty()) {
        return nullptr;
    }
    // 线程同步锁,使用区域锁模式
    SCOPEDLOCK(g_instanceLock);

    // static unordered_map<std::string, MMKV *> *g_instanceDic;
    // 从全局的缓存对象中获取一个对象,如果缓存中没有,就创建一个新的对象,并加到全局缓存中
    auto itr = g_instanceDic->find(mmapID);
    if (itr != g_instanceDic->end()) {
        MMKV *kv = itr->second;
        return kv;
    }
    auto kv = new MMKV(mmapID, size, mode, cryptKey);
    (*g_instanceDic)[mmapID] = kv;
    return kv;
}

题外话,MMKV 中的锁

MMKV 中封装了2个与锁相关的类:

  • ThreadLock
  • ScopedLock

    class ThreadLock {
    private:
    pthread_mutex_t m_lock;
    
    public:
    ThreadLock();
    ~ThreadLock();
    
    void lock();
    bool try_lock();
    void unlock();
    };
    
    ThreadLock::ThreadLock() {
    // 创建一个互斥锁属性
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    // 锁的类型: PTHREAD_MUTEX_RECURSIVE: 如果线程在不首先解除锁定互斥锁的情况下尝试重新锁定该互斥锁,则可成功锁定该互斥锁
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
    
    // 通过属性创建初始化锁
    pthread_mutex_init(&m_lock, &attr);
    // 销毁属性
    pthread_mutexattr_destroy(&attr);
    }
    
    ThreadLock::~ThreadLock() {
    // 析构时销毁互斥锁
    pthread_mutex_destroy(&m_lock);
    }
    
    template <typename T>
    class ScopedLock {
    T *m_lock;
    
    // just forbid it for possibly misuse
    ScopedLock(const ScopedLock<T> &other) = delete;
    
    ScopedLock &operator=(const ScopedLock<T> &other) = delete;
    
    public:
    ScopedLock(T *oLock) : m_lock(oLock) {
        assert(m_lock);
        lock();
    }
    
    ~ScopedLock() {
        unlock();
        m_lock = nullptr;
    }
    
    void lock() {
        if (m_lock) {
            m_lock->lock();
        }
    }
    
    bool try_lock() {
        if (m_lock) {
            return m_lock->try_lock();
        }
        return false;
    }
    
    void unlock() {
        if (m_lock) {
            m_lock->unlock();
        }
    }
    };
    
    #define SCOPEDLOCK(lock) _SCOPEDLOCK(lock, __COUNTER__)
    #define _SCOPEDLOCK(lock, counter) __SCOPEDLOCK(lock, counter)
    #define __SCOPEDLOCK(lock, counter) ScopedLock<decltype(lock)> __scopedLock##counter(&lock)
    

TODO ⬇️

内存准备

通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。

  • 默认文件大小:系统内存分页大小,大小一般是 4096 byte, 4KB
  • 文件存不够时,先对 key 去重,之后空间还是不够就增大文件,以内存分页大小的整数倍增加

    void MMKV::loadFromFile() {
    //获取文件句柄
    m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    if (m_fd < 0) {
        MMKVError("fail to open:%s, %s", m_path.c_str(), strerror(errno));
    } else {
        m_size = 0;
        // 获取文件大小
        struct stat st = {0};
        if (fstat(m_fd, &st) != -1) {
            m_size = static_cast<size_t>(st.st_size);
        }
        // round up to (n * pagesize)
        // 如果 size 小于系统内存分页大小 或 不是整数倍,就增加到整数倍
        if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
            size_t oldSize = m_size;
            m_size = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
            //改变文件大小到新的 size
            if (ftruncate(m_fd, m_size) != 0) {
                m_size = static_cast<size_t>(st.st_size);
            }
            //用 0 把新增的空间填满
            zeroFillFile(m_fd, oldSize, m_size - oldSize);
        }
        //建立内存映射
        m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
        if (m_ptr == MAP_FAILED) {
            MMKVError("fail to mmap [%s], %s", m_mmapID.c_str(), strerror(errno));
        } else {
            memcpy(&m_actualSize, m_ptr, Fixed32Size);
            bool loaded = false;
            if (m_actualSize > 0) {
                if (m_actualSize < m_size && m_actualSize + Fixed32Size <= m_size) {
                    if (checkFileCRCValid()) {
                        // 将文件解码为字典,存到内存中
                        m_dic = MiniPBCoder::decodeMap(inputBuffer);
                        // 将原始内存映射指针位移到已经填充之后的位置,构造成一个负责输出数据到文件的对象,供后面写数据使用
                        m_output = new CodedOutputData(m_ptr + Fixed32Size + m_actualSize, m_size - Fixed32Size - m_actualSize);
                        loaded = true;
                    }
                }
            }
        }
    }
    m_needLoadFromFile = false;
    }
    

数据组织

数据序列化方面选用 protobuf 协议

写入优化

考虑到主要使用场景是频繁地进行写入更新,将 kv 对象序列化后,append 到内存末尾。

比如写入一个字符串

bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
    if (key.empty()) {
        return false;
    }
    // 序列化 value
    auto data = MiniPBCoder::encodeDataWithObject(value);
    return setDataForKey(std::move(data), key);
}

bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {
    if (data.length() == 0 || key.empty()) {
        return false;
    }
    
    // check 文件是否已经加载到内存,没有就加载一次
    checkLoadData();

    // m_dic[key] = std::move(data);
    // 从内存中的字典拿
    auto itr = m_dic.find(key);
    if (itr == m_dic.end()) {
        // 没有就插入
        itr = m_dic.emplace(key, std::move(data)).first;
    } else {
        // 有就更新
        itr->second = std::move(data);
    }
    // 至此内存中操作结束
    // 然后进行将数据写到内存映射
    return appendDataWithKey(itr->second, key);
}

空间增长

使用 append 实现增量更新

bool MMKV::appendDataWithKey(const MMBuffer &data, const std::string &key) {

    // 扩容
    bool hasEnoughSize = ensureMemorySize(size);

    if (!hasEnoughSize || !isFileValid()) {
        return false;
    }
    // 如果实际大小为0,表示第一次写入数据
    if (m_actualSize == 0) {
        // 直接将内存中的字典全部写入
        auto allData = MiniPBCoder::encodeDataWithObject(m_dic);
        if (allData.length() > 0) {
            writeAcutalSize(allData.length());
            m_output->writeRawData(allData);
            return true;
        }
        return false;
    } else {
        // 向末尾追加数据
        writeAcutalSize(m_actualSize + size);
        m_output->writeString(key);
        m_output->writeData(data);
        auto ptr = (uint8_t *) m_ptr + Fixed32Size + m_actualSize - size;
        if (m_crypter) {
            m_crypter->encrypt(ptr, ptr, size);
        }
        updateCRCDigest(ptr, size, KeepSequence);

        return true;
    }
}

总结

  • 适应于频繁写入读取的地方
  • 文件大小会比常规方式大一些
  • 内部的一些实现比较高效
    • 函数传参采用右值引用,外部通过 move调用将函数中局部变量的内容直接通过移动内存指向形参中,避免直接传参的内存拷贝