從 YYCache 原始碼 Get 到如何設計一個優秀的快取

Lision發表於2017-10-30

前言

iOS 開發中總會用到各種快取,但是各位有沒有考慮過什麼樣的快取才能被叫做優秀的快取,或者說優秀的快取應該具備哪些特質?

閉上眼睛,想一想如果面試官讓你設計一個快取你會怎麼回答?

本文將結合 YYCache 的原始碼逐步帶大家找到答案。

YYCache 是一個執行緒安全的高效能鍵值快取(該專案是 YYKit 元件之一)。YYKit 是在 2015 年釋出到 Github 的,由於其程式碼質量很高,在短時間內就收穫了大量的 Star(目前已經 1w+ Star 了),而且在 iOS 各大社群反響廣泛,Google 一下也是漫天讚歎。

YYKit 作者是 @ibireme,原名郭曜源(猜測 YY 字首來源於曜源?),是我個人非常喜歡的國人開發者(何止喜歡,簡直是迷弟?)。

YYCache 的程式碼邏輯清晰,註釋詳盡,加上自身不算太大的程式碼量使得其閱讀非常簡單,更加難能可貴的是它的效能還非常高。

從 YYCache 原始碼 Get 到如何設計一個優秀的快取

從 YYCache 原始碼 Get 到如何設計一個優秀的快取

我對它的評價是小而美,這種小而美的快取原始碼對於我們今天的主題太合適不過了(本文中 YYCache 原始碼版本為 v1.0.4)。

索引

  • YYCache 簡介
  • YYMemoryCache 細節剖析
  • YYDiskCache 細節剖析
  • 優秀的快取應該具備哪些特質
  • 總結

YYCache 簡介

從 YYCache 原始碼 Get 到如何設計一個優秀的快取

簡單把 YYCache 從頭到尾擼了一遍,最大的感觸就是程式碼風格乾淨整潔,程式碼思路清晰明瞭。

由於程式碼整體閱讀難度不是非常大,本文不會去逐字逐句的解讀原始碼,而是提煉 YYCache 作為一個小而美的快取實現了哪些快取該具備的特質,並且分析實現細節。

我們先來簡單看一下 YYCache 的程式碼結構,YYCache 是由 YYMemoryCache 與 YYDiskCache 兩部分組成的,其中 YYMemoryCache 作為高速記憶體快取,而 YYDiskCache 則作為低速磁碟快取。

通常一個快取是由記憶體快取和磁碟快取組成,記憶體快取提供容量小但高速的存取功能,磁碟快取提供大容量但低速的持久化儲存。

@interface YYCache : NSObject

@property (copy, readonly) NSString *name;
@property (strong, readonly) YYMemoryCache *memoryCache;
@property (strong, readonly) YYDiskCache *diskCache;

