在Web服务开发中,服务端缓存是服务实现中所常常采用的一种提高服务性能的方法。其通过记录某部分计算结果来尝试避免再次执行得到该结果所需要的复杂计算,从而提高了服务的运行效率。
除了能够提高服务的运行效率之外,服务端缓存还常常用来提高服务的扩展性。因此一些大规模的Web应用,如Facebook,常常构建一个庞大的服务端缓存。而它们所最常使用的就是Memcached。
在本文中,我们就将对Memcached进行简单地介绍。
Memcached简介
在介绍Memcached之前,让我们首先通过一个示例了解什么是服务端缓存。
相信大家都玩过一些网络联机游戏吧。在我那个年代(03年左右),这些游戏常常添加了对战功能,并提供了天梯来显示具有最优秀战绩的玩家以及当前玩家在天梯系统中的排名。这是游戏开发商所常常采用的一种聚拢玩家人气的手段。而希望在游戏中证明自己的玩家则会由此激发斗志,进而花费更多时间来在天梯中取得更好的成绩。
就天梯系统来说,其最主要的功能就是为玩家提供天梯排名的信息,而并不允许玩家对该系统中所记录的数据作任何修改。这样设定的结果就是,整个天梯系统的读操作居多,而写操作很少。反过来,由于一个游戏中的玩家可能有上千万甚至上亿人,而且在线人数常常达到上万人,因此对天梯的访问也会是非常频繁的。这样的话,即使每秒钟只有10个人访问天梯中的排名,对这上亿个玩家的天梯排名进行读取及排序也是一件非常消耗性能的事情。
一个自然而然的想法就是:在对天梯排名进行一次计算后,我们在服务端将该天梯排名缓存起来,并在其它玩家访问的时候直接返回该缓存中所记录的结果。而在一定时间段之后,如一个小时,我们再对缓存中的数据进行更新。这样我们就不再需要每个小时执行成千上万次的天梯排名计算了。
而这就是服务端缓存所提供的最重要功能。其既可以提高单个请求的响应速度,又可以降低服务层及数据库层的压力。除此之外,多个服务实例都可以读取该服务端缓存所缓存的信息,因此我们也不再需要担心这些数据在各个服务实例中都保存了一份进而需要彼此同步的问题,也即是提高了扩展性。
而Memcached就是一个使用了BSD许可的服务端缓存实现。但是与其它服务端缓存实现不同的是,其主要由两部分组成:独立运行的Memcached服务实例,以及用于访问这些服务实例的客户端。因此相较于普通服务端缓存实现中各个缓存都运行在服务实例之上的情况,Memcached服务实例则是在服务实例之外独立运行的:
从上图中可以看出,由于Memcached缓存实例是独立于各个应用服务器实例运行的,因此应用服务实例可以访问任意的缓存实例。而传统的缓存则与特定的应用实例绑定,因此每个应用实例将只能访问特定的缓存。这种绑定一方面会导致整个应用所能够访问的缓存容量变得很小,另一方面也可能导致不同的缓存实例中存在着冗余的数据,从而降低了缓存系统的整体效率。
在运行时,Memcached服务实例只需要消耗非常少的CPU资源,却需要使用大量的内存。因此在决定如何组织您的服务端缓存结构之前,您首先需要搞清当前服务中各个服务实例的负载情况。如果一个服务器的CPU使用率非常高,却存在着非常多的空余内存,那么我们就完全可以在其上运行一个Memcached实例。而如果当前服务中的所有服务实例都没有过多的空余内存,那么我们就需要使用一系列独立的服务实例来搭建服务端缓存。一个大型服务常常拥有上百个Memcached实例。而在这上百个Memcached实例中所存储的数据则不尽相同。由于这种数据的异构性,我们需要在访问由Memcached所记录的信息之前决定在该服务端缓存系统中到底由哪个Memcached实例记录了我们所想要访问的数据:
如上图所示,用户需要通过一个Memcached客户端来完成对缓存服务所记录信息的访问。该客户端知道服务端缓存系统中所包含的所有Memcached服务实例。在需要访问具有特定键值的数据时,该客户端内部会根据所需要读取的数据的键值,如“foo”,以及当前Memcached缓存服务的配置来计算相应的哈希值,以决定到底是哪个Memcached实例记录了用户所需要访问的信息。在决定记录了所需要信息的Memcached实例之后,Memcached客户端将从配置中读取该Memcached服务实例所在地址,并向该Memcached实例发送数据访问请求,以从该Memcached实例中读取具有键值“foo”的信息。在各个论坛的讨论中,这被称为是Memcached的两阶段哈希(Two-stage hash)。
而对数据的记录也使用了类似的流程:假设用户希望通过服务端缓存记录数据“bar”,并为其指定键值“foo”。那么Memcached客户端将首先对用户所赋予的键值“foo”及当前服务端缓存所记录的可用服务实例个数执行哈希计算,并根据哈希计算结果来决定存储该数据的Memcached服务实例。接下来,客户端就会向该实例发送请求,以在其中记录具有键值“foo”的数据“bar”。
这样做的好处则在于,每个Memcached服务实例都是独立的,而彼此之间并没有任何交互。在这种情况下,我们可以省略很多复杂的功能逻辑,如各个节点之间的数据同步以及结点之间消息的广播等等。这种轻量级的架构可以简化很多操作。如在一个节点失效的时候,我们仅仅需要使用一个新的Memcached节点替代老节点即可。而在对缓存进行扩容的时候,我们也只需要添加额外的服务并修改客户端配置。
这些记录在服务端缓存中的数据是全局可见的。也就是说,一旦在Memcached服务端缓存中成功添加了一条新的记录,那么其它使用该缓存服务的应用实例将同样可以访问该记录:
在Memcached中,每条记录都由四部分组成:记录的键,有效期,一系列可选的标记以及表示记录内容的数据。由于记录内容的数据中并不包含任何数据结构,因此我们在Memcached中所记录的数据需要是经过序列化之后的表示。
内存管理
在使用缓存时,我们不得不考虑的一个问题就是如何对这些缓存数据的生存期进行管理。这其中包括如何使一个记录在缓存中的数据过期,如何在缓存空间不够时执行数据的替换等。因此在本节中,我们将对Memcached的内存管理机制进行介绍。
首先我们来看一看Memcached的内存管理模型。通常情况下,一个内存管理算法所最需要考虑的问题就是内存的碎片化(Fragmentation):在长时间地分配及回收之后,被系统所使用的内存将趋向于散落在不连续的空间中。这使得系统很难找到连续内存空间,一方面增大了内存分配失败的概率,另一方面也使得内存分配工作变得更为复杂,降低了运行效率。
为了解决这个问题,Memcached使用了一种叫Slab的结构。在该分配算法中,内存将按照1MB的大小划分为页,而该页内存则会继续被分割为一系列具有相同大小的内存块:
因此Memcached并不是直接根据需要记录的数据的大小来直接分配相应大小的内存。在一条新的记录到来时,Memcached会首先检查该记录的大小,并根据记录的大小选择记录所需要存储到的Slab类型。接下来,Memcached就会检查其内部所包含的该类型Slab。如果这些Slab中有空余的块,那么Memcached就会使用该块记录该条信息。如果已经没有Slab拥有空闲的具有合适大小的块,那么Memcached就会创建一个新的页,并将该页按照目标Slab的类型进行划分。
一个需要考虑的特殊情况就是对记录的更新。在对一个记录进行更新的时候,记录的大小可能会发生变化。在这种情况下,其所对应的Slab类型也可能会发生变化。因此在更新时,记录在内存中的位置可能会发生变化。只不过从用户的角度来说,这并不可见。
Memcached使用这种方式来分配内存的好处则在于,其可以降低由于记录的多次读写而导致的碎片化。反过来,由于Memcached是根据记录的大小选择需要插入到的块类型,因此为每个记录所分配的块的大小常常大于该记录所实际需要的内存大小,进而造成了内存的浪费。当然,您可以通过Memcached的配置文件来指定各个块的大小,从而尽可能地减少内存的浪费。
但是需要注意的是,由于默认情况下Memcached中每页的大小为1MB,因此其单个块最大为1MB。除此之外,Memcached还限制每个数据所对应的键的长度不能超过250个字节。
一般来说,Slab中各个块的大小以及块大小的递增倍数可能会对记录所载位置的选择及内存利用率有很大的影响。例如在当前的实现下,各个Slab中块的大小默认情况下是按照1.25倍的方式来递增的。也就是说,在一个Memcached实例中,某种类型Slab所提供的块的大小是80K,而提供稍大一点空间的Slab类型所提供的块的大小就将是100K。如果现在我们需要插入一条81K的记录,那么Memcached就会选择具有100K块大小的Slab,并尝试找到一个具有空闲块的Slab以存入该记录。
同时您也需要注意到,我们使用的是100K块大小的Slab来记录具有81K大小的数据,因此记录该数据所导致的内存浪费是19K,即19%的浪费。而在需要存储的各条记录的大小平均分布的情况下,这种内存浪费的幅度也在9%左右。该幅度实际上取决于我们刚刚提到的各个Slab中块大小的递增倍数。在Memcached的初始实现中,各个Slab块的递增倍数在默认情况下是2,而不是现在的1.25,从而导致了平均25%左右的内存浪费。而在今后的各个版本中,该递增倍数可能还会发生变化,以优化Memcached的实际性能。
如果您一旦知道了您所需要缓存的数据的特征,如通常情况下数据的大小以及各个数据的差异幅度,那么您就可以根据这些数据的特征来设置上面所提到的各个参数。如果数据在通常情况下都比较小,那么我们就需要将最小块的大小调整得小一些。如果数据的大小变动不是很大,那么我们可以将块大小的递增倍数设置得小一些,从而使得各个块的大小尽量地贴近需要存储的数据,以提高内存的利用率。
还有一个值得注意的事情就是,由于Memcached在计算到底哪个服务实例记录了具有特定键的数据时并不会考虑用来组成缓存系统中各个服务器的差异性。如果每个服务器上只安装了一个Memcached实例,那么各个Memcached实例所拥有的可用内存将存在着数倍的差异。但是由于各个实例被选中的概率基本相同,因此具有较大内存的Memcached实例将无法被充分利用。我们可以通过在具有较大内存的服务器上部署多个Memcached实例来解决这个问题:
例如上图所展示的缓存系统是由两个服务器组成。这两个服务器中的内存大小并不相同。第一个服务器的内存大小为32G,而第二个服务器的内存大小仅仅有8G。为了能够充分利用这两个服务器的内存,我们在具有32G内存的服务器上部署了4个Memcached实例,而在只有8G内存的服务器上部署了1个Memcached实例。在这种情况下,32G内存服务器上的4个Memcached实例将总共得到4倍于8G服务器所得到的负载,从而充分地利用了32G内存服务器上的内存。
当然,由于缓存系统拥有有限的资源,因此其会在某一时刻被服务所产生的数据填满。如果此时缓存系统再次接收到一个缓存数据的请求,那么它就会根据LRU(Least recently used)算法以及数据的过期时间来决定需要从缓存系统中移除的数据。而Memcached所使用的过期算法比较特殊,又被称为延迟过期(Lazy expiration):当用户从Memcached实例中读取数据的时候,其将首先通过配置中所设置的过期时间来决定该数据是否过期。如果是,那么在下一次写入数据却没有足够空间的时候,Memcached会选择该过期数据所在的内存块作为新数据的目标地址。如果在写入时没有相应的记录被标记为过期,那么LRU算法才被执行,从而找到最久没有被使用的需要被替换的数据。
这里的LRU是在Slab范围内的,而不是全局的。假设Memcached缓存系统中的最常用的数据都存储在100K的块中,而该系统中还存在着另外一种类型的Slab,其块大小是300K,但是存在于其中的数据并不常用。当需要插入一条99K的数据而Memcached已经没有足够的内存再次分配一个Slab实例的时候,其并不会释放具有300K块大小的Slab,而是在100K块大小的各个Slab中找到需要释放的块,并将新数据添加到该块中。
高可用性
在企业级应用中,我们常常强调一个系统需要拥有高可用性和高可靠性。而对于一个组成而言,其需要能够稳定地运行,并在出现异常的时候尽量使得异常的影响限制在某个特定的范围内,而不会导致整个系统不能正常工作。而且在出现异常之后,该组成需要能较为容易地恢复到正常的工作状态。
那么Memcached需要什么样的高可用性呢?在讲解这个问题之前,我们先来看看在一个大型服务中Memcached所组成的服务端缓存是什么样的:
从上图中可以看到,在一个大型服务中,由Memcached所组成的服务端缓存实际上是由非常多的Memcached实例组成的。在前面我们已经介绍过,Memcached实例实际上是完全独立的,不存在Memcached实例之间的相互交互。因此在其中一个发生了故障的时候,其它的各个Memcached服务实例并不会受到影响。如果一个拥有了16个Memcached实例的服务端缓存系统中的一个Memcached实例发生了故障,那么整个系统将还有93.75%的缓存容量可以继续工作。虽然缓存容量的减少会略微增加其后的各个服务实例的压力,但是一个应用所经历的负载波动常常比这个大得多,因此该服务应该还是能够正常工作的。
而这也恰恰表明了Memcached所具有的独立性的正确性。由于Memcached本身致力于创建一个高效而且简单,却具有较强扩展性的缓存组件,因此其并没有强调数据的安全性。一旦其中的一个Memcached实例发生了故障,那么我们还可以从数据库及服务端再次计算得到该数据,并将其记录在其它可用的Memcached实例上。
我想您读到这里一定会想:“不,还有一个问题,那就是由于Memcached实例的个数变化会导致哈希计算的结果发生变化,从而导致所有对数据的请求会导向到不正确的Memcached实例,使得由Memcached实例集群所提供的缓存服务全部失效,从而导致数据库的压力骤增。”
是的,这也是我曾经有所顾虑的地方。而且这不仅仅在服务端缓存失效的时候存在。只要服务端缓存中Memcached实例的数量发生了变化,那么该问题就会发生。
Memcached所使用的解决方法就是Consistent Hashing。在该算法的帮助下,Memcached实例数量的变化将只可能导致其中的一小部分键的哈希值发生改变。那该算法到底是怎么运行的呢?
首先请考虑一个圆,在该圆上分布了多个点,以表示整数0到1023。这些整数平均分布在整个圆上:
而在上图中,我们则突出地显示了6个蓝点。这六个蓝点基本上将该圆进行了六等分。而它们所对应的就是在当前Memcached缓存系统中所包含的三个Memcached实例m1,m2以及m3。好,接下来我们则对当前需要存储的数据执行哈希计算,并找到该哈希结果900在该圆上所对应的点:
可以看到,该点在顺时针距离上离表示0的那个蓝点最近,因此这个具有哈希值900的数据将记录在Memcached实例m1中。
如果其中的一个Memcached实例失效了,那么需要由该实例所记录的数据将暂时失效,而其它实例所记录的数据仍然还在:
从上图中可以看到,在Memcached实例m1失效的情况下,值为900的数据将失效,而其它的值为112和750的数据将仍然记录在Memcached实例m2及m3上。也就是说,一个节点的失效现在将只会导致一部分数据不再在缓存系统中存在,而并没有导致其它实例上所记录的数据的目标实例发生变化。
但是我们还不得不考虑另一个问题,那就是在一个服务的服务端缓存仅仅由一个或几个Memcached实例组成的情况。在这种情况下,其中一个Memcached实例失效是较为致命的,因为数据库以及服务器实例将接收到大量的需要进行复杂计算的请求,并将最终导致服务器实例和数据库过载。因此在设计服务端缓存时,我们常常采取超出需求容量的方法来定义这些缓存。例如在服务实际需要5个Memcached结点时我们设计一个包含6个节点的服务端缓存系统,以增加整个系统的容错能力。
使用Memcached搭建缓存系统
OK,在对Memcached内部运行原理介绍完毕之后,我们就来看看如何使用Memcached为您的服务搭建缓存系统。
首先,您需要从Memcached官方网站上下载Memcached的安装文件,并在您作为缓存服务器的系统上进行安装。在安装时,您需要对Memcached进行适当地配置,如其所需要侦听的端口,为其所分配的内存大小等。在Memcached正确配置并启动之后,我们就可以通过一系列客户端软件访问这些Memcached实例并对其进行操作了。由于我并不是一个运维人员,因此在这里我们将不再对这些配置进行详细地介绍。
而我们要介绍的,则是如何用Memcached的Java客户端去读写数据。 而一个较为常见的Memcached客户端则是SpyMemcached。就让我们来看一看Spy Memcached的Main函数中是如何对其所提供的功能进行使用的:
1 public static void main(String[] args) throws Exception{ 2 if(args.length < 2){ 3 System.out.println("Please specify command line options"); 4 return; 5 } 6 7 MemcachedClient memcachedClient = new MemcachedClient(AddrUtil.getAddresses("127.0.0.1:11211")); 8 if (commandName.equals("get")){ 9 String keyName= args[1]; 10 System.out.println("Key Name " + keyName); 11 System.out.println("Value of key " + memcachedClient.get(keyName)); 12 } else if(commandName.equals("set")){ 13 String keyName =args[1]; 14 String value=args[2]; 15 System.out.println("Key Name " + keyName + " value=" + value); 16 Future<Boolean> result= memcachedClient.set(keyName, 0, value); 17 System.out.println("Result of set " + result.get()); 18 } else if(commandName.equals("add")){ 19 String keyName =args[1]; 20 String value=args[2]; 21 System.out.println("Key Name " + keyName + " value=" + value); 22 Future<Boolean> result= memcachedClient.add(keyName, 0, value); 23 System.out.println("Result of add " + result.get()); 24 } else if(commandName.equals("replace")){ 25 String keyName =args[1]; 26 String value=args[2]; 27 System.out.println("Key Name " + keyName + " value=" + value); 28 Future<Boolean> result= memcachedClient.replace(keyName, 0, value); 29 System.out.println("Result of replace " + result.get()); 30 } else if(commandName.equals("delete")){ 31 String keyName =args[1]; 32 System.out.println("Key Name " + keyName ); 33 Future<Boolean> result= memcachedClient.delete(keyName); 34 System.out.println("Result of delete " + result.get()); 35 } else{ 36 System.out.println("Command not found"); 37 } 38 memcachedClient.shutdown(); 39 }
可以看到,在该客户端的帮助下,对存储在Memcached实例中的数据的读取已经变得非常简单了。我们可以简单地通过调用该客户端的get(),set(),add(),replace()以及delete()函数来完成对数据的操作。