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 实例

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

对应的 Native 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 类的静态方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 声明
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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 去重,之后空间还是不够就增大文件,以内存分页大小的整数倍增加
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 到内存末尾。

比如写入一个字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 实现增量更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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调用将函数中局部变量的内容直接通过移动内存指向形参中,避免直接传参的内存拷贝
赏杯咖啡 🍵 Donate