- (BOOL)containsObjectForKey:(NSString *)key;
- (nullable id<NSCoding>)objectForKey:(NSString *)key;
- (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key;
- (void)removeObjectForKey:(NSString *)key;

@end
複製程式碼

上面的程式碼我做了簡化,只保留了最基本的程式碼(我認為作者在最初設計 YYCache 雛形時很可能也只是提供了這些基本的介面),其他的介面只是通過呼叫基本的介面再附加對應處理程式碼而成。

Note: 其實原始碼中作者用了一些技巧性的巨集,例如 NS_ASSUME_NONNULL_BEGINNS_ASSUME_NONNULL_END 來通過編譯器層檢測入參是否為空並給予警告,參見 Nullability and Objective-C

類似上述的編碼技巧還有很多,我並非不想與大家分享我 get 到的這些編碼技巧,只是覺得它與本文的主題似乎不太相符。我準備在之後專門寫一篇文章來與大家分享我在閱讀各大原始碼庫過程中 get 到的編碼技巧(感興趣的話可以 關注我)。

從程式碼中我們可以看到 YYCache 中持有 YYMemoryCache 與 YYDiskCache,並且對外提供了一些介面。這些介面基本都是基於 Key 和 Value 設計的,類似於 iOS 原生的字典類介面(增刪改查)。

YYMemoryCache 細節剖析

從 YYCache 原始碼 Get 到如何設計一個優秀的快取

YYMemoryCache 是一個高速的記憶體快取,用於儲存鍵值對。它與 NSDictionary 相反,Key 被保留並且不復制。API 和效能類似於 NSCache,所有方法都是執行緒安全的。

YYMemoryCache 物件與 NSCache 的不同之處在於:

  • YYMemoryCache 使用 LRU(least-recently-used) 演算法來驅逐物件;NSCache 的驅逐方式是非確定性的。
  • YYMemoryCache 提供 age、cost、count 三種方式控制快取;NSCache 的控制方式是不精確的。
  • YYMemoryCache 可以配置為在收到記憶體警告或者 App 進入後臺時自動逐出物件。

Note: YYMemoryCache 中的 Access Methods 消耗時長通常是穩定的 (O(1))

@interface YYMemoryCache : NSObject

#pragma mark - Attribute
@property (nullable, copy) NSString *name; // 快取名稱,預設為 nil
@property (readonly) NSUInteger totalCount; // 快取物件總數
@property (readonly) NSUInteger totalCost; // 快取物件總開銷


#pragma mark - Limit
@property NSUInteger countLimit; // 快取物件數量限制,預設無限制,超過限制則會在後臺逐出一些物件以滿足限制
@property NSUInteger costLimit; // 快取開銷數量限制,預設無限制,超過限制則會在後臺逐出一些物件以滿足限制
@property NSTimeInterval ageLimit; // 快取時間限制,預設無限制,超過限制則會在後臺逐出一些物件以滿足限制

@property NSTimeInterval autoTrimInterval; // 快取自動清理時間間隔,預設 5s

@property BOOL shouldRemoveAllObjectsOnMemoryWarning; // 是否應該在收到記憶體警告時刪除所有快取內物件
@property BOOL shouldRemoveAllObjectsWhenEnteringBackground; // 是否應該在 App 進入後臺時刪除所有快取內物件

@property (nullable, copy) void(^didReceiveMemoryWarningBlock)(YYMemoryCache *cache); // 我認為這是一個 hook,便於我們在收到記憶體警告時自定義處理快取
@property (nullable, copy) void(^didEnterBackgroundBlock)(YYMemoryCache *cache); // 我認為這是一個 hook,便於我們在收到 App 進入後臺時自定義處理快取

@property BOOL releaseOnMainThread; // 是否在主執行緒釋放物件,預設 NO,有些物件(例如 UIView/CALayer)應該在主執行緒釋放
@property BOOL releaseAsynchronously; // 是否非同步釋放物件,預設 YES

- (BOOL)containsObjectForKey:(id)key;

- (nullable id)objectForKey:(id)key;

- (void)setObject:(nullable id)object forKey:(id)key;
- (void)setObject:(nullable id)object forKey:(id)key withCost:(NSUInteger)cost;
- (void)removeObjectForKey:(id)key;
- (void)removeAllObjects;


#pragma mark - Trim
- (void)trimToCount:(NSUInteger)count; // 用 LRU 演算法刪除物件,直到 totalCount <= count
- (void)trimToCost:(NSUInteger)cost; // 用 LRU 演算法刪除物件,直到 totalCost <= cost
- (void)trimToAge:(NSTimeInterval)age; // 用 LRU 演算法刪除物件,直到所有到期物件全部被刪除

@end
複製程式碼

YYMemoryCache 的定義程式碼比較簡單~ 該有的註釋我已經加到了上面,這裡 LRU 演算法的實現我準備單獨拎出來放到後面和(_YYLinkedMapNode_YYLinkedMap)一起講。我們這裡只需要再關注一下 YYMemoryCache 是如何做到執行緒安全的。

YYMemoryCache 是如何做到執行緒安全的

@implementation YYMemoryCache {
    pthread_mutex_t _lock; // 執行緒鎖,旨在保證 YYMemoryCache 執行緒安全
    _YYLinkedMap *_lru; // _YYLinkedMap,YYMemoryCache 通過它間接操作快取物件
    dispatch_queue_t _queue; // 序列佇列,用於 YYMemoryCache 的 trim 操作
}
複製程式碼

沒錯,這裡 ibireme 選擇使用 pthread_mutex 執行緒鎖來確保 YYMemoryCache 的執行緒安全。

有趣的是,這裡 ibireme 使用 pthread_mutex 是有一段小故事的。在最初 YYMemoryCache 這裡使用的鎖是 OSSpinLock 自旋鎖(詳見 YYCache 設計思路 備註-關於鎖),後面有人在 Github 向作者提 issue 反饋 OSSpinLock 不安全,經過作者的確認(詳見 不再安全的 OSSpinLock)最後選擇用 pthread_mutex 替代 OSSpinLock

從 YYCache 原始碼 Get 到如何設計一個優秀的快取

上面是 ibireme 在確認 OSSpinLock 不再安全之後為了尋找替代方案做的簡單效能測試,對比了一下幾種能夠替代 OSSpinLock 鎖的效能。在 不再安全的 OSSpinLock 文末的評論中,我找到了作者使用 pthread_mutex 的原因。

ibireme: 蘋果員工說 libobjc 裡 spinlock 是用了一些私有方法 (mach_thread_switch),貢獻出了高執行緒的優先來避免優先順序反轉的問題,但是我翻了下 libdispatch 的原始碼倒是沒發現相關邏輯,也可能是我忽略了什麼。在我的一些測試中,OSSpinLockdispatch_semaphore 都不會產生特別明顯的死鎖,所以我也無法確定用 dispatch_semaphore 代替 OSSpinLock 是否正確。能夠肯定的是,用 pthread_mutex 是安全的。

_YYLinkedMapNode_YYLinkedMap

上文介紹了 YYMemoryCache,其實 YYMemoryCache 並不直接操作快取物件,而是通過內部的 _YYLinkedMapNode_YYLinkedMap 來間接的操作快取物件。這兩個類對於上文中提到的 LRU 快取演算法的理解至關重要,所以我把他們倆單獨拎出來放在這裡詳細解讀一下。


/**
 _YYLinkedMap 中的一個節點。
 通常情況下我們不應該使用這個類。
 */
@interface _YYLinkedMapNode : NSObject {
    @package
    __unsafe_unretained _YYLinkedMapNode *_prev; // __unsafe_unretained 是為了效能優化,節點被 _YYLinkedMap 的 _dic 強引用
    __unsafe_unretained _YYLinkedMapNode *_next; // __unsafe_unretained 是為了效能優化,節點被 _YYLinkedMap 的 _dic 強引用
    id _key;
    id _value;
    NSUInteger _cost; // 記錄開銷,對應 YYMemoryCache 提供的 cost 控制
    NSTimeInterval _time; // 記錄時間,對應 YYMemoryCache 提供的 age 控制
}
@end


/**
 YYMemoryCache 內的一個連結串列。
 _YYLinkedMap 不是一個執行緒安全的類,而且它也不對引數做校驗。
 通常情況下我們不應該使用這個類。
 */
@interface _YYLinkedMap : NSObject {
    @package
    CFMutableDictionaryRef _dic; // 不要直接設定該物件
    NSUInteger _totalCost;
    NSUInteger _totalCount;
    _YYLinkedMapNode *_head; // MRU, 最常用節點,不要直接修改它
    _YYLinkedMapNode *_tail; // LRU, 最少用節點,不要直接修改它
    BOOL _releaseOnMainThread; // 對應 YYMemoryCache 的 releaseOnMainThread
    BOOL _releaseAsynchronously; // 對應 YYMemoryCache 的 releaseAsynchronously
}

// 連結串列操作,看介面名稱應該不需要註釋吧~
- (void)insertNodeAtHead:(_YYLinkedMapNode *)node;
- (void)bringNodeToHead:(_YYLinkedMapNode *)node;
- (void)removeNode:(_YYLinkedMapNode *)node;
- (_YYLinkedMapNode *)removeTailNode;
- (void)removeAll;

@end

複製程式碼

為了方便大家閱讀,我標註了必要的中文註釋。其實對資料結構與演算法不陌生的同學應該一眼就看的出來 _YYLinkedMapNode_YYLinkedMap 這倆貨的本質。沒錯,丫就是雙向連結串列節點和雙向連結串列。

_YYLinkedMapNode 作為雙向連結串列節點,除了基本的 _prev_next,還有鍵值快取基本的 _key_value我們可以把 _YYLinkedMapNode 理解為 YYMemoryCache 中的一個快取物件

_YYLinkedMap 作為由 _YYLinkedMapNode 節點組成的雙向連結串列,使用 CFMutableDictionaryRef _dic 字典儲存 _YYLinkedMapNode。這樣在確保 _YYLinkedMapNode 被強引用的同時,能夠利用字典的 Hash 快速定位使用者要訪問的快取物件,這樣既符合了鍵值快取的概念又省去了自己實現的麻煩(笑)。

嘛~ 總得來說 YYMemoryCache 是通過使用 _YYLinkedMap 雙向連結串列來操作 _YYLinkedMapNode 快取物件節點的。

LRU(least-recently-used) 演算法的實現

上文我們認清了 _YYLinkedMap_YYLinkedMapNode 本質上就是雙向連結串列和連結串列節點,這裡我們簡單講一下 YYMemoryCache 是如何利用雙向連結串列實現 LRU(least-recently-used) 演算法的。

快取替換策略

首先 LRU 是快取替換策略(Cache replacement policies)的一種,還有很多快取替換策略諸如:

  • First In First Out (FIFO)
  • Last In First Out (LIFO)
  • Time aware Least Recently Used (TLRU)
  • Most Recently Used (MRU)
  • Pseudo-LRU (PLRU)
  • Random Replacement (RR)
  • Segmented LRU (SLRU)
  • Least-Frequently Used (LFU)
  • Least Frequent Recently Used (LFRU)
  • LFU with Dynamic Aging (LFUDA)
  • Low Inter-reference Recency Set (LIRS)
  • Adaptive Replacement Cache (ARC)
  • Clock with Adaptive Replacement (CAR)
  • Multi Queue (MQ) caching algorithm|Multi Queue (MQ)
  • Pannier: Container-based caching algorithm for compound objects

是不是被唬到了?不要擔心,我這裡會表述的儘量易懂。

快取命中率

從 YYCache 原始碼 Get 到如何設計一個優秀的快取

為什麼有這麼多快取替換策略,或者說搞這麼多名堂究竟是為了什麼呢?

答案是提高快取命中率,那麼何謂快取命中率呢?

Google 一下自然是有不少解釋,不過很多都是 web 相關的,而且不說人話(很難理解),我個人非常討厭各種不說人話的“高深”抽象概念。

這裡抖了好幾抖膽才敢談一下我對於快取命中率的理解(限於 YYCache 和 iOS 開發)。

  • 快取命中 = 使用者要訪問的快取物件在快取記憶體中,我們直接在快取記憶體中通過 Hash 將其找到並返回給使用者。
  • 快取命中率 = 使用者要訪問的快取物件在快取記憶體中被我們訪問到的概率。

既然談到了自己的理解,我索性說個夠。

  • 快取丟失 = 由於快取記憶體數量有限(佔據記憶體等原因),所以使用者要訪問的快取物件很有可能被我們從有限的快取記憶體中淘汰掉了,我們可能會將其儲存於低速的磁碟快取中(如果磁碟快取還有資源的話),那麼就要從磁碟快取中獲取該快取物件以返回給使用者,這種情況我理解為(高速)快取未命中,即快取丟失(並不是真的被我們丟掉了,但肯定是被我們從快取記憶體淘汰掉了)。

快取命中是 cache-hit,那麼如果你玩遊戲,可以理解為這次 hit miss 了(笑,有人找我開黑嗎)。

LRU

首先來講一下 LRU 的概念讓大家有一個基本的認識。LRU(least-recently-used) 翻譯過來是“最近最少使用”,顧名思義這種快取替換策略是基於使用者最近訪問過的快取物件而建立。

我認為 LRU 快取替換策略的核心思想在於:LRU 認為使用者最新使用(訪問)過的快取物件為高頻快取物件,即使用者很可能還會再次使用(訪問)該快取物件;而反之,使用者很久之前使用(訪問)過的快取物件(期間一直沒有再次訪問)為低頻快取物件,即使用者很可能不會再去使用(訪問)該快取物件,通常在資源不足時會先去釋放低頻快取物件。

_YYLinkedMapNode_YYLinkedMap 實現 LRU

YYCache 作者通過 _YYLinkedMapNode_YYLinkedMap 雙向連結串列實現 LRU 快取替換策略的思路其實很簡捷清晰,我們一步一步來看。

雙向連結串列中有頭結點和尾節點:

  • 頭結點 = 連結串列中使用者最近一次使用(訪問)的快取物件節點,MRU。
  • 尾節點 = 連結串列中使用者已經很久沒有再次使用(訪問)的快取物件節點,LRU。

如何讓頭結點和尾節點指向我們想指向的快取物件節點?我們結合程式碼來看:

  • 在使用者使用(訪問)時更新快取節點資訊,並將其移動至雙向連結串列頭結點。
- (id)objectForKey:(id)key {
    // 判斷入參
    if (!key) return nil;
    pthread_mutex_lock(&_lock);
    // 找到對應快取節點
    _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
    if (node) {
        // 更新快取節點時間,並將其移動至雙向連結串列頭結點
        node->_time = CACurrentMediaTime();
        [_lru bringNodeToHead:node];
    }
    pthread_mutex_unlock(&_lock);
    // 返回找到的快取節點 value
    return node ? node->_value : nil;
}
複製程式碼
  • 在使用者設定快取物件時,判斷入參 key 對應的快取物件節點是否存在?存在則更新快取物件節點並將節點移動至連結串列頭結點;不存在則根據入參生成新的快取物件節點並插入連結串列表頭。
- (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost {
    // 判斷入參,省略
    ...
    pthread_mutex_lock(&_lock);
    // 判斷入參 key 對應的快取物件節點是否存在
    _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
    NSTimeInterval now = CACurrentMediaTime();
    if (node) {
        // 存在則更新快取物件節點並將節點移動至連結串列頭結點
        _lru->_totalCost -= node->_cost;
        _lru->_totalCost += cost;
        node->_cost = cost;
        node->_time = now;
        node->_value = object;
        [_lru bringNodeToHead:node];
    } else {
        // 不存在則根據入參生成新的快取物件節點並插入連結串列表頭
        node = [_YYLinkedMapNode new];
        node->_cost = cost;
        node->_time = now;
        node->_key = key;
        node->_value = object;
        [_lru insertNodeAtHead:node];
    }
    // 判斷插入、更新節點之後是否超過了限制 cost、count,如果超過則 trim,省略
    ...
    pthread_mutex_unlock(&_lock);
}
複製程式碼
  • 在資源不足時,從雙線連結串列的尾節點(LRU)開始清理快取,釋放資源。
// 這裡拿 count 資源舉例,cost、age 自己舉一反三
- (void)_trimToCount:(NSUInteger)countLimit {
    // 判斷 countLimit 為 0,則全部清空快取,省略
    // 判斷 _lru->_totalCount <= countLimit,沒有超出資源限制則不作處理,省略
    ...
    
    NSMutableArray *holder = [NSMutableArray new];
    while (!finish) {
        if (pthread_mutex_trylock(&_lock) == 0) {
            if (_lru->_totalCount > countLimit) {
                // 從雙線連結串列的尾節點(LRU)開始清理快取,釋放資源
                _YYLinkedMapNode *node = [_lru removeTailNode];
                if (node) [holder addObject:node];
            } else {
                finish = YES;
            }
            pthread_mutex_unlock(&_lock);
        } else {
            // 使用 usleep 以微秒為單位掛起執行緒,在短時間間隔掛起執行緒
            // 對比 sleep 用 usleep 能更好的利用 CPU 時間
            usleep(10 * 1000); //10 ms
        }
    }
    
    // 判斷是否需要在主執行緒釋放,採取釋放快取物件操作
    if (holder.count) {
        dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
        dispatch_async(queue, ^{
            // 非同步釋放,我們單獨拎出來講
            [holder count]; // release in queue
        });
    }
}
複製程式碼

嘛~ 是不是感覺敲簡單?上面程式碼去掉了可能會分散大家注意力的程式碼,我們這裡僅僅討論 LRU 的實現,其餘部分的具體實現原始碼也非常簡單,我覺得沒必要貼出來單獨講解,感興趣的同學可以自己去 YYCache 下載原始碼查閱。

非同步釋放技巧

關於上面的非同步釋放快取物件的程式碼,我覺得還是有必要單獨拎出來講一下的:

dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
dispatch_async(queue, ^{
    // 非同步釋放,我們單獨拎出來講
    [holder count]; // release in queue
});
複製程式碼

這個技巧 ibireme 在他的另一篇文章 iOS 保持介面流暢的技巧 中有提及:

Note: 物件的銷燬雖然消耗資源不多,但累積起來也是不容忽視的。通常當容器類持有大量物件時,其銷燬時的資源消耗就非常明顯。同樣的,如果物件可以放到後臺執行緒去釋放,那就挪到後臺執行緒去。這裡有個小 Tip:把物件捕獲到 block 中,然後扔到後臺佇列去隨便傳送個訊息以避免編譯器警告,就可以讓物件在後臺執行緒銷燬了。

而上面程式碼中的 YYMemoryCacheGetReleaseQueue 這個佇列原始碼為:

// 靜態內聯 dispatch_queue_t
static inline dispatch_queue_t YYMemoryCacheGetReleaseQueue() {
    return dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
}
複製程式碼

在原始碼中可以看到 YYMemoryCacheGetReleaseQueue 是一個低優先順序 DISPATCH_QUEUE_PRIORITY_LOW 佇列,猜測這樣設計的原因是可以讓 iOS 在系統相對空閒時再來非同步釋放快取物件。

YYDiskCache 細節剖析

從 YYCache 原始碼 Get 到如何設計一個優秀的快取

YYDiskCache 是一個執行緒安全的磁碟快取,用於儲存由 SQLite 和檔案系統支援的鍵值對(類似於 NSURLCache 的磁碟快取)。

YYDiskCache 具有以下功能:

  • 它使用 LRU(least-recently-used) 來刪除物件。
  • 支援按 cost,count 和 age 進行控制。
  • 它可以被配置為當沒有可用的磁碟空間時自動驅逐快取物件。
  • 它可以自動抉擇每個快取物件的儲存型別(sqlite/file)以便提供更好的效能表現。

Note: 您可以編譯最新版本的 sqlite 並忽略 iOS 系統中的 libsqlite3.dylib 來獲得 2x〜4x 的速度提升。

@interface YYDiskCache : NSObject

#pragma mark - Attribute
@property (nullable, copy) NSString *name; // 快取名稱,預設為 nil
@property (readonly) NSString *path; // 快取路徑

@property (readonly) NSUInteger inlineThreshold; // 閾值,大於閾值則儲存型別為 file;否則儲存型別為 sqlite

@property (nullable, copy) NSData *(^customArchiveBlock)(id object); // 用來替換 NSKeyedArchiver,你可以使用該程式碼塊以支援沒有 conform `NSCoding` 協議的物件
@property (nullable, copy) id (^customUnarchiveBlock)(NSData *data); // 用來替換 NSKeyedUnarchiver,你可以使用該程式碼塊以支援沒有 conform `NSCoding` 協議的物件

@property (nullable, copy) NSString *(^customFileNameBlock)(NSString *key); // 當一個物件將以 file 的形式儲存時,該程式碼塊用來生成指定檔名。如果為 nil,則預設使用 md5(key) 作為檔名

#pragma mark - Limit
@property NSUInteger countLimit; // 快取物件數量限制,預設無限制,超過限制則會在後臺逐出一些物件以滿足限制
@property NSUInteger costLimit; // 快取開銷數量限制,預設無限制,超過限制則會在後臺逐出一些物件以滿足限制
@property NSTimeInterval ageLimit; // 快取時間限制,預設無限制,超過限制則會在後臺逐出一些物件以滿足限制
@property NSUInteger freeDiskSpaceLimit; // 快取應該保留的最小可用磁碟空間(以位元組為單位),預設無限制,超過限制則會在後臺逐出一些物件以滿足限制

@property NSTimeInterval autoTrimInterval; // 快取自動清理時間間隔,預設 60s
@property BOOL errorLogsEnabled; // 是否開啟錯誤日誌

#pragma mark - Initializer
- (nullable instancetype)initWithPath:(NSString *)path
                      inlineThreshold:(NSUInteger)threshold NS_DESIGNATED_INITIALIZER;

- (BOOL)containsObjectForKey:(NSString *)key;

- (nullable id<NSCoding>)objectForKey:(NSString *)key;

- (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key;

- (void)removeObjectForKey:(NSString *)key;
- (void)removeAllObjects;
                                 
- (NSInteger)totalCount;
- (NSInteger)totalCost;

#pragma mark - Trim
- (void)trimToCount:(NSUInteger)count;
- (void)trimToCost:(NSUInteger)cost;
- (void)trimToAge:(NSTimeInterval)age;

#pragma mark - Extended Data
+ (nullable NSData *)getExtendedDataFromObject:(id)object;
+ (void)setExtendedData:(nullable NSData *)extendedData toObject:(id)object;

@end
複製程式碼

YYDiskCache 結構與 YYMemoryCache 類似,由於很多介面都是基於基本的介面做了擴充套件所得,這裡貼的程式碼省略了一些介面。程式碼還是一如既往的乾淨簡潔,相信各位都能看懂。

YYDiskCache 是基於 sqlite 和 file 來做的磁碟快取,我們的快取物件可以自由的選擇儲存型別,下面簡單對比一下:

  • sqlite: 對於小資料(例如 NSNumber)的存取效率明顯高於 file。
  • file: 對於較大資料(例如高質量圖片)的存取效率優於 sqlite。

所以 YYDiskCache 使用兩者配合,靈活的儲存以提高效能。

NSMapTable

NSMapTable 是類似於字典的集合,但具有更廣泛的可用記憶體語義。NSMapTable 是 iOS6 之後引入的類,它基於 NSDictionary 建模,但是具有以下差異:

  • 鍵/值可以選擇 “weakly” 持有,以便於在回收其中一個物件時刪除對應條目。
  • 它可以包含任意指標(其內容不被約束為物件)。
  • 您可以將 NSMapTable 例項配置為對任意指標進行操作,而不僅僅是物件。

Note: 配置對映表時,請注意,只有 NSMapTableOptions 中列出的選項才能保證其餘的 API 能夠正常工作,包括複製,歸檔和快速列舉。 雖然其他 NSPointerFunctions 選項用於某些配置,例如持有任意指標,但並不是所有選項的組合都有效。使用某些組合,NSMapTableOptions 可能無法正常工作,甚至可能無法正確初始化。

更多資訊詳見 NSMapTable 官方文件

需要特殊說明的是,YYDiskCache 內部是基於一個單例 NSMapTable 管理的,這點有別於 YYMemoryCache。

static NSMapTable *_globalInstances; // 引用管理所有的 YYDiskCache 例項
static dispatch_semaphore_t _globalInstancesLock; // YYDiskCache 使用 dispatch_semaphore 保障 NSMapTable 執行緒安全

static void _YYDiskCacheInitGlobal() {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _globalInstancesLock = dispatch_semaphore_create(1);
        _globalInstances = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsWeakMemory capacity:0];
    });
}

