kv数据持久化需要的功能
假设要设计一个kv的存储功能:
- 首先是可靠性,在各种情况下能够将kv保存
- 性能的要求,当时是越快越好,存储占用的越少越好
MMKV号称满足这些特性:
- 可靠,实时写入
- 高性能
如果撇去高可靠性,可以采取内存缓冲的模式,例如先存入dic,然后在合适的时间同步到文件。这种方式考虑同步的时机是一方面,而且在crash时可能dic未同步到文件。
如果撇如高性能,可以采用直接的读写文件,例如采用增量式的编码,将kv写入文件,面临的问题也很明显,就是频繁的磁盘io,效率是很低的。
MMKV的设计
下面对基本流程的总结:
- 内存映射 mmap
- 写
- 读
- crc校验
- aes加密
- 线程安全
- 内存警告
mmap
有关mmap相关的知识和使用可以看这里。对于常用kv存储来说,兼顾性能和可靠性
所以由mmap的相关知识和MMKV的设计可以猜想,MMKV使用mmap要做什么事情:
- 映射文件到内存,保存映射区的指针,方便写操作(定义了MiniCodedOutputData实现了对data按字节拷贝到指定区域内存)
- 从映射区为dic初始化,方便读操作
mmap在MMKV中的使用:
// MMKV.mm
- (void)loadFromFile {
m_fd = open(m_path.UTF8String, O_RDWR, S_IRWXU); // open 得到文件描述符m_fd
if (m_fd < 0) {
MMKVError(@"fail to open:%@, %s", m_path, strerror(errno));
} else {
m_size = 0;
struct stat st = {};
if (fstat(m_fd, &st) != -1) {
m_size = (size_t) st.st_size; // 获取文件大小,为按页对齐做准备
}
// round up to (n * pagesize) 按页对齐
if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
m_size = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
if (ftruncate(m_fd, m_size) != 0) { // 按页对齐
MMKVError(@"fail to truncate [%@] to size %zu, %s", m_mmapID, m_size, strerror(errno));
m_size = (size_t) st.st_size;
return;
}
}
// 1: 映射内存,获取内存中的指针m_ptr
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", m_mmapID, strerror(errno));
} else {
const int offset = pbFixed32Size(0);
NSData *lenBuffer = [NSData dataWithBytesNoCopy:m_ptr length:offset freeWhenDone:NO];
@try {
// 文件中真正使用的空间有多大,因为文件被按页对齐后,真正使用的空间清楚,所以在文件开始做了记录
m_actualSize = MiniCodedInputData(lenBuffer).readFixed32();
} @catch (NSException *exception) {
MMKVError(@"%@", exception);
}
MMKVInfo(@"loading [%@] with %zu size in total, file size is %zu", m_mmapID, m_actualSize, m_size);
if (m_actualSize > 0) { // 当文件中有记录时,如果第一次使用或是已经清理过,实际使用空间将为0
bool loadFromFile, needFullWriteback = false;
if (m_actualSize < m_size && m_actualSize + offset <= m_size) { // 检查文件是否正常
if ([self checkFileCRCValid] == YES) {
loadFromFile = true;
} else { // 校验失败后的行为
loadFromFile = false;
if (g_callbackHandler && [g_callbackHandler respondsToSelector:@selector(onMMKVCRCCheckFail:)]) {
auto strategic = [g_callbackHandler onMMKVCRCCheckFail:m_mmapID];
if (strategic == MMKVOnErrorRecover) { // 如果校验失败后要继续使用
loadFromFile = true;
needFullWriteback = true;
}
}
}
} else { // 根据文件中记录,文件不正常
MMKVError(@"load [%@] error: %zu size in total, file size is %zu", m_mmapID, m_actualSize, m_size);
loadFromFile = false;
if (g_callbackHandler && [g_callbackHandler respondsToSelector:@selector(onMMKVFileLengthError:)]) {
auto strategic = [g_callbackHandler onMMKVFileLengthError:m_mmapID];
if (strategic == MMKVOnErrorRecover) { // 文件不正常后要继续使用
loadFromFile = true;
needFullWriteback = true;
[self writeAcutalSize:m_size - offset]; // 重新记录下文件的相关信息
}
}
}
if (loadFromFile) { // 假定文件是正常的,从文件中读取
NSData *inputBuffer = [NSData dataWithBytesNoCopy:m_ptr + offset length:m_actualSize freeWhenDone:NO];
if (m_cryptor) {
inputBuffer = decryptBuffer(*m_cryptor, inputBuffer);
}
// 2. 初始化m_dic
// 如果文件存在错误(例如crc校验不通过),会导致数据错误或是丢失
m_dic = [MiniPBCoder decodeContainerOfClass:NSMutableDictionary.class withValueClass:NSData.class fromData:inputBuffer];
// 定位到文件尾部
m_output = new MiniCodedOutputData(m_ptr + offset + m_actualSize, m_size - offset - m_actualSize);
// 如果文件存在错误,decode到m_dic过程中可能会丢弃部分数据,所以要将m_dic,保证m_dic与文件的同步
if (needFullWriteback) {
[self fullWriteback];
}
} else { // 文件不正常且不打算恢复,需要重建,丢弃原来的数据
[self writeAcutalSize:0];
m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
[self recaculateCRCDigest];
}
} else { // 文件中没有kv,没有必要读入dic
m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
[self recaculateCRCDigest];
}
MMKVInfo(@"loaded [%@] with %zu values", m_mmapID, (unsigned long) m_dic.count);
}
}
if (m_dic == nil) {
m_dic = [NSMutableDictionary dictionary];
}
if (![self isFileValid]) {
MMKVWarning(@"[%@] file not valid", m_mmapID);
}
// 修改文件的属性
tryResetFileProtection(m_path);
tryResetFileProtection(m_crcPath);
m_needLoadFromFile = NO;
}
复制代码
写入
为了保证性能,采用增量写入的方式,这需要编解码支持,MMKV的实现了一套增量编解码方案。增量编码是基于性能的考虑,不用将m_dic中的数据全部写回。
所以写入要实现的功能:
- 将kv写入m_dic
- 检查文件剩余空间是否够,不够的话按照一定的策略分配(分配策略会选择牺牲少量磁盘空间换取效率,并且会整理kv防止占用过大存储空间),将kv写入内存映射区域,保持两者同步
在iOS的中,当app进入后台后,内存可能会被swap出,提供给活跃的app,这样会降低效率(因为要再换进内存呀),MMKV提供了后台写保护的功能(基于性能考虑):
// MMKV.mm
/// 提供对映射内存的保护,防止被系统交换
- (BOOL)protectFromBackgroundWritting:(size_t)size writeBlock:(void (^)(MiniCodedOutputData *output))block {
if (m_isInBackground) { // 如果在后台,锁定要写入的内存,防止被换出,影响效率
// 因为mlock的offset是以页为单位的,所以要计算锁定的页偏移
static const int offset = pbFixed32Size(0);
static const int pagesize = getpagesize();
size_t realOffset = offset + m_actualSize - size;
size_t pageOffset = (realOffset / pagesize) * pagesize;
size_t pointerOffset = realOffset - pageOffset;
size_t mmapSize = offset + m_actualSize - pageOffset;
char *ptr = m_ptr + pageOffset;
// 锁定要写入的内存区域
if (mlock(ptr, mmapSize) != 0) {
MMKVError(@"fail to mlock [%@], %s", m_mmapID, strerror(errno));
// just fail on this condition, otherwise app will crash anyway
//block(m_output);
return NO;
} else {
@try {
MiniCodedOutputData output(ptr + pointerOffset, size);
block(&output);
m_output->seek(size);
} @catch (NSException *exception) {
MMKVError(@"%@", exception);
return NO;
} @finally {
munlock(ptr, mmapSize);
}
}
} else {
block(m_output); // 未在后台,不需要锁定
}
return YES;
}
复制代码
// MMKV.mm
// 检查文件剩余空间是否够,不够的话按照一定的策略分配
- (BOOL)ensureMemorySize:(size_t)newSize {
[self checkLoadData];
if (![self isFileValid]) {
MMKVWarning(@"[%@] file not valid", m_mmapID);
return NO;
}
if (newSize >= m_output->spaceLeft()) {
// try a full rewrite to make space
static const int offset = pbFixed32Size(0);
NSData *data = [MiniPBCoder encodeDataWithObject:m_dic];
size_t lenNeeded = data.length + offset + newSize;
size_t avgItemSize = lenNeeded / std::max<size_t>(1, m_dic.count);
// 将要使用的空间,持续扩容一半直到足够,并在扩容后,重新映射
size_t futureUsage = avgItemSize * std::max<size_t>(8, m_dic.count / 2);
// 1. no space for a full rewrite, double it
// 2. or space is not large enough for future usage, double it to avoid frequently full rewrite
if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {
size_t oldSize = m_size;
do {
m_size *= 2;
} while (lenNeeded + futureUsage >= m_size);
MMKVInfo(@"extending [%@] file size from %zu to %zu, incoming size:%zu, futrue usage:%zu",
m_mmapID, oldSize, m_size, newSize, futureUsage);
// if we can't extend size, rollback to old state
if (ftruncate(m_fd, m_size) != 0) { // 扩充文件
MMKVError(@"fail to truncate [%@] to size %zu, %s", m_mmapID, m_size, strerror(errno));
m_size = oldSize;
return NO;
}
// 文件大小变了,所以要重新映射,先关闭原来的
if (munmap(m_ptr, oldSize) != 0) {
MMKVError(@"fail to munmap [%@], %s", m_mmapID, strerror(errno));
}
// 从老位置开始映射,因为可能系统没把这块内存分配出去,可能效率会高一些,没有找到证明此写法的详细资料
m_ptr = (char *) mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_ptr == MAP_FAILED) {
MMKVError(@"fail to mmap [%@], %s", m_mmapID, strerror(errno));
}
// check if we fail to make more space
if (![self isFileValid]) {
MMKVWarning(@"[%@] file not valid", m_mmapID);
return NO;
}
// keep m_output consistent with m_ptr -- writeAcutalSize: may fail
delete m_output;
m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
m_output->seek(m_actualSize);
}
// 加密
if (m_cryptor) {
m_cryptor->reset();
auto ptr = (unsigned char *) data.bytes;
m_cryptor->encrypt(ptr, ptr, data.length);
}
if ([self writeAcutalSize:data.length] == NO) {
return NO;
}
delete m_output;
m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
BOOL ret = [self protectFromBackgroundWritting:m_actualSize // 全量写回,实现kv的排重
writeBlock:^(MiniCodedOutputData *output) {
output->writeRawData(data);
}];
if (ret) {
[self recaculateCRCDigest];
}
return ret;
}
return YES;
}
复制代码
// MMKV.mm
// 2. 检查文件剩余空间是否够,不够的话按照一定的策略分配,将kv写入内存映射区域,保持两者同步
- (BOOL)appendData:(NSData *)data forKey:(NSString *)key {
size_t keyLength = [key lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength); // size needed to encode the key
size += data.length + pbRawVarint32Size((int32_t) data.length); // size needed to encode the value
BOOL hasEnoughSize = [self ensureMemorySize:size];
if (hasEnoughSize == NO || [self isFileValid] == NO) {
return NO;
}
// 文件是空的,全量写入,case与编码方式相关
if (m_actualSize == 0) {
NSData *allData = [MiniPBCoder encodeDataWithObject:m_dic];
if (allData.length > 0) {
if (m_cryptor) {
m_cryptor->reset();
auto ptr = (unsigned char *) allData.bytes;
m_cryptor->encrypt(ptr, ptr, allData.length);
}
BOOL ret = [self writeAcutalSize:allData.length];
if (ret) {
ret = [self protectFromBackgroundWritting:m_actualSize
writeBlock:^(MiniCodedOutputData *output) {
output->writeRawData(allData); // note: don't write size of data
}];
if (ret) {
[self recaculateCRCDigest];
}
}
return ret;
}
return NO;
} else { // case与编码方式相关,增量写入
BOOL ret = [self writeAcutalSize:m_actualSize + size];
if (ret) {
static const int offset = pbFixed32Size(0);
ret = [self protectFromBackgroundWritting:size
writeBlock:^(MiniCodedOutputData *output) {
output->writeString(key);
output->writeData(data); // note: write size of data
}];
if (ret) {
auto ptr = (uint8_t *) m_ptr + offset + m_actualSize - size;
if (m_cryptor) { // 这里是在写入内存映射区后才做的加密,因为写入的data加入了其他需要的bit(data长度)
m_cryptor->encrypt(ptr, ptr, size);
}
[self updateCRCDigest:ptr withSize:size];
}
}
return ret;
}
}
复制代码
// MMKV.mm
// 写入方法
- (BOOL)setRawData:(NSData *)data forKey:(NSString *)key {
if (data.length <= 0 || key.length <= 0) {
return NO;
}
CScopedLock lock(m_lock);
[m_dic setObject:data forKey:key]; // 1. 写入m_dic
m_hasFullWriteBack = NO;
return [self appendData:data forKey:key]; // 2. 写入文件
}
复制代码
读:
因为m_dic已经保证与文件同步了,所以直接读m_dic就可以了,在需要读数据时进行解码,所以在读时是要明确知道数据类型,如果搞错了,行为是不确定的
// MMKV.mm
// 从m_dic中获取value(NSData类型)
- (NSData *)getRawDataForKey:(NSString *)key {
CScopedLock lock(m_lock);
[self checkLoadData];
return [m_dic objectForKey:key];
}
- (id)getObjectOfClass:(Class)cls forKey:(NSString *)key {
if (key.length <= 0) {
return nil;
}
NSData *data = [self getRawDataForKey:key]; // 从获取data
if (data.length > 0) { // 解码, 支持NSObject<NSCoding>的类型和自定义解码器支持的类型
if ([MiniPBCoder isMiniPBCoderCompatibleType:cls]) {
return [MiniPBCoder decodeObjectOfClass:cls fromData:data];
} else {
if ([cls conformsToProtocol:@protocol(NSCoding)]) {
return [NSKeyedUnarchiver unarchiveObjectWithData:data];
}
}
}
return nil;
}
复制代码
crc校验
对于大文件的写入,可能发生错误的几率较大,所以对保存kv的文件使用crc32进行校验(可靠性),产生crc码也需要保存,但是因为crc比较小,所以发生错误的几率是比较小的,如果crc文件也要校验,那就是个无尽的循环了。在每次映射结束后都会做crc校验。每次写入时要更新crc码。crc码的更新方式有两种:
- 重新计算全部数据的crc码
- 做增量的crc码计算
// MMKV
- (BOOL)checkFileCRCValid {
if (m_ptr != nullptr && m_ptr != MAP_FAILED) {
int offset = pbFixed32Size(0);
m_crcDigest = (uint32_t) crc32(0, (const uint8_t *) m_ptr + offset, (uint32_t) m_actualSize); // 获取文件的crc码
// for backward compatibility
if (!isFileExist(m_crcPath)) {
MMKVInfo(@"crc32 file not found:%@", m_crcPath);
return YES;
}
NSData *oData = [NSData dataWithContentsOfFile:m_crcPath];
uint32_t crc32 = 0;
@try {
MiniCodedInputData input(oData);
crc32 = input.readFixed32(); // 获取已经记录的crc码
} @catch (NSException *exception) {
MMKVError(@"%@", exception);
}
if (m_crcDigest == crc32) {
return YES; // 校验通过
}
MMKVError(@"check crc [%@] fail, crc32:%u, m_crcDigest:%u", m_mmapID, crc32, m_crcDigest);
}
return NO;
}
复制代码
// MMKV.mm
// 通过增量更新crc码
- (void)updateCRCDigest:(const uint8_t *)ptr withSize:(size_t)length {
if (ptr == nullptr) {
return;
}
// 将原来crc码传入,进行增量的crc码计算,第一个参数是原来的crc码,如果原来的crc码为0,则相当于全量
m_crcDigest = (uint32_t) crc32(m_crcDigest, ptr, (uint32_t) length);
if (m_crcPtr == nullptr || m_crcPtr == MAP_FAILED) {
[self prepareCRCFile];
}
if (m_crcPtr == nullptr || m_crcPtr == MAP_FAILED) {
return;
}
static const size_t bufferLength = pbFixed32Size(0);
if (m_isInBackground) {
if (mlock(m_crcPtr, bufferLength) != 0) {
MMKVError(@"fail to mlock crc [%@]-%p, %d:%s", m_mmapID, m_crcPtr, errno, strerror(errno));
// just fail on this condition, otherwise app will crash anyway
return;
}
}
@try {
MiniCodedOutputData output(m_crcPtr, bufferLength);
output.writeFixed32((int32_t) m_crcDigest);
} @catch (NSException *exception) {
MMKVError(@"%@", exception);
}
if (m_isInBackground) {
munlock(m_crcPtr, bufferLength);
}
}
复制代码
aes加密
MMKV 使用了 AES CFB-128 算法来加密/解密。具体是采用了 OpenSSL(1.1.0i 版)的实现。我们选择 CFB 而不是常见的 CBC 算法,主要是因为 MMKV 使用 append-only 实现插入/更新操作,流式加密算法更加合适。-- 摘自MMKV github wiki
线程安全
MMKV是线程安全的
MMKV使用c++的类初始化和析构的特性定义了ScopedLock(作用域锁):
class CScopedLock {
NSRecursiveLock *m_oLock;
public:
CScopedLock(NSRecursiveLock *oLock) : m_oLock(oLock) { [m_oLock lock]; } // 初始化时加锁
~CScopedLock() { // 析构时解锁
[m_oLock unlock];
m_oLock = nil;
}
};
/*
{
CScopedLock lock(g_instanceLock);
操作临界资源。。。
} 超出作用域,调用lock的析构函数,解锁
*/
复制代码
使用了NSRecursiveLock进行加锁,这降低了死锁的风险,但是对性能会有少量的消耗
- 对于每个mmkv实例都会放入一个global的dic保存(缓存),来避免每次都要走做初始化,并且为该对象添加了强引用,防止被释放,并添加了g_instanceLock锁来保障,每次dic进行写操作时,加锁保护,而且保证初始化行为线程安全
- 在mmkv中的实例变量是临界资源,所以每次都要加锁,这里需要注意的是在多线程的其情况下,close之后再使用,其行为是不确定的,原因如下:
// call this method if the instance is no longer needed in the near future
// any subsequent call to the instance is undefined behavior
- (void)close;
- (void)close {
CScopedLock g_lock(g_instanceLock);
CScopedLock lock(m_lock);
MMKVInfo(@"closing %@", m_mmapID);
[self clearMemoryCache];
// 这里从dic中移除了该实例,所以引用计数会-1,不同线程有不同autoreleasepool,所以可能被释放
[g_instanceDic removeObjectForKey:m_mmapID];
}
复制代码
内存警告
因为内存过高肯能会OOM,而且会降低app运行速度(内存交换),所以在内存警告时对内存释放
// MMKV
// 主要是两个工作:1. 清理内存中m_dic 2. 关闭映射
- (void)clearMemoryCache {
CScopedLock lock(m_lock);
if (m_needLoadFromFile) {
MMKVInfo(@"ignore %@", m_mmapID);
return;
}
m_needLoadFromFile = YES;
[m_dic removeAllObjects]; // 清理m_dic
m_hasFullWriteBack = NO;
if (m_output != nullptr) {
delete m_output;
}
m_output = nullptr;
if (m_ptr != nullptr && m_ptr != MAP_FAILED) {
if (munmap(m_ptr, m_size) != 0) { // 关闭映射
MMKVError(@"fail to munmap [%@], %s", m_mmapID, strerror(errno));
}
}
m_ptr = nullptr;
if (m_fd >= 0) {
if (close(m_fd) != 0) { // 关闭文件
MMKVError(@"fail to close [%@], %s", m_mmapID, strerror(errno));
}
}
m_fd = -1;
m_size = 0;
m_actualSize = 0;
if (m_cryptor) {
m_cryptor->reset();
}
}
复制代码