YYCache学习缓存设计

作者: 雨润听潮 | 来源:发表于2018-02-01 10:14 被阅读228次

    前言

    日常的iOS开发过程中,经常会用到缓存,但是什么样的缓存才能被叫做优秀的缓存,或者说优秀的缓存应该具备哪些特质?YYCache我认为是一个比较优秀的缓存,代码逻辑清晰,注释详尽,加上自身不算太大的代码量使得其阅读非常简单,更可贵的是它的性能还很高。

    YYCache简介

    我们先来简单看一下 YYCache 的代码结构,YYCache 是由 YYMemoryCache 与 YYDiskCache 两部分组成的,其中 YYMemoryCache 作为高速内存缓存,而 YYDiskCache 则作为低速磁盘缓存。

    通常一个缓存是由内存缓存和磁盘缓存组成,内存缓存提供容量小但高速的存取功能,磁盘缓存提供大容量但低速的持久化存储。

    @interface YYCache : NSObject

    /** 缓存名称 */

    @property (copy, readonly) NSString *name;

    /** memoryCache*/

    @property (strong, readonly) YYMemoryCache *memoryCache;

    /** diskCache*/

    @property (strong, readonly) YYDiskCache *diskCache;

    /**判断key是否存在*/

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

    /**判断key是否存在,并执行block*/

    - (void)containsObjectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key, BOOL contains))block;

    /**获取key值对应的对象 会阻塞调用的进程*/

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

    /** 获取key值对应的对象,并执行block*/

    - (void)objectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key, id object))block;

    /** 对某个key设置对象,阻塞线程*/

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

    /** 设置key的对象,线程会立即返回,设置成功后回调block*/

    - (void)setObject:(nullable id)object forKey:(NSString *)key withBlock:(nullable void(^)(void))block;

    /**删除key对应的对象 阻塞线程 */

    - (void)removeObjectForKey:(NSString *)key;

    /**删除key对应的object 线程会立即返回,删除成功后回调block*/

    - (void)removeObjectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key))block;

    /**清空缓存*/

    - (void)removeAllObjects;

    /** 清空缓存, 线程会立即返回,清空成功后回调block */

    - (void)removeAllObjectsWithBlock:(void(^)(void))block;

    /**清空缓存, 线程会立即返回,后台线程执行block*/

    - (void)removeAllObjectsWithProgressBlock:(nullable void(^)(int removedCount, int totalCount))progress  endBlock:(nullable void(^)(BOOL error))end;

    上边整理了几个常用的方法,做了简单的中文注释,从代码中我们可以看到 YYCache 中持有 YYMemoryCache 与 YYDiskCache,并且对外提供了一些接口。这些接口基本都是基于 Key 和 Value 设计的,类似于 iOS 原生的字典类接口(增删改查)

    YYMemoryCache

    YYMemoryCache 是一个高速的内存缓存,用于存储键值对。它与 NSDictionary 相反,Key 被保留并且不复制。API 和性能类似于 NSCache,所有方法都是线程安全的。

    YYMemoryCache 使用 LRU(least-recently-used) 算法来驱逐对象。介绍一下LRU:

    LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。最常见的实现是使用一个链表保存缓存数据,详细算法实现如下:

               1. 新数据插入到链表头部;

               2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;

               3. 当链表满的时候,将链表尾部的数据丢弃。

         分析

           【命中率】

                当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。

           【复杂度】

                实现简单。

          【代价】 

                命中时需要遍历链表,找到命中的数据块索引,然后需要将数据移到头部。

    YYMemoryCache是线程安全的

    @implementation YYMemoryCache {

        pthread_mutex_t _lock; // 线程锁,旨在保证 YYMemoryCache 线程安全

        _YYLinkedMap *_lru; // _YYLinkedMap,YYMemoryCache 通过它间接操作缓存对象

        dispatch_queue_t _queue; // 串行队列,用于 YYMemoryCache 的 trim 操作

    }

      没错,YYMemoryCache使用 pthread_mutex线程锁来确保线程安全。最初YYMemoryCache 这里使用的锁是 OSSpinLock 自旋锁,后面有人在 Github 向作者提 issue 反馈 OSSpinLock 不安全,经过作者的确认(详见 不再安全的 OSSpinLock)最后选择用 pthread_mutex 替代 OSSpinLock。

    具体来说,如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。这并不只是理论上的问题,libobjc 已经遇到了很多次这个问题了,于是苹果的工程师停用了 OSSpinLock。

    _YYLinkedMap 与 _LinkedMapNode

    YYMemoryCache 无法直接操作缓存,而是通过内部的 _YYLinkedMapNode 与 _YYLinkedMap 来的操作缓存对象。这两个类对于上文中提到的 LRU 缓存算法的理解至关重要。

    @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

    @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;

    }

    对数据结构与算法不陌生的同学,应该一眼就看的出来 _YYLinkedMapNode 与 _YYLinkedMap 这的本质。其实就是双向链表节点和双向链表。

      _YYLinkedMapNode 作为双向链表节点,除了基本的 _prev、_next,还有键值缓存基本的 _key 与 _value,我们可以把 _YYLinkedMapNode 理解为 YYMemoryCache 中的一个缓存对象。_YYLinkedMap 作为由 _YYLinkedMapNode 节点组成的双向链表,使用 CFMutableDictionaryRef _dic 字典存储 _YYLinkedMapNode。这样在确保 _YYLinkedMapNode 被强引用的同时,能够利用字典的 Hash 快速定位用户要访问的缓存对象,这样既符合了键值缓存的概念又省去了自己实现的麻烦。总得来说 YYMemoryCache 是通过使用 _YYLinkedMap双向链表来操作 _YYLinkedMapNode 缓存对象节点的。

    YYDiskCache简介

    YYDiskCache 是一个线程安全的磁盘缓存,用于存储由 SQLite 和文件系统支持的键值对(类似于 NSURLCache 的磁盘缓存)。

    YYDiskCache 具有以下功能:

    通过 LRU 算法来删除对象。

    它可以被配置为当没有可用的磁盘空间时自动驱逐缓存对象。

    它可以自动抉择每个缓存对象的存储类型(sqlite/file)以便提供更好的性能表现。

    @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)objectForKey:(NSString *)key;

    - (void)setObject:(nullable id)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 是基于 sqlite 和 file 来做的磁盘缓存,我们的缓存对象可以自由的选择存储类型,下面简单对比一下:

    sqlite: 对于小数据(例如 NSNumber)的存取效率明显高于 file。

    file: 对于较大数据(例如高质量图片)的存取效率优于 sqlite。

    所以 YYDiskCache 使用两者配合,灵活的存储以提高性能。

    YYDiskCache 内部是基于一个单例 NSMapTable 管理,

    NSMapTable 是类似于字典的集合,但具有更广泛的可用内存语义。NSMapTable 是 iOS6 之后引入的类,它基于 NSDictionary 建模,但是具有以下差异:

    键/值可以选择 “weakly” 持有,以便于在回收其中一个对象时删除对应条目。

    它可以包含任意指针(其内容不被约束为对象)。

    您可以将 NSMapTable 实例配置为对任意指针进行操作,而不仅仅是对象

    每当一个 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;

    }

    dispatch_semaphore 是信号量,但当信号总量设为 1 时也可以当作锁来。在没有等待情况出现时,它的性能比 pthread_mutex 还要高,但一旦有等待情况出现时,性能就会下降许多。相对于 OSSpinLock 来说,它的优势在于等待时不会消耗 CPU 资源。对磁盘缓存来说,它比较合适。

    YYKVStorageItem 与 YYKVStorage

    在上边的代码中,我们看到了YYKVStorage,YYDiskCache是通过YYKVStorage来操作缓存对象(sqlite/file),YYKVStorage 和 YYMemoryCache 中的双向链表 _YYLinkedMap扮演的角色是一样的,而对应于 _YYLinkedMap 中的节点 _YYLinkedMapNode,YYKVStorage 中也有一个类 YYKVStorageItem 充当着与缓存对象的角色。

    /**

     用于YYStorage存储键值对和属性信息

     通常情况下,我们不应该直接使用这个类。

     */

    @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 和file的键值存储。

     通常情况下,我们不应该直接使用这个类。

     @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 的存储类型

    typedef NS_ENUM(NSUInteger, YYKVStorageType) {

        /// The `value` is stored as a file in file system.

        YYKVStorageTypeFile = 0,

        /// The `value` is stored in sqlite with blob type.

        YYKVStorageTypeSQLite = 1,

        /// The `value` is stored in file system or sqlite based on your choice.

        YYKVStorageTypeMixed = 2,

    };

    再看YYKVStorage代码的同时,发现一个细节

        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 的开销。

    相关文章

      网友评论

        本文标题:YYCache学习缓存设计

        本文链接:https://www.haomeiwen.com/subject/uvorzxtx.html