static YYDiskCache *_YYDiskCacheGetGlobal(NSString *path) {
    if (path.length == 0) return nil;
    _YYDiskCacheInitGlobal();
    dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
    id cache = [_globalInstances objectForKey:path];
    dispatch_semaphore_signal(_globalInstancesLock);
    return cache;
}

static void _YYDiskCacheSetGlobal(YYDiskCache *cache) {
    if (cache.path.length == 0) return;
    _YYDiskCacheInitGlobal();
    dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
    [_globalInstances setObject:cache forKey:cache.path];
    dispatch_semaphore_signal(_globalInstancesLock);
}
複製程式碼

每當一個 YYDiskCache 被初始化時,其實會先到 NSMapTable 中獲取對應 path 的 YYDiskCache 例項,如果獲取不到才會去真正的初始化一個 YYDiskCache 例項,並且將其引用在 NSMapTable 中,這樣做也會提升不少效能。

- (instancetype)initWithPath:(NSString *)path
             inlineThreshold:(NSUInteger)threshold {
    // 判斷是否可以成功初始化,省略
    ...
    
    // 先從 NSMapTable 單例中根據 path 獲取 YYDiskCache 例項,如果獲取到就直接返回該例項
    YYDiskCache *globalCache = _YYDiskCacheGetGlobal(path);
    if (globalCache) return globalCache;
    
    // 沒有獲取到則初始化一個 YYDiskCache 例項
    // 要想初始化一個 YYDiskCache 首先要初始化一個 YYKVStorage
    YYKVStorage *kv = [[YYKVStorage alloc] initWithPath:path type:type];
    if (!kv) return nil;
    
    // 根據剛才得到的 kv 和 path 入參初始化一個 YYDiskCache 例項,程式碼太長省略
    ...
    
    // 開啟遞迴清理,會根據 _autoTrimInterval 對 YYDiskCache trim
    [self _trimRecursively];
    // 向 NSMapTable 單例註冊新生成的 YYDiskCache 例項
    _YYDiskCacheSetGlobal(self);
    
    // App 生命週期通知相關程式碼,省略
    ...
    return self;
}
複製程式碼

