一、目標
瞭解移動端的資料持久化方式和對應的使用場景,提供相關技術選型做技術儲備。
二、資料持久化的目的
- 快速展示,提升體驗
- 已經載入過的資料,使用者下次檢視時,不需要再次從網路(磁碟)載入,直接展示給使用者
- 節省使用者流量(節省伺服器資源)
- 對於較大的資源資料進行快取,下次展示無需下載消耗流量
- 同時降低了伺服器的訪問次數,節約伺服器資源。(圖片)
- 離線使用。
- 使用者瀏覽過的資料無需聯網,可以再次檢視。
- 部分功能使用解除對網路的依賴。(百度離線地圖、圖書閱讀器)
- 無網路時,允許使用者進行操作,等到下次聯網時同步到服務端。
- 記錄使用者操作
- 草稿:對於使用者需要花費較大成本進行的操作,對使用者的每個步驟進行快取,使用者中斷操作後,下次使用者操作時直接繼續上次的操作。
- 已讀內容標記快取,幫助使用者識別哪些已讀。
- 搜尋記錄快取
…
三、資料持久化方式分類
在移動端的資料持久化方式總體可以分為以下兩類:
1、記憶體快取
-
定義
對於使用頻率比較高的資料,從網路或者磁碟載入資料到記憶體以後,使用後並不馬上銷燬,下次使用時直接從記憶體載入。
-
案例
- iOS系統圖片載入——[UIImage imageNamed:@”imageName”]
- 網路圖片載入三方庫:SDWebImage
2、磁碟快取
-
定義
將從網路載入的、使用者操作產生的資料寫入到磁碟,使用者下次檢視、繼續操作時,直接從磁碟載入使用。
-
案例
- 使用者輸入內容草稿快取(如:評論、文字編輯)
- 網路圖片載入三方庫:SDWebImage
- 搜尋歷史快取
四、快取策略(常見快取演算法)
在快取設計中,由於硬體裝置的儲存空間不是無限的,我們期望儲存空間不要佔用過多,僅能快取有限的資料,但是我們希望獲得更高的命中率。想達到這一目的。通常需要藉助快取演算法來實現。
1、FIFO(First in First out)
實現原理:
FIFO 先進先出的核心思想如果一個資料最先進入快取中,則應該最早淘汰掉。類似實現一個按照時間先後順序的佇列來管理快取,將淘汰最早訪問的資料快取。
示意圖:
問題:
沒有考慮時間最近和訪問頻率對快取命中率的影響。對於使用者較高概率訪問最近訪問資料的情況,命中率會比較低。
2、LFU(Least Frequently Used)
實現原理:
LFU 最近最少使用演算法是基於“如果一個資料在最近一段時間內使用次數很少,那麼在將來一段時間內被使用的可能性也很小”的思路。記錄使用者對資料的訪問次數,將訪問次數多的資料降序排列在一個容器中,淘汰訪問次數最少的資料。
問題:
LFU僅維護各項的被訪問頻率資訊,對於某快取項,如果該項在過去有著極高的訪問頻率而最近訪問頻率較低,當快取空間已滿時該項很難被從快取中替換出來,進而導致命中率下降。
3、 LRU (LeastRecentlyUsed)
實現原理:
LRU 是一種應用廣泛的快取演算法。該演算法維護一個快取項佇列,佇列中的快取項按每項的最後被訪問時間排序。當快取空間已滿時,將處於隊尾,即刪除最後一次被訪問時間距現在最久的項,將新的區段放入佇列首部。
示意圖:
問題:
LRU演算法僅維護了快取塊的訪問時間資訊,沒有考慮被訪問頻率等因素,當存在熱點資料時,LRU的效率很好,但偶發性的、週期性的批量操作會導致LRU命中率急劇下降。例如對於VoD(視訊點播)系統,使用者已經訪問過的資料不會重複訪問等場景。
4、 LRU-K (LeastRecentlyUsed)
實現原理:
相比LRU,其核心思想是將“最近使用過1次”的判斷標準擴充套件為“最近使用過K次”。具體來說它多維護一個佇列,記錄所有快取資料被訪問的歷史。僅當資料的訪問次數達到K次的時候,才將資料放入快取。當需要淘汰資料時,LRU-K會淘汰第K次訪問時間距當前時間最大的資料。
示意圖:
問題:
需要額外的空間來儲存訪問歷史,維護兩個佇列增加了演算法的複雜度,提升了CPU等消耗。
5、2Q(Two queues)
實現原理:
2Q演算法類似於LRU-2,不同點在於2Q將LRU-2演算法中的訪問歷史佇列(注意這不是快取資料的)改為一個FIFO快取佇列,即:2Q演算法有兩個快取佇列,一個是FIFO佇列,一個是LRU佇列。
示意圖:
問題:
需要兩個佇列,但兩個佇列本身都比較簡單,2Q演算法和LRU-2演算法命中率、記憶體消耗都比較接近,但對於最後快取的資料來說,2Q會減少一次從原始儲存讀取資料或者計算資料的操作。
6、MQ(Multi Queue)
實現原理:
MQ演算法根據優先順序(訪問頻率)將資料劃分為多個LRU佇列,其核心思想是:優先快取訪問次數多的資料。
示意圖:
問題:
多個佇列需要額外的空間來儲存快取,維護多個佇列增加了演算法的複雜度,提升了CPU等消耗。
五、iOS端可供選擇的資料持久化方案
1. 記憶體快取
實現記憶體快取的技術手段包括蘋果官方提供的NSURLCache,NSCache,還有效能和API上比較有優勢的開源快取庫YYCache、PINCache等。
2. 磁碟快取
-
NSUserDefault
適合小規模資料,弱業務相關資料的快取。
-
keychain
Keychain是蘋果提供的帶有可逆加密的儲存機制,普遍用在各種存使用者名稱、密碼的需求上。另外,Keychain是系統級儲存,還可以被iCloud同步,即使App被刪除,Keychain資料依然保留,使用者下次安裝App,可以直接讀取,通常會用來儲存使用者唯一標識串。所以需要加密、同步iCloud的敏感小資料,一般使用Keychain存取。
-
檔案儲存
- Plist:一般結構化的資料可以Plist的方式去持久化
- archive:Archive方式可以存取遵循協議的資料,比較方便的是存取使用的都是物件,不過中間的序列化和反序列化需要花費一定的效能,可以在想要使用物件直接進行磁碟存取時使用。
- Stream:指檔案儲存,一般用來存圖片、視訊檔案等資料
-
資料庫儲存
資料庫適合存取一些關係型的資料;可以在有大量的條件查詢排序類需求時使用。
3. 應該用哪種快取方案
根據需求選擇:
- 簡單資料儲存直接寫檔案、key-value存取即可。
- 需要按照一些條件查詢、排序等需求的,可以使用sqlite等關係型儲存方式。
- 敏感性高的資料,加密儲存。
- 不希望App刪除後清除的小容量資料(使用者名稱、密碼、token)存keychain。
六、記憶體、磁碟資料持久化方案對比
1、可選方案詳解
1.1、NSCache
蘋果提供的一個簡單的記憶體快取,它有著和 NSDictionary 類似的 API,不同點是它是執行緒安全的,並且不會 retain key,內部實現了記憶體警告處理(僅應用在後臺時,會移除一部分快取)。
1.1.1、特性
- 屬性
- 名稱
- delegate:obj從cache移除時,通知代理
- countLimit:儲存數限制
- costLimit:儲存空間開銷值限制(不精確)
- evictsObjectsWithDiscardedContent(自動回收廢棄內容,沒看到這個屬性的使用場景)
- 方法
- 使用key同步存、取、刪
- 刪除所有內容
1.1.2、實現
- NSCacheEntry:內部類,將key-value轉換成改實體,用來實現雙向連結串列儲存結構
- key:鍵
- value:值
- cost:開銷
- prevByCost:上個節點
- nextByCost:下個節點
- NSCacheKey:對存取使用的key的封裝,用於實現存取使用不支援NSCopy協議的object
- value:存取使用的key的值
- _entries:NSDictionary,使用它以鍵值對形式存取NSCacheEntry例項
- _head:雙向連結串列頭節點,連結串列按cost升序排序;setObject觸發costLimit/countLimit trim時,從根節點開始刪除
- NSLock:實現讀寫執行緒安全
1.2、TMCache
TMCache 最初由 Tumblr 開發,但現在已經不再維護了。TMMemoryCache 實現了很多 NSCache 並沒有提供的功能,比如數量限制、總容量限制、存活時間限制、記憶體警告或應用退到後臺時清空快取等。TMMemoryCache 在設計時,主要目標是執行緒安全,它把所有讀寫操作都放到了同一個 concurrent queue 中,然後用 dispatch_barrier_async 來保證任務能順序執行。它錯誤的用了大量非同步 block 回撥來實現存取功能,以至於產生了很大的效能和死鎖問題。由於該庫很久不再維護,不做詳細對比。
1.3、PINCache
Tumblr 宣佈不在維護 TMCache 後,由 Pinterest 維護和改進的一個快取SDK。它的功能和介面基本和 TMCache 一樣,但修復了效能和死鎖的問題。它同樣也用 dispatch_semaphore 來保證執行緒安全,但去掉了dispatch_barrier_async,避免了執行緒切換帶來的巨大開銷,也避免了可能的死鎖。
1.3.1、特性:
-
PINCaching(protocal)
- 屬性
- 名稱
- 方法
- 同步/非同步使用key存、取、刪、判斷存在、設定ttl時長、儲存空間消耗值
- 同步/非同步刪除指定日期之前的資料(磁碟快取指建立日期)
- 同步/非同步刪除過期資料
- 同步/非同步刪除所有資料
- 屬性
-
PINMemoryCache
- 屬性
- totalCost:已經使用的總開銷
- costLimit:開銷(記憶體)使用限制(每次賦值時,觸發trim)
- ageLimit:統一生命週期限制(每次賦值時,觸發trim;GCD timer迴圈觸發)
- ttlCache:是否ttl,配置此項,獲取資料只會返回生命週期存活狀態的資料
- removeAllObjectsOnMemoryWarning
- removeAllObjectsOnEnteringBackground
- 將要/已經新增、移除快取物件block監聽
- 將要/已經移除快取所有物件block監聽
- 已經接收記憶體警告、已經進入後臺block監聽
- 方法
- 同步/非同步刪除資料到指定的cost以下
- 同步/非同步刪除在指定日期之前的資料,繼續刪除資料到指定的cost以下(trimToCostLimitByDate)
- 同步/非同步遍歷所有快取資料
- 內部實現
- 通過NSMutableDictionary儲存需要快取的資料,通過額外的NSMutableDictionary來儲存createdDates(建立時間)、accessDates(最近訪問時間)、costLimit、ageLimit等資訊
- 使用互斥鎖保證多執行緒安全
- 使用PINOperationQueue實現非同步操作
- setObject觸發costLimit trim時,對accessDates進行排序,實現LRU策略
- 屬性
-
PINDiskCache
- 屬性
- prefix:快取名字首
- cacheURL:快取路徑url
- byteCount:硬碟已儲存資料大小
- byteLimit:最大硬碟儲存空間限制,預設50M(每次賦值時,觸發trim)使用時注意,丟資料時不清楚為什麼
- ageLimit:同PINMemoryCache;預設30天
- writingProtectionOption:
- ttlCache:同PINMemoryCache
- removeAllObjectsOnMemoryWarning(同PINMemoryCache)
- removeAllObjectsOnEnteringBackground(同PINMemoryCache)
- 將要/已經新增、移除快取物件block監聽(同PINMemoryCache)
- 將要/已經移除快取所有物件block監聽(同PINMemoryCache)
- 已經接收記憶體警告、已經進入後臺block監聽(同PINMemoryCache)
- 支援對key進行自定義編碼和解碼(預設移除特殊字元
.:/%
) - 支援對資料進行自定義序列化和反序列化(預設NSKeyedArchiver,需要遵守NSCoding協議)
- 方法
- lockFileAccessWhileExecutingBlockAsync、synchronouslyLockFileAccessWhileExecutingBlock:執行完所有檔案寫操作後回撥block
- fileURLForKey:獲取指定檔案的fileUrl
- 同步/非同步刪除資料到指定的cost以下(同PINMemoryCache)
- 同步/非同步刪除在指定日期之前的資料,繼續刪除資料到costLimit以下(同PINMemoryCache)
- 同步/非同步遍歷所有快取資料(同PINMemoryCache)
- 內部實現
- 通過PINDiskCacheMetadata儲存資料資訊:createdDate、lastModifiedDate、size、ageLimit;初始化時,載入所有檔案的metadata,儲存在一個NSMutableDictionary中,通過fileKey存取;
- 讀取檔案獲取createdDate、lastModifiedDate、size等資訊回寫metadata;setxattr、removexattr、getxattr儲存ageLimit資訊,回寫metadata
- trimDiskToSize:按照檔案大小降序排序刪除,先刪大檔案
- trimDiskToSizeByDate:按最近修改時間升序排序,先刪較長時間未訪問的(LRU)
- trimToDate:刪除建立日期在指定日期之前的檔案(按修改時間倒序)
- 使用互斥鎖保證多執行緒安全:
- 使用PINOperationQueue實現非同步操作
- 對accessDates進行排序,實現LRU策略
- 屬性
-
PINCache
- 屬性
- diskByteCount:設定diskCache,byteCount
- diskCache:磁碟快取
- memoryCache:記憶體快取
- 方法
- 僅有初始化方法及 的實現
- 實現
- 二級快取實現:先取記憶體;後取磁碟,取磁碟同時更新記憶體
- 使用同一個PINOperationQueue實現非同步操作
- PINOperationGroup來實現記憶體快取和磁碟快取結束回撥
- 屬性
1.3.2、實現
- PINOperationQueue(async任務通過自定義的PINOperationQueue實現)
- pthread_mutex PTHREAD_MUTEX_RECURSIVE(新增operation,執行緒安全)
- dispatch_queue:
- DISPATCH_QUEUE_SERIAL:併發數1時,直接使用序列佇列執行;使用序列佇列保證對訊號量資料操作是安全的(修改併發數時,修改訊號量數量)
- DISPATCH_QUEUE_CONCURRENT:執行block中的耗時操作
- dispatch_group:阻塞當前執行緒,用來實現 waitUntilAllOperationsAreFinished
- dispatch_semaphore:併發數量控制,併發數為大於1時使用。
- PINOperationGroup
- dispatch_group_enter、dispatch_group_leave、dispatch_group_notify,來回撥group結束block
- LRU淘汰
- 每次設定新的object時,超出costLimit部分,根據訪問時間倒序刪除
- 執行緒安全
pthread_mutex_lock
互斥?PINOperationQueue
實現多執行緒佇列任務
1.4、YYCache
大神郭曜源開源的一個記憶體快取實現,YYCache是對標PINCache實現的,實現了PINCache大部分的能力,同時做了一些針對性效能優化。記憶體快取相對於 PINMemoryCache 來說,去掉了非同步訪問的介面,儘量優化了同步訪問的效能,用 OSSpinLock pthread_mutex_t互斥鎖來保證執行緒安全。另外,快取內部用雙向連結串列和 NSDictionary 實現了 LRU 淘汰演算法。磁碟快取支援設定檔案尺寸閾值來控制寫磁碟還是存資料庫。
1.4.1、特性:
-
YYMemoryCache
- 屬性
- name:名稱
- totalCount:快取數
- totalCost:已經使用的總開銷
- countLimit:快取數限制(並非嚴格限制,GCD timer定時觸發後臺執行緒trim)
- costLimit:開銷(記憶體)使用限制(並非嚴格限制,GCD timer定時觸發後臺執行緒trim)
- ageLimit:統一生命週期限制(並非嚴格限制,GCD timer定時觸發後臺執行緒trim)
- autoTrimInterval:定時觸發trim時長,預設5s
- shouldRemoveAllObjectsOnMemoryWarning
- shouldRemoveAllObjectsWhenEnteringBackground
- releaseOnMainThread:是否允許主執行緒銷燬記憶體鍵值對,預設NO;注意,指定該值為YES後,YYMemoryCache的快取只有回到主執行緒才把快取的物件銷燬,即執行release操作。
- releaseAsynchronously:是否非同步執行緒銷燬記憶體鍵值對,預設YES
- 已經接收記憶體警告、已經進入後臺block監聽
- 方法
- 同步使用key存、取、刪、判斷存在、設定每個儲存記憶體開銷值
- 同步/非同步刪除所有快取(根據releaseOnMainThread、releaseAsynchronously決定)
- 同步trim刪除資料到指定的count以下
- 同步trim刪除資料到指定的cost以下(從tail開始移除,即移除最近未訪問資料)
- 同步trim刪除在指定日期之前的資料
- 內部實現
- _YYLinkedMapNode:連結串列節點,key、value、pre、next、cost、time(CACurrentMediaTime,最近訪問時間)資訊儲存
- _YYLinkedMap:最終使用_YYLinkedMap的節點通過連結串列方式執行增、刪、改操作
- dic、totalCost、totalCount、head(MRU)、tail(LRU)、releaseOnMainThread、releaseAsynchronously
- insertNodeAtHead
- bringNodeToHead
- removeNode
- removeTailNode
- removeAll
- 連結串列最新訪問的放在頭結點,便於執行trim操作,直接從尾節點開始刪除
- 使用pthread_mutex_t互斥鎖保證執行緒安全
- 使用DISPATCH_QUEUE_SERIAL執行增加obj快取觸發costLimit情況下的trim任務
- 屬性
-
YYDiskCache
- 屬性
- name:快取名
- path:快取路徑
- inlineThreshold:控制儲存sqlite或檔案的閾值,大於該值存檔案,預設20KB
- customArchiveBlock、customUnarchiveBlock:對資料進行自定義序列化和反序列化(預設NSKeyedArchiver,需要遵守NSCoding協議)
- customFileNameBlock:根據key名稱對檔名做自定義
- countLimit:同YYMemoryCache;預設無限制
- costLimit:同YYMemoryCache,這裡指真實的磁碟儲存大小;預設無限制
- ageLimit:同YYMemoryCache;預設無限制
- freeDiskSpaceLimit:磁碟可快取最小剩餘空間限制;預設0
- autoTrimInterval:同YYMemoryCache,預設60s
- errorLogsEnabled:錯誤日誌
- 方法
- 同步/非同步使用key存、取、判存、刪資料
- 同步/非同步刪除所有資料
- 非同步刪除所有資料並在block回撥進度
- 同步/非同步獲取totalCount、totalCost
- 同步/非同步trimToCount、trimToCost、trimToAge
- 為指定object繫結extendedData
- 內部實現
- 使用dispatch_semaphore_t:訊號量設定為1,作為鎖使用了
- 使用dispatch_queue_t:DISPATCH_QUEUE_CONCURRENT,非同步執行緒執行trim、CRUD等
- 注意:這導致所有的非同步操作回撥block都是在非同步執行緒,沒在主執行緒
- _globalInstances:NSMapTable快取了所有初始化的diskCache例項,key strong,value weak
- YYKVStorage
- 屬性
- path:快取路徑
- type:YYKVStorageTypeFile、YYKVStorageTypeSQLite、YYKVStorageTypeMixed
- errorLogsEnabled
- 方法
- 儲存key-value資料
- 根據key刪除key-value資料;刪除超過指定size的資料(訪問時間倒序刪除,每次刪除16個);刪除指定時間之前的資料(同);刪除資料到整體儲存空間到指定size內;刪除資料到整體儲存數量到指定count內;刪除所有資料
- 使用key取資料
- 判斷指定key是否存在資料;獲取儲存數量;獲取儲存佔用size
- 實現
- 內部使用selite存取資料
- 刪除所有資料:先移動到指定的trash目錄下,然後後臺刪除trash目錄?移動檔案比刪除檔案更快?
- DISPATCH_QUEUE_SERIAL:後臺刪除trash
- 屬性
-
YYCache
- 屬性
- name:名稱
- memoryCache:記憶體快取
- diskCache:磁碟快取
- 方法
- 同步/非同步使用key存、取、判存、刪除資料
- 同步/非同步刪除所有資料
- 非同步刪除所有資料並在block回撥進度
- 實現
- 二級快取:先取記憶體,再取磁碟
- 非同步操作直接使用globalQueue執行了。
- 屬性
1.4.2、實現
- 磁碟存取:封裝YYKVStorage執行檔案讀寫、seqlite操作,具體的存取操作交給它完成
- 記憶體LRU淘汰:每次設定新的object時,超出costLimit部分,根據訪問時間倒序刪除(藉助連結串列)
- 執行緒安全
pthread_mutex_lock
互斥? 實現記憶體快取執行緒安全- dispatch_semaphore_t:訊號量設定為1,作為鎖使用了
2、記憶體快取方案對比
2.1、效能
YYCache的讀寫效能均較為優秀。NSCache和PINCache各有優劣。
- 摘自YYCache 設計思路的單執行緒效能測試圖:
- 我的效能測試圖:
效能測試說明:
在YYCache Demo基礎上進行的效能測試,使用的debug包,並不代表真實使用效能情況。複製程式碼
2.1、對比
SDK | API能力 | 易用性 | 實現 | 優缺點 | 是否維護 |
---|---|---|---|---|---|
NSCache | 同步存、取、刪,設定costLimit,countLimit、delegate(僅觸發trim刪除時通知) | 中 | NSLock實現執行緒安全,內部將key-value資訊轉換為連結串列物件實體,使用NSDictionary存取實體,觸發trim時使用連結串列按cost降序刪除;應用後臺狀態觸發記憶體警告清除部分儲存 | 官方較可靠,但缺乏擴充,功能不完善,效能一般 | apple維護中 |
PINMemoryCache | 同步/非同步存、取、刪、判存、執行trim、遍歷所有已儲存資料;設定costLimit、ageLimit、ttlCache(超時資料不返回,清除)、removeAllObjectsOnMemoryWarning、removeAllObjectsOnEnteringBackground;新增刪除key-value block回撥;應用進後臺、記憶體警告block回撥; | 高 | 使用pthread_mutex_t互斥鎖實現執行緒安全,使用NSDictionary存取實體,使用額外的NSDictionary存取實體的建立時間、更新時間、cost、ageLimit等資訊,來實現相關能力,使用GCDtimer來定時trim | 功能完善,易用性高,面向協議實現,整體架構清晰,根據儲存的更新時間實現了LRU策略,但內部儲存拆分了多個NSDictionary,導致效能下降 | Pinterest維護中 |
YYMemoryCache | 同步存、取、刪、判存、trim;設定countLimit、costLimit、ageLimit、autoTrimInterval、shouldRemoveAllObjectsOnMemoryWarning、shouldRemoveAllObjectsWhenEnteringBackground、應用進入後臺/接收記憶體警告block監聽 | 高 | 使用pthread_mutex_t互斥鎖實現執行緒安全,使用_YYLinkedMapNode內部類實體儲存鍵值對資訊來實現雙向列表儲存結構,資料按訪問時間降序排序,基於此實現LRU cache | 功能完善,易用性高,實現了LRU策略,效能高;但未抽象相關協議,記憶體和磁碟快取重複度高 | 作者已不在維護 |
3、磁碟快取方案對比
3.1、效能
小資料存取YYCache完勝。20KB以上檔案存取YYCache較快。
- 摘自YYCache 設計思路的效能測試圖:
- 我的效能測試
效能測試說明:在YYCache Demo基礎上進行的效能測試,使用的debug包,並不代表真實使用效能情況。
3.2、對比
SDK | API能力 | 易用性 | 實現 | 優缺點 | 是否維護 |
---|---|---|---|---|---|
PINDiskCache | 同步/非同步存、取、刪、判斷存在、執行trim date/size/sizeByDate;設定byteLimit、ageLimit、ttlCache(超時資料不返回,清除)、NSDataWritingOptions(檔案寫入模式),設定data自定義序列化block、key的自定義編解碼block;新增刪除key-value block回撥;刪除所有資料回撥;獲取快取url、空間佔用大小,單個檔案的儲存fileUrl;執行指定操作等待檔案寫入鎖定開啟;遍歷所有的已儲存檔案 | 高 | 使用pthread_mutex_t互斥鎖實現讀寫執行緒安全,使用pthread_cond_t實現檔案讀防寫,使用PINDiskCacheMetadata將檔案資訊儲存在記憶體中方便快速讀取,使用NSDictionary用key存取實體,,使用GCDtimer來定時trim,使用dispatch_semaphore_t控制併發實現自定義OperationQueue,按順序執行快取佇列任務 | 功能完善,易用性高,面向協議實現,整體架構清晰,trim操作根據儲存的更新時間實現了LRU策略 | Pinterest維護中 |
YYDiskCache | 同步/非同步存、取、刪、判斷存在、執行trim count/cost/age、獲取totalCost、totalCount;設定inlineThreshold、countLimit、costLimit、ageLimit、freeDiskSpaceLimit、autoTrimInterval;設定data自定義序列化block、fileName自定義的block | 高 | 使用dispatch_semaphore_t訊號量實現執行緒安全;使用YYKVStorageItem內部類實體儲存鍵值對key、value、filename、size、modTime、accessTime、extendedData等資訊;由YYKVStorage實現具體檔案存取,根據sqlite存取小空間資料速度優於直接檔案讀寫的特性,設定存取方式閾值,空間小於閾值資料直接存sqlite,超過的閾值的資料索引資訊存sqlite,資料存檔案,基於此小資料存取效能較PINDiskCache提升數倍 | 功能完善,易用性高,實現了LRU策略,效能高;實現檔案不同儲存策略更高效;但未抽象相關協議,記憶體和磁碟快取重複度高 | 作者已不在維護 |
七、資料庫快取
1.1、背景
原生的sqlite使用十分繁瑣,需要大量的程式碼來完成一項sql操作,並且是c語言的API,對OC或者其它語言開發者並不友好,假如你想執行一個sql,需要做類似下面的操作:
- (void)example {
sqlite3 *conn = NULL;
//1. 開啟資料庫 NSString *path = [NSSearchPathForDirectoriesInDomains(NSDocumentationDirectory, NSUserDomainMask, YES).firstObject stringByAppendingPathComponent:@"MyDatabase.db"];
int result = sqlite3_open(path.UTF8String, &
conn);
if (result != SQLITE_OK) {
sqlite3_close(conn);
return;
} const char *createTableSQL = "CREATE TABLE t_test_table (int_col INT, float_col REAL, string_col TEXT)";
sqlite3_stmt* stmt = NULL;
int len = strlen(createTableSQL);
//2. 準備建立資料表,如果建立失敗,需要用sqlite3_finalize釋放sqlite3_stmt物件,以防止記憶體洩露。 if (sqlite3_prepare_v2(conn,createTableSQL,len,&
stmt,NULL) != SQLITE_OK) {
if (stmt) sqlite3_finalize(stmt);
sqlite3_close(conn);
return;
} //3. 通過sqlite3_step命令執行建立表的語句。對於DDL和DML語句而言,sqlite3_step執行正確的返回值只有SQLITE_DONE。 //對於SELECT查詢而言,如果有資料返回SQLITE_ROW,當到達結果集末尾時則返回SQLITE_DONE。 if (sqlite3_step(stmt) != SQLITE_DONE) {
sqlite3_finalize(stmt);
sqlite3_close(conn);
return;
} //4. 釋放建立表語句物件的資源。 sqlite3_finalize(stmt);
printf("Succeed to create test table now.\n");
//5. 構造查詢表資料的sqlite3_stmt物件。 const char* selectSQL = "SELECT * FROM TESTTABLE WHERE 1 = 0";
sqlite3_stmt* stmt2 = NULL;
if (sqlite3_prepare_v2(conn,selectSQL,strlen(selectSQL),&
stmt2,NULL) != SQLITE_OK) {
if (stmt2) sqlite3_finalize(stmt2);
sqlite3_close(conn);
return;
} //6. 根據select語句的物件,獲取結果集中的欄位數量。 int fieldCount = sqlite3_column_count(stmt2);
printf("The column count is %d.\n",fieldCount);
//7. 遍歷結果集中每個欄位meta資訊,並獲取其宣告時的型別。 for (int i = 0;
i <
fieldCount;
++i) {
//由於此時Table中並不存在資料,再有就是SQLite中的資料型別本身是動態的,所以在沒有資料時無法通過sqlite3_column_type函式獲取,此時sqlite3_column_type只會返回SQLITE_NULL, //直到有資料時才能返回具體的型別,因此這裡使用了sqlite3_column_decltype函式來獲取表宣告時給出的宣告型別。 string stype = sqlite3_column_decltype(stmt2,i);
stype = strlwr((char*)stype.c_str());
//資料型別以決定欄位親緣性的規則解析 if (stype.find("int") != string::npos) {
printf("The type of %dth column is INTEGER.\n",i);
} else if (stype.find("char") != string::npos || stype.find("text") != string::npos) {
printf("The type of %dth column is TEXT.\n",i);
} else if (stype.find("real") != string::npos || stype.find("floa") != string::npos || stype.find("doub") != string::npos ) {
printf("The type of %dth column is DOUBLE.\n",i);
}
} sqlite3_finalize(stmt2);
sqlite3_close(conn);
}複製程式碼
由於sqlite在移動端不易直接使用,所以衍生出了許多對seqlite的封裝,包括以下被大家所熟知的流行庫,它們的最終實現都指向sqlite:
- CoreData:蘋果基於sqlite封裝的ORM(Object Relational Mapping)的資料庫,直接物件對映————由於CoreData的效能較差和學習成本較高,坑又不少(見唐巧老師的我為什麼不喜歡 Core Data一文),下文不做詳細介紹
- FMDB:iOS端github使用最廣的針對OC對sqlite的封裝,支援佇列操作
- WCDB:微信技術團隊開源的對sqlite操作的封裝,支援物件和資料庫對映,ORM資料庫的一種實現,比FMDB更高效
有一個特例,它通過自建搜尋引擎實現了一套ORM資料儲存:
- Realm:realm團隊
對sqlite的封裝通過自建搜尋引擎實現的一套移動端資料庫,也是ORM資料庫的一種實現,是一個 MVCC 資料庫
1.2、對比
sqlite資料庫的使用包括增、刪、改、查等基本操作,同時在專案中運用,還需要資料轉模型、資料庫通過增刪表、欄位和資料遷移完成版本升級等操作,下文通過對這些操作在各個流行庫中的使用示例來對比各個庫的易用性。
1.2.1、FMDB
FMDB是對sqlite的面向OC的封裝,把c語言對sql的操作封裝成OC風格程式碼。主要有以下特點:
- OC風格,省去了大量重複、冗餘的C語言程式碼
- 提供了多執行緒安全的資料庫操作方法,保證資料的一致性
- 相比CoreData、Realm等更加輕量。
- 支援事務
- 支援全文檢索(fts subspec)
- 支援對WAL(Write ahead logging)模式執行checkpoint操作
FMDB基本操作示例:
// 建表NSString *sql = [NSString stringWithFormat:@"CREATE TABLE IF NOT EXISTS t_test_1 ('%@' INTEGER PRIMARY KEY AUTOINCREMENT,'%@' TEXT NOT NULL, '%@' TEXT NOT NULL, '%@' TEXT NOT NULL, '%@' TEXT NOT NULL, '%@' TEXT NOT NULL, '%@' TEXT NOT NULL, '%@' TEXT NOT NULL, '%@' TEXT NOT NULL, '%@' INTEGER NOT NULL, '%@' FLOAT NOT NULL)", KEY_ID, KEY_MODEL_ID, KEY_MODEL_NAME, KEY_SERIES_ID, KEY_SERIES_NAME, KEY_TITLE, KEY_PRICE, KEY_DEALER_PRICE, KEY_SALES_STATUS, KEY_IS_SELECTED, KEY_DATE];
FMDatabaseQueue *_dbQueue = [FMDatabaseQueue databaseQueueWithPath:@"path"];
[_dbQueue inDatabase:^(FMDatabase *db) {
BOOL result = [db executeUpdate:sql];
if (result) {
//
}
}];
// 插入一條資料NSString *insertSql = [NSString stringWithFormat:@"INSERT INTO 't_test_1'(%@,%@,%@,%@,%@,%@,%@,%@,%@,%@) VALUES(\"%@\",\"%@\",\"%@\",\"%@\",\"%@\",\"%@\",\"%@\",\"%@\",%d,%.2f)", KEY_MODEL_ID, KEY_MODEL_NAME, KEY_SERIES_ID, KEY_SERIES_NAME, KEY_TITLE, KEY_PRICE, KEY_DEALER_PRICE, KEY_SALES_STATUS, KEY_IS_SELECTED, KEY_DATE, model.model_id, model.model_name, model.Id, model.Name, model.title, model.price, model.dealer_price, model.sales_status, isSelected,time];
[_dbQueue inDatabase:^(FMDatabase *db) {
BOOL result = [db executeUpdate:sql];
if (result) {
//
}
}];
// 更新NSString *sql = @"UPDATE t_userData SET userName = ? , userAge = ? WHERE id = ?";
[_dbQueue inDatabase:^(FMDatabase *db) {
BOOL res = [db executeUpdate:sql,_nameTxteField.text,_ageTxteField.text,_userId];
if (result) {
//
}
}];
// 刪除NSString *str = [NSString stringWithFormat:@"DELETE FROM t_userData WHERE id = %ld",userid];
[_dbQueue inDatabase:^(FMDatabase *db) {
BOOL res = [db executeUpdate:str];
if (res) {
//
}
}];
// 查詢[_dbQueue inDatabase:^(FMDatabase *db) {
FMResultSet *resultSet = [db executeQuery:@"SELECT * FROM message"];
NSMutableArray<
Message *>
*messages = [[NSMutableArray alloc] init];
while ([resultSet next]) {
Message *message = [[Message alloc] init];
message.localID = [resultSet intForColumnIndex:0];
message.content = [resultSet stringForColumnIndex:1];
message.createTime = [NSDate dateWithTimeIntervalSince1970:[resultSet doubleForColumnIndex:2]];
message.modifiedTime = [NSDate dateWithTimeIntervalSince1970:[resultSet doubleForColumnIndex:3]];
[messages addObject:message];
}
}];
複製程式碼
1.2.2、WCDB
WCDB是微信技術團隊內部在微信app sqlite使用實踐抽取的一套開源封裝,主要具有以下特點:
- 通過巨集定義的方式實現了ORM對映關係,根據對映關係完成建表、資料庫新增欄位、修改欄位名(繫結別名)、資料初始化繫結等操作
- 自研了WINQ的語法,大部分場景不需要直接寫原生sqlite語句,易用性高
- 內部實現了安全的多執行緒讀寫操作(寫操作還是序列)和資料庫初始化優化,提升了效能(微信iOS SQLite原始碼優化實踐)
提供了其它較多場景的解決方案:
- 錯誤統計
- 效能統計
- 損壞修復(微信移動端資料庫元件WCDB系列(二) — 資料庫修復三板斧)
- 反注入
- 加密
在WCDB內,ORM(Object Relational Mapping)是指
- 將一個ObjC的類,對映到資料庫的表和索引;
- 將類的property,對映到資料庫表的欄位;
這一過程。通過ORM,可以達到直接通過Object進行資料庫操作,省去拼裝過程的目的。
WCDB基本操作示例:
//Message.h@interface Message : NSObject@property int localID;
@property(retain) NSString *content;
@property(retain) NSDate *createTime;
@property(retain) NSDate *modifiedTime;
@property(assign) int unused;
//You can only define the properties you need@end//Message.mm#import "Message.h"@implementation MessageWCDB_IMPLEMENTATION(Message)WCDB_SYNTHESIZE(Message, localID)WCDB_SYNTHESIZE(Message, content)WCDB_SYNTHESIZE(Message, createTime)WCDB_SYNTHESIZE(Message, modifiedTime)WCDB_PRIMARY(Message, localID)WCDB_INDEX(Message, "_index", createTime)@end//Message+WCTTableCoding.h#import "Message.h"#import <
WCDB/WCDB.h>
@interface Message (WCTTableCoding) <
WCTTableCoding>
WCDB_PROPERTY(localID)WCDB_PROPERTY(content)WCDB_PROPERTY(createTime)WCDB_PROPERTY(modifiedTime)@end複製程式碼
// 建表WCTDatabase *database = [[WCTDatabase alloc] initWithPath:path];
/* CREATE TABLE messsage (localID INTEGER PRIMARY KEY, content TEXT, createTime BLOB, modifiedTime BLOB) */BOOL result = [database createTableAndIndexesOfName:@"message" withClass:Message.class];
//插入Message *message = [[Message alloc] init];
message.localID = 1;
message.content = @"Hello, WCDB!";
message.createTime = [NSDate date];
message.modifiedTime = [NSDate date];
/* INSERT INTO message(localID, content, createTime, modifiedTime) VALUES(1, "Hello, WCDB!", 1496396165, 1496396165);
*/BOOL result = [database insertObject:message into:@"message"];
//刪除//DELETE FROM message WHERE localID>
0;
BOOL result = [database deleteObjectsFromTable:@"message" where:Message.localID >
0];
//修改//UPDATE message SET content="Hello, Wechat!";
Message *message = [[Message alloc] init];
message.content = @"Hello, Wechat!";
BOOL result = [database updateRowsInTable:@"message" onProperties:Message.content withObject:message];
//查詢//SELECT * FROM message ORDER BY localIDNSArray<
Message *>
*message = [database getObjectsOfClass:Message.class fromTable:@"message" orderBy:Message.localID.order()];
複製程式碼
1.2.3、Realm
Realm團隊 基於sqlite封裝 自建搜尋引擎實現的一套ORM資料庫操作模式,它是MVCC 資料庫,主要具有以下特點:
- 物件就是一切(ORM對映)
- MVCC 資料庫
- Realm 採用了零拷貝 架構
- 自動更新物件和查詢
- String &
Int 優化(String轉換為列舉,類似OC tagged point,) - 崩潰保護(系統異常崩潰時,通過copy-on-wirte機制儲存了你已經修改的內容)
- 真實的懶載入(使用時才從磁碟載入真實資料)
- 內部加密(引擎層內建了加密)
- 文件詳細,且有中文版
- 社群活躍,Stackoverflow能解決你幾乎所有問題
- 跨平臺,支援iOS、Android
- 提供Mac版Realm Browser,檢視資料很方便
- 簡便的資料庫版本升級。Realm可以配置資料庫版本,進行判斷升級。
- 支援KVC/KVO
- 支援監聽屬性變化通知(寫入操作觸發通知)
限制:
- 類名長度最大57個UTF8字元。
- 屬性名長度最大63個UTF8字元。
- NSData及NSString屬性不能儲存超過16M資料。
- 對字串進行排序以及不區分大小寫查詢只支援“基礎拉丁字符集”、“拉丁字元補充集”、“拉丁文擴充套件字符集 A” 以及”拉丁文擴充套件字符集 B“(UTF-8 的範圍在 0~591 之間)。
- 多執行緒訪問時需要新建新的Realm物件。
- Realm物件的 Setters &
Getters 不能被過載 - Realm沒有自增屬性。也就是沒有自增主鍵,如果需要,需要自己去賦值,如果只要求unique,那麼可以設為[[NSUUID UUID] UUIDString]
- 所有的資料模型必須直接繼承自RealmObject。這阻礙我們利用資料模型中的任意型別的繼承。(如JsonModel)
- Realm不支援集合型別,僅有一個集合RLMArray,服務端返回的陣列資料需要自己轉換。支援以下的屬性型別:BOOL、bool、int、NSInteger、long、long long、float、double、NSString、NSDate、NSData以及 被特殊型別標記的NSNumber。
Realm基本操作示例:
// 定義模型的做法和定義常規 Objective‑C 類的做法類似@interface Dog : RLMObject@property NSString *name;
@property NSData *picture;
@property NSInteger age;
@end@implementation Dog@endRLM_ARRAY_TYPE(Dog)Dog *mydog = [[Dog alloc] init];
mydog.name = @"Rex";
mydog.age = 1;
mydog.picture = nil;
// 該屬性是可空的NSLog(@"Name of dog: %@", mydog.name);
RLMRealm *realm = [RLMRealm defaultRealm];
[Dog createOrUpdateInRealm:realm withValue:mydog];
// 查詢;找到小於2歲名叫Rex的所有狗RLMResults<
Dog *>
*puppies = [Dog objectsWhere:@"age <
2 ADN name = 'Rex'"];
puppies.count;
// =>
0 因為目前還沒有任何狗狗被新增到了 Realm 資料庫中// 儲存[realm transactionWithBlock:^{
[realm addObject:mydog];
}];
// 檢索結果會實時更新puppies.count;
// =>
1/// 刪除資料[realm transactionWithBlock:^{
[realm deleteObject:mydog];
}];
//修改資料[realm transactionWithBlock:^{
theDog.age = 1;
}];
// 可以在任何一個執行緒中執行檢索、更新操作dispatch_async(dispatch_queue_create("background", 0), ^{
@autoreleasepool {
Dog *theDog = [[Dog objectsWhere:@"age == 1"] firstObject];
RLMRealm *realm = [RLMRealm defaultRealm];
[realm beginWriteTransaction];
theDog.age = 3;
[realm commitWriteTransaction];
}
});
複製程式碼
1.3 資料庫存取效能測試
效能測試說明:
測試資料見下方。由於樣本比較少(僅1種資料),只進行了部分寫入和讀取操作,並不能完全反應某個SDK的綜合效能,僅作為參考。
測試資料和測試結果見下圖:
順序插入1W條資料:
使用事務插入1W條資料:
讀取1W條資料:
多執行緒(2條)插入共2W條資料:
1.4、資料庫方案對比
SDK | 優點 | 缺點 | 是否維護 |
---|---|---|---|
FMDB | 較為輕量級的sqlite封裝,API較原生使用方便許多,對SDK本省的學習成本較低,基本支援sqlite的所有能力,如事務、FTS等 | 不支援ORM,需要每個編碼人員寫具體的sql語句,沒有較多的效能優化,資料庫操作相對複雜,關於資料加密、資料庫升級等操作需要使用者自己實現 | 是 |
WCDB | 跨平臺;sqlite的深度封裝,支援ORM,基類支援自己繼承,不需要使用者直接寫sql,上手成本低,基本支援sqlite的所有能力;內部較多的效能優化;文件較完善;擴充實現了錯誤統計、效能統計、損壞修復、反注入、加密等諸多能力,使用者需要做的事情較少 | 內部基於c++實現,基類需要.mm字尾(或者通過category解決),需要額外的巨集來標記model和資料庫的對映關係 | 是 |
REALM | 跨平臺;支援ORM;文件十分完善;MVCC的實現;零拷貝提升效能;API十分友好;提供了配套視覺化工具 | 不是基於sqlite的關係型資料庫,不能或很難建立表之間的關聯關係,專案中遇到類似場景可能較難解決; 基類只能繼承自RLMObject,不能自由繼承,不方便實現類似JsonModel等屬性繫結 | 是 |
效能資料:
八、持久化在專案中的應用(小結)
1、 圖片快取
以SDWebImage(KingFisher)為代表的圖片快取庫基本都實現了二級快取、佇列下載、非同步解壓、Category擴充等能力,常用的圖片載入展示需求都可以使用它們來完成。
2、 簡單key-value存取
系統的如NSCache、NSKeyedArchive等快取功能能滿足基本的存取需求,但是並不易用。PINCache 和 YYCache 等這些三方庫擴充了相當多的能力來滿足大部分的使用場景,並且內部通過LRU等策略來提升效率,同時內部實現了二級快取來加快載入速度,可以考率直接使用。其中PINCache雖然在一些測試資料上效能並不如YYCache,但是可以看到github的PINCache最近依然有更新,而YYCache已經兩年沒有程式碼提交了,issue沒有處理,遇到問題需要自己處理。如果考慮維護成本的比例高一些,不妨使用PINCache,反之使用YYCache。
3、 資料庫
Core Data (本人未使用過)由於入門門檻高、坑多等原因導致口碑並不太好,這裡就不推薦嘗試了。FMDB可以說經過了大量iOS App的驗證,它雖然在一些擴充套件能力上並不盡人意,但是其穩定性久經考驗,基於sqlite實現,不改變表結構資料的情況下,便於直接遷移到如WCDB等實現。WCDB和Realm同樣都是支援ORM的,基本不需要寫sql語句就能完成增刪改查,都跨平臺,擴充套件瞭如加密、資料升級等很多便捷的封裝,用起來都比FMDB更爽。但兩者相較,假如你真的想使用ORM,我更推薦WCDB,因為Realm的搜尋引擎暫不支援關聯表查詢是硬傷,而WCDB是基於sqlite的,支援直接使用sql語句查詢,如果業務中遇到類似場景無法解決,還需要從Realm遷移到sqlite花費的力氣就大了。除此之外,微信團隊本身就在使用WCDB,他們在數億使用者量的情況下遇到的效能、資料損壞等問題比我們要多得多,他們做的優化也就更多,而這些優化,你使用WCDB就可以體驗到。
4、 其它
- 封裝無論你使用哪個三方庫進行快取實現,最好做一層封裝,這樣便於你在想要切換別的實現時,直接內部做好資料遷移,對於使用方完全無感知遷移,或者僅需要其做極少的工作,而不是全量的替換
- 區分使用者目錄儲存每個使用者都使用單獨的資料夾來儲存他的資料,對資料庫也一樣,這樣做的好處在於,使用者資料不會相互汙染(比如資料庫中存在複雜的多表關聯關係時,會使你的sql語句變得很複雜,提升了你區分使用者出錯的概率),也便於進行資料診斷。
- 單例建議對於某個時間段的資料操作都交給一個物件去做,內部來保證多執行緒讀寫安全,降低出錯的概率。
- 使用者切換的處理由於區分使用者儲存目錄,切換登入使用者時,需要我們切換資料存取的例項,此時,不要馬上銷燬上個例項,上個例項可能還有未完成的讀寫任務,等待完成或中斷其操作後再銷燬。
#參考
- 文章
- iOS架構師之路:本地持久化方案
- IOS(資料持久化1)
- iOS應用架構談 本地持久化方案及動態部署
- 常見快取演算法和快取策略
- 快取淘汰演算法–LRU演算法
- iOS快取框架-PINCache解讀
- IOS 快取管理之 PINCache 使用
- YYCache 設計思路
- Sqlite學習筆記(四)&
&
SQLite-WAL原理 - 微信iOS SQLite原始碼優化實踐
- 微信移動端資料庫元件WCDB系列(二) — 資料庫修復三板斧
- 資料庫的設計:深入理解 Realm 的多執行緒處理機制
- Realm 核心資料庫引擎探祕
- Realm資料庫 從入門到“放棄”
- 使用Realm的一些總結
- Realm、WCDB與SQLite移動資料庫效能對比測試
- Realm、WCDB與SQLite移動資料庫效能測試
- 開源庫