iOS 存储方案从入门到精通
引导语
在业务开发过程中,对于数据的处理,总是占有很大一部分时间。而又根据不同的业务需要,存储方案可以进行不同的选择。从数据存储的位置角度来分的话,存储可以分为内存存储和硬盘存储。内存存储,数据交互快,在一些需要复杂耗时的地方可以考虑内存缓存来解决。而磁盘存储,由于磁盘本身的特点,当触发操作时,需要一些磁盘变道等物理操作,交互速度会大大降低,但是由于可以保存大量数据,数据可以一直持有,可以在app被关闭后,数据可以重新被加载到程序中,所以在iOS端也提供了非常多的方式来把数据保存到磁盘。
在接下来的内容中,我会分别介绍内存存储和磁盘存储的适用场景,由于内存存储比较简单,所以重点会放在磁盘存储空间上。而磁盘存储又可以分为文件系统和数据库系统,而数据库系统也是我们在业务中遇到问题最多的地方,所以在简单介绍文件系统的相关适用场景及各个文件存储方案的原理及优劣后,会花大量篇幅介绍数据库系统,而数据库系统,在移动端的解决方案中,SQLite
数据库的解决方案也是应用最为广泛的。我会介绍SQLite
的相关原理,以此来探究一些性能问题。由于多线程操作一直很神秘,也经常容易出错,所以从SQLite
底层的角度来一探究竟。
内存存储
内存存储也可以称为内存缓存,因为内存存储的数据只保留在APP启动时。比如保存一些从服务端获取到的数据,来缓解服务器的压力,并且节约了用户流量和时间,提高了用户使用体验。NSURLConnection
默认会缓存资源在内存。内存缓存还可以保留一些处理好的数据,比如Feed流把处理好的数据存储到内存缓存中,等到再次使用的时候直接从缓存中取。常见的内存缓存框架有NSCache
、TMMemoryCache
、PINMemoryCache
、YYMemoryCache
。NSCache
是苹果提供的一个简单的内存缓存,它有着和 NSDictionary
类似的 API,不同点是它是线程安全的。YYMemoryCache
缓存内部用双向链表
和 NSDictionary
实现了 LRU 淘汰算法
(Least recently used,最近最少使用)。 一般内存缓存的大致流程如下所示:
- APP会优先请求内存缓冲中的资源
- 如果内存缓冲中有,则直接返回资源文件, 如果没有的话,则会请求资源文件,这时资源文件可能存储在服务端,需要进行网络请求获取,也可以是本地文件,需要操作文件系统或数据库来获取。
- 获取到的资源文件,先缓存到内存缓存,方便以后不再重复获取,节省时间。
- 然后就是从缓存中取到数据然后给app使用。
磁盘存储
内存存储适合存储一些app高频次使用,并且所占空间不大的文件。而磁盘存储则可以存储一些需要持久化的文件,信息等。简单的讲就是app被杀死以后,文件仍然在。而磁盘存储根据数据管理方式又可以分为两大类:文件系统存储和数据库系统存储。 文件系统把数据组织成相互独立的数据文件。实现了记录内部结构性,但整体无结构。而数据库系统实现了整体数据的结构化。 数据库系统主要管理数据库的存储,事务,以及对数据库的操作。而文件系统是操作系统管理文件和存储空间的子系统。
磁盘存储方式的选择
根据操作系统和文件系统的特点,假如存储的数据是结构化的,想要易于统计分析,那么就可以选择数据库来存储。如果存储数据结构单一,并且可能单个数据量大,则可以使用文件系统管理。 比如说在百度音乐app内,歌曲信息包含歌曲播放链接,歌曲名,歌手名,所属专辑等信息,这些信息数据量小,但是结构化,并且对于所有的歌曲要进行频繁的统计,和比较细粒度的数据处理。那么就非常适合用数据库来管理。而下载到本地的歌曲,下载到本地的视频等信息,则非常适合用文件管理系统来存储数据。
文件系统管理方式
文件系统是操作系统管理文件和存储空间的子程序。在iOS系统中还提供了一些文件存储方案。 存储方案有plist文件存储
,NSUserDefalut存储
,keyChain存储
,NSKeyedArchiver
(序列化存储)。
plist文件
plist文件
通常用来存储用户设置,还可以存储程序中经常用到而不经常改动的数据。比如一个app内部常用的颜色,配置信息,可以存储在plist
中。结构清晰,易于查找。 plist文件是一种存储串行化后的对象文件。文件格式是XML的。plist文件可以通过系统的方法进行读取,却不可以直接对plist文件进行修改。(plist可以变换一种思路进行修改,但是却不建议使用。)
NSUserDefaults存储
NSUserDefaults
使用起来简单,如果要存储一些简单的字符串,比如存储字符串,数字等,则NSUSerDefaults
是首选。 NSUserDefaults
是plist
文件的缓存,当用NSUserDefaults
写数据的时候,其实是将数据写入到一个专门为NSUserDefaults
准备的plist
文件中的。 NSUserDefaults
可以对这个特殊的plist
文件读和写。 由于NSUserDefaults
会将plist
文件的数据读取到缓存中,因此访问速度会很快。
KeyChain
KeyChain
是一个安全的存储容器,所以非常适合保存一些敏感信息到设备中。KeyChain
里的数据独立于每个app沙盒之外。即便app被卸载,只要不重新安装系统,存储的信息依然存在,再次安装app,存储的信息依然可以被app使用。通过keychain access groups
可以在不同应用之间共享keychain
中的数据。要求在保存数据到keychain
的时候指定group
。 发现百度系的一些app实现的账号互通,应该就是通过keychain access groups
来实现的。 KeyChain
可以存储一些即便app被删除了,下次安装的时候仍然想被用户使用的数据。
KeyChain
数据保存的地方是一个sqlite数据库
,位于/private/var/Keychains/keychain-2.db
,其保存的所有数据都是加密过的。从这个意义讲,KeyChain的数据是存储在数据库中的,然而由于他只用来存储比较简单的数据,所以就放到了文件分类里。
NSKeyedArchiver
几乎任何类型的对象都能被归档储存。可以使用NSKeyedArchiver
进行归档,NSKeyedUnarchiver
进行解档。这种方式会在写入、读取数据之前对数据进行序列化、反序列化操作。 对系统自带的简单对象(比如对Foundation
框架里的NSString
,NSNumber
等)可以直接进行归档,而对自定义的对象需要实现NSCoding协议,实现encode
和decode
方法。
NSKeyedArchiver
提供了序列化和反序列化的方法,而真正要存储到文件系统的什么地方是可以自定义的。可以将序列化的数据直接写入到文件系统中。也可以利用 NSUSerDefaults
写到到 plist
文件中。 所以 NSKeyedArchiver
非常适合把一个自定义的对象序列化后存储到文件系统中去。 文件 存储
将数据转化为NSData
对象,然后直接利用系统函数,把数据保存到指定的文件目录下。图片,歌曲,视频一般都是通过这种形式存储的。
感悟
可以看到,iOS提供一些非常方便的文件存储方式。 如果有些数据结构比较简单,存储的条数不是很多,或者单个的数据量太大(音频文件),一些不用修改的信息,选择iOS系统提供的方法就能完成任务需求。
数据库
当数据信息量比较大,并且结构比较的多的时候,存成文件,每次都要把所有的文件读取完,才能进行文件的统计和相关修改操作。 并且文件系统没法了解文件内部数据之间的关系。因此便有了数据库系统,这个系统专门用维护数据以及数据之间的关系。 数据库可以理解为仓库管理系统。 一个家具厂的仓库,要取家具,添加新的家具,把原来的家具改为新的家具样式,都需要通过仓库管理系统来实现。只不过这个数据库管理系统管理的是数据而已。
iOS也提供了一些操作数据库的实现,包括CoreData
和直接操作SQlite数据库
。 由于CoreData底层的存储方式一般也都是SQLite数据库
,因此,本文会详细了解SQLite
底层原理及读写锁的控制,并尝试解释CoreData多线程
到底做了什么。
数据库基本概念
了解SQLite数据库之前先了解数据库的基本概念,包括什么是库,什么是表,SQL语句是什么
库
库相当于一个大的仓库。当我们想要使用仓库的时候就需要先建一个仓库。 创建数据库的语法是:
create database xxx;复制代码
表
表相当于仓库里具体的一间库房。有的库房专门存放衣服,有的库房专门存放零食。 表由字段和记录组成。字段用于组织表结构(即组织库房结构),而记录就是根据字段来存放具体的东西。
CREATE TABLE `CLOTHES` ( `key` int(11) NOT NULL AUTO_INCREMENT, `value` char(255) NOT NULL DEFAULT '', PRIMARY KEY (`k`)) ENGINE=InnoDB DEFAULT CHARSET=utf8复制代码
上面就把表建好了。 key,value就是字段。 而真正的存放的每件衣服就是记录。
SQL
SQL
相当于我们给仓库管理员说的一句话。比如说我们想从1号仓库里的2号房间里拿出来型号为3的衣服。听到这么一句话,仓库管理员就可以行动了。 SQL
是我们同数据库打交道的指令。 SQL
语句大致可以分为一下几类:
- 数据定义(
SQL DDL
)用于定义SQL模式、基本表、视图、索引的创建和撤销操作 - 数据操纵 (
SQL DML
)数据操纵分成数据查询和数据更新两类。数据更新又分为插入、删除和修改三种操作。也就是我们所说的增删改查操作。 - 数据控制 (
DCL
)包括基本表的授权,完整性描述,事务控制等内容。
###常见的数据库及应用场景 最常见的数据库模型主要是两种,即关系型数据库和非关系型数据库。
关系型数据库
关系型数据库模型是把复杂的数据结构归结为简单的二元关系(即二维表格形式)。在关系型数据库中,对数据的操作几乎全部建立在一个或多个关系表格上,通过对这些关联的表格分类、合并、连接或选取等运算来实现数据库的管理。 在服务端上有Oracle
和MySQL
。 在移动端里最常用的SQlite 就是关系型数据库。本文会对SQLite进行详细的解释。
非关系型数据库
非关系型数据库在超大规模和高并发的SNS类型的web2.0纯动态网站中有着非常好的性能。 NoSQL(NoSQL = Not Only SQL )
,意即“不仅仅是SQL”, 泛指非关系型数据库。 非关系型数据库 又分为键值存储数据库(key-value)(比如Redis数据库)、列存储(Column-oriented)数据库、面向文档(Document-Oriented)数据库、图形数据库。服务端特别互联网时代,对非关系行数据库有非常广泛的应用。 在移动端有代表性的是Realm。
###Sqlite
SQLite
,是一款轻型的数据库,是遵守ACID
的关系型数据库管理系统。它的设计目标用于嵌入式系统,占用资源少,只需要几百K就够了,并且是跨平台的。目前主流的移动端操作系统Android和iOS的设备内置的都是SQLite数据库。
ACID,是指数据库管理系统(
DBMS
)在写入或更新资料的过程中,为保证事务(transaction
)是正确可靠的,所必须具备的四个特性:原子性(atomicity
,或称不可分割性)、一致性(consistency
)、隔离性(isolation
,又称独立性)、持久性(durability
)。
要想优化SQLite运行速度,必须要对SQLite的运行原理有所了解。
它运行共分为4部分。Core层:包括接口层,编译器和虚拟机。 SQL编译层,包括语法分析,词法分析,代码生成。Backend
层包括 B-Tree
、 Pager
, OS
三部分,实现了数据库的主要存储逻辑。 Accessories
是辅助层,包括工具类和测试代码。 比如一个对Employee
表的数据操作流程如下:
流程大致如下: 1. SQL语句 -> 2. 触发磁盘I/O ->3.磁盘返回一个表对应的Index
表的一页Page,包含若干条记录 -> 4. 在上述记录当中找到目标记录,如果没有找到满足条件的记录,则循环第2步骤和第3步骤 直到找到满足条件的记录-> 5.根据Index
表的记录里存放的position
信息找到原始表记录的位置 ->6.对原始表当中的目标记录进行操作,完成SQL操作。 这里出现了几个名词:Index
表和原始表
,接下来会进行解释。
磁盘I/O瓶颈
我们知道数据库文件其实是放在磁盘上的。而对磁盘的操作要比内存的操作要耗时的多。而一般情况下,一次查询往往无法通过一次I/O 操作完成。所以如何减少磁盘I/O 的次数成为我们优化SQlite
性能的关键。 ####磁盘读取方式 磁盘读取方式是以Page
为单位。页(page
)是计算机存储时,所使用的基础逻辑单位。内存和磁盘中的数据存储和交互都是以页为单位的。即使内存只需要1个字节的数据,从磁盘读取的时候也是拿到一个或多个Page
,这是系统级别的一种预先缓存策略。
Index表
了解了磁盘I/O非常消耗性能后,我们可以看到如果一个Page里包含的记录比较多,那么SQL每次I/O被命中的概率就更大。 而如果要查询的是一个完整的表,比如一个表里有30个字段,一个字段占用100个字节,那么一个记录占用大概是 30 *100 = 3000 个这字节。假设一页 大小为 4KB,那么一页只存在一个记录。而假设我们使用一个表和原来的表做映射,但是只保留索引字段,比如说只保留key字段,和原始表对应的position字段,那么一页Page就可以装下100多个记录了。 这个和原始表对应的表就叫做索引表
。
B树
虽然二叉树可以实现log(n)的查找,和插入,但是由于二叉树的每个节点其实对应磁盘的一个Page
。所以为了减少磁盘I/O 的次数,需要尽可能减少树的高度。而二叉树类似于一个高瘦的树,我们需要找到一个矮胖的树。 而B树正好符合这个性质。 B树
是一个多路查找平衡树
,他的每个节点最多包含k个孩子。 k被称为B树的阶
。k的大小取决于磁盘页的大小。
SQLite索引表
和原始表都是B树形式组织的。 也就说如果不建立Index
表,原始表的每条记录的大小决定了B树
的阶数。 阶数越大,B树越矮胖。 也就是要尽量减少原始表和Index
表的大小。 尽量少用string
类型,而用数值类型的字段。因为数值类型的字段占的字节少。
SQlite 文件
db
文件包括 .db
文件, .db_wal
文件和 .db_shm
文件。 其中 .db
文件是各个 table
存储的位置,原始表和索引表都在这个文件中。 .db_wal
是 _journal
文件的替代品。所有的事务会先提交到 wal
文件中,如果事务在写入 wal
过程中,失败,则把事务丢弃,否则就先存入 .db_wal
文件中。 然后在某个时机,比如 .db_wal
的文件达到了设定的阈值,(比如CoreData默认设置大约有4MB左右)会把 wal
的数据merge到 .db
文件中。 当然自己也可以手动merge。 利用 _wal
的好处是允许不同的连接,一个读 db
文件,另个写 wal
文件。 读和写可以并行。 读和读也可以并发,但是写操作和写操作不能并发。 这个和Sqlite多线程操作原理有关。 _shm
文件是用来辅助 -wal
文件的。为了辅助 sqlite
快速定位 wal
文件信息。 SQLite多线程
Sqlite
是支持多线程访问的。SQLite支持3种线程模式:
- 单线程: 单线程下会禁用所有的
mutex
锁,并发使用时会出错。 - 多线程: 只要一个数据库连接不被多个线程同时使用,就是安全的。底层就是禁用数据库连接和
prepared statement
上的锁,实现多线程。因此不能在多个线程中并发使用同一个数据库连接和prepared statement
。 - 串行: 启用所有的锁,包括
bCoreMutex
和bFullMutex
。因为数据库连接和prepared statement
都已加锁,所以多线程使用这些对象时,没法并发,也就变成串行了。
- 数据库连接: 每次打开一次数据库,获取到的
database
就是一个数据库连接
static sqlite3 *openDb() { if (sqlite3_open(dbPath, &database) != SQLITE_OK) { sqlite3_close(database); NSLog(@"Failed to open database: %s", sqlite3_errmsg(database)); } return database;}复制代码
Prepared statement
,它是由数据库连接来管理的,使用它也可以看成使用这个数据库连接。 因此在多线程模式下,并发对同一个数据库连接调用sqlite3_prepare_v2()
来创建prepared statement
,或者对同一个数据库连接的任何prepared statement
并发的调用sqlite3_blind_*()
和sqlite3_step()
等函数会报错。
SQLite标准发行版是串行模式
,而iOS内置的SQLite库是多线程模式
,python的sqlite是用串行模式。
根据各种模式下线程安全的考虑,可以有四种访问数据库的模式可以选择:
- SQLite 使用单线程模式,用一个专门的线程访问数据库,需要线程间通讯,实现起来比较麻烦。
- SQlite 使用单线程模式,用一个线程队列来访问数据库, 队列一次只允许一个线程执行,队列里的线程公用一个数据库连接。 可以使用
dispatch_queue_create()
来创建一个serial queue
,来作为队列。 - SQLite使用多线程模式,每个线程创建自己的数据库连接。 这种情况需要每次都要打开和关闭数据库连接,所以会额外消耗一些时间。这种情况可以选用一个
并发队列
。每次读写的时候都要开启和关闭数据库连接 。 - SQLite使用串行模式,所有的线程公用全局的数据库连接
SQLite
的串行模式相当于让SQlite
自己来维护队列,只不过SQL的执行是乱序的,因此无法保证事务性。
FMDB多线程
FMDB
是用于数据存储的框架。它是iOS平台下对SQLite
数据的封装,FMDB
是面向对象的,它以OC语言封装了SQLite
的C语言的API,使用起来非常方便。是iOS平台上使用最多的第三方数据存储框架。
FMDB使用iOS官方的SQLite库,也就是默认模式是多线程模式。
FMDB中对多线程的管理是用串行队列来完成的。用 FMDatabaseQueue
来管理这个队列。 FMDatabaseQueue
初始化的时候,初始化了一个串行队列,并给其添加唯一标识:
_queue = dispatch_queue_create([[NSString stringWithFormat:@"fmdb.%@", self] UTF8String], NULL); dispatch_queue_set_specific(_queue, kDispatchQueueSpecificKey, (__bridge void *)self, NULL); 复制代码
而当操作数据库的时候,使用了这个串行队列并且同步执行。这样能够保证一个数据库连接在同一时刻只有一个事务在操作数据库。就满足了SQLite数据库同一时刻不能有多个操作的要求。
dispatch_sync(_queue, ^() { ///数据库操作 });复制代码
一个FMDatabaseQueue就是一个串行队列。就算你开启多线程执行,它依然还是串行执行的。保证了线程的安全性。
如果想实现多个线程操作数据库,可以创建多个FMDatabaseQueue
,虽然每个queue
内部是串行运行的,但是queue
与queue
可以并发执行。
FMDB
使用了一个串行队列,并且同步执行,假如这个串行队列中有两个任务,任务1先开始,任务1依赖于任务2的执行结果,任务2需要等待任务1执行完,才开始执行,那么就会出现死锁。所以在使用FMDB的时候一定要注意,尽量不要在任务中嵌套使用 。
Core Data多线程
Core Data
是苹果官方推出的数据持久化框架。它类似与ORM
(对象关系映射),但是要比ORM做的更多。Core Data
存储一般选用SQLite
数据库作为持久化存储区,但是也可以选用二进制,XML等形式的持久化存储。 在使用SQLite的时候,CoreData
默认是开启多线程模式的。 从NSManagedObjectContext
不能跨线程使用来看,Core Data
在实现上可能是一个MOC
对象对应着一个数据库连接。 所以建立连接的时候有如下规定:
- 不同的线程要建立自己的
NSManagedObjectContext
,维护自己的对象。 NSManagedObject
对象不能跨线程使用。
由于数据库操作的时候,不能跨线程,并且需要同步,为了防止死锁等问题,现有比较好的解决方案是利用三层NSManagedObjectContext
来操作数据库。
MOC 可以设置parentContext
,一个parentContext
可以拥有多个childContext
,在childContext
执行的Save
操作,会将操作push
到parentContext
中,由parentContext
来执行真正的save
操作。而childContext
的所有改变会被parentContontext
知晓。 这样解决了手动同步的问题。
三层模型可以让我们在MainContext
里执行UI相关的操作,而保存操作会在子线程 Context
中,子线程是后台线程,当执行save的时候,会把状态保存到MainContext
中,MainContext
再次save
的时候,会把状态保存到最上层的唯一的私有context
中。当最上层的context
执行了save,才会真正触发执行把数据保存到数据库中的操作。 由于最上层的操作,是在子线程进行的,所以不会影响UI。
Context
可以有多个。并且应用原则应该是用完即扔,不用保存这个Context
。这样不会导致Context
混乱的问题。 子Context之间不用进行通讯,意义不大,还会惹出一堆问题。我们的MainContext
全程只有一个,用来进行UI相关的操作。并且最上层的私有context也是唯一一个。这就保证了我们只有一个数据库连接。 这样以后,其实是人为的保证了最上层只有一个Context
在和数据库打交到。 而这个Context
又是在单个线程下的。所以能够保证对SQLite的操作是能满足多线程模式的要求的。 然而也可以看出,这样的操作,并没有发挥出多线程模式的优势来。有点类似于串行模式。 但是这样能够保证线程安全,并且不会阻塞主线程。 结束语
iOS端的存储方案有很多。到底选择哪种存储方案,还是要依赖于当时的业务场景出发。本文大体介绍了各种存储方式的原理,进而来探讨各个方案优劣点,及使用场景。 并没有对各个方案的实现细节有太多的描述。文件存储方案实现简单,在较简单的场景下非常有用。 数据库存储适合比较复杂的业务场景。当然坑也非常多,也有很多技术难点,在接下的文章中我们会对FMDB
,CoreData
进行详细的探讨。
##参考链接