我在 YYCache 設計思路 中找到了作者使用 dispatch_semaphore 作為 YYDiskCache 鎖的原因:

dispatch_semaphore 是訊號量,但當訊號總量設為 1 時也可以當作鎖來。在沒有等待情況出現時,它的效能比 pthread_mutex 還要高,但一旦有等待情況出現時,效能就會下降許多。相對於 OSSpinLock 來說,它的優勢在於等待時不會消耗 CPU 資源。對磁碟快取來說,它比較合適。

YYKVStorageItem 與 YYKVStorage

剛才在 YYDiskCache 的初始化原始碼中,我們不難發現一個類 YYKVStorage。與 YYMemoryCache 相對應的,YYDiskCache 也不會直接操作快取物件(sqlite/file),而是通過 YYKVStorage 來間接的操作快取物件。

從這一點上不難發現,YYKVStorage 等價於 YYMemoryCache 中的雙向連結串列 _YYLinkedMap,而對應於 _YYLinkedMap 中的節點 _YYLinkedMapNode,YYKVStorage 中也有一個類 YYKVStorageItem 充當著與快取物件一對一的角色。

// YYKVStorageItem 是 YYKVStorage 中用來儲存鍵值對和後設資料的類
// 通常情況下,我們不應該直接使用這個類
@interface YYKVStorageItem : NSObject
@property (nonatomic, strong) NSString *key;                ///< key
@property (nonatomic, strong) NSData *value;                ///< value
@property (nullable, nonatomic, strong) NSString *filename; ///< filename (nil if inline)
@property (nonatomic) int size;                             ///< value's size in bytes
@property (nonatomic) int modTime;                          ///< modification unix timestamp
@property (nonatomic) int accessTime;                       ///< last access unix timestamp
@property (nullable, nonatomic, strong) NSData *extendedData; ///< extended data (nil if no extended data)
@end


