移动应用缓存模块设计方案的探讨
在大多数人眼中,移动应用开发者做的事情很简单,无非就是:
获取数据(网络请求、本地读取) ===> 绘制界面 ===> 展示数据
或者是:
绘制界面 ===> 获取用户输入 ===> 将数据发送服务器
诚然,在一些简单的应用中,大量重复的这种工作;但其实当应用涉及到一些特定的场景的时候,对数据只做简单的处理是远远不够的。
假设有这样一个业务场景:
有这样一组数据,数据具有以下特点:
- 数据量特别大
- 组装数据相当繁琐
- 这组数据会在不同场合被用到很多次。
假设有n处会使用到这组数据,按照我们之前的思路,我们需要去数据库读取n次这组数据;但显然,这样频繁的数据读取势必会对性能造成影响。
再假设开发过程中,遇到了大文件读取的问题,且这个文件有可能会在打开之后,连续的多次被访问,那么按照正常的流程,就会多次去数据库读取文件,打开文件,这样就会有一个问题,就是每次打开一个大文件时,用户都需要等待一些时间,即使打开的文件是刚刚已经打开过的,这样势必给用户一个不好的体验。
那么此时,基于以上问题,缓存 的作用就体现出来了。
概念介绍
提到缓存,大多数人可能第一印象中就会想到Cache,其实Cache应该是一个大的概念,从它字面意思来理解,是存储的意思。在大多数缓存的三方库中,像YYCache、PINCache、TMCache也都区分开了Memory Cache 和 Disk Cache。
那么,缓存究竟是什么一个概念,个人理解,
缓存:首先应该是存在于内存中的一些数据的集合,这个数据集合存在的意义就在于解决频繁的数据读取的性能消耗问题 或 解决大文件打开速度偏慢的问题 。
基于以上概念,我们就可以把频繁使用的数据 或 会被多次打开的大文件 缓存到内存中,当数据使用者使用数据时,只需去内存中读取数据或文件,这样速度显然会提升很多。但,其实有利也有弊,缓存势必会造成程序占用内存的增长,这里就会涉及缓存数据更新的策略 和 缓存数据大小的问题。这个问题我们后边小节会有继续讨论。
方案来源
在计算机存储结构中,也有一个缓存;虽然和我们这里的缓存概念不同,但其实我们也可以参考一下,下面我们来看一下计算机的存储结构;
计算机存储结构.png
在整个存储结构中,一共涉及了CPU中的寄存器、缓存、内存、辅存四个模块的存储器;基于存储器的特性,按照考核存储器性能的指标来对比:
速度 :寄存器 > 缓存 > 主存 > 辅存
容量 :寄存器 < 缓存 < 主存 < 辅存
为了兼顾速度与容量,计算机的存储结构分为两个层次,分别是:
1.缓存-主存层次
2.主存-辅存层次
缓存-主存层次主要解决CPU和主存速度不匹配的问题。由于缓存的速度比主存高,主要讲CPU近期要用的信息调入缓存,CPU就可以直接从缓存中获取信息,从而提高访问速度。但由于缓存容量较小,因此需要不断的奖不断地将主存的内容调入缓存,将缓存原来的信息替换掉。
主存-辅存层次主要解决了存储系统容量问题。辅存的速度比主存速度低,而且不能喝CPU交换信息,但是容量要比主存大的多,可以存放更多用不到信息。当CPU需要这些信息时,再将其调入主存,供CPU访问。
从CPU的角度来看,缓存-主存这一层次的速度近于缓存,高于主存;容量却接近主存;主存-辅存层次这一层次从整体分析速度近于主存,而容量又近于辅存;这样就解决了速度、容量、成本三者的矛盾。
参照上述计算机存储的解决方案,我们可以大致的去整理思路,做出一套相似的缓存方案;
方案介绍
上面也介绍了一些对于缓存概念的理解,但是如果要真正的去做一个缓存模块的时候,只考虑内存的缓存是不够的,而需要在内存缓存的基础上增加磁盘存储的功能。
那么,基于这个思路去考虑,缓存模块的设计应该包含以下部分:
1.内存缓存及数据的更新策略
2.磁盘存储及数据清理策略
3.磁盘<=>内存数据交互策略
首先,内存模块,我们需要先确认一个存储结构,例如数组、链表等;可根据业务场景选择不同的缓存算法,例如YYCache使用的LRU算法;为方便实现相关算法这里可以选用链表;
其次,磁盘存储模块,根据文件读写以及数据库书写的性能对比;当单条数据小于 20K 时,数据越小 SQLite 读取性能越高;单条数据大于 20K 时,直接写为文件速度会更快一些。基于 SQLite 的这种表现,磁盘缓存最好是把 SQLite 和文件存储结合起来:key-value 元数据保存在 SQLite 中,而 value 数据则根据大小不同选择 SQLite 或文件存储。参照
对于磁盘-内存数据交互的策略,可以参考下图:
数据读取流程.png
首先,数据使用者使用数据时,会调用缓存模块API查找数据,缓存模块内部会先在内存中查找,如果有则直接返回,并且根据相应的算法操作当前数据,如果没有则去磁盘查找,磁盘有则返回数据,并且把数据加载到内存中,没有的话则返回无数据结果;
问题点
由于缓存使用的一个特点,会有多个数据使用者同时操作数据,对数据进行读写,而多个数据使用者很有可能是不同线程所持有的对象,那么就会有一个问题,多线程在同时操作同一数据的时候,造成资源竞争问题,并极有可能导致程序直接崩溃;
对于这个问题,常见的有两种解决方案:
-
加锁
既然是资源竞争问题,那么很简单的就可以联想到加锁,确实,加锁可以是处理这种问题的一种解决方式。或者说我们可以将属性使用atomic
关键字来修饰;
但其实这两种都有一定的缺点的;atomic
关键字可以保证读的安全,加锁也可以是先读写安全,但是大规模使用锁很可能会导致出现各种不可预测的问题,锁竞争,优先级反转,死锁等,会让整个APP复杂性增大,问题难以排查,并不是一个好的解决方案; -
数据不可变
数据不可变指的并不是指缓存数据完全不可变,而是指对于我们存储的对象单位的属性的不可变。在这里,可以给所有数据对象添加一个标示:如change
,标示的作用就是这个对象是不是已经改变。那么,当数据使用者对数据进行修改的时候,不是直接对对象进行修改,而是生成一个新的对象,去替换老的对象;
这样做的优点就是:即使同时有多个数据使用者读写数据,也不会造成资源争夺问题导致程序崩溃,但这样同时也有一点小瑕疵,就是当我们缓存数据更新时,但是,数据使用者并不知道数据更新,使用的仍然是老数据;这里就涉及到数据更新后通知使用者的策略了。
- push
push 的方式就是 cache 层把更新 push 给上层,cache对整个对象更新替换掉时,发送广播通知上层,这里发通知的粒度可以按需求斟酌,上层监听自己关心的通知,如果发现自己持有的对象更新了,就要更新自己的数据,但这里的更新数据也是件挺麻烦的事。
举个例子,加入有一个列表HYViewController
,存着一个数组 objs
,保存着 obj
数据对象。然后这时 cache 层通知这个 HYViewController
,某个 obj
对象有属性变了,这时这个 HYViewController
要怎样处理呢?有两个选择:
- 遍历
objs
数组,取到obj
对象,如果更新的是这个obj
对象,就把这个对象替换更新。 - 什么都不管,只要有数据更新的通知过来,所有数据都重新往 cache 层读一遍,重新组装数据,界面全部刷新。
第一种是精细化的做法,优点是不影响性能,缺点是蛋疼,工作量增多,还容易漏更新,需要清楚知道当前模块持有了哪些数据,有哪些需要更新。第二种是简单粗暴的做法,优点是省事省心,全部大刷一遍就行了,缺点是在一些复杂页面需要组装数据,会对性能造成较大影响。
- pull
pull 的方式是指上层在特定时机自己去判断数据有没有更新。
首先所有数据对象都会有一个属性,暂时命名为 change
,在 cache 层更新替换数据对象前,先把旧对象的 change
属性设为 YES,表示这个旧对象已经从 cache 里被抛弃了,属于脏数据,需要更新。然后上层在合适的时候自行去判断自己持有的对象的 change
属性是否为 YES,若是则重新在 cache 里取最新数据。具体步骤如下:
1.将要修改对象的
change
标示修改为YES
2.初始化新的对象,将需要修改的值赋值给新的对象
3.然后将缓存中对象替换为新对象
实际上这样做发生了多线程读写 change
属性,是有线程安全问题的,但因为 change
属性读取不频繁,可以直接给这个属性的读写加锁,不会像对所有属性加锁那样引发各种问题,解决对这个 change
属性读写的线程安全问题。
这里主要的问题是上层应该在什么时机去 pull 数据更新。可以在每次界面显示 -viewWillAppear
或用户操作后去检查,例如用户点个赞,就可以触发一次检查,去更新赞的数据,在这适当的地方做检查其实基本可以解决差不多的问题,剩下的就是同个界面联动的问题,例如:在商品详情里面收藏了这个商品,在push过来的上级界面也需要展示收藏的标示,也可以结合上面 push 的方式去做通知。
push 和 pull 两种是可以结合在一起用的,pull 的方式弥补了 push 后数据全部重新读取大刷导致的性能低下问题,push 弥补了 pull 更新时机的问题,实际使用中配合一些事先制定的规则或框架一起使用效果更佳。