iOS的高效能、高實時性key-value持久化元件

ryanly發表於2018-08-23

今年上半年時候看到微信開發團隊的這麼一篇文章MMKV–基於 mmap 的 iOS 高效能通用 key-value 元件,文中提到了用mmap實現一個高效能KV元件,雖然並沒有展示太多的具體程式碼,但是基本思路講的還是很清楚的。
文章最後提到了開源計劃,等了快半年還沒看到這個元件原始碼,於是決定自己試著寫一個。

輪子

按照慣例先上輪子,可以先給個小星星哦~

FastKV github

關於NSUserDefaults

在開始寫這個元件之前,應該先調研一下NSUserDefaults效能(ps:這裡有個失誤,事實上我是在寫完這個元件以後才調研的)。

據我所知NSUserDefaults有一層記憶體快取的,所以它提供了一個叫synchronize的方法用於同步磁碟和快取,但是這個方法現在蘋果在文件中告訴我們for any other reason: remove the synchronize call,總之就是再也不需要呼叫這個方法了。

測試結果如下(寫入1w次,值型別是NSInteger,環境:iPhone 8 64G, iOS 11.4)

synchronize耗時:137ms

synchronize耗時:3758ms

很明顯synchronize 對效能的損耗非常大,因為本文需要的是一個高效能高實時性的key-value持久化元件,也就是說在一些極端情況下資料也需要能夠被持久化,同時又不影響效能。所謂極端情況,比如說在App發生Crash的時候資料也能夠被儲存到磁碟中,並不會因為快取和磁碟沒來得及同步而造成資料丟失。

從資料上我們可以看到非synchronize下的效能還是挺好的,比上面那篇微信的文章中的測試結果貌似要好很多嘛。那麼mmapNSUserDefaults在高效能上的優勢似乎並不明顯的。

那麼我們再來看一下高實時性這個方面。既然蘋果在文件中告訴我們remove the synchronize,難道蘋果已經解決的NSUserDefaults的高實時性和高效能兼顧的問題?抱著試一試的心態筆者做了一下測試,答案是否定的。在不使用synchronize 的情況下,極端情況依舊會出現資料丟失的問題。那麼我們的mmap還是有它的用武之地的,至少它在保證的高實時性的時候還兼顧到了效能問題。

為了便於更好的理解,在閱讀接下來的部分前請先閱讀這篇文章。MMKV–基於 mmap 的 iOS 高效能通用 key-value 元件

資料序列化

具體的實現筆者還是參考了上面微信團隊的MMKV,那篇文章已經講得比較詳細了,因此對那篇文章的分析在這裡就不再展開了。

在這裡要提到的一個點是有關於資料序列化。MMKV在序列化時使用了Google開源的protobuf,筆者在實現的時候考慮到各方面原因決定自定義一個記憶體資料格式,這樣就避免了對protobuf的依賴。

自定義協議主要分為3個部分:Header Segment、Data Segment、Check Code。

Header Segment

32/64bit 32bit 32/64bit 32/64bit 32/64bit
VALUE_TYPE VERSION OBJC_TYPE length KEY length DATA length

這部分的長度是固定的,160bit或288bit。

VALUE_TYPE:資料的型別,目前有8種型別bool、nil、int32、int64、float、double、string、data。

VERSION:資料記錄時的版本。

OBJC_TYPE length:OC類名字串的長度。

KEY length:key的長度。

DATA length:value的長度。

Data Segment

Data Data Data
OBJC_TYPE KEY DATA

OBJC_TYPE:OC類名的字串。

KEY:key。

DATA:value。

Check Code

16bit
CRC code

CRC code:倒數16位之前資料的CRC-16迴圈冗餘檢測碼,用於後期資料校驗。

空間增長

在MMKV的文章中提到,在append時遇到記憶體不夠用的時候,會進行序列化排重;在序列化排重後還是不夠用的話就將檔案擴大一倍,直到夠用。

在只考慮在新增新的key的情況下這確實是一種簡單有效的記憶體分配策略,但是在多次更新key時可能會出現連續的排重操作,下面用一個例子來說明。

如果當前分配的mmap size僅僅只比當前正在使用的size多出極少極少一點,以至於接下來任何的append操作都會觸發排重,但是由於每次都是對key進行更新操作,如果當前mmap的資料已經是最小集合了(沒有任何重複key的資料),於是在排重完成後mmap size又剛好夠用,不需要重新分配mmap size。這時候mmap size又是僅僅只比當前正在使用的size多出極少極少一點,然後任何的append又會走一遍上述邏輯。

為了解決這個問題,筆者在append操作的時候附加了一個邏輯:如果當前是對key進行更新操作,那麼重新分配mmap size的需求大小將會擴大1倍。也就是說如果對key進行更新操作後觸發排重,這時mmap size的將會按當前需求2倍的大小嚐試進行重新分配,以空間來換取時間效能。

if (data.length + _cursize >= _mmsize) {
     // 如果是對key是update操作,那麼就按照真實需求大小2倍的來嘗試進行重新分配。
    [self reallocWithExtraSize:data.length scale:isUpdated?2:1];
} else {
    memcpy((char *)_mmptr + _cursize, data.bytes, data.length);
    _cursize += data.length;

    uint64_t dataLength = _cursize - FastKVHeaderSize;
    memcpy((char *)_mmptr + sizeof(uint32_t) + [FastKVMarkString lengthOfBytesUsingEncoding:NSUTF8StringEncoding], &dataLength, 8);
}
    

其他優化

有一些OC物件的儲存是可以優化的,比如NSDate、NSURL,在實際儲存時可以當成double和NSString來進行序列化,既提高了效能又減少了空間的佔用。

效能比較

測試結果如下(1w次,值型別是NSInteger,環境:iPhone 8 64G, iOS 11.4)

add耗時:70ms (NSUserDefults Sync:3469ms

update耗時:80ms (NSUserDefults Sync:3521ms

get耗時:10ms (NSUserDefults:48ms

測試下來mmap效能確實比NSUserDefults Sync要好不少,也和微信那篇文章中對MMKV的效能測試結果基本一致。總的來說,如果對實時性要求不高的專案,建議還是使用官方的NSUserDefults

其他開源作品

TinyPart —模組化框架 github 思否

Coolog —可擴充套件的log框架 github 思否

WhiteElephantKiller —無用程式碼掃描工具 github

相關文章