/**
 YYKVStorage 是基於 sqlite 和檔案系統的鍵值儲存。
 通常情況下,我們不應該直接使用這個類。
 
 @warning 
  這個類的例項是 *非* 執行緒安全的,你需要確保
  只有一個執行緒可以同時訪問該例項。如果你真的
  需要在多執行緒中處理大量的資料,應該分割資料
  到多個 KVStorage 例項(分片)。
 */
@interface YYKVStorage : NSObject

#pragma mark - Attribute
@property (nonatomic, readonly) NSString *path;        /// storage 路徑
@property (nonatomic, readonly) YYKVStorageType type;  /// storage 型別
@property (nonatomic) BOOL errorLogsEnabled;           /// 是否開啟錯誤日誌

#pragma mark - Initializer
- (nullable instancetype)initWithPath:(NSString *)path type:(YYKVStorageType)type NS_DESIGNATED_INITIALIZER;

#pragma mark - Save Items
- (BOOL)saveItem:(YYKVStorageItem *)item;
...

#pragma mark - Remove Items
- (BOOL)removeItemForKey:(NSString *)key;
...

#pragma mark - Get Items
- (nullable YYKVStorageItem *)getItemForKey:(NSString *)key;
...

#pragma mark - Get Storage Status
- (BOOL)itemExistsForKey:(NSString *)key;
- (int)getItemsCount;
- (int)getItemsSize;

