MMKV_MMKV简介
內(nèi)容來自官網(wǎng)
MMKV——基于 mmap 的高性能通用 key-value 組件
MMKV 是基于 mmap 內(nèi)存映射的 key-value 組件,底層序列化/反序列化使用 protobuf 實(shí)現(xiàn),性能高,穩(wěn)定性強(qiáng)。從 2015 年中至今,在 iOS 微信上使用已有近 3 年,其性能和穩(wěn)定性經(jīng)過了時(shí)間的驗(yàn)證。近期也已移植到 Android 平臺(tái),一并開源。
MMKV 源起
在微信客戶端的日常運(yùn)營(yíng)中,時(shí)不時(shí)就會(huì)爆發(fā)特殊文字引起系統(tǒng)的 crash,參考文章,文章里面設(shè)計(jì)的技術(shù)方案是在關(guān)鍵代碼前后進(jìn)行計(jì)數(shù)器的加減,通過檢查計(jì)數(shù)器的異常,來發(fā)現(xiàn)引起閃退的異常文字。在會(huì)話列表、會(huì)話界面等有大量 cell 的地方,希望新加的計(jì)時(shí)器不會(huì)影響滑動(dòng)性能;另外這些計(jì)數(shù)器還要永久存儲(chǔ)下來——因?yàn)殚W退隨時(shí)可能發(fā)生。這就需要一個(gè)性能非常高的通用 key-value 存儲(chǔ)組件,我們考察了 SharedPreferences、NSUserDefaults、SQLite 等常見組件,發(fā)現(xiàn)都沒能滿足如此苛刻的性能要求??紤]到這個(gè)防 crash 方案最主要的訴求還是實(shí)時(shí)寫入,而 mmap 內(nèi)存映射文件剛好滿足這種需求,我們嘗試通過它來實(shí)現(xiàn)一套 key-value 組件。
MMKV 原理內(nèi)存準(zhǔn)備
通過 mmap 內(nèi)存映射文件,提供一段可供隨時(shí)寫入的內(nèi)存塊,App 只管往里面寫數(shù)據(jù),由操作系統(tǒng)負(fù)責(zé)將內(nèi)存回寫到文件,不必?fù)?dān)心 crash 導(dǎo)致數(shù)據(jù)丟失。
數(shù)據(jù)組織
數(shù)據(jù)序列化方面我們選用 protobuf 協(xié)議,pb 在性能和空間占用上都有不錯(cuò)的表現(xiàn)。
寫入優(yōu)化
考慮到主要使用場(chǎng)景是頻繁地進(jìn)行寫入更新,我們需要有增量更新的能力。我們考慮將增量 kv 對(duì)象序列化后,append 到內(nèi)存末尾。
空間增長(zhǎng)
使用 append 實(shí)現(xiàn)增量更新帶來了一個(gè)新的問題,就是不斷 append 的話,文件大小會(huì)增長(zhǎng)得不可控。我們需要在性能和空間上做個(gè)折中。
更詳細(xì)的設(shè)計(jì)原理參考 MMKV 原理。
iOS 指南
安裝引入
推薦使用 CocoaPods:打開命令行, cd 到你的項(xiàng)目工程目錄, 輸入 pod repo update 讓 CocoaPods 感知最新的 MMKV 版本;
打開 Podfile, 添加 pod 'MMKV' 到你的 app target 里面;
在命令行輸入 pod install;
用 Xcode 打開由 CocoaPods 自動(dòng)生成的 .xcworkspace 文件;
添加頭文件 #import ,就可以愉快地開始你的 MMKV 之旅了。
更多安裝指引參考 iOS Setup。
快速上手
MMKV 的使用非常簡(jiǎn)單,無需任何配置,所有變更立馬生效,無需調(diào)用 synchronize:
MMKV *mmkv = [MMKV defaultMMKV];
[mmkv setBool:YES forKey:@"bool"];
BOOL bValue = [mmkv getBoolForKey:@"bool"];
[mmkv setInt32:-1024 forKey:@"int32"];
int32_t iValue = [mmkv getInt32ForKey:@"int32"];
[mmkv setObject:@"hello, mmkv" forKey:@"string"];
NSString *str = [mmkv getObjectOfClass:NSString.class forKey:@"string"];
性能對(duì)比
循環(huán)寫入隨機(jī)的int 1w 次,我們有如下性能對(duì)比:
Android 指南
安裝引入
推薦使用 Maven:
dependencies {
implementation 'com.tencent:mmkv:1.0.11'
// replace "1.0.11" with any available version
}
快速上手
MMKV 的使用非常簡(jiǎn)單,所有變更立馬生效,無需調(diào)用 sync、apply。 在 App 啟動(dòng)時(shí)初始化 MMKV,設(shè)定 MMKV 的根目錄(files/mmkv/),例如在 MainActivity 里:
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String rootDir = MMKV.initialize(this);
System.out.println("mmkv root: " + rootDir);
//……
}
MMKV 提供一個(gè)全局的實(shí)例,可以直接使用:
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 for Android
MMKV 是基于 mmap 內(nèi)存映射的 key-value 組件,底層序列化/反序列化使用 protobuf 實(shí)現(xiàn),性能高,穩(wěn)定性強(qiáng)。從2015年中至今,在 iOS 微信上使用已有 3 年,其性能和穩(wěn)定性經(jīng)過了時(shí)間的驗(yàn)證。
使用指南
MMKV 的使用非常簡(jiǎn)單,所有變更立馬生效,無需調(diào)用 sync、apply。
配置 MMKV 根目錄
在 App 啟動(dòng)時(shí)初始化 MMKV,設(shè)定 MMKV 的根目錄(files/mmkv/),例如在 MainActivity 里:
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
String rootDir = MMKV.initialize(this);
System.out.println("mmkv root: " + rootDir);
}
CRUD 操作MMKV 提供一個(gè)全局的實(shí)例,可以直接使用:
import com.tencent.mmkv.MMKV;
...
MMKV kv = MMKV.defaultMMKV();
kv.encode("bool", true);
System.out.println("bool: " + kv.decodeBool("bool"));
kv.encode("int", Integer.MIN_VALUE);
System.out.println("int: " + kv.decodeInt("int"));
kv.encode("long", Long.MAX_VALUE);
System.out.println("long: " + kv.decodeLong("long"));
kv.encode("float", -3.14f);
System.out.println("float: " + kv.decodeFloat("float"));
kv.encode("double", Double.MIN_VALUE);
System.out.println("double: " + kv.decodeDouble("double"));
kv.encode("string", "Hello from mmkv");
System.out.println("string: " + kv.decodeString("string"));
byte[] bytes = {'m', 'm', 'k', 'v'};
kv.encode("bytes", bytes);
System.out.println("bytes: " + new String(kv.decodeBytes("bytes")));
可以看到,MMKV 在使用上還是比較簡(jiǎn)單的。刪除 & 查詢:
MMKV kv = MMKV.defaultMMKV();
kv.removeValueForKey("bool");
System.out.println("bool: " + kv.decodeBool("bool"));
kv.removeValuesForKeys(new String[]{"int", "long"});
System.out.println("allKeys: " + Arrays.toString(kv.allKeys()));
boolean hasBool = kv.containsKey("bool");
如果不同業(yè)務(wù)需要區(qū)別存儲(chǔ),也可以單獨(dú)創(chuàng)建自己的實(shí)例:
MMKV* mmkv = MMKV.mmkvWithID("MyID");
mmkv.encode("bool", true);
如果業(yè)務(wù)需要多進(jìn)程訪問,那么在初始化的時(shí)候加上標(biāo)志位 MMKV.MULTI_PROCESS_MODE:
MMKV* mmkv = MMKV.mmkvWithID("InterProcessKV", MMKV.MULTI_PROCESS_MODE);
mmkv.encode("bool", true);
支持的數(shù)據(jù)類型支持以下 Java 語言基礎(chǔ)類型:boolean、int、long、float、double、byte[]
支持以下 Java 類和容器:String、Set
SharedPreferences 遷移MMKV 提供了 importFromSharedPreferences() 函數(shù),可以比較方便地遷移數(shù)據(jù)過來。
MMKV 還額外實(shí)現(xiàn)了一遍 SharedPreferences、SharedPreferences.Editor 這兩個(gè) interface,在遷移的時(shí)候只需兩三行代碼即可,其他 CRUD 操作代碼都不用改。
private void testImportSharedPreferences() {
// SharedPreferences preferences = getSharedPreferences("myData", MODE_PRIVATE);
MMKV preferences = MMKV.mmkvWithID("myData");
// 遷移舊數(shù)據(jù)
{
SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);
preferences.importFromSharedPreferences(old_man);
old_man.edit().clear().commit();
}
// 跟以前用法一樣
SharedPreferences.Editor editor = preferences.edit();
editor.putBoolean("bool", true);
editor.putInt("int", Integer.MIN_VALUE);
editor.putLong("long", Long.MAX_VALUE);
editor.putFloat("float", -3.14f);
editor.putString("string", "hello, imported");
HashSet set = new HashSet();
set.add("W"); set.add("e"); set.add("C"); set.add("h"); set.add("a"); set.add("t");
editor.putStringSet("string-set", set);
// 無需調(diào)用 commit()
//editor.commit();
}
MMKV for Android 多進(jìn)程設(shè)計(jì)與實(shí)現(xiàn)
前言
將 MMKV 遷移到 Android 平臺(tái)之后,很多同事反饋需要支持多進(jìn)程訪問——這在之前是沒有考慮過的(因?yàn)?iOS 不支持多進(jìn)程),需要進(jìn)行全盤的設(shè)計(jì)和仔細(xì)的實(shí)現(xiàn)。
IPC 選型
說到 IPC,首要的問題就是架構(gòu)選型,不同的架構(gòu)效果大相徑庭。
CS 架構(gòu) vs 去中心化架構(gòu)
Android 平臺(tái)第一個(gè)想到的就是 ContentProvider:一個(gè)單獨(dú)進(jìn)程管理數(shù)據(jù),數(shù)據(jù)同步不易出錯(cuò),簡(jiǎn)單好用易上手。然而它的問題也很明顯,就是一個(gè)字慢:啟動(dòng)慢,訪問也慢。這個(gè)可以說是 Android 下基于 Binder 的 CS 架構(gòu)組件的通用痛點(diǎn)。至于其他的 CS 架構(gòu),例如經(jīng)典的 socket、PIPE、message queue,因?yàn)橐辽?2 次的內(nèi)存拷貝,就更加慢了。
MMKV 追求的是極致的訪問速度,我們要盡可能地避免進(jìn)程間通信,CS 架構(gòu)是不可取的。再考慮到 MMKV 底層使用 mmap 實(shí)現(xiàn),采用去中心化的架構(gòu)是很自然的選擇。我們只需要將文件 mmap 到每個(gè)訪問進(jìn)程的內(nèi)存空間,加上合適的進(jìn)程鎖,再處理好數(shù)據(jù)的同步,就能夠?qū)崿F(xiàn)多進(jìn)程并發(fā)訪問。
挑選進(jìn)程鎖
然而去中心化的架構(gòu)實(shí)現(xiàn)起來并不簡(jiǎn)單,Android 是個(gè)閹割版的 Linux,IPC 組件的支持比較殘缺。例如,說到進(jìn)程鎖第一個(gè)想到的就是 pthread 庫(kù)的 pthread_mutex,創(chuàng)建于共享內(nèi)存的 pthread_mutex 是可以用作進(jìn)程鎖的,然而 Android 版的 pthread_mutex 并不保證robust,亦即對(duì) pthread_mutex 加了鎖的進(jìn)程被 kill,系統(tǒng)不會(huì)進(jìn)行清理工作,這個(gè)鎖會(huì)一直存在下去,那么其他等鎖的進(jìn)程就會(huì)永遠(yuǎn)餓死。其他的 IPC 組件,例如信號(hào)量、條件變量,也有同樣問題,Android 為了能夠盡快關(guān)閉進(jìn)程,真是無所不用其極。
找了一圈,能夠保證 robust 的,只有已打開的文件描述符,以及基于文件描述符的文件鎖和 Binder 組件的死亡通知(是的,Binder 也是依賴這個(gè)清理機(jī)制運(yùn)作,打開的文件是 /dev/binder)。
我們有兩個(gè)選擇:文件鎖,優(yōu)點(diǎn)是天然 robust,缺點(diǎn)是不支持遞歸加鎖,也不支持讀寫鎖升級(jí)/降級(jí),需要自行實(shí)現(xiàn)。
pthread_mutex,優(yōu)點(diǎn)是 pthread 庫(kù)支持遞歸加鎖,也支持讀寫鎖升級(jí)/降級(jí),缺點(diǎn)是不 robust,需要自行清理。
關(guān)于 mutex 清理,有個(gè)可能的方案是基于 Binder 死亡通知進(jìn)行清理:A、B進(jìn)程相互注冊(cè)對(duì)方的死亡通知,在對(duì)方死亡的時(shí)候進(jìn)行清理。但有個(gè)比較棘手的場(chǎng)景:只有 A 進(jìn)程存在,那么他的死亡通知就沒人處理,留下一個(gè)永遠(yuǎn)加鎖的 mutex。Binder 規(guī)定死亡通知不能本進(jìn)程自行處理,必須由其他進(jìn)程處理,所以這個(gè)問題不好解決。
綜合各種考慮,我們先將文件鎖作為一個(gè)簡(jiǎn)單的互斥鎖,進(jìn)行 MMKV 的多進(jìn)程開發(fā),稍后再回頭解決遞歸鎖和讀寫鎖升級(jí)/降級(jí)的問題。
多進(jìn)程實(shí)現(xiàn)細(xì)節(jié)
首先我們簡(jiǎn)單回顧一下 MMKV 原來的邏輯。MMKV 本質(zhì)上是將文件 mmap 到內(nèi)存塊中,將新增的 key-value 統(tǒng)統(tǒng) append 到內(nèi)存中;到達(dá)邊界后,進(jìn)行重整回寫以騰出空間,空間還是不夠的話,就 double 內(nèi)存空間;對(duì)于內(nèi)存文件中可能存在的重復(fù)鍵值,MMKV 只選用最后寫入的作為有效鍵值。那么其他進(jìn)程為了保持?jǐn)?shù)據(jù)一致,就需要處理這三種情況:寫指針增長(zhǎng)、內(nèi)存重整、內(nèi)存增長(zhǎng)。但首先還得解決一個(gè)問題:怎么讓其他進(jìn)程感知這三種情況?
狀態(tài)同步寫指針的同步
我們可以在每個(gè)進(jìn)程內(nèi)部緩存自己的寫指針,然后在寫入鍵值的同時(shí),還要把最新的寫指針位置也寫到 mmap 內(nèi)存中;這樣每個(gè)進(jìn)程只需要對(duì)比一下緩存的指針與 mmap 內(nèi)存的寫指針,如果不一樣,就說明其他進(jìn)程進(jìn)行了寫操作。事實(shí)上 MMKV 原本就在文件頭部保存了有效內(nèi)存的大小,這個(gè)數(shù)值剛好就是寫指針的內(nèi)存偏移量,我們可以重用這個(gè)數(shù)值來校對(duì)寫指針。
內(nèi)存重整的感知
考慮使用一個(gè)單調(diào)遞增的序列號(hào),每次發(fā)生內(nèi)存重整,就將序列號(hào)遞增。將這個(gè)序列號(hào)也放到 mmap 內(nèi)存中,每個(gè)進(jìn)程內(nèi)部也緩存一份,只需要對(duì)比序列號(hào)是否一致,就能夠知道其他進(jìn)程是否觸發(fā)了內(nèi)存重整。
內(nèi)存增長(zhǎng)的感知
事實(shí)上 MMKV 在內(nèi)存增長(zhǎng)之前,會(huì)先嘗試通過內(nèi)存重整來騰出空間,重整后還不夠空間才申請(qǐng)新的內(nèi)存。所以內(nèi)存增長(zhǎng)可以跟內(nèi)存重整一樣處理。至于新的內(nèi)存大小,可以通過查詢文件大小來獲得,無需在 mmap 內(nèi)存另外存放。
狀態(tài)同步邏輯用偽碼表達(dá)大概是這個(gè)樣子:
void checkLoadData() {
if (m_sequence != mmapSequence()) {
m_sequence = mmapSequence();
if (m_size != fileSize()) {
m_size = fileSize();
// 處理內(nèi)存增長(zhǎng)
} else {
// 處理內(nèi)存重整
}
} else if (m_actualSize != mmapActualSize()) {
auto lastPosition = m_actualSize;
m_actualSize = mmapActualSize();
// 處理寫指針增長(zhǎng)
} else {
// 什么也沒發(fā)生
return;
}
}
寫指針增長(zhǎng)
當(dāng)一個(gè)進(jìn)程發(fā)現(xiàn) mmap 寫指針增長(zhǎng),就意味著其他進(jìn)程寫入了新鍵值。這些新的鍵值都 append 在原有寫指針后面,可能跟前面的 key 重復(fù),也可能是全新的 key,而原寫指針前面的鍵值都是有效的。那么我們就要把這些新鍵值都讀出來,插入或替換原有鍵值,并將寫指針同步到最新位置。
auto lastPosition = m_actualSize;
m_actualSize = mmapActualSize();
// 處理寫指針增長(zhǎng)
auto bufferSize = m_actualSize - lastPosition;
auto buffer = Buffer(lastPosition, bufferSize);
map dictionary = decodeMap(buffer);
for (auto& itr : dictionary) {
// m_cache 還是有效的
m_cache[itr.first] = itr.second;
}
內(nèi)存重整
當(dāng)一個(gè)進(jìn)程發(fā)現(xiàn)內(nèi)存被重整了,就意味著原寫指針前面的鍵值全部失效,那么最簡(jiǎn)單的做法是全部拋棄掉,從頭開始重新加載一遍。
// 處理內(nèi)存重整
m_actualSize = mmapActualSize();
auto buffer = Buffer(0, m_actualSize);
m_cache = decodeMap(buffer);
內(nèi)存增長(zhǎng)
正如前文所述,發(fā)生內(nèi)存增長(zhǎng)的時(shí)候,必然已經(jīng)先發(fā)生了內(nèi)存重整,那么原寫指針前面的鍵值也是統(tǒng)統(tǒng)失效,處理邏輯跟內(nèi)存重整一樣。
文件鎖
到這里我們已經(jīng)完成了數(shù)據(jù)的多進(jìn)程同步工作,是時(shí)候回頭處理鎖事了,亦即前面提到的遞歸鎖和鎖升級(jí)/降級(jí)。遞歸鎖
意思是如果一個(gè)進(jìn)程/線程已經(jīng)擁有了鎖,那么后續(xù)的加鎖操作不會(huì)導(dǎo)致卡死,并且解鎖也不會(huì)導(dǎo)致外層的鎖被解掉。對(duì)于文件鎖來說,前者是滿足的,后者則不然。因?yàn)槲募i是狀態(tài)鎖,沒有計(jì)數(shù)器,無論加了多少次鎖,一個(gè)解鎖操作就全解掉。只要用到子函數(shù),就非常需要遞歸鎖。
鎖升級(jí)/降級(jí)
鎖升級(jí)是指將已經(jīng)持有的共享鎖,升級(jí)為互斥鎖,亦即將讀鎖升級(jí)為寫鎖;鎖降級(jí)則是反過來。文件鎖支持鎖升級(jí),但是容易死鎖:假如 A、B 進(jìn)程都持有了讀鎖,現(xiàn)在都想升級(jí)到寫鎖,就會(huì)陷入相互等待的困境,發(fā)生死鎖。另外,由于文件鎖不支持遞歸鎖,也導(dǎo)致了鎖降級(jí)無法進(jìn)行,一降就降到?jīng)]有鎖。
為了解決這兩個(gè)難題,需要對(duì)文件鎖進(jìn)行封裝,增加讀鎖、寫鎖計(jì)數(shù)器。處理邏輯如下表:
需要注意的地方有兩點(diǎn):加寫鎖時(shí),如果當(dāng)前已經(jīng)持有讀鎖,那么先嘗試加寫鎖,try_lock 失敗說明其他進(jìn)程持有了讀鎖,我們需要先將自己的讀鎖釋放掉,再進(jìn)行加寫鎖操作,以避免死鎖的發(fā)生。
解寫鎖時(shí),假如之前曾經(jīng)持有讀鎖,那么我們不能直接釋放掉寫鎖,這樣會(huì)導(dǎo)致讀鎖也解了。我們應(yīng)該加一個(gè)讀鎖,將鎖降級(jí)。
MMKV 多進(jìn)程性能
寫了個(gè)簡(jiǎn)單的測(cè)試,創(chuàng)建兩個(gè) Service,測(cè)試 MMKV、MultiProcessSharedPreferences、SQLite 多進(jìn)程讀寫的性能,具體代碼見 git repo。
測(cè)試環(huán)境:Pixel 2 XL 64G, Android 8.1.0,單位:ms。每組測(cè)試分別循環(huán) 1000 次;MultiProcessSharedPreferences 使用 apply() 同步數(shù)據(jù);SQLite 打開 WAL 選項(xiàng)。
總結(jié)
以上是生活随笔為你收集整理的MMKV_MMKV简介的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: GJB 软件定型测评大纲(模板)
- 下一篇: android 音频加载hal so调试