缓存概述
缓存在计算机世界里从来都是一个不可忽视的重要因素,我们在计算机系统中经常能见到缓存的存在,例如网卡上的硬件缓存、数据库系统中用来加速数据查询的缓存区、Web Server及浏览器用来加快网站访问速度的网页缓存目录等。总体上来说,会影响运行速度的逻辑都可能通过缓存的方式来改善或者解决,不管是硬件设备还是软件系统。
缓存也被称为Cache(不同于CPU内部的Cache),本质上来说,缓存就是数据交换的一段缓冲区,相当于一个“台阶”,用来大幅度缩减数据交换双方在“数据匹配速度”方面的巨大鸿沟。
我们在使用缓存时需要清楚地认识到缓存的数据随时可能会丢失,即使某些缓存组件或者缓存中间件提供了一些数据持久化的功能,例如Redis可以把缓存的数据写入磁盘中,但我们要明白这种辅助功能并不是缓存系统的核心,因为缓存的目的是提供高速的数据访问性能,而一旦涉及磁盘IO操作,则其性能必然大打折扣,从而降低缓存的价值。
在有限的存储空间中,究竟哪些数据适合缓存呢?一般原则是优先考虑缓存符合下述特征的数据。
一旦生成就基本不会变的数据很符合缓存的要求,这类数据由于不涉及复杂的数据同步问题,所以只要将其装载到缓存中即可,所以编程很简单。如果这类数据被频繁使用,则特别适合将其放入缓存中,比如网站中用户的Session信息、浏览过的商品列表、历史足迹等。
某些计算代价很大的数据,在特定的一些情况下也适合进行缓存。比如对于一个复杂的报表或查询,需要1分钟或更长的时间才能计算出来,此时,如果预先在后台计算出来并且缓存结果,那么用户在单击并访问报表时,就会感觉速度非常快,体验非常好。再比如,某个SQL查询非常消耗数据库的CPU,因此会对数据库服务器造成比较大的压力,如果几个用户同时单击了这个查询按钮,那么可能导致数据不堪重负而停止响应!在这种情况下,我们也可以采用缓存技术来解决问题,即缓存查询结果集5分钟,后面的用户直接访问之前用户的查询缓存结果。虽然这种做法可能会导致某些用户看到过时的数据,但总比系统崩溃影响全部用户好得多。另外,在很多情况下,用户只是浏览一下数据而已,并不关心数据的细节,比如电商网站里的商品列表信息;用户真正关心的是商品的图片、价格,而对于库存在当前究竟是99件还是999件,并不在意。
缓存数据具有很强的时效性,存储空间又有限,这便决定了缓存系统必须以某种方式淘汰旧数据,缓存新数据。最优的淘汰策略就是把缓存中最没用的数据给踢出去,但未来是不能够被预知的,所以这种策略只是我们的一厢情愿!我们设计了很多策略,都是奔着这个“终极目标”去努力的,这些不同的策略没有哪个更好,只有哪个更合适。在不同的场景下如何判断和选择最合适的淘汰策略也是一个需要经验和技能二合一的难题。下面讲解在缓存系统中采用的缓存淘汰策略。
(1)Least Frequently Used(LFU)策略:缓存系统会为每个缓存条目都计算被使用的频率,在淘汰时先淘汰最不常用的缓存条目。CPU的Cache所采用的淘汰策略为LFU策略。
(2)Least Recently Used (LRU)策略:缓存系统会把最近使用最少的缓存条目淘汰。(3)Adaptive Replacement Cache(ARC)策略:被认为是性能最好的缓存算法之一,介于LRU和LFU之间,具有记忆效果,能够自调。该策略由两个LRU组成,其中第1个LRU包含的条目是最近只被使用过一次的条目,放的是新的对象;而第2个LRU包含的是最近被使用过两次的条目,放的是常用的对象。
(4)还有一些基于缓存时间的淘汰策略,比如淘汰存活时间(或者最近一次访问的时间)超过5分钟的缓存条目。
软件系统中实现缓存机制的几种方式如下。
进程内缓存是用得最多的一种缓存实现机制,缓存的数据占用进程的内存空间,在这种情况下,缓存数据的访问速度最快,编程也最简单,在极端情况下用一个HashTable即可实现目的。进程内缓存的缺点是占用主进程的内存,能缓存的数据量比较有限,而且对于Java来说,比较大的堆内存容易导致GC响应缓慢,从而产生让人意想不到的影响。为了解决这个矛盾,通常有两种做法。
其中堆外缓存相对来说效果更好,但实现难度很大,所以目前在Java界几乎没有什么开源、免费的产品。我们所熟悉的知名Java缓存组件 Ehcache的开源版本并不提供堆外存储功能,它的堆外存储被称为BigMemory (为Java分布式内存领域里一个知名公司的产品),只在企业版本的 Ehcache 中提供,它是利用Java的 DirectByteBuffer实现的,比存储到磁盘上快,而且完全不受GC的影响,可以保证响应时间的稳定性。
单机版的缓存中间件正在成为一些大型分布式系统中不可或缺的基础设施之一,特别是电商系统和互联网应用,原因很简单,如下所述。
缓存中间件的出现,从程序来看更像数据从数据库里被“复制”到缓存中间件里,这样一来,即使还是需要通过网络获取数据,数据的访问速度也提高了很多倍。当前比较知名的单机版的缓存中间件有历史悠久的 Memcache及后起之秀Redis,它们都是开源产品,都支持多语言的客户端访问,经过多年发展和大量使用,在缓存领域的地位已经不可动摇。
单机版的缓存中间件也带来一个问题,即跨网络访问缓存数据所增加的延时问题。这个问题其实并不明显,主要因为客户端的 Drive通常采用TCP 长连接与缓存中间件进行数据通信,而且用了类似于JDBC的连接池机制。如果特别在意这个延时问题,则还可以采用如下图所示的进程内缓存+缓存中间件的设计思路,程序先从进程内的缓存模块里查询数据,若查询不到则再去中间件中查询,对于命中率高的热点数据来说,采用这种方式的效果不错。
单机版的缓存中间件基本上能解决大部分系统的数据缓存问题,毕竟在大多数情况下,需要缓存的业务数据只是整个数据中的热点数据,往往只占一小部分比例。但也有一些特殊情况,比如需要存放大量的数据到缓存中间件中,此时单机版的中间件无法承受这么多数据,于是出现了分布式缓存中间件。
分布式缓存中间件通过整合多台服务器来实现一个容量巨大的缓存系统,在绝大多数情况下,分布式缓存中间件都采用Hash算法进行数据分片,将数量庞大的缓存项均匀分布到集群中的每个节点上,比如从Redis 3.0开始实现的分布式集群功能就采用了Hash算法,将缓存项均匀分布到16384个 Slot 上。而以Redis 2.x为基础改造的开源分布式缓存解决方案Codis,可以说是国内开源的一个典范,它出自豆瓣网,在生产系统中有不少案例。Memcache 本身并没有提供集群功能,但很多客户端Driver 都实现了Hash算法的分片逻辑,因此也可以被看作一种分布式缓存的解决方案。
设计和开发一个好的缓存系统时,最难解决的问题是什么?答案是如何在有限的内存里保存尽可能多的数据。这个问题其实很不好解决,原因如下。
本质上,缓存系统的核心就是一个高效的动态内存管理组件,这个组件精确地控制和管理整个系统的内存,最大可能地提升内存的空间使用率及缓存命中率,从而使宝贵、有限的服务器内存被更高效地利用。
如果对计算机系统的底层原理有一些深入了解,就会知道操作系统中内存的管理基于块而不是字节,Linux将物理内存按固定大小的页面(一般为4KB)划分内存,如果在系统中有76GB的物理内存,则物理内存页面数为76×1024×1024/4=19 922944个页面,内存分配和管理以内存页为单元。固定内存页大小的做法实现起来比较简单,操作系统很容易根据应用程序的要求,分配所需容量的连续内存空间给应用。在 Memcache申请了一块很大的内存空间(比如1GB)后,应该如何有效地使用这个内存空间来缓存数据呢?
与大多数采用固定大小的内存单元格来存储缓存项的常规设计思路不同,Memcache很巧妙地创新了一种步进递增的内存单元格设计思路,很好地解决了缓存对象大小不同与高效内存使用之间的矛盾。具体思路如下。
首先,Memcache 以1MB为单位将内存分为许多page,每个page再继续被切分为更小的单元格——chunk,每个chunk都存放一个缓存项。独特之处在于,不同page划分的chunk 大小可能是不一样的,在默认情况下,最小的chunk容量是80个字节,第2级chunk 的大小为80×1.25,第3级 chunk 的大小为80×1.25^2,以此类推。
然后,具有相同尺寸规格的chunk 的一组.page成为一个slab,同规格的一组 slab就成为一种 slab_class 以区分于其他规格的slab。出于对效率的考虑,采用数组记录上述数据结构,整个结构如下图所示。
于是,我们得到下面的结果。
我们看到,在这种方式下,不同slab_class里的chunk 尺寸并非是剧烈变化的,而是缓慢递增的。如此一来,程序中大部分相似的缓存对象基本都成了邻居,因为它们会被存储在相近的几个slab_class 内,通过合理设置初始chunk的大小,还能让这些slab内的chunk 大小很适合实际的缓存对象。
如果仔细思考,则你可能会觉得有问题,如果page页事先已经按照预定的chunk大小分配好了单元格,比如最小的为88个字节,那么如果所有缓存对象都是60个字节,岂不是后面比较大的 slab都浪费了巨大的内存空间? Memcache面对这一问题采用了我们常见的策略——延时创建: page页只有在需要时才被分配给指定的 slab,此时才会根据该slab 的规格进行 chunk的划分。这样一来,如果我们缓存的对象都是同一类对象,而且它们的大小差不多,则几乎内存中的所有slab都是同一个或者相邻的几个slab_class。
Memcache 的内存管理很简单、很优雅、适应能力很强,但也有一个严重缺陷: page页一旦被分配给 slab,在 Memcache生命周期内便不再更改,在内存总量确定的情况下,其他 slab可能出现饿死及内存大量浪费的现象。举个例子,在某程序中一开始缓存的对象大部分是小对象(80Byte),后面突然来了一些大对象(1000Byte),则会导致 Memcache分配没有被使用的page页给新的 slab,这些新的slab里的chunk很大,所以同样1MB的 page只能存放少量的大对象,这个过程也导致可分配的page页被大量消耗,如果后面又忽然大量冒出另一个规格的对象(500Byte),则所有的空闲page页(可能还不够)很快被消耗完,最后程序恢复正常,又开始缓存大量的小对象,Memcache就没有多余的page页来分配了,从而导致内存的极度浪费。
如果应用程序出现上述现象,则客观地来说,不是 Memcache 的问题,而是应用程序开发者或者架构师本身的问题。Memcache后来增加了slab automove的特性,用来自动将某个slab内的page页转移到其他slab下,进而解决了这个问题,但官方表示,这个过程很缓慢并且比较保守。
在探讨完 Memcache的内存设计思路之后,我们不得不承认,Memcache的内存管理模块是一个很适合缓存这种特定需求场景的、高度自适应的、设计精妙的同时体现了简单即美的大师作品。
只要稍微了解缓存中间件,我们就知道Memcache和 Redis是这个领域无可代替的王者,但为什么早在2003年就诞生的Memcache被比其晚出现6年的Redis超越了呢?其中很关键的原因有以下两点。
这两种截然不同的指导思想也导致了Memcache与 Redis的重大差异。虽然 Memcache仍然在缓存领域占据着不可代替的地位,但目前在知名度和使用率方面被Redis超越了不少。
Redis的优秀特性有很多,主要有以下几点。
Redis的设计在很多方面看起来都是违背常理的。首先,它采用了单线程模型,这似乎是不可思议的做法,但是仔细想想的确有合理之处:缓存系统在绝大多数情况下的使用场景只是内存数据的简单读取操作,单线程避免了复杂的线程同步、上下文切换、锁等复杂逻辑,也使得原子性计算这种功能很容易被实现。另外,在这种网络IO的场景下,CPU很难成为瓶颈,所以,Redis用单线程模型是一个经过深思熟虑的决定。
那么,问题也出现了,单线程只能用一个 Core(或者一个超线程),在多核CPU的情况下如何充分发挥CPU的功能呢﹖答案就是分片(Sharding)。启动多个Redis进程实例(如果每个进程都通过NUMA技术绑定到一个Core,则理论上性能更高),每个实例都承担一部分缓存,多个Redis实例组成一个集群。
为什么Redis作者没有采用大家都推荐的高大上的一致性Hash算法作为集群分片规则,而是采用了固定Slot分片方式呢?主要原因是一致性Hash算法非常复杂,举个例子,90%的人认为一致性Hash算法在某个节点宕机的情况下无须迁移数据!而实际上,一致性Hash算法仅仅减少了节点数据的迁移量,要计算出哪些虚拟节点上的哪些数据被迁移到哪些虚拟节点上,这个逻辑并不好懂,而且很复杂。另外,如果集群要扩容和增加节点,则也涉及复杂的计算和数据迁移问题,因此从整体来看,一致性Hash算法的实用性并不是很强。