@end
複製程式碼

程式碼美哭了有木有!?這種程式碼根本不需要翻譯,我覺得相比於逐行的翻譯,直接看程式碼更舒服。這裡我們只需要看一下 YYKVStorageType 這個列舉,他決定著 YYKVStorage 的儲存型別。

YYKVStorageType

/**
 儲存型別,指示“YYKVStorageItem.value”儲存在哪裡。
 
 @discussion
  通常,將資料寫入 sqlite 比外部檔案更快,但是
  讀取效能取決於資料大小。在我的測試(環境 iPhone 6 64G),
  當資料較大(超過 20KB)時從外部檔案讀取資料比 sqlite 更快。
 */
typedef NS_ENUM(NSUInteger, YYKVStorageType) {
    YYKVStorageTypeFile = 0, // value 以檔案的形式儲存於檔案系統
    YYKVStorageTypeSQLite = 1, // value 以二進位制形式儲存於 sqlite
    YYKVStorageTypeMixed = 2, // value 將根據你的選擇基於上面兩種形式混合儲存
};
複製程式碼

在 YYKVStorageType 的註釋中標記了作者寫 YYCache 時做出的測試結論,大家也可以基於自己的環境去測試驗證作者的說法(這一點是可以討論的,我們可以根據自己的測試來設定 YYDiskCache 中的 inlineThreshold 閾值)。

