一, 目标
了解移动端的数据持久化方式和对应的使用场景, 提供相关技术选型做技术储备.
二, 数据持久化的目的
快速展示, 提升体验
已经加载过的数据, 用户下次查看时, 不需要再次从网络 (磁盘) 加载, 直接展示给用户
节省用户流量(节省服务器资源)
对于较大的资源数据进行缓存, 下次展示无需下载消耗流量
同时降低了服务器的访问次数, 节约服务器资源.(图片)
离线使用.
用户浏览过的数据无需联网, 可以再次查看.
部分功能使用解除对网络的依赖.(百度离线地图, 图书阅读器)
无网络时, 允许用户进行操作, 等到下次联网时同步到服务端.
记录用户操作
草稿: 对于用户需要花费较大成本进行的操作, 对用户的每个步骤进行缓存, 用户中断操作后, 下次用户操作时直接继续上次的操作.
已读内容标记缓存, 帮助用户识别哪些已读.
搜索记录缓存
...
三, 数据持久化方式分类
在移动端的数据持久化方式总体可以分为以下两类:
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: 指文件存储, 一般用来存图片, 视频文件等数据
数据库存储
数据库适合存取一些关系型的数据; 可以在有大量的条件查询排序类需求时使用.
Core Data: 苹果官方封装的 ORM(Object Relational Mapping)
FMDB https://github.com/ccgus/fmdb :GitHub 最受欢迎的 iOS SQLite 封装开源库之一
WCDB https://github.com/Tencent/wcdb : 微信团队在自己使用的 SQLite 封装基础上的开源实现, 具有 ORM(Object Relational Mapping)的特性, 支持 iOS,Android.
Realm https://realm.io/cn/docs/objc/latest/ : 由 Y Combinator 孵化的创业团队开源出来的一款跨平台 (iOS,Android) 移动数据库.
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
郭耀源开发的一个内存缓存, 相对于 PINMemoryCache 来说, 我去掉了异步访问的接口, 尽量优化了同步访问的性能, 用 OSSpinLock 来保证线程安全. 另外, 缓存内部用双向链表和 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 设计思路 https://blog.ibireme.com/2015/10/26/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 设计思路 https://blog.ibireme.com/2015/10/26/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 策略,性能高;实现文件不同存储策略更高效;但未抽象相关协议,内存和磁盘缓存重复度高 | 作者已不在维护 |
4, 数据库缓存
4.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 数据库
4.2, 对比
SQLite 数据库的使用包括增, 删, 改, 查等基本操作, 同时在项目中运用, 还需要数据转模型, 数据库通过增删表, 字段和数据迁移完成版本升级等操作, 下文通过对这些操作在各个流行库中的使用示例来对比各个库的易用性.
4.2.1,FMDB https://github.com/ccgus/fmdb
FMDB 是对 SQLite 的面向 OC 的封装, 把 c 语言对 sql 的操作封装成 OC 风格代码. 主要有以下特点:
OC 风格, 省去了大量重复, 冗余的 C 语言代码
提供了多线程安全的数据库操作方法, 保证数据的一致性
相比 CoreData,Realm 等更加轻量.
支持事务
支持全文检索(fts subspec)
支持对 WAL(Write ahead logging) https://www.sqlite.org/wal.html 模式执行 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];
- }
- }];
4.2.2,WCDB https://github.com/Tencent/wcdb
WCDB 是微信技术团队内部在微信 App SQLite 使用实践抽取的一套开源封装, 主要具有以下特点:
通过宏定义的方式实现了 ORM 映射关系, 根据映射关系完成建表, 数据库新增字段, 修改字段名(绑定别名), 数据初始化绑定等操作
自研了的语法, 大部分场景不需要直接写原生 SQLite 语句, 易用性高
内部实现了安全的多线程读写操作 (写操作还是串行) 和数据库初始化优化, 提升了性能(微信 iOS SQLite 源码优化实践)
提供了其它较多场景的解决方案:
错误统计
性能统计
损坏修复(微信移动端数据库组件 WCDB 系列(二) - 数据库修复三板斧)
反注入 https://baike.baidu.com/item/SQL注入
加密
在 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 Message
- WCDB_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 localID
- NSArray<Message *> *message = [database getObjectsOfClass:Message.class
- fromTable:@"message"
- orderBy:Message.localID.order()];
4.2.3,Realm https://realm.io/cn/docs/objc/latest/
Realm 团队基于 SQLite 封装的一套 ORM 数据库操作模式, 它是 MVCC 数据库, 主要具有以下特点:
对象就是一切(ORM 映射)
MVCC 数据库
Realm 采用了零拷贝 https://en.wikipedia.org/wiki/Zero-copy 架构
自动更新对象和查询
String & Int 优化(String 转换为枚举, 类似 OC tagged point,)
崩溃保护(系统异常崩溃时, 通过 copy-on-wirte 机制保存了你已经修改的内容)
真实的懒加载(使用时才从磁盘加载真实数据)
内部加密(引擎层内建了加密)
文档详细, 且有中文版
社区活跃, Stack Overflow 能解决你几乎所有问题
跨平台, 支持 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 基本操作示例:
- // 定义模型的做法和定义常规 ObjectiveC 类的做法类似
- @interface Dog : RLMObject
- @property NSString *name;
- @property NSData *picture;
- @property NSInteger age;
- @end
- @implementation Dog
- @end
- RLM_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];
- }
- });
4.3 数据库存取性能测试
性能测试说明:
测试数据见下方. 由于样本比较少(仅 1 种数据), 只进行了部分写入和读取操作, 并不能完全反应某个 SDK 的综合性能, 仅作为参考.
测试数据和测试结果见下图:
顺序插入 1W 条数据:
使用事务插入 1W 条数据:
读取 1W 条数据:
多线程 (2 条) 插入共 2W 条数据:
4.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, 图片缓存
以 https://github.com/SDWebImage/SDWebImage (KingFisher https://github.com/onevcat/Kingfisher )为代表的图片缓存库基本都实现了二级缓存, 队列下载, 异步解压, Category 拓展等能力, 常用的图片加载展示需求都可以使用它们来完成.
2, 简单 key-value 存取
系统的如 NSCache,NSKeyedArchive 等缓存功能能满足基本的存取需求, 但是并不易用. https://github.com/pinterest/PINCache 和 https://github.com/ibireme/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 架构师之路: 本地持久化方案 https://www.jianshu.com/p/aa86d6202602
iOS(数据持久化 1)
iOS 应用架构谈 本地持久化方案及动态部署
常见缓存算法和缓存策略
缓存淘汰算法 --LRU 算法 https://flychao88.iteye.com/blog/1977653
iOS 缓存框架 - PINCache 解读 https://www.jianshu.com/p/ce5e7427e740
iOS 缓存管理之 PINCache 使用
YYCache 设计思路 https://blog.ibireme.com/2015/10/26/yycache/
SQLite 学习笔记(四)&&SQLite-WAL 原理 https://www.cnblogs.com/cchust/p/4754619.html
微信 iOS SQLite 源码优化实践
微信移动端数据库组件 WCDB 系列(二) - 数据库修复三板斧
数据库的设计: 深入理解 Realm 的多线程处理机制
Realm 核心数据库引擎探秘
Realm 数据库 从入门到 "放弃" https://www.jianshu.com/p/50e0efb66bdf
使用 Realm 的一些总结 https://www.jianshu.com/p/c1a259e3bfc0
Realm,WCDB 与 SQLite 移动数据库性能对比测试
Realm,WCDB 与 SQLite 移动数据库性能测试
开源库
- https://github.com/Tencent/wcdb
- https://github.com/realm/realm-cocoa
- https://github.com/pinterest/PINCache
- https://github.com/ibireme/YYCache
来源: https://juejin.im/post/5c3c2821f265da61542dd57b