iOS数据持久化设计

7,321

一、目标

了解移动端的数据持久化方式和对应的使用场景,提供相关技术选型做技术储备。

二、数据持久化的目的

  1. 快速展示,提升体验
    • 已经加载过的数据,用户下次查看时,不需要再次从网络(磁盘)加载,直接展示给用户
  2. 节省用户流量(节省服务器资源)
    • 对于较大的资源数据进行缓存,下次展示无需下载消耗流量
    • 同时降低了服务器的访问次数,节约服务器资源。(图片)
  3. 离线使用。
    • 用户浏览过的数据无需联网,可以再次查看。
    • 部分功能使用解除对网络的依赖。(百度离线地图、图书阅读器)
    • 无网络时,允许用户进行操作,等到下次联网时同步到服务端。
  4. 记录用户操作
    • 草稿:对于用户需要花费较大成本进行的操作,对用户的每个步骤进行缓存,用户中断操作后,下次用户操作时直接继续上次的操作。
    • 已读内容标记缓存,帮助用户识别哪些已读。
    • 搜索记录缓存
      ...

三、数据持久化方式分类

在移动端的数据持久化方式总体可以分为以下两类:

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:github最受欢迎的iOS sqlite 封装开源库之一
    • WCDB:微信团队在自己使用的sqlite封装基础上的开源实现,具有ORM(Object Relational Mapping)的特性,支持iOS、Android。
    • Realm:由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

大神郭曜源开源的一个内存缓存实现,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 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 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内,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()];

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
@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];
    }
});

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、 图片缓存

SDWebImageKingFisher)为代表的图片缓存库基本都实现了二级缓存、队列下载、异步解压、Category拓展等能力,常用的图片加载展示需求都可以使用它们来完成。

2、 简单key-value存取

系统的如NSCache、NSKeyedArchive等缓存功能能满足基本的存取需求,但是并不易用。 PINCacheYYCache 等这些三方库拓展了相当多的能力来满足大部分的使用场景,并且内部通过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、 其它

  1. 封装 无论你使用哪个三方库进行缓存实现,最好做一层封装,这样便于你在想要切换别的实现时,直接内部做好数据迁移,对于使用方完全无感知迁移,或者仅需要其做极少的工作,而不是全量的替换
  2. 区分用户目录存储 每个用户都使用单独的文件夹来存储他的数据,对数据库也一样,这样做的好处在于,用户数据不会相互污染(比如数据库中存在复杂的多表关联关系时,会使你的sql语句变得很复杂,提升了你区分用户出错的概率),也便于进行数据诊断。
  3. 单例 建议对于某个时间段的数据操作都交给一个对象去做,内部来保证多线程读写安全,降低出错的概率。
  4. 用户切换的处理 由于区分用户存储目录,切换登录用户时,需要我们切换数据存取的实例,此时,不要马上销毁上个实例,上个实例可能还有未完成的读写任务,等待完成或中断其操作后再销毁。

#参考

  • 文章
  1. iOS架构师之路:本地持久化方案
  2. IOS(数据持久化1)
  3. iOS应用架构谈 本地持久化方案及动态部署
  4. 常见缓存算法和缓存策略
  5. 缓存淘汰算法--LRU算法
  6. iOS缓存框架-PINCache解读
  7. IOS 缓存管理之 PINCache 使用
  8. YYCache 设计思路
  9. Sqlite学习笔记(四)&&SQLite-WAL原理
  10. 微信iOS SQLite源码优化实践
  11. 微信移动端数据库组件WCDB系列(二) — 数据库修复三板斧
  12. 数据库的设计:深入理解 Realm 的多线程处理机制
  13. Realm 核心数据库引擎探秘
  14. Realm数据库 从入门到“放弃”
  15. 使用Realm的一些总结
  16. Realm、WCDB与SQLite移动数据库性能对比测试
  17. Realm、WCDB与SQLite移动数据库性能测试
  • 开源库
  1. wcdb
  2. realm
  3. PINCache
  4. YYCache