MMKV——基于 mmap 的高性能通用 key-value 缓存组件for iOS/macOS,Android ,Windows

MMKV——基于 mmap 的高性能通用 key-value 组件

https://github.com/tencent/mmkv

MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。从 2015 年中至今在微信上使用,其性能和稳定性经过了时间的验证。近期也已移植到 Android / macOS / Windows 平台,一并开源。

MMKV 源起

在微信客户端的日常运营中,时不时就会爆发特殊文字引起系统的 crash,参考文章,文章里面设计的技术方案是在关键代码前后进行计数器的加减,通过检查计数器的异常,来发现引起闪退的异常文字。在会话列表、会话界面等有大量 cell 的地方,希望新加的计时器不会影响滑动性能;另外这些计数器还要永久存储下来——因为闪退随时可能发生。这就需要一个性能非常高的通用 key-value 存储组件,我们考察了 SharedPreferences、NSUserDefaults、SQLite 等常见组件,发现都没能满足如此苛刻的性能要求。考虑到这个防 crash 方案最主要的诉求还是实时写入,而 mmap 内存映射文件刚好满足这种需求,我们尝试通过它来实现一套 key-value 组件。

MMKV 原理

  • 内存准备
    通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。
  • 数据组织
    数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。
  • 写入优化
    考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力。我们考虑将增量 kv 对象序列化后,append 到内存末尾。
  • 空间增长
    使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。我们需要在性能和空间上做个折中。

更详细的设计原理参考 MMKV 原理。

iOS/macOS 指南

安装引入

推荐使用 CocoaPods:

  1. 安装 CocoaPods;
  2. 打开命令行, cd 到你的项目工程目录, 输入 pod repo update 让 CocoaPods 感知最新的 MMKV 版本;
  3. 打开 Podfile, 添加 pod 'MMKV' 到你的 app target 里面;
  4. 在命令行输入 pod install
  5. 用 Xcode 打开由 CocoaPods 自动生成的 .xcworkspace 文件;
  6. 添加头文件 #import ,就可以愉快地开始你的 MMKV 之旅了。

更多安装指引参考 iOS/macOS Setup。

快速上手

MMKV 的使用非常简单,无需任何配置,所有变更立马生效,无需调用 synchronize:

MMKV is an efficientsmalleasy-to-use mobile key-value storage framework used in the WeChat application. It's currently available on both iOSmacOSAndroid and Windows.

Turtorial

You can use MMKV as you go, no configurations needed. All changes are saved immediately, no synchronize calls needed.

CRUD Operations

  • MMKV has a default instance, which could be used directly:

    MMKV *mmkv = [MMKV defaultMMKV];
    
    [mmkv setBool:YES forKey:@"bool"];
    NSLog(@"bool:%d", [mmkv getBoolForKey:@"bool"]);
    
    [mmkv setInt32:-1024 forKey:@"int32"];
    NSLog(@"int32:%d", [mmkv getInt32ForKey:@"int32"]);
    
    [mmkv setInt64:std::numeric_limits::min() forKey:@"int64"];
    NSLog(@"int64:%lld", [mmkv getInt64ForKey:@"int64"]);
    
    [mmkv setFloat:-3.1415926 forKey:@"float"];
    NSLog(@"float:%f", [mmkv getFloatForKey:@"float"]);
    
    [mmkv setString:@"hello, mmkv" forKey:@"string"];
    NSLog(@"string:%@", [mmkv getStringForKey:@"string"]);
    
    [mmkv setDateorKey:@"date"];
    NSLog(@"date:%@", [mmkv getDateForKey:@"date"]);
    
    NSData *data = [@"hello, mmkv again and again" dataUsingEncoding:NSUTF8StringEncoding];
    [mmkv setDataForKey:@"data"];
    data = [mmkv getDataForKey:@"data"];

NSLog(@"data:%@", [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]);

NSDictionary *dic = @{@"key1" : @"value1",
                      @"key2" : @(2)};
[mmkv setObject:dic forKey:@"dictionary"];
dic = [mmkv getObjectOfClass:[NSDictionary class] forKey:@"dictionary"];
NSLog(@"dictionary:%@", dic);

