架构专家高磊:缓存为王——无线缓存架构优化
高磊 中生代技术
1
无线缓存的定义、限制条件及影响
本章仅讨论运行在移动设备上的 App 所涉及的缓存。移动端缓存的作用是使 App 尽量少地通过网络通信访问应用服务器,以降低网络及服务器的负载,同时提高移动端的性能。
但是,由于移动端应用的宿主是移动设备,移动设备是属于内存受限型的,无法水平扩展,所以它不可能像服务器那样创造出分布式缓存一类的缓存类型,所以它无法缓存那么多内容,即使有缓存的内容,也不可能被存放太久(需要让出空间给其他需要缓存的内容)。移动端应用的缓存被禁锢在本机上,好处是其实现的复杂度大大小于在服务器上进行缓存。此外,移动设备处于整个系统的前端,其缓存体现出了前端缓存应该有的特点,它不仅仅缓存服务器返回的数据,还要缓存提交的请求及响应内容,或缓存类似图片这样的资源。
2
无线缓存要从全局考虑
2.1
服务器架构对无线缓存的影响
无论移动端与服务端通信的协议如何,移动端的网络层必须能够屏蔽其协议上的差异并能够将数据转换为统一的格式,这样做不仅简化了移动端的实现复杂性,更重要的是隔离了对缓存的影响,使缓存不必关心协议及数据格式的差异,比如网络层把数据全部转换成 Java 对象,这样缓存只需要保存 Java 对象即可,如图 19.1 所示。
图 19.1
但是我们要警惕服务器对移动端缓存的侵入。因为缓存组件缓存的是服务器上的内容, 一旦服务器发生变更就必须要有一种机制可以通知移动端对应的缓存项失效,否则会有数据不一致等情况发生。比如移动端上首页展示的商品价格在服务端有变动,如果不使原价格失效,那么极有可能使用户按原来的价格进行交易。另外,本地也有一些辅助的失效策略,比如超时、LRU 及主体相关性缓存失效策略等。
2.2
无线缓存对服务器架构的影响
服务器通知移动端缓存失效的办法一般是通知系统分离失效事件和失效推送,这是因为在移动端数量巨大的情况下,失效通知的发布是一项体量巨大的工作,不可能由应用服务器自己承担,所以应用服务器只是激发一个失效事件,将事件传递给一个有能力推动给移动端的大规模分布式系统,并由它负责推送给各个移动端,
图 19.2
如图 19.2 所示。在服务器集群中一般会部署无线网关服务,移动端的所有业务都将与其通信,要使移动端能够找到它,就需要网关的定位提供一种机制,在网络上提供一个给移动端访问的网关注册管理中心,为移动端提供透明化的位置服务和名字服务,移动端只需要知道服务名称而不需要知道具体的 IP 地址,当服务器上的服务迁移或者变更时也不会影响移动端对服务的可访问性。服务器要确保访问它的是合法的移动端(是自己组织开发的 App 或者经过自己组织认证可以接入的 App),所以往往也需要提供认证机制。另外,还需要提供一整套安全防御机制
2.3
大流量下无线缓存作用的劣化
上面提到了无线缓存的作用,一方面是提高移动端的性能,另一方面是降低服务器负载。但是第二个作用会随着移动端数量的增加而逐渐变得没那么明显,这是由移动端资源限制导致的。因为内存小,能够承载的缓存内容较少,所以有不少对资源的请求会落在服务器上。随着用户规模的不断增大,这种对服务器的请求负载会越来越高,所以无线缓存最主要的作用是优化移动端的性能,而降低服务器负载的作用是处于次要地位的。有了这样的认识,架构师在设计系统时就必须在服务器上下功夫,使这种逐渐增加的负载不会对服务器的稳定性产生很大的影响。
2.4
无线缓存与本机移动端组件的关系
无线缓存在移动端内作为一种资源层而存在,移动端本身可能会以多种方式访问它, 其中会时常使用多线程,所以缓存本身的实现必须是线程安全的。
2.5
无线缓存存储介质的选择
无线缓存存储介质一般会采用内存,但是移动设备的操作系统对移动端进程所能使用的内存有所限制,比如 Android 中对每个进程的限制为 16MB(但是这不是必然的数值,在各个厂商从 Google 原版 Android 代码移植到自己的硬件上时,可能会对此限制做出改动),所以为了增加容量,也存在这样的架构设计——使用 Sdcard 作为二级缓存,理由是 Sdcard 的读写速度和内存很接近。这是一个好的决策,可以使由于移动端规模增大而造成的对服务器负载的影响进一步减小。
3
数据、资源缓存及失效策略
3.1
架构详论
架构实例
无线缓存是本地集合对象所作的内存缓存,为了简化对它的管理,可以将其与网络框架融合起来,这样可以起到一种缓存透明化的作用,使得不需要过多考虑移动端的缓存如何实现和管理,更能集中精力把功能实现好。
以图 19.3 所示的 Volley 架构为例,其实这里并不想给予一个十分有倾向性的架构定义, 因为业务场景千差万别,只想通过一个具体存在的实例给予一定的设计思路,使你在决定采用开源产品时有所依据,或者在决定自己“创造一个新的轮子”时有相应的参考。我们采用开源的 Volley 来说明思路,希望起到抛砖引玉的作用。
图 19.3
Volley 主要通过两种 Diapatch Thread 不断从 RequestQueue 中取出请求,而请求本身被抽象成几种数据获取接口。以便从服务器中获取不同的数据或者资源,根据是否已缓存调用 Cache 或 Network 这两类数据获取接口之一,从缓存(内存缓存或者 Sdcard 缓存)或是服务器中取得请求的数据,然后交由 ResponseDelivery 去做结果分发及回调处理。它的构成包含:
● Volley:Volley 对外暴露的 API 供调用方使用,通过 newRequestQueue(…)函数新建并启动一个请求队列 RequestQueue。
● Request:表示一个请求的抽象类,StringRequest、JsonRequest、ImageRequest 都是它的子类,表示某种类型的请求,指示从服务器获取不同的资源。
● RequestQueue:表示请求队列,里面包含一个 CacheDispatcher(用于处理缓存请求的调度线程)、NetworkDispatcher 数组(用于处理走网络请求的调度线程)、一个 ResponseDelivery (返回结果分发接口 ),通 过 start() 函数启动时会启动 CacheDispatcher 和 NetworkDispatchers。
● CacheDispatcher:一个线程,用于调度处理缓存中的请求。启动后会不断地从缓存请求队列中取出请求处理,队列为空则等待, 请求处理结束则将结果传递给 ResponseDelivery 去执行后续处理。当结果未缓存过、缓存失效或缓存需要刷新时, 该请求都需要重新进入 NetworkDispatcher 去调度处理,这种机制叫作失败缓存重发。
● NetworkDispatcher:一个线程,用于调度处理走网络的请求。启动后会不断从网络请求队列中取出请求处理,队列为空则等待, 请求处理结束则将结果传递给 ResponseDelivery 去执行后续处理,并判断结果是否要进行缓存。
● ResponseDelivery:返回结果分发接口,目前只有基于 ExecutorDelivery 的在入参 handler 对应线程内进行分发。
● HttpStack:处理 HTTP 请求,返回请求结果。目前 Volley 中有基于 HttpURLConnection 的 HurlStack 和基于 Apache HttpClient 的 HttpClientStack。
● Network:调用 HttpStack 处理请求,并将结果转换为可被 ResponseDelivery 处理的 NetworkResponse。
● Cache:缓存请求结果。Volley 默认使用的是基于内存的缓存,也可以指定基于 Sdcard 的 DiskBasedCache。NetworkDispatcher 得到请求结果后判断是否需要存储在 Cache 中,CacheDispatcher 会从 Cache 中取出缓存结果。
本地缓存
图 19.4
如图 19.4 所示,App 进程是缓存组件的宿主和 App 共享内存,而进程所能够使用的内存多是被限制的,所以缓存组件必然侵吞 App 使用的内存,目前有很多 App 还在采用这种方式,不过也有使用 Sdcard 方案缓解以上问题的。
服务式缓存
图 19.5
为了克服本地缓存的缺点,可以把缓存作为服务独立地运行在另外一个进程中,如图 19.5 所示,比如 Android 的 Service 就可以作为缓存的宿主,App 通过 IPC 与之通信,缓存可用的内存会大大增加,而且使用 Sdcard 可以进一步增加可用的存储空间。
3.2
实现失效策略
服务器主动失效策略
订单生成后需要同步移动端缓存中的用户 profile 数据,后台管理端(比如运营平台) 修改了一个商品的价格后也需要同步移动端缓存中的商品数据以便用户可以观察到最新的价格,此类种种操作都与功能、业务触达有关,很难将其抽取到平台上,但是服务器可以提供推送同步数据请求的机制而非具体的策略。
由移动端根据业务需要定义缓存的 Key,将其注册到服务器上并与关心的数据源绑定, 一旦数据源发生变化就发出数据 Key 失效事件,凡是订阅了此事件的推送服务集群都会将此变更数据(连同 Key 和最新的数据)再推送给所有订阅了此 Key 的移动端事件接收者, 令其缓存项失效并同步最新的数据。这样做的好处是,没有必要只让移动端接收事件而后再到服务端去取最新的数据,从而导致瞬间加大应用服务器的负载;另外应用服务器只是发出一个失效事件,由推送集群发送到移动端可以避免失效推送对应用服务器性能的影响。
这种机制也被封装在移动端的网络层,服务器也有相对应的实现机制,不过就是把定义和业务相关的 Key 等这样的事情交给了移动端实现,这样就简化了失效策略的实现方式, 也最大限度地防止了因实现方对缓存失效策略的理解不同而造成的错误使用。
本地失效策略
1. 超时
超时可以作为一种辅助手段,而不是主要手段,这是因为超时的大小会引起一些麻烦, 比如超时过长且服务器推送失效事件的机制没有打开时,客户端总是看到旧的数据,这就会引起一些业务数据处理不一致的情况;而如果设定超时过短,服务器的负载就会过高;再者,如果我们设定的时间整齐划一,也就是会同时失效,那么就会出现雪崩效应。所以, 在使用超时机制时需要谨慎一些。为防止以上问题,可采取如下措施。
- 设定合理的超时时间值。
- 时间设定之间要加入一定的散列值,防止整齐划一的设定。
- 服务器的失效推送机制需要设定为启动。
2. LRU
LRU(Least Recently Used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的概率更高”,如图 19.6 所示。最常见的实现是使用一个链表保存缓存数据,详细算法实现如下:
- 将新数据插入链表头部。
- 每当出现缓存命中(即缓存数据被访问)时,便将数据移到链表头部。
- 当链表已满时,将链表尾部的数据丢弃。这样的实现固然简单,但也有固有的缺点。当存在热点数据时,LRU 的效率很好,但偶发性的、周期性的批量操作会导致 LRU 命中率急剧下降,缓存污染情况比较严重。命中时需要遍历链表,找到命中的数据块索引,然后将数据移到头部,因此性能比较差。
图 19.6
另一种比较科学的实现是 LRU-K。LRU-K 中的 K 代表最近使用的次数,因此 LRU 可以认为是 LRU-18000。LRU-K 的主要目的是解决 LRU 算法“缓存污染”的问题,其核心思想是将“最近使用过 1 次”的判断标准扩展为“最近使用过 K 次”,如图 19.7 所示。和 LRU 相比,LRU-K 需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到 K 次时,才将数据放入缓存。当需要淘汰数据时,LRU-K 会淘汰第 K 次访问时当前时间间距最大的数据。详细实现如下:
- 数据第一次被访问,加入访问历史列表。
- 如果数据在访问历史列表中没有达到 K 次访问,则按照一定规则(FIFO①,LRU)淘汰。
- 当访问历史队列中的数据访问次数达到 K 次后,将数据索引从历史队列中删除, 并将数据移到缓存队列中,缓存此数据,缓存队列重新按照时间排序。
- 缓存数据队列中被再次访问后,重新排序。
- 需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即淘汰“倒数第 K 次访问离现在最久”的数据。
LRU-K 具有 LRU 的优点,同时能够避免 LRU 的缺点。在实际的应用中,LRU-2 是综合各种因素后最优的选择,LRU-3 或者拥有更大 K 值的 LRU 策略的命中率会更高,但适应性差,需要大量的数据访问才能将历史访问记录清除掉。LRU-K 虽然降低了“缓存污染” 带来的问题而且命中率比 LRU 要高,但是也有一定的代价。由于 LRU-K 需要记录那些被访问过但还没有被放入缓存的对象,因此内存消耗会比 LRU 多,当数据量很大的时候,内存消耗会比较可观。LRU-K 需要基于时间进行排序(可以在需要淘汰时再排序,也可以即时排序),CPU 消耗比 LRU 要高。一些其他的算法实现,比如 Two queues(Q2)、Multi Queue(MQ)等,此处不做详解,留给读者自己分析。
图 19.7
3. 主体相关性的失效
移动端经常采用 MVC、MVVM、MVP 等架构模式,其中网络层经常被定位为 Model 层的一部分,当诸如 Android 的 Activity(可以看作控制器)所控制的 View 关闭后,也许会过一段时间才会被再次激活。所以,该策略的思想把一个 Activity 中所缓存的东西和宿主 Activity 的生命周期绑定在一起,当 Activity 关闭时也就会把相关的缓存项清空,以便达到节约内存使用的目的。
4
总结
天下没有完美的架构,能够支持演进的需要、满足目前需求的架构就是好架构。恰到好处是我们追求的目标,灵活使用无线缓存并深知它的限制和优势对移动端的设计是非常有好处的。另外,这也使架构师能够将移动端与服务端作为一个整体去考虑问题,而不再从单纯的角度(设计服务器就是单独设计服务器,设计移动端就是单独设计移动端)去考虑,使得架构的演进方向更加科学和健康。
本文节选自《架构宝典》
- EOF –