如果想要了解更多的資訊可以點選 Internal Versus External BLOBs in SQLite 查閱 SQLite 官方文件。

YYKVStorage 效能優化細節

上文說到 YYKVStorage 可以基於 SQLite 和檔案系統做磁碟儲存,這裡再提一些我閱讀原始碼發現到的有趣細節:

@implementation YYKVStorage {
	...
	CFMutableDictionaryRef _dbStmtCache; // 焦點集中在這裡
	...
}
複製程式碼

可以看到 CFMutableDictionaryRef _dbStmtCache; 是 YYKVStorage 中的私有成員,它是一個可變字典充當著 sqlite3_stmt 快取的角色。

- (sqlite3_stmt *)_dbPrepareStmt:(NSString *)sql {
    if (![self _dbCheck] || sql.length == 0 || !_dbStmtCache) return NULL;
    // 先嚐試從 _dbStmtCache 根據入參 sql 取出已快取 sqlite3_stmt
    sqlite3_stmt *stmt = (sqlite3_stmt *)CFDictionaryGetValue(_dbStmtCache, (__bridge const void *)(sql));
    if (!stmt) {
        // 如果沒有快取再從新生成一個 sqlite3_stmt
        int result = sqlite3_prepare_v2(_db, sql.UTF8String, -1, &stmt, NULL);
        // 生成結果異常則根據錯誤日誌開啟標識列印日誌
        if (result != SQLITE_OK) {
            if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite stmt prepare error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db));
            return NULL;
        }
        // 生成成功則放入 _dbStmtCache 快取
        CFDictionarySetValue(_dbStmtCache, (__bridge const void *)(sql), stmt);
    } else {
        sqlite3_reset(stmt);
    }
    return stmt;
}
複製程式碼

這樣就可以省去一些重複生成 sqlite3_stmt 的開銷。

sqlite3_stmt: 該物件的例項表示已經編譯成二進位制形式並準備執行的單個 SQL 語句。