```

As you can see,MMKV is quite simple to use.

  • If different modules/logics need isolated storage, you can also create your own MMKV instance separately:

    MMKV *mmkv = [MMKV mmkvWithID:@"MyID"];
    [mmkv setBool:NO forKey:@"bool"];
  • Deleting, Querying & Enumerating:

    MMKV *mmkv = [MMKV defaultMMKV];
    
    [mmkv removeValueForKey:@"bool"];
    [mmkv removeValuesForKeys:@[@"int32", @"int64"]];
    
    BOOL hasBool = [mmkv containsKey:@"bool"];
        
    [mmkv enumerateKeys:^(NSString *key, BOOL *stop) {
        if ([key isEqualToString:@"string"]) {
            NSString *value = [mmkv getStringForKey:key];
            NSLog(@"%@ = %@", key, value);
            *stop = YES;
        }
    }];
    
    // delete everything
    [mmkv clearAll];

Supported Types

  • C/C++ Primitive Types:
    • bool, int32, int64, uint32, uint64, float, double
  • Objective-C Class:
    • NSString, NSData, NSDate
  • Any Class that implements  protocol.

Import from NSUserDefaults

  • MMKV provides -[MMKV migrateFromUserDefaults:],you can migrate from NSUserDefaults with one line of code.

    auto userDefault = [[NSUserDefaults alloc] initWithSuiteName:@"myDefault"];
    auto mmkv = [MMKV mmkvWithID:@"testImportNSUserDefaults"];
    [mmkv migrateFromUserDefaults:userDefault];
    // delete keys & values from userDefault when you're done

In Advance

There're some settings on MMKV that tune things to suit your need.

Log

  • By default, MMKV prints log to console, which is not convenient for diagnosing online issues. You can setup MMKV log redirecting on App startup. Implement protocol MMKVHandler, add some code like these:

    - (void)mmkvLogWithLevel:(MMKVLogLevel)level file:(const char *)file line:(int)line func:(const char *)funcname message:(NSString *)message {
        const char *levelDesc = nullptr;
        switch (level) {
            case MMKVLogDebug:
                levelDesc = "D";
                break;
                case MMKVLogInfo:
                levelDesc = "I";
                break;
            case MMKVLogWarning:
                levelDesc = "W";
                break;
            case MMKVLogError:
                levelDesc = "E";
                break;
            default:
                levelDesc = "N";
                break;
        }
        // use your own logging tool
        //NSLog(@"[%s] <%s:%d::%s> %@", levelDesc, file, line, funcname, message);
    }

    As for a logging tool, we recommand using xlog, which also comes from WeChat team.

  • You can turn off MMKV's logging once and for all, should you don't want to see those logs (which we strongly disrecommend).

    [MMKV setLogLevel:MMKVLogNone];

Recover from data corruption

  • By default, MMKV discards all data when there's a crc check fail, or file length is not correct, which might happen on accidentally shutdown. You can tell MMKV to recover as much data as possible. The repair rate is not promised, though. And you might get unexpected key-values from recovery. Implement protocol MMKVHandler, add some code like these:

    - (MMKVRecoverStrategic)onMMKVCRCCheckFail:(NSString *)mmapID {
        return MMKVOnErrorRecover;
    }
    
    - (MMKVRecoverStrategic)onMMKVFileLengthError:(NSString *)mmapID {
        return MMKVOnErrorRecover;
    }

Customize location

  • By default, MMKV stores file inside $(Documents)/mmkv/. You can customize MMKV's root directory on App startup:

    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES);
    auto libraryPath = (NSString *) [paths firstObject];
    auto rootDir = [libraryPath stringByAppendingPathComponent:@"mmkv_2"];
    [MMKV setMMKVBasePath:rootDir];
  • You can even customize any MMKV instance's location:

    auto path = [MMKV mmkvBasePath];
    path = [path stringByDeletingLastPathComponent];
    path = [path stringByAppendingPathComponent:@"mmkv_3"];
    auto mmkv = [MMKV mmkvWithID:@"test/case1" relativePath:path];

 

性能对比

循环写入随机的int 1w 次,我们有如下性能对比:

iOS 性能对比

我们将 MMKV 和 NSUserDefaults 进行对比,重复读写操作 1w 次。相关测试代码在 iOS/MMKVDemo/MMKVDemo/,结果见如下图表。

MMKV——基于 mmap 的高性能通用 key-value 缓存组件for iOS/macOS,Android ,Windows_第1张图片

 

(测试机器是 iPhone X 256 G,iOS 12 beta 2,每组操作重复 1w 次,时间单位是 ms。)

可见,MMKV 在写入性能上远远超越 NSUserDefaults,在读取性能上也有相近或超越的表现。

Android 性能对比

我们将 MMKV 和 SharedPreferences、SQLite 进行对比, 重复读写操作 1k 次。相关测试代码在 Android/MMKV/mmkvdemo/。结果如下图表。

  • 单进程性能
    可见,MMKV 在写入性能上远远超越 SharedPreferences & SQLite,在读取性能上也有相近或超越的表现。

    MMKV——基于 mmap 的高性能通用 key-value 缓存组件for iOS/macOS,Android ,Windows_第2张图片

     

    (测试机器是 Pixel 2 XL 64G,Android 8.1,每组操作重复 1k 次,时间单位是 ms。)

  • 多进程性能
    可见,MMKV 无论是在写入性能还是在读取性能,都远远超越 MultiProcessSharedPreferences & SQLite & SQLite, MMKV 在 Android 多进程 key-value 存储组件上是不二之选

    MMKV——基于 mmap 的高性能通用 key-value 缓存组件for iOS/macOS,Android ,Windows_第3张图片

    MMKV——基于 mmap 的高性能通用 key-value 缓存组件for iOS/macOS,Android ,Windows_第4张图片

    (测试机器是 Pixel 2 XL 64G,Android 8.1,每组操作重复 1k 次,时间单位是 ms。

更详细的性能对比参考 iOS/macOS Benchmark。

Android 指南

安装引入

推荐使用 Maven:

dependencies {
    implementation 'com.tencent:mmkv:1.0.22'
    // replace "1.0.22" with any available version
}

更多安装指引参考 Android Setup。

快速上手

MMKV 的使用非常简单,所有变更立马生效,无需调用 syncapply。 在 App 启动时初始化 MMKV,设定 MMKV 的根目录(files/mmkv/),例如在 MainActivity 里:

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    String rootDir = MMKV.initialize(this);
    System.out.println("mmkv root: " + rootDir);
    //……
}

MMKV 提供一个全局的实例,可以直接使用:

import com.tencent.mmkv.MMKV;
//……

MMKV kv = MMKV.defaultMMKV();

kv.encode("bool", true);
boolean bValue = kv.decodeBool("bool");

kv.encode("int", Integer.MIN_VALUE);
int iValue = kv.decodeInt("int");

kv.encode("string", "Hello from mmkv");
String str = kv.decodeString("string");

MMKV 支持多进程访问,更详细的用法参考 Android Tutorial。

性能对比

循环写入随机的int 1k 次,我们有如下性能对比:

更详细的性能对比参考 Android Benchmark。

Windows 指南

安装引入

推荐使用子工程:

  1. 获取 MMKV 源码:

    git clone https://github.com/Tencent/MMKV.git
    
  2. 添加工程 Win32/MMKV/MMKV.vcxproj 到你的项目里;

  3. 设置你的主工程依赖于 MMKV 工程;

  4. 添加目录 $(OutDir)include 到你主工程的 C/C++ -> 常规 -> 附加包含目录;

  5. 添加目录 $(OutDir) 到你主工程的 链接器 -> 常规 -> 附加库目录;

  6. 添加 MMKV.lib 到你主工程的 链接器 -> 输入 -> 附加依赖项;

  7. 添加头文件 #include ,就可以愉快地开始你的 MMKV 之旅了。

注意:

  1. MMKV 默认使用 MT/MTd 运行时库来编译,如果你发现主工程的配置不一样,请修改 MMKV 的配置再编译;
  2. MMKV 使用 Visual Studio 2017 开发,如果你在使用其他版本的 Visual Studio,请修改 MMKV 的工具集与主工程一致,再编译.

更多安装指引参考 Windows Setup。

快速上手

MMKV 的使用非常简单,所有变更立马生效,无需调用 savesync。 在 App 启动时初始化 MMKV,设定 MMKV 的根目录,例如在 main() 里:

#include 

int main() {
    std::wstring rootDir = getYourAppDocumentDir();
    MMKV::initializeMMKV(rootDir);
    //...
}

MMKV 提供一个全局的实例,可以直接使用:

auto mmkv = MMKV::defaultMMKV();

mmkv->set(true, "bool");
std::cout << "bool = " << mmkv->getBool("bool") << std::endl;

mmkv->set(1024, "int32");
std::cout << "int32 = " << mmkv->getInt32("int32") << std::endl;

mmkv->set("Hello, MMKV for Win32", "string");
std::string result;
mmkv->getString("string", result);
std::cout << "string = " << result << std::endl;

MMKV 支持多进程访问,更详细的用法参考 Windows Tutorial。

 

MMKV 原理

内存准备

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

数据组织

数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。考虑到我们要提供的是通用 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];
}

写入优化

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

空间增长

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

-(BOOL)append:(NSData*)data {
	if (space >= data.length) {
		append(fd, data);
	} else {
		newData = unique(m_allKV);
		if (total_space >= newData.length) {
			write(fd, newData);
		} else {
			while (total_space < newData.length) {
				total_space *= 2;
			}
			ftruncate(fd, total_space);
			write(fd, newData);
		}
	}
}

数据有效性

考虑到文件系统、操作系统都有一定的不稳定性,我们另外增加了 crc 校验,对无效数据进行甄别。在 iOS 微信现网环境上,我们观察到有平均约 70万日次的数据校验不通过。

Android 多进程访问

将 MMKV 迁移到 Android 平台之后,很多同事反馈需要支持多进程访问——这在之前是没有考虑过的(因为 iOS 不支持多进程),需要进行全盘的设计和仔细的实现。具体设计与实现参见 Android 多进程设计与实现。

你可能感兴趣的:(MMKV——基于 mmap 的高性能通用 key-value 缓存组件for iOS/macOS,Android ,Windows)