kv資料持久化需要的功能
假設要設計一個kv的儲存功能:
- 首先是可靠性,在各種情況下能夠將kv儲存
- 效能的要求,當時是越快越好,儲存佔用的越少越好
MMKV號稱滿足這些特性:
- 可靠,實時寫入
- 高效能
如果撇去高可靠性,可以採取記憶體緩衝的模式,例如先存入dic,然後在合適的時間同步到檔案。這種方式考慮同步的時機是一方面,而且在crash時可能dic未同步到檔案。
如果撇如高效能,可以採用直接的讀寫檔案,例如採用增量式的編碼,將kv寫入檔案,面臨的問題也很明顯,就是頻繁的磁碟io,效率是很低的。
MMKV的設計
在記憶體對映後,操作檔案使用指標就可以完成,檔案與對映區的同步由核心完成,MMKV維護著一個<String, AnyObject>的dic,在寫時同時寫入dic和對映區,也就是同時寫入dic和檔案,所以dic和持久化的資料是同步的,既然是同步的,所以讀時直接取dic中的值就好了。下面對基本流程的總結:
- 記憶體對映 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();
}
}
複製程式碼