更多關於 SQLite 的資訊請點選 SQLite 官方文件 查閱。

優秀的快取應該具備哪些特質

從 YYCache 原始碼 Get 到如何設計一個優秀的快取

嘛~ 我們回到文章最初提到的問題,優秀的快取應該具備哪些特質?

如果跟著文章一步步讀到這裡,相信很容易舉出以下幾點:

  • 記憶體快取和磁碟快取
  • 執行緒安全
  • 快取控制
  • 快取替換策略
  • 快取命中率
  • 效能

我們簡單的總結一下 YYCache 原始碼中是如何體現這些特質的。

記憶體快取和磁碟快取

YYCache 是由記憶體快取 YYMemoryCache 與磁碟快取 YYDiskCache 相互配合組成的,記憶體快取提供容量小但高速的存取功能,磁碟快取提供大容量但低速的持久化儲存。這樣的設計支援使用者在快取不同物件時都能夠有很好的體驗。

在 YYCache 中使用介面訪問快取物件時,會先去嘗試從記憶體快取 YYMemoryCache 中訪問,如果訪問不到(沒有使用該 key 快取過物件或者該物件已經從容量有限的 YYMemoryCache 中淘汰掉)才會去從 YYDiskCache 訪問,如果訪問到(表示之前確實使用該 key 快取過物件,該物件已經從容量有限的 YYMemoryCache 中淘汰掉成立)會先在 YYMemoryCache 中更新一次該快取物件的訪問資訊之後才返回給介面。

執行緒安全

如果說 YYCache 這個類是一個純邏輯層的快取類(指 YYCache 的介面實現全部是呼叫其他類完成),那麼 YYMemoryCache 與 YYDiskCache 還是做了一些事情的(並沒有 YYCache 當甩手掌櫃那麼輕鬆),其中最顯而易見的就是 YYMemoryCache 與 YYDiskCache 為 YYCache 保證了執行緒安全。

YYMemoryCache 使用了 pthread_mutex 執行緒鎖來確保執行緒安全,而 YYDiskCache 則選擇了更適合它的 dispatch_semaphore,上文已經給出了作者選擇這些鎖的原因。

快取控制

YYCache 提供了三種控制維度,分別是:cost、count、age。這已經滿足了絕大多數開發者的需求,我們在自己設計快取時也可以根據自己的使用環境提供合適的控制方式。

快取替換策略

在上文解析 YYCache 原始碼的時候,介紹了快取替換策略的概念並且列舉了很多經典的策略。YYCache 使用了雙向連結串列(_YYLinkedMapNode_YYLinkedMap)實現了 LRU(least-recently-used) 策略,旨在提高 YYCache 的快取命中率。

快取命中率

這一概念是在上文解析 _YYLinkedMapNode_YYLinkedMap 小節介紹的,我們在自己設計快取時不一定非要使用 LRU 策略,可以根據我們的實際使用環境選擇最適合我們自己的快取替換策略。

效能

其實效能這個東西是隱而不見的,又是到處可見的(笑)。它從我們最開始設計一個快取架構時就被帶入,一直到我們具體的實現細節中慢慢成形,最後成為了我們設計出來的快取優秀與否的決定性因素。

上文中剖析了太多 YYCache 中對於效能提升的實現細節:

  • 非同步釋放快取物件
  • 鎖的選擇
  • 使用 NSMapTable 單例管理的 YYDiskCache
  • YYKVStorage 中的 _dbStmtCache
  • 甚至使用 CoreFoundation 來換取微乎其微的效能提升

看到這裡是不是恍然大悟,效能是怎麼來的?就是這樣對於每一個細節的極致追求一點一滴積少成多摳出來的。

總結

  • 文章系統的解讀了 YYCache 原始碼,相信可以讓各位讀者對 YYCache 的整體架構有一個清晰的認識。
  • 文章結合作者 YYCache 設計思路 中的內容對 YYCache 具體功能點實現原始碼做了深入剖析,再用我自己的理解表述出來,希望可以對讀者理解 YYCache 中具體功能的實現提供幫助。
  • 根據我自己的原始碼理解,把我認為做的不錯的提升效能的原始碼細節單獨拎出來做出詳細分析。
  • 總結歸納出“一個優秀快取需要具備哪些特質?”這一問題的答案,希望大家在面試中如果被問及“如何設計一個快取”這類問題時可以遊刃有餘。額,至少可以為大家提供一些回答思路,拋磚引玉(笑)。

文章寫得比較用心(是我個人的原創文章,轉載請註明 lision.me/),如果發現錯誤會優先在我的 個人部落格 中更新,也推薦大家去那裡與我交流(嘛~ 貌似我還沒有開放評論?)。

希望我的文章能為你帶來價值~ 也希望可以動動手指幫我分享出去?


補充~ 我建了一個技術交流微信群,想在裡面認識更多的朋友!如果各位同學對文章有什麼疑問或者工作之中遇到一些小問題都可以在群裡找到我或者其他群友交流討論,期待你的加入喲~

從 YYCache 原始碼 Get 到如何設計一個優秀的快取

Emmmmm..由於微信群人數過百導致不可以掃碼入群,所以請掃描上面的二維碼關注公眾號進